使用 Spring Boot 应用程序进行客户端开发

工程技术 | Dave Syer | December 17, 2021 | ...

本文探讨了 Spring Boot 开发人员在其应用程序的客户端(浏览器端)使用 Javascript 和 CSS 的不同选项。计划的一部分是探索一些在 Spring Web 应用程序传统的服务器端渲染世界中配合良好的 Javascript 库。这些库对应用程序开发人员来说往往是轻量级的,因为它们允许你完全避免使用 Javascript,但仍然拥有漂亮渐进的“现代”UI。我们还查看了一些更“纯粹”的 Javascript 工具和框架。这可以说是一个范围,所以总而言之 (TL;DR),这里是示例应用的列表,大致按 Javascript 内容从低到高排序

  • htmx: HTMX 是一个库,允许你直接从 HTML 访问现代浏览器功能,而非使用 javascript。它非常易于使用,并且非常适合服务器端渲染,因为它通过直接用远程响应替换 DOM 的一部分来实现。它似乎在 Python 社区中被广泛使用和赞赏。

  • turbo: Hotwired(包括 Turbo 和 Stimulus)。Turbo 有点类似于 HTMX。它在 Ruby on Rails 中得到广泛使用和良好支持。Stimulus 是一个轻量级库,可用于实现少量最好在客户端执行的逻辑。

  • vue: Vue 也非常轻量级,并自称“渐进式”且“可增量采用”。它非常多用途,你可以使用少量 Javascript 来实现一些不错的功能,也可以深入使用并将其作为完整的框架。

  • react-webjars: 使用 React 框架,但无需 Javascript 构建或打包器。React 在这方面很好,因为它像 Vue 一样,允许你只在少量区域使用它,而无需接管整个源代码树。

  • nodejs: 类似于 turbo 示例,但使用 Node.js 来构建和打包脚本,而非 Webjars。如果你认真使用 React,你最终可能会采取这种或类似的方法。这里的目标是使用 Maven 驱动构建,至少是可选地,这样正常的 Spring Boot 应用程序开发过程就可以工作。Gradle 也会以同样的方式工作。

  • react: 是 react-webjars 示例,但包含了来自 nodejs 示例的 Javascript 构建步骤。

这里还有另一个使用 Spring Boot 和 HTMX 的示例在这里。如果你想了解更多关于 React 和 Spring 的信息,Spring 网站上有一个教程。Spring 网站上的另一个教程也介绍了关于 Angular 的内容,以及相关的入门内容在这里。如果你对 Angular 和 Spring Boot 感兴趣,Matt Raible 写了一本Minibookspring.io 网站(源代码)也使用了 Node.js 构建,并使用一套完全不同的工具链和库。另一种备选方法来源是 JHipster,它也支持这里使用的一些库。最后,Petclinic 虽然它没有 Javascript,但确实有一些客户端代码在样式表中,并有一个由 Maven 驱动的构建过程。

目录

入门

所有示例都可以使用标准的 Spring Boot 流程构建和运行(例如,请参阅此入门指南)。Maven wrapper 位于父目录中,因此,从每个示例的命令行中,你可以运行 ../mvnw spring-boot:run 运行应用程序,或运行 ../mvnw package 获取可执行 JAR。例如

$ cd htmx
$ ../mvnw package
$ java -jar target/js-demo-htmx-0.0.1.jar

Github 项目Codespaces 运行良好,并且大部分是在本地使用 VSCode 开发的。你可以自由使用你喜欢的任何 IDE,它们都应该能正常工作。

缩小选择范围

浏览器应用程序开发是一个选择众多且不断变化的巨大领域。不可能在一个连贯的画面中呈现所有这些选项,因此我们有意限制了我们关注的工具和框架的范围。我们的出发点是倾向于寻找轻量级、易于使用或至少可以增量采用的工具。还有之前提到的,倾向于与服务器端渲染器配合良好的库——那些处理 HTML 片段和子树的库。此外,我们尽可能使用了 Javascript 模块 (ESM),因为现在大多数浏览器都支持它。然而,大多数发布了可供 import 导入模块的库,也有你可以 require 引入的等效打包文件,因此如果你愿意,总是可以使用后者。

许多示例使用 Webjars 将 Javascript(和 CSS)资产交付给客户端。这对于具有 Java 后端的应用程序来说非常简单合理。不过,并非所有示例都使用了 Webjars,并且不难将使用 Webjars 的示例转换为使用 CDN(例如 unpkg.comjsdelivr.com),或使用构建时 Node.js 打包器。这里包含打包器的示例使用了 Rollup,但你也可以使用 Webpack,例如。它们还使用了纯粹的 NPM,而非 YarnGulp,它们都是流行的选择。所有示例都使用了 Bootstrap 来处理 CSS,但还有其他选择。

