使用 Spring Boot 应用进行客户端开发 - 第 2 部分

工程 | Dave Syer | 2021 年 12 月 17 日 | ...

第 1 部分

使用 SSE Stream 的原生 Javascript

在这个简单的 HTML 替换用例中,Vue 并没有真正增加太多价值,对于 SSE 示例也完全没有价值,因此我们将直接使用原生 Javascript 来实现。下面是一个 stream 选项卡

<div class="tab-pane fade" id="stream" role="tabpanel">
	<div class="container">
		<div id="load"></div>
	</div>
</div>

以及填充它的一些 Javascript

<script type="module">
	var events = new EventSource("/stream");
	events.onmessage = e => {
		document.getElementById("load").innerHTML = e.data;
	}
</script>

使用 React 的动态内容

大多数使用 React 的人可能不仅仅是编写一些逻辑,最终会把所有的布局和渲染都放在 Javascript 中。你不必这样做,只使用少量 React 来感受一下也相当容易。你可以止步于此,将其用作一个工具库,或者逐步发展到完整的 Javascript 客户端组件方法。

我们可以在不改变太多的情况下开始尝试。如果你想先睹为快,示例代码最终会类似于 react-webjars 示例。首先是 pom.xml 中的依赖项

<dependency>
	<groupId>org.webjars.npm</groupId>
	<artifactId>react</artifactId>
	<version>17.0.2</version>
</dependency>
<dependency>
	<groupId>org.webjars.npm</groupId>
	<artifactId>react-dom</artifactId>
	<version>17.0.2</version>
</dependency>

以及 index.html 中的模块映射

<script type="importmap">
	{
		"imports": {
			...
			"react": "/npm/react/umd/react.development.js",
			"react-dom": "/npm/react-dom/umd/react-dom.development.js"
		}
	}
</script>

React 没有打包成 ESM bundle(至少目前还没有),因此没有“module”元数据,我们不得不硬编码资源路径,就像这样。资源路径中的“umd”指的是“Universal Module Definition”,这是一个较早期的 Javascript 模块化尝试。它足够接近,如果你仔细看,可以以类似的方式使用它。

有了这些,你就可以导入它们定义的函数和对象了

<script type="module">
	import * as React from 'react';
	import * as ReactDOM from 'react-dom';
</script>

由于它们不是真正的 ESM 模块,你可以在 HTML <head/> 中的 <script/> 中以“全局”级别导入它们,例如我们在导入 bootstrap 的地方。然后你可以通过创建一个 React.Component 来定义一些内容。这里有一个非常基本的静态示例

<script type="module">
	const e = React.createElement;
	class RootComponent extends React.Component {
		constructor(props) {
			super(props);
		}
		render() {
			return e(
				'h1',
				{},
				'Hello, world!'
			);
		}
	}
	ReactDOM.render(e(RootComponent), document.querySelector('#root'));
</script>

render() 方法返回一个函数,该函数创建一个新的 DOM 元素(一个内容为“Hello, world!”的 <h1/> 标签)。它通过 ReactDOM 附加到 id="root" 的元素上,所以我们最好也添加一个这样的元素,例如在“test”选项卡中

<div class="tab-pane fade" id="test" role="tabpanel">
	<div class="container" id="root"></div>
</div>

如果你运行它,它应该能工作,并且在该选项卡中显示“Hello World”。

Javascript 中的 HTML:XJS

大多数 React 应用使用通过一个称为“XJS”的模板语言(它可以用在其他方面,但现在实际上是 React 的一部分)嵌入到 Javascript 中的 HTML。上面的 hello world 示例看起来像这样

<script type="text/babel">
	class Hello extends React.Component {
		render() {
			return <h1>Hello, {this.props.name}!</h1>;
		}
	}
	ReactDOM.render(
		<Hello name="World"/>,
		document.getElementById('root')
	);
</script>

