Spring Boot应用程序的客户端开发

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

本文探讨了 Spring Boot 开发人员在应用程序的客户端(浏览器)端使用 Javascript 和 CSS 的各种选择。计划的一部分是探索一些在 Spring Web 应用程序传统的服务器端渲染世界中表现良好的 Javascript 库。这些库通常对应用程序开发人员来说是一种轻量级的方式,因为它们允许您完全避免使用 Javascript,但仍然拥有一个不错的、渐进式的“现代”UI。我们还将研究一些更“纯粹”的 Javascript 工具和框架。这有点像一个光谱,所以作为 TL;DR,这里是 示例应用程序 的列表,按 Javascript 内容从少到多大致排序

  • htmxHTMX 是一个库,允许您直接从 HTML 访问现代浏览器功能,而不是使用 Javascript。它非常易于使用,并且非常适合服务器端渲染,因为它通过直接从远程响应替换 DOM 的一部分来工作。它似乎在 Python 社区中被广泛使用并受到好评。

  • turboHotwired(Turbo 和 Stimulus)。Turbo 有点像 HTMX。它被广泛使用,并在 Ruby on Rails 中得到了很好的支持。Stimulus 是一个轻量级库,可用于实现偏好驻留在客户端的小块逻辑。

  • vueVue 也非常轻量级,并将其描述为“渐进式”和“可增量采用”。它的多功能性在于,您可以使用非常少量的 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 有一本 迷你书spring.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 可以轻松实现相同的目标。所有示例实际上都有一个静态的首页(甚至不是作为模板渲染的),但它们都有一些动态内容,我们为此选择了 JMustacheThymeleaf(和其他模板引擎)同样适用。事实上,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 库,所以我们可以通过利用它来更充分地使用它。我们可以在 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>

(Webjars 使用“__”而不是“@”前缀来命名空间 NPM 模块名称。) 然后可以将其添加到 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)不标准化——没有命名约定可以让您猜测 ESM 模块在 Webjar 中的位置,或者一个 NPM 模块(这相当于同样的事情)。但是 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",
...
}

unpkg.com 等 CDN 会利用这些信息,因此当您只知道 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”,因为一部分示例将探索服务器发送事件流的使用。选项卡内容在 <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”,并且两者都有 id 与标题中的 data-bs-target 属性匹配。这就是为什么我们需要一些 Javascript——来处理选项卡上的单击事件,以便显示或隐藏正确的内容。 Bootstrap 文档 提供了许多不同选项卡样式和布局的示例。这里基本功能的一个好处是它们可以在窄设备(如手机)上自动渲染为下拉菜单(通过对 <nav/> 中的类属性进行一些小更改——您可以查看 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 库抓取并用于增强页面。这些属性的意思是“当用户单击此按钮时,向 /greet 发送一个 POST 请求,将‘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 还有许多其他很棒的功能,其中之一是渲染 服务器发送事件 (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 组件来实现这一点(在 Framework 中 这里 请求的功能),其中 @Controller 方法可以返回一个 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 还能做更多。端点可以返回常规 HTTP 响应,而不是 SSE 流,但可以将其组合为要在页面上交换的元素集。HTMX 称之为“带外”交换,因为它涉及增强触发下载的元素之外的页面元素的内容。

要看到这一点,我们可以添加另一个带有 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 获取动态内容,它还设置了 2 个空的 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 的另一项功能是“增强”页面上的所有链接和表单操作,使它们自动使用 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

这让我们能够用一些 HTML 的修改来完成我们之前用 HTMX 完成的动态内容操作。这是 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”。

服务器发送事件

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 就会按要求执行(替换 id 为“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 的端点时,它会很有用。我们可以通过在 pom.xml 中添加 Stimulus 作为依赖项来开始使用 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’控制器上的‘greet’函数”。并且有一些装饰来标识哪些元素具有控制器的输入和输出(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”在控制器中显示为对名为 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”的控制器,并用 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”操作的新控制器

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 控制器绑定,例如

<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 了,只是看看它的样子。这是图表选项卡(基本上与以前相同,但没有控制器装饰)

<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>

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

服务器端片段

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 示例中的状态)。

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

第二部分

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获得支持

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

了解更多

即将举行的活动

查看 Spring 社区所有即将举行的活动。

查看所有