服务器端也可以做出选择。我们使用了 Spring Webflux,但 Spring MVC 也会以同样的方式工作。我们使用了 Maven 作为构建工具,但使用 Gradle 也可以轻松实现同样的目标。所有示例实际上都有一个静态首页(甚至不是通过模板渲染的),但它们都有一些动态内容,我们选择了 JMustache 来实现。 Thymeleaf(以及其他模板引擎)也会同样好用。事实上,Thymeleaf 内建支持片段,这在动态更新页面部分内容时非常有用,这也是我们的目标之一。通过一些工作,你(可能)也可以用 Mustache 实现同样的功能,但在这些示例中我们不需要它。

创建一个新应用程序

要开始使用 Spring Boot 和客户端开发,让我们从头开始,从 Spring Initializr 创建一个空应用程序。你可以访问该网站并下载一个包含 Web 依赖(选择 Webflux 或 WebMVC)的项目,并在你的 IDE 中打开它。或者,你可以使用 curl 从命令行生成项目,从一个空目录开始

$ curl https://start.spring.io/starter.tgz -d dependencies=webflux -d name=js-demo | tar -xzvf -

我们可以在 src/main/resources/static/index.html 添加一个非常基本的静态首页

<!doctype html>
<html lang="en">

<head>
	<meta charset="utf-8" />
	<meta http-equiv="X-UA-Compatible" content="IE=edge" />
	<title>Demo</title>
	<meta name="description" content="" />
	<meta name="viewport" content="width=device-width" />
	<base href="/" />
</head>

<body>
	<header>
		<h1>Demo</h1>
	</header>
	<main>
		<div class="container">
			<div id="greeting">Hello World</div>
		</div>
	</main>

</body>

</html>

然后运行应用程序

$ ./mvnw package
$ java target/js-demo-0.0.1-SNAPSHOT.jar

你可以在 localhost:8080 上看到结果。

Webjars

要开始构建客户端功能,让我们从 Bootstrap 现成地添加一些 CSS。我们可以使用 CDN,例如像这样在 index.html

...
<head>
	...
	<link rel="stylesheet" type="text/css" href="https://unpkgs.com/bootstrap/dist/css/bootstrap.min.css" />
</head>
...

如果你想快速入门的话,这非常方便。对于某些应用程序来说,这可能就足够了。这里我们采取一种不同的方法,这使我们的应用程序更自包含,并且与我们习惯的 Java 工具链很好地契合——即使用 Webjar 并将 Bootstrap 库打包到我们的 JAR 文件中。为此,我们需要在 pom.xml 中添加一些依赖项

<dependency>
	<groupId>org.webjars</groupId>
	<artifactId>webjars-locator-core</artifactId>
</dependency>
<dependency>
	<groupId>org.webjars.npm</groupId>
	<artifactId>bootstrap</artifactId>
	<version>5.1.3</version>
</dependency>

然后在 index.html 中,我们不使用 CDN,而是使用应用程序内部的资源路径

...
<head>
	...
	<link rel="stylesheet" type="text/css" href="/webjars/bootstrap/dist/css/bootstrap.min.css" />
</head>
...

如果你重新构建和/或重新运行应用程序,你将看到漂亮的原始 Bootstrap 样式,而非无聊的默认浏览器样式。Spring Boot 使用 webjars-locator-core 来定位类路径中资源的版本和确切位置,然后浏览器将该样式表引入页面。

看看 JavaScript 的应用

Bootstrap 也是一个 Javascript 库,因此我们可以通过利用其 JavaScript 功能来更充分地使用它。我们可以在 index.html 中添加 Bootstrap 库,像这样

...
<head>
...
	<script src="/webjars/bootstrap/dist/js/bootstrap.min.js"></script>
</head>
...

它目前还没有做任何可见的事情,但你可以通过使用浏览器开发者工具视图(Chrome 或 Firefox 中的 F12)来验证它是否已被浏览器加载。

我们在介绍中说过,只要可用,我们就会使用 ESM 模块,而 Bootstrap 就有一个,因此让我们来实现它。将 index.html 中的 <script> 标签替换为

<script type="importmap">
	{
		"imports": {
			"bootstrap": "/webjars/bootstrap/dist/js/bootstrap.esm.min.js"
		}
	}
</script>
<script type="module">
	import 'bootstrap';
</script>

这包含两个部分:一个“importmap”和一个“module”。import map 是浏览器的一项功能,允许你按名称引用 ESM 模块,将名称映射到资源。如果你现在运行应用程序并在浏览器中加载它,控制台中应该会出现错误,因为 Bootstrap 的 ESM 打包文件依赖于 PopperJS

Uncaught TypeError: Failed to resolve module specifier "@popperjs/core". Relative references must start with either "/", "./", or "../".

PopperJS 不是 Bootstrap Webjar 的强制传递依赖项,所以我们必须将其包含在我们的 pom.xml

<dependency>
	<groupId>org.webjars.npm</groupId>
	<artifactId>popperjs__core</artifactId>
	<version>2.10.1</version>
</dependency>

(对于带命名空间的 NPM 模块名称,Webjars 使用“__”作为中缀,而非“@”作为前缀。)然后可以将其添加到 import map 中