该组件定义了一个自定义元素 <Hello/>,它与组件的类名匹配,并且习惯上以大写字母开头。<Hello/> 片段是一个 XJS 模板,组件还有一个 render() 函数,它返回一个 XJS 模板。花括号用于插值,props 是一个包含自定义元素所有属性的 map(所以这里是“name”)。最后是 <script type="text/babel">,它用于将 XJS 转译成浏览器能理解的实际 Javascript。上面的脚本在浏览器学会识别它之前是不会做任何事情的。我们通过导入另一个模块来做到这一点

<script type="importmap">
{
  "imports": {
    ...
    "react": "/npm/react/umd/react.development.js",
    "react-dom": "/npm/react-dom/umd/react-dom.development.js",
    "@babel/standalone": "/npm/@babel/standalone"
  }
}
</script>
<script type="module">
...
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import '@babel/standalone';
</script>

React 用户指南建议不要在大型应用程序中使用 @babel/standalone,因为它需要在浏览器中做大量工作,而同样的工作可以在构建时一次性完成,效率更高。但对于尝试一些东西,以及对于像这样的少量 React 代码的应用程序来说,它是一个不错的选择。

基本事件和用户输入处理

我们现在可以将主要的“message”选项卡迁移到 React 了。所以我们修改 Hello 组件,并将其附加到另一个元素上。message 选项卡可以简化为一个空的元素,准备好接受 React 内容

<div class="tab-pane fade show active" id="message" role="tabpanel">
	<div class="container" id="hello"></div>
</div>

我们可以预见到需要第二个组件来渲染已认证用户的名称,所以我们先用这个来将一些代码附加到上面选项卡中的元素上

ReactDOM.render(
	<div className="container" id="hello">
		<Auth/>
		<Hello/>
	</div>,
	document.getElementById('hello')
);

然后我们可以像这样定义 Auth 组件

class Auth extends React.Component {
	constructor(props) {
		super(props);
		this.state = { user: 'Unauthenticated' };
	};
	componentDidMount() {
		let hello = this;
		fetch("/user").then(response => {
			response.json().then(data => {
				hello.setState({user: `Logged in as: ${data.name}`});
			});
		});
	};
	render() {
		return <div id="auth">{this.state.user}</div>;
	}
};

本例中的生命周期回调是 componentDidMount,它在组件被激活时由 React 调用,所以我们将初始化代码放在那里。

另一个组件是将“name”输入转换成问候语的组件

class Hello extends React.Component {
	constructor(props) {
		super(props);
		this.state = { name: '', message: '' };
		this.greet = this.greet.bind(this);
		this.change = this.change.bind(this);
	};
	greet() {
		this.setState({message: `Hello ${this.state.name}!`})
	}
	change(event) {
		console.log(event)
		this.setState({name: event.target.value})
	}
	render() {
		return <div>
			<div id="greeting">{this.state.message}</div>
			<input id="name" name="value" type="text" value={this.state.name} onChange={this.change}/>
			<button className="btn btn-primary" onClick={this.greet}>Greet</button>
		</div>;
	}
}

render() 方法必须返回单个元素,所以我们必须将内容包装在一个 <div> 中。另一件值得指出的是,状态从 HTML 到 Javascript 的转移不是自动的 - React 中没有“双向绑定模型”,你必须为输入框添加 change 监听器来显式更新状态。此外,我们必须对所有我们想用作监听器的组件方法(本例中的 greetchange)调用 bind()

图表选择器

为了将 Stimulus 的其余内容迁移到 React,我们需要编写一个新的图表选择器。所以我们可以从一个空的“chart”选项卡开始

<div class="tab-pane fade" id="chart" role="tabpanel" data-controller="chart">
	<div class="container">
		<canvas id="canvas"></canvas>
	</div>
	<div class="container" id="chooser"></div>
</div>

并将一个 ReactDOM 元素附加到“chooser”上

ReactDOM.render(
	<ChartChooser/>,
	document.getElementById('chooser')
);

ChartChooser 是一个封装在组件中的按钮列表