<script type="importmap">
	{
		"imports": {
			"bootstrap": "/webjars/bootstrap/dist/js/bootstrap.esm.min.js",
			"@popperjs/core": "/webjars/popperjs__core/lib/index.js"
		}
	}
</script>

这将修复控制台错误。

规范化资源路径

Webjar 内部的资源路径(例如 /bootstrap/dist/js/bootstrap.esm.min.js)未标准化——没有命名约定可以让你猜测 Webjar 内部 ESM 模块的位置,或 NPM 模块,这与 Webjar 内部类似。但 NPM 模块中存在一些约定,可以实现自动化:大多数模块都有一个 package.json 文件,其中包含“module”字段。例如,从 Bootstrap 中你可以找到版本和模块资源路径

{
  "name": "bootstrap",
  "description": "The most popular front-end framework for developing responsive, mobile first projects on the web.",
  "version": "5.1.3",
...
  "module": "dist/js/bootstrap.esm.js",
...
}

CDN,例如 unpkg.com 或 jsdelivr.com 利用了这些信息,因此当你只知道 ESM 模块名称时可以使用它们。例如,这应该会起作用

<script type="importmap">
	{
		"imports": {
			"bootstrap": "https://unpkg.com/bootstrap",
			"@popperjs/core": "https://unpkg.com/@popperjs/core"
		}
	}
</script>

如果能对 /webjars 资源路径做同样的事情就更好了。这就是所有示例中的 NpmVersionResolver 所做的事情。如果你不使用 Webjars 并能使用 CDN,你就不需要它,如果你不介意手动打开所有 package.json 文件并查找模块路径,你也不需要它。但如果不需要考虑这些,那就更方便了。Spring Boot 中有一个功能请求要求将此功能包含进来。NpmVersionResolver 的另一个功能是它知道 Webjars 元数据,因此它可以从类路径中解析每个 Webjar 的版本,这样我们就不需要 `webjars-locator-core` 依赖项了(Spring Framework 中有一个开放问题旨在添加此功能)。

所以在示例中,import map 像这样

<script type="importmap">
	{
		"imports": {
			"bootstrap": "/npm/bootstrap",
			"@popperjs/core": "/npm/@popperjs/core"
		}
	}
</script>

你只需要知道 NPM 模块名称,解析器会找出如何找到解析到 ESM 打包文件的资源。如果存在 Webjar,它会使用 Webjar,否则会重定向到 CDN。

注意:大多数现代浏览器都支持模块和模块映射。不支持的浏览器可以在我们的应用程序中使用,代价是添加一个shim 库。示例中已经包含了它。

添加标签页

既然我们已经把它全部设置好了,不妨使用 Bootstrap 的样式。那么来一些带有内容的标签页,以及一两个按钮来按怎么样?听起来不错。首先是 index.html 中包含标签页链接的 <header/>

<header>
	<h1>Demo</h1>
	<nav class="nav nav-tabs">
		<a class="nav-link active" data-bs-toggle="tab" data-bs-target="#message" href="#">Message</a>
		<a class="nav-link" data-bs-toggle="tab" data-bs-target="#stream" href="#">Stream</a>
	</nav>
</header>

第二个(默认非激活的)标签页称为“stream”,因为部分示例将探讨 Server Sent Event 流的使用。标签页内容在 <main/> 部分看起来像这样

<main>
	<div class="tab-content">
		<div class="tab-pane fade show active" id="message" role="tabpanel">
			<div class="container">
				<div id="greeting">Hello World!</div>
			</div>
		</div>
		<div class="tab-pane fade" id="stream" role="tabpanel">
			<div class="container">
				<div id="load">Nothing here yet...</div>
			</div>
		</div>
	</div>
</main>

注意其中一个标签页是“active”状态,并且两者都有与 header 中 data-bs-target 属性匹配的 id。这就是为什么我们需要一些 Javascript——处理标签页上的点击事件,以便显示或隐藏正确的内容。Bootstrap 文档提供了大量不同标签页样式和布局的示例。这里基本功能的一个好处是它们可以在像手机这样的窄屏设备上自动渲染为下拉菜单(需要对 <nav/> 中的 class 属性进行一些小改动——你可以看看 Petclinic 了解如何实现)。在浏览器中看起来像这样

tabs

当然,如果你点击“Stream”标签页,它会显示一些不同的内容。

使用 HTMX 实现动态内容

我们可以非常快速地使用 HTMX 添加一些动态内容。首先我们需要该 Javascript 库,所以我们将其添加为一个 Webjar

<dependency>
	<groupId>org.webjars.npm</groupId>
	<artifactId>htmx.org</artifactId>
	<version>1.6.0</version>
</dependency>

然后将其导入到 index.html

<script type="importmap">
	{
		"imports": {
			"bootstrap": "/npm/bootstrap",
			"@popperjs/core": "/npm/@popperjs/core",
			"htmx": "/npm/htmx.org"
		}
	}
</script>
<script type="module">
	import 'bootstrap';
	import 'htmx';
</script>

然后我们可以将问候语从“Hello World”改为来自用户输入的内容。让我们向主标签页添加一个输入字段和一个按钮

<div class="container">
	<div id="greeting">Hello World</div>
	<input id="name" name="value" type="text" />
	<button hx-post="/greet" hx-target="#greeting" hx-include="#name">Greet</button>
</div>

输入字段没有任何修饰,按钮有一些 hx-* 属性,这些属性会被 HTMX 库捕获并用于增强页面。这些属性表示“当用户点击此按钮时,发送一个 POST 请求到 /greet,请求中包含 'name' 的值,并通过替换 'greeting' 元素的内容来渲染结果”。如果用户在输入字段中输入“Foo”,该 POST 请求的表单编码请求体将是 value=Foo,因为“value”是 `#name` 标识的字段的名称。

然后我们只需要在后端有一个 /greet 资源

@SpringBootApplication
@RestController
public class JsDemoApplication {

	@PostMapping("/greet")
	public String greet(@ModelAttribute Greeting values) {
		return "Hello " + values.getValue() + "!";
	}

	...

	static class Greeting {
		private String value;

		public String getValue() {
			return value;
		}

		public void setValue(String value) {
			this.value = value;
		}
	}
}

Spring 会将传入请求中的“value”参数绑定到 Greeting 对象,我们将其转换为文本,然后注入到页面上的 <div id="greeting"/> 中。你可以像这样使用 HTMX 注入纯文本,或者整个 HTML 片段。或者你可以追加(或前置)到现有元素列表,例如表格中的行,或者列表中的项。

这是你可以做的另一件事

<div class="container">
	<div id="auth" hx-trigger="load" hx-get="/user">
		Unauthenticated
	</div>
	...
</div>

这会在页面加载时向 /user 发送一个 GET 请求,并替换该元素的内容。示例应用程序有这个端点,并且它返回“Fred”,所以你看到它渲染出来是这样

user

SSE 流

使用 HTMX 还可以做许多其他巧妙的事情,其中之一就是渲染 Server Sent Event (SSE) 流。首先我们会在后端应用程序中添加一个端点

@SpringBootApplication
@RestController
public class JsDemoApplication {