class ChartChooser extends React.Component {
	constructor(props) {
		super(props);
		this.state = {};
		this.clear = this.clear.bind(this);
		this.bar = this.bar.bind(this);
	};
	bar() {
		let chart = this;
		this.clear();
		fetch("/pops").then(response => {
			response.json().then(data => {
				data.type = "bar";
				chart.setState({ active: new Chart(document.getElementById("canvas"), data) });
			});
		});
	};
	clear() {
		if (this.state.active) {
			this.state.active.destroy();
		}
	};
	render() {
		return <div>
			<button className="btn btn-primary" onClick={this.clear}>Clear</button>
			<button className="btn btn-primary" onClick={this.bar}>Bar</button>
		</div>;
	}
}

我们还需要 Vue 示例中的 chart 模块设置(它在 <script type="text/babel"> 中不起作用)

<script type="module">
	import { Chart, BarController, BarElement, LinearScale, CategoryScale, Title, Legend } from 'chart.js';
	Chart.register(BarController, BarElement, LinearScale, CategoryScale, Title, Legend);
	window.Chart = Chart;
</script>

Chart.js 没有打包成可以直接导入到 Babel 脚本中的形式。我们在一个单独的模块中导入它,并且 Chart 必须定义为全局变量,以便我们仍然可以在 React 组件中使用它。

服务器端片段

为了使用 React 渲染“test”选项卡,我们可以从选项卡本身开始,再次清空以接受来自 React 的内容

<div class="tab-pane fade" id="test" role="tabpanel">
	<div class="container" id="root"></div>
</div>

并在 React 中绑定到“root”元素

ReactDOM.render(
	<Content />,
	document.getElementById('root')
);

然后我们可以将 <Content/> 实现为一个从 /test 端点获取 HTML 的组件

class Content extends React.Component {
	constructor(props) {
		super(props);
		this.state = { html: '' };
		this.fetch = this.fetch.bind(this);
	};
	fetch() {
		let hello = this;
		fetch("/test").then(response => {
			response.text().then(data => {
				hello.setState({ html: data });
			});
		});
	}
	render() {
		return <div>
			<div dangerouslySetInnerHTML={{ __html: this.state.html }}></div>
			<button className="btn btn-primary" onClick={this.fetch}>Fetch</button>
		</div>;
	}
}

dangerouslySetInnerHTML 属性由 React 特意命名,旨在阻止人们将其用于直接从用户收集的内容(XSS 问题)。但我们是从服务器获取该内容的,因此我们可以相信那里的 XSS 防护并忽略警告。

如果我们使用那个 <Content/> 组件和上面示例中的 SSE 加载器,那么我们就可以完全从这个示例中移除 Hotwired 了。

使用 Node.js 构建和打包

Webjars 很棒,但有时你需要更接近 Javascript 的东西。对一些人来说,Webjars 的一个问题是 jar 的大小 - Bootstrap jar 将近 2MB,其中大部分在运行时永远不会用到 - 而 Javascript 工具则非常注重减少这种开销,通过不将整个 NPM 模块打包到你的应用中,以及将资产打包在一起以提高下载效率。Java 工具也存在一些问题 - 特别是在 Sass 方面,缺乏好的工具,就像我们最近在 最近的 Petclinic 中发现的那样。所以也许我们应该看看使用 Node.js 工具链进行构建的选项。

你需要的第一样东西是 Node.js。获取它的方法有很多,你可以使用任何你喜欢的工具。我们将展示如何使用 Frontend Plugin 来完成。

安装 Node.js

我们将插件添加到 turbo 示例中。(如果你想先睹为快,最终结果就是 nodejs 示例)在 pom.xml

<plugins>
	<plugin>
		<groupId>com.github.eirslett</groupId>
		<artifactId>frontend-maven-plugin</artifactId>
		<version>1.12.0</version>
		<executions>
			<execution>
				<id>install-node-and-npm</id>
				<goals>
					<goal>install-node-and-npm</goal>
				</goals>
				<configuration>
					<nodeVersion>v16.13.1</nodeVersion>
				</configuration>
			</execution>
			<execution>
				<id>npm-install</id>
				<goals>
					<goal>npm</goal>
				</goals>
				<configuration>
					<arguments>install</arguments>
				</configuration>
			</execution>
			<execution>
				<id>npm-build</id>
				<goals>
					<goal>npm</goal>
				</goals>
				<configuration>
					<arguments>run-script build</arguments>
				</configuration>
				<phase>generate-resources</phase>
			</execution>
		</executions>
	</plugin>
	...
</plugins>COPY

这里我们有 3 个执行:install-node-and-npm 在本地安装 Node.js 和 NPM,npm-install 运行 npm installnpm-build 运行一个脚本来构建 Javascript 和可能的 CSS。我们需要一个最小化的 package.json 来运行它们。如果你已经安装了 npm,你可以运行 npm init 来生成一个新的,或者手动创建它

$ cat > package.json
{
	"scripts": { "build": "echo Building"}
}

然后我们可以构建

$ ./mvnw generate-resources

你将看到结果是一个新目录

$ ls -d node*
node

npm 像这样在本地安装时,有一个快速的方法从命令行运行它非常有用。所以一旦你有了 Node.js,就可以通过在本地创建一个脚本来简化操作

$ cat > npm
#!/bin/sh
cd $(dirname $0)
PATH="$PWD/node/":$PATH
node "node/node_modules/npm/bin/npm-cli.js" "$@"

使其可执行并试运行

$ chmod +x npm
$ ./npm install

up to date, audited 1 package in 211ms

found 0 vulnerabilities

添加 NPM 包

现在我们准备好构建一些东西了,让我们用之前 Webjars 中所有的依赖项来设置 package.json

{
    "name": "js-demo",
    "version": "0.0.1",
    "dependencies": {
        "@hotwired/stimulus": "^3.0.1",
        "@hotwired/turbo": "^7.1.0",
        "@popperjs/core": "^2.10.1",
        "bootstrap": "^5.1.3",
        "chart.js": "^3.6.0",
        "@springio/utils": "^1.0.5",
        "es-module-shims": "^1.3.0"
    },
    "scripts": {
        "build": "echo Building"
    }
}

运行 ./npm install(或 ./mvnw generate-resources)会将这些依赖项下载到 node_modules

$ ./npm install

added 7 packages, and audited 8 packages in 8s

2 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
$ ls node_modules/
@hotwired  @popperjs  @springio  bootstrap  chart.js  es-module-shims

将所有下载和生成的代码添加到你的 .gitignore 中是可以的(即 node/node_modules/package-lock.json)。

使用 Rollup 构建

Bootstrap 的维护者使用 Rollup 来打包他们的代码,所以这看起来是个不错的选择。它做得非常好的一点是“tree shaking”,可以减少你需要随应用程序一起发布的 Javascript 代码量。你可以随意尝试其他工具。要开始使用 Rollup,我们需要在 package.json 中添加一些开发依赖项和一个新的构建脚本

{
    ...
    "devDependencies": {
        "rollup": "^2.60.2",
        "rollup-plugin-node-resolve": "^2.0.0"
    },
    "scripts": {
        "build": "rollup -c"
    }
}

Rollup 有自己的配置文件,下面是一个将本地 Javascript 源代码打包到应用程序中,并在运行时从 /index.js 提供 Javascript 的配置。这是 rollup.config.js

import resolve from 'rollup-plugin-node-resolve';

export default {
	input: 'src/main/js/index.js',
	output: {
	  file: 'target/classes/static/index.js',
	  format: 'esm'
	},
	plugins: [
		resolve({
			esm: true,
			main: true,
			browser: true
		  })
	]
};

所以如果我们把所有的 Javascript 都移到 src/main/js/index.js 中,那么 index.html 中就只需要一个 <script> 标签了,例如放在 <body> 的末尾

<script type="module">
import '/index.js';
</script>

我们暂时保留 CSS,稍后可以处理它的本地构建。所以在 index.js 中,我们将所有 <script> 标签的内容合并在一起(或者我们可以将其拆分成模块并导入)

import 'bootstrap';
import '@hotwired/turbo';
import '@springio/utils';
import { Application, Controller } from '@hotwired/stimulus';
import { Chart, BarController, BarElement, PieController, ArcElement, LinearScale, ategoryScale, Title, Legend } from 'chart.js';

Turbo.connectStreamSource(new EventSource("/stream"))
window.Stimulus = Application.start();

Chart.register(BarController, BarElement, PieController, ArcElement, LinearScale, CategoryScale, itle, Legend);

Stimulus.register("hello", class extends Controller {
	...
});

Stimulus.register("chart", class extends Controller {
	...
});

如果我们构建并运行应用程序,一切都应该正常工作,Rollup 会在 target/classes/static 中创建一个新的 index.js,可执行 JAR 将会在这里获取它。由于 Rollup 中“resolve”插件的作用,新的 index.js 包含了运行我们应用程序所需的所有代码。如果任何依赖项被打包成一个合适的 ESM bundle,Rollup 将能够剔除其中未使用的部分。这至少对 Hotwired Stimulus 有效,而大多数其他的依赖项会被整体包含进来,但结果仍然只有 750K(大部分是 Bootstrap)

$ ls -l target/classes/static/index.js
-rw-r--r-- 1 dsyer dsyer 768778 Dec 14 09:34 target/classes/static/index.js

浏览器只需要下载一次,这在服务器使用 HTTP 1.1 时是一个优势(HTTP 2 有些改变),这意味着可执行 JAR 不会因为从未用到的东西而臃肿。Rollup 还有其他插件选项可以压缩 Javascript,我们将在下一节看到其中的一些。

使用 Sass 构建 CSS

到目前为止,我们使用了打包在某些 NPM 库中的纯 CSS。大多数应用程序需要自己的样式表,开发人员更喜欢使用某种形式的模板库和构建时工具来编译成 CSS。最流行的此类工具(但不是唯一的)是 Sass。Bootstrap 使用它,实际上也将器源文件打包在 NPM bundle 中,这样你就可以根据自己的需求扩展和调整 Bootstrap 样式。

我们可以通过为我们的应用程序构建 CSS 来了解它是如何工作的,即使我们没有做太多定制。首先在 NPM 中添加一些工具依赖项

$ ./npm install --save-dev rollup-plugin-scss rollup-plugin-postcss sass

这会在 package.json 中产生一些新条目

{
    ...
    "devDependencies": {
        "rollup": "^2.60.2",
        "rollup-plugin-node-resolve": "^2.0.0",
        "rollup-plugin-postcss": "^0.2.0",
        "rollup-plugin-scss": "^3.0.0",
        "sass": "^1.44.0"
    },
    ...
}

这意味着我们可以更新 rollup.config.js 来使用这些新工具

import resolve from "rollup-plugin-node-resolve";
import scss from "rollup-plugin-scss";
import postcss from "rollup-plugin-postcss";

export default {
  input: "src/main/js/index.js",
  output: {
    file: "target/classes/static/index.js",
    format: "esm",
  },
  plugins: [
    resolve({
      esm: true,
      main: true,
      browser: true,
    }),
    scss(),
    postcss(),
  ],
};

CSS 处理器查找的位置与主输入文件相同,所以我们只需在 src/main/js 中创建一个 style.scss 并导入 Bootstrap 代码

@import 'bootstrap/scss/bootstrap';

如果我们真的要这样做,SCSS 中的自定义内容将紧随其后。然后在 index.js 中,我们为这个文件和 Spring utils 库添加导入

import './style.scss';
import '@springio/utils/style.css';
...