	@GetMapping(path = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
	public Flux<String> stream() {
		return Flux.interval(Duration.ofSeconds(5)).map(
			value -> value + ":" + System.currentTimeMillis()
		);
	}

	...
}

因此我们有一个消息流,Spring 通过端点映射上的 produces 属性来渲染它

$ curl localhost:8080/stream
data:0:1639472861461

data:1:1639472866461

data:2:1639472871461

...

HTMX 可以将这些消息注入到我们的页面中。以下是在 index.html 中实现的方法,添加到“stream”标签页

<div class="container">
	<div id="load" hx-sse="connect:/stream">
		<div id="load" hx-sse="swap:message"></div>
	</div>
</div>

我们使用 connect:/stream 属性连接到 /stream,然后使用 swap:message 提取事件数据。实际上,“message”是默认的事件类型,但 SSE 负载也可以通过包含以 event: 开头的行来指定其他类型,因此你可以有一个多路复用多种不同事件类型的流,并让它们以不同的方式影响 HTML。

我们后端上面的端点非常简单:它只发送回纯字符串,但它可以做得更多。例如,它可以发送回 HTML 片段,这些片段将被注入到页面中。示例应用程序使用一个名为 CompositeViewRenderer 的自定义 Spring Webflux 组件来实现(这是一个作为功能请求在此提交的),其中 @Contoller 方法可以返回 Flux<Rendering>(在 MVC 中将是 Flux<ModelAndView>)。这使得端点可以流式传输动态视图

@GetMapping(path = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Rendering> stream() {
	return Flux.interval(Duration.ofSeconds(5)).map(value -> Rendering.view("time")
			.modelAttribute("value", value)
			.modelAttribute("time", System.currentTimeMillis()).build());
}

这与一个名为“time”的视图配对,并且正常的 Spring 机制会渲染模型

$ curl localhost:8080/stream
data:<div>Index: 0, Time: 1639474490435</div>

data:<div>Index: 1, Time: 1639474495435</div>

data:<div>Index: 2, Time: 1639474500435</div>

...

HTML 来自模板

<div>Index: {{value}}, Time: {{time}}</div>

这之所以自动工作,是因为我们在 pom.xml 中包含了 JMustache 在类路径上

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-mustache</artifactId>
</dependency>

动态替换和增强 HTML

HTMX 还可以做更多。除了 SSE 流之外,端点可以返回一个常规的 HTTP 响应,但将其组织成一组要在页面上替换的元素。HTMX 将这称为“带外(out of band)”替换,因为它涉及增强页面上那些与触发下载的元素不同的元素的内容。

为了看到它的效果,我们可以添加另一个带有 HTMX 功能内容的标签页

<div class="tab-pane fade" id="test" role="tabpanel">
	<div class="container">
		<div id="hello"></div>
		<div id="world"></div>
		<button class="btn btn-primary" hx-get="/test" hx-swap="none">Fetch</button>
	</div>
</div>

别忘了添加一个导航链接,以便用户能看到这个标签页

<nav class="nav nav-tabs">
	...
	<a class="nav-link" data-bs-toggle="tab" data-bs-target="#test" href="#">Test</a>
</nav>
...

新标签页有一个按钮,可以从 /test 获取动态内容,它还设置了两个空的 div 元素“hello”和“world”来接收内容。hx-swap="none" 很重要——它告诉 HTMX 不要替换触发 GET 请求的元素的内容。

如果我们有一个返回如下内容的端点

$ curl localhost:8080/test
<div id="hello" hx-swap-oob="true">Hello</div>
<div id="world" hx-swap-oob="true">World</div>

那么页面会像这样渲染(在点击“Fetch”按钮后)

test

这个端点的简单实现会是

@GetMapping(path = "/test")
public String test() {
	return "<div id=\"hello\" hx-swap-oob=\"true\">Hello</div>\n"
		+ "<div id=\"world\" hx-swap-oob=\"true\">World</div>";
}

或(使用自定义视图渲染器)

@GetMapping(path = "/test")
public Flux<Rendering> test() {
	return Flux.just(
			Rendering.view("test").modelAttribute("id", "hello")
				.modelAttribute("value", "Hello").build(),
			Rendering.view("test").modelAttribute("id", "world")
				.modelAttribute("value", "World").build());
}

使用模板文件“test.mustache”

<div id="{{id}}" hx-swap-oob="true">{{value}}</div>

HTMX 做的另一件事是“增强(boost)”页面中的所有链接和表单动作,以便它们自动使用 XHR 请求而不是完全刷新页面来工作。这是一种非常简单的方式,可以按功能划分页面,并仅更新你需要的部分。你还可以轻松地以“渐进式”方式实现这一点——即,如果 Javascript 被禁用,应用程序通过完全刷新页面工作,但如果 Javascript 被启用,则会更快,感觉更“现代”。

使用 Hotwired 实现动态内容

Hotwired 与 HTMX 有些相似,因此让我们替换这些库并让应用程序工作。移除 HTMX 并将 Hotwired (Turbo) 添加到应用程序中。在 pom.xml

<dependency>
	<groupId>org.webjars.npm</groupId>
	<artifactId>hotwired__turbo</artifactId>
	<version>7.1.0</version>
</dependency>

然后我们可以通过添加 import map 将其导入到我们的页面中

<script type="importmap">
	{
		"imports": {
			...
			"@hotwired/turbo": "/npm/@hotwired/turbo"
		}
	}
</script>

以及一个用于导入该库的脚本

<script type="module">
	import * as Turbo from '@hotwired/turbo';
</script>

动态替换和增强 HTML 1

这让我们可以实现我们之前用 HTMX 完成的动态内容功能,只需对 HTML 进行一些更改。这是 index.html 中的“test”标签页

<div class="tab-pane fade" id="test" role="tabpanel">
	<turbo-frame id="turbo">
		<div class="container" id="frame">
			<div id="hello"></div>
			<div id="world"></div>
			<form action="/test" method="post">
				<button class="btn btn-primary" type="submit">Fetch</button>
			</form>
		</div>
	</turbo-frame>
</div>

Turbo 的工作方式与 HTMX 略有不同。<turbo-frame/> 告诉 Turbo 其内部的所有内容都将得到增强(有点像 HTMX 的增强)。要替换按钮点击时的“hello”和“world”元素,我们需要按钮通过表单发送一个 POST 请求,而不仅仅是普通的 GET(Turbo 在这方面比 HTMX 更具倾向性)。然后 /test 端点会发送回一些包含我们想要替换的内容模板的 <turbo-stream/> 片段

<turbo-stream action="replace" target="hello">
        <template>
                <div id="hello">Hi Hello!</div>
        </template>
</turbo-frame>

<turbo-stream action="replace" target="world">
        <template>
                <div id="world">Hi World!</div>
        </template>
</turbo-frame>

为了让 Turbo 注意到传入的 <turbo-stream/>,我们需要 /test 端点返回一个自定义的 Content-Type: text/vnd.turbo-stream.html,所以实现看起来像这样

@PostMapping(path = "/test", produces = "text/vnd.turbo-stream.html")
public Flux<Rendering> test() {
	return ...;
}

为了处理自定义内容类型,我们需要一个自定义视图解析器

@Bean
@ConditionalOnMissingBean
MustacheViewResolver mustacheViewResolver(Compiler mustacheCompiler, MustacheProperties mustache) {
	MustacheViewResolver resolver = new MustacheViewResolver(mustacheCompiler);
	resolver.setPrefix(mustache.getPrefix());
	resolver.setSuffix(mustache.getSuffix());
	resolver.setViewNames(mustache.getViewNames());
	resolver.setRequestContextAttribute(mustache.getRequestContextAttribute());
	resolver.setCharset(mustache.getCharsetName());
	resolver.setOrder(Ordered.LOWEST_PRECEDENCE - 10);
	resolver.setSupportedMediaTypes(
			Arrays.asList(MediaType.TEXT_HTML, MediaType.valueOf("text/vnd.turbo-stream.html")));
	return resolver;
}

以上是 Spring Boot 自动定义的 @Bean 的复制,但增加了一个额外的支持媒体类型。有一个开放的功能请求允许通过 application.properties 来完成此操作。

点击“Fetch”按钮的结果应该是像之前一样渲染“Hello”和“World”。

Server Sent Events (SSE)

Turbo 也内建支持 SSE 渲染,但这次事件数据必须包含 <turbo-stream/> 元素。例如

$ curl localhost:8080/stream
data:<turbo-stream action="replace" target="load">
data:   <template>
data:           <div id="load">Index: 0, Time: 1639482422822</div>
data:   </template>
data:</turbo-stream>

data:<turbo-stream action="replace" target="load">
data:   <template>
data:           <div id="load">Index: 1, Time: 1639482427821</div>
data:   </template>
data:</turbo-stream>

然后“stream”标签页只需要一个空的 <div id="load"></div>,Turbo 就会按照要求执行(替换由“load”标识的元素)

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

Turbo 和 HTMX 都允许你通过 id 或 CSS 样式匹配器来定位要实现动态内容的元素,对于常规 HTTP 响应和 SSE 流都适用。

Stimulus

Hotwired 中还有一个库,叫做 Stimulus,它允许你使用少量 Javascript 添加更多定制的行为。如果你的后端服务中有端点返回 JSON 而非 HTML,例如,它会派上用场。我们可以通过将 Stimulus 添加为依赖项来开始使用它

<dependency>
	<groupId>org.webjars.npm</groupId>
	<artifactId>hotwired__stimulus</artifactId>
	<version>3.0.1</version>
</dependency>

并在 index.html 中添加一个 import map

<script type="importmap">
	{
		"imports": {
			...
			"@hotwired/stimulus": "/npm/@hotwired/stimulus"
		}
	}
</script>

然后我们就可以着手替换之前用 HTMX 实现的主“message”标签页的那部分内容了。这是仅包含按钮和自定义消息的标签页内容

<div class="tab-pane fade show active" id="message" role="tabpanel">
	<div class="container" data-controller="hello">
		<div id="greeting" data-hello-target="output">Hello World</div>
		<input id="name" name="value" type="text" data-hello-target="name" />
		<button class="btn btn-primary" data-action="click->hello#greet">Greet</button>
	</div>
</div>

注意 data-* 属性。在容器 <div> 上声明了一个我们需要实现的 controller(“hello”)。按钮元素中的动作表示“当此按钮被点击时,调用‘hello’ controller 上的函数‘greet’”。还有一些装饰用来标识哪些元素是 controller 的输入和输出(即 data-hello-target 属性)。实现自定义消息渲染器的 Javascript 如下所示:

<script type="module">
	import { Application, Controller } from '@hotwired/stimulus';
	window.Stimulus = Application.start();

	Stimulus.register("hello", class extends Controller {
		static targets = ["name", "output"]
		greet() {
			this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!`;
		};
	});
</script>

Controller 使用 HTML 中的 data-controller 名称注册,并且有一个 targets 字段,列举了它想要定位的所有元素的 ID。然后,它可以通过命名约定来引用它们,例如,“output”在 controller 中显示为对名为 outputTarget 的 DOM 元素的引用。

你可以在 Controller 中做任何你想做的事情,所以例如,你可以从后端拉取一些内容。turbo 示例通过从 /user 端点拉取一个字符串并将其插入到“auth”目标元素中来实现这一点。

<div class="container" data-controller="hello">
	<div id="auth" data-hello-target="auth"></div>
	...
</div>

配合相应的 Javascript

Stimulus.register("hello", class extends Controller {
	static targets = ["name", "output", "auth"]
	initialize() {
		let hello = this;
		fetch("/user").then(response => {
			response.json().then(data => {
				hello.authTarget.textContent = `Logged in as: ${data.name}`;
			});
		});
	}
	...
});

添加一些图表

我们可以有趣地添加其他 Javascript 库,例如一些漂亮的图形。这是 index.html 中的一个新标签页(记得也要添加 <nav/> 链接)。

<div class="tab-pane fade" id="chart" role="tabpanel" data-controller="chart">
	<div class="container">
		<canvas data-chart-target="canvas"></canvas>
	</div>
	<div class="container">
		<button class="btn btn-primary" data-action="click->chart#clear">Clear</button>
		<button class="btn btn-primary" data-action="click->chart#bar">Bar</button>
	</div>
</div>

它有一个空的 <canvas/>,我们可以使用 Chart.js 用柱状图填充它。为此,我们在上面的 HTML 中声明了一个名为“chart”的 controller,并使用 data-*-target 标记了其目标元素。所以,让我们先将 Chart.js 添加到应用程序中。在 pom.xml 中:

<dependency>
	<groupId>org.webjars.npm</groupId>
	<artifactId>chart.js</artifactId>
	<version>3.6.0</version>
</dependency>

并在 index.html 中添加一个 import map 和一些用于渲染图表的 Javascript:

<script type="importmap">
{
	"imports": {
		...
		"chart.js": "/npm/chart.js"
	}
}
</script>

以及实现 HTML 中按钮的“bar”和“clear”操作的新 controller:

import { Chart, BarController, BarElement, LinearScale, CategoryScale, Title, Legend } from 'chart.js';
Chart.register(BarController, BarElement, LinearScale, CategoryScale, Title, Legend);

Stimulus.register("chart", class extends Controller {
	static targets = ["canvas"]
	bar(type) {
		let chart = this;
		this.clear();
		fetch("/pops").then(response => {
			response.json().then(data => {
				data.type = "bar";
				chart.active = new Chart(chart.canvasTarget, data);
			});
		});;
		clear() {
			if (this.active) {
				this.active.destroy();
			}
		};
	};
});

为了支持这一点,我们需要一个 /pops 端点,其中包含一些图表数据(根据维基百科估算的各大洲世界人口)。

$ curl localhost:8080/pops | jq .
{
  "data": {
    "labels": [
      "Africa",
      "Asia",
      "Europe",
      "Latin America",
      "North America"
    ],
    "datasets": [
      {
        "backgroundColor": [
          "#3e95cd",
          "#8e5ea2",
          "#3cba9f",
          "#e8c3b9",
          "#c45850"
        ],
        "label": "Population (millions)",
        "data": [
          2478,
          5267,
          734,
          784,
          433
        ]
      }
    ]
  },
  "options": {
    "plugins": {
      "legend": {
        "display": false
      },
      "title": {
        "text": "Predicted world population (millions) in 2050",
        "display": true
      }
    }
  }
}

示例应用程序还有几个图表,都以不同的格式显示相同的数据。它们都由上面所示的同一端点提供服务。

@GetMapping("/pops")
@ResponseBody
public Chart bar() {
	return new Chart();
}

代码块隐藏

在 Spring 指南和参考文档中,我们经常看到按“类型”分段的代码块(例如 Maven vs. Gradle,或 XML vs. Java)。它们会显示一个选项是活动的,其余的被隐藏,如果用户点击另一个选项,不仅最近的代码片段会显示出来,文档中所有与点击匹配的代码片段都会同时显示。例如,如果用户点击“Gradle”,所有引用“Gradle”的代码片段都会同时激活。驱动此功能的 Javascript 存在多种形式,具体取决于哪个指南或项目在使用它,其中一种形式是一个 NPM 包 @springio/utils。它并非严格意义上的 ESM 模块,但我们仍然可以导入它并看到该功能起作用。以下是它在 index.html 中的样子:

<script type="importmap">
	{
		"imports": {
			...
			"@springio/utils": "/npm/@springio/utils"
		}
	}
</script>
<script type="module">
	...
	import '@springio/utils';
</script>

然后我们可以添加一个新标签页,其中包含一些“代码片段”(在这种情况下只是些垃圾内容)。

<div class="tab-pane fade" id="docs" role="tabpanel">
	<div class="container" title="Content">
		<div class="content primary"><div class="title">One</div><div class="content">Some content</div></div>
		<div class="content secondary"><div class="title">Two</div><div class="content">Secondary</div></div>
		<div class="content secondary"><div class="title">Three</div><div class="content">Third option</div></div>
	</div>
	<div class="container" title="Another">
		<div class="content primary"><div class="title">One</div><div class="content">Some more content</div></div>
		<div class="content secondary"><div class="title">Two</div><div class="content">Secondary stuff</div></div>
		<div class="content secondary"><div class="title">Three</div><div class="content">Third option again</div></div>
	</div>
</div>

如果用户选择“One”块类型,它看起来像这样:

one

驱动此行为的是 HTML 结构,其中一个元素被标记为“primary”,备用元素标记为“secondary”,然后在实际内容之前有一个嵌套的 class="title"。标题会被 Javascript 提取到按钮中。

使用 Vue 的动态内容

Vue 是一个轻量级的 Javascript 库,你可以少量使用也可以大量使用。要开始使用 Webjars,我们需要在 pom.xml 中添加依赖:

<dependency>
	<groupId>org.webjars.npm</groupId>
	<artifactId>vue</artifactId>
	<version>2.6.14</version>
</dependency>

并将其添加到 index.html 中的 import map 中(使用手动资源路径,因为 NPM 包中的“module”指向浏览器中不起作用的内容)。

<script type="importmap">
	{
		"imports": {
			...
			"vue": "/npm/vue/dist/vue.esm.browser.js"
		}
	}
</script>

然后我们可以编写一个组件并将其“挂载”到指定的元素中(这是 Vue 用户指南中的一个示例)。

<script type="module">
	import Vue from 'vue';

	const EventHandling = {
		data() {
			return {
				message: 'Hello Vue.js!'
			}
		},
		methods: {
			reverseMessage() {
				this.message = this.message
					.split('')
					.reverse()
					.join('')
			}
		}
	}

	new Vue(EventHandling).$mount("#event-handling");
</script>

为了接收动态内容,我们需要一个匹配 #event-handling 的元素,例如:

<div class="tab-pane fade" id="test" role="tabpanel">
	<div class="container" id="event-handling">
		<p>{{ message }}</p>
		<button class="btn btn-primary" v-on:click="reverseMessage">Reverse Message</button>
	</div>
</div>

因此,模板渲染发生在客户端,并通过 Vue 的 v-on 属性触发点击事件。

如果我们想用 Vue 替换 Hotwired,可以从主“message”标签页的内容开始。因此,我们可以用以下代码替换 Stimulus controller 的绑定,例如:

<div class="tab-pane fade show active" id="message" role="tabpanel">
	<div class="container">
		<div id="auth">
			{{user}}
		</div>
		<div id="greeting">{{greeting}}</div>
		<input id="name" name="value" type="text" v-model="name" />
		<button class="btn btn-primary" v-on:click="greet">Greet</button>
	</div>
</div>

然后通过 Vue 绑定 usergreeting 属性。

import Vue from 'vue';

const EventHandling = {
	data() {
		return {
			greeting: '',
			name: '',
			user: 'Unauthenticated'
		}
	},
	created: function () {
		let hello = this;
		fetch("/user").then(response => {
			response.json().then(data => {
				hello.user = `Logged in as: ${data.name}`;
			});
		});
	},
	methods: {
		greet() {
			this.greeting = `Hello, ${this.name}!`;
		},
	}
}

new Vue(EventHandling).$mount("#message");

created 钩子作为 Vue 组件生命周期的一部分运行,因此它不一定与 Stimulus 运行的时间完全相同,但已经足够接近了。

我们还可以用 Vue 替换图表选择器,然后就可以摆脱 Stimulus,看看是什么样子。这是图表标签页(基本与之前相同,但没有 controller 装饰):

<div class="tab-pane fade" id="chart" role="tabpanel">
	<div class="container">
		<canvas id="canvas"></canvas>
	</div>
	<div class="container">
		<button class="btn btn-primary" v-on:click="clear">Clear</button>
		<button class="btn btn-primary" v-on:click="bar">Bar</button>
	</div>
</div>

这是用于渲染图表的 Javascript 代码:

<script type="module">
	import Vue from 'vue';

	import { Chart, BarController, BarElement, LinearScale, CategoryScale, Title, Legend } from 'chart.js';
	Chart.register(BarController, BarElement, LinearScale, CategoryScale, Title, Legend);

	const ChartHandling = {
		methods: {
			clear() {
				if (this.active) {
					this.active.destroy();
				}
			},
			bar() {
				let chart = this;
				this.clear();
				fetch("/pops").then(response => {
					response.json().then(data => {
						data.type = "bar";
						chart.active = new Chart(document.getElementById("canvas"), data);
					});
				});
			}
		}
	}

	new Vue(ChartHandling).$mount("#chart");
</script>

示例代码除了“bar”图表类型外,还有“pie”和“doughnut”,它们的工作方式相同。

服务器端片段

Vue 可以使用 v-html 属性替换元素的整个内部 HTML,所以我们可以开始用它重新实现 Turbo 内容。这是新的“test”标签页:

<div class="tab-pane fade" id="test" role="tabpanel">
	<div class="container" id="frame">
		<div id="hi" v-html="html"></div>
		<button class="btn btn-primary" v-on:click="hello">Fetch</button>
	</div>
</div>

它有一个点击处理程序,引用一个“hello”方法,以及一个等待接收内容的 div。我们可以这样将按钮附加到“hi”容器:

<script type="module">
	import Vue from 'vue';

	const HelloHandling = {
		data: {
			html: ''
		},
		methods: {
			hello() {
				const handler = this;
				fetch("/test").then(response => {
					response.text().then(data => {
						handler.html = data;
					});
				});
			},
		}
	}

	new Vue(HelloHandling).$mount("#test");
</script>

为了使其工作,我们只需要从服务器端模板中移除 <turbo-frame/> 元素(恢复到 HTMX 示例中的样子)。

用 Vue(或其他库,甚至纯 Javascript)替换我们的 Turbo(和 HTMX)代码是绝对可能的,但从示例中可以看出,这不可避免地会涉及一些样板 Javascript。

第二部分

订阅 Spring 电子报

通过 Spring 电子报保持联系

订阅

领先一步

VMware 提供培训和认证,助您快速提升。

了解更多

获得支持

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

了解更多

近期活动

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

查看全部