然后重新构建。这将创建一个新的 index.css 文件(与主输入 Javascript 的文件名相同),然后我们就可以在 index.html<head> 中链接到它

<head>
	...
	<link rel="stylesheet" type="text/css" href="index.css" />
</head>COPY

就是这样。我们现在有一个 index.js 脚本驱动我们 Turbo 示例的所有 Javascript 和 CSS,并且现在可以移除 pom.xml 中所有剩余的 Webjars 依赖项了。

使用 Node.js 打包 React 应用

最后,我们可以将相同的想法应用到 react-webjars 示例中,移除 Webjars 并将 Javascript 和 CSS 提取到单独的源文件中。这样,我们也可以最终摆脱有点问题的 @babel/standalone。我们可以从 react-webjars 示例开始,像上面那样添加 Frontend Plugin(或者以其他方式获取 Node.js),然后手动或通过 npm CLI 创建一个 package.json。我们需要 React 依赖项,以及 Babel 的构建时工具。最终结果如下所示

{
    "name": "js-demo",
    "version": "0.0.1",
    "dependencies": {
        "@popperjs/core": "^2.10.1",
        "@springio/utils": "^1.0.4",
        "bootstrap": "^5.1.3",
        "chart.js": "^3.6.0",
        "react": "^17.0.2",
        "react-dom": "^17.0.2"
    },
    "devDependencies": {
        "@babel/core": "^7.16.0",
        "@babel/preset-env": "^7.16.0",
        "@babel/preset-react": "^7.16.0",
        "@rollup/plugin-babel": "^5.3.0",
        "@rollup/plugin-commonjs": "^21.0.1",
        "@rollup/plugin-node-resolve": "^13.0.6",
        "@rollup/plugin-replace": "^3.0.0",
        "postcss": "^8.4.5",
        "rollup": "^2.60.2",
        "rollup-plugin-postcss": "^4.0.2",
        "rollup-plugin-scss": "^3.0.0",
        "sass": "^1.44.0",
        "styled-jsx": "^4.0.1"
    },
    "scripts": {
        "build": "rollup -c"
    }
}

我们需要 commonjs 插件,因为 React 没有打包成 ESM,并且如果不进行一些转换,导入将无法工作。Babel 工具附带一个配置文件 .babelrc,我们用它来告诉 Babel 如何构建 JSX 和 React 组件

{
        "presets": ["@babel/preset-env", "@babel/preset-react"],
        "plugins": ["styled-jsx/babel"]
}

有了这些构建工具,我们可以将 index.html 中的所有 Javascript 提取出来,放到 src/main/resources/static/index.js 中。这几乎就是复制粘贴,但我们需要添加 CSS 导入

import './style.scss';
import '@springio/utils/style.css';

以及从 React 导入的代码看起来像这样

import React from 'react';
import ReactDOM from 'react-dom';

你可以使用 npm run build(或 ./mvnw generate-resources)来构建它,它应该能工作 - 所有选项卡都有内容,所有按钮都能生成一些内容。

最后,我们只需要整理 index.html,使其只导入 index.jsindex.css,然后 Webjars 项目的所有功能都应该按预期工作了。

结论

客户端开发有很多可用的选择,而 Spring Boot 对它们几乎没有影响,所以你可以自由选择适合你的任何东西。本文的范围必然有限(我们确实无法从各个角度审视一切),但希望能够突出一些有趣的可能性。我个人最近在一些小型项目中使用 HTMX 后,对此非常喜欢,但一如既往,你的体验可能会有所不同。请在博客上评论或通过 Github 或愤怒小鸟应用发送反馈 - 很高兴听到大家的想法。例如,我们是否应该将本文发布到 spring.io 作为教程?

订阅 Spring 新闻通讯

订阅 Spring 新闻通讯,保持连接

订阅

领先一步

VMware 提供培训和认证,助你飞速进步。

了解更多

获取支持

Tanzu Spring 通过一份简单的订阅,提供对 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

近期活动

查看 Spring 社区的所有近期活动。

查看全部