使用 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 包装器位于父目录中,因此在命令行上的每个示例中,你可以使用../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。例如,我们可以在index.html中使用 CDN,如下所示

...
<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)并非标准化——没有命名约定允许您猜测 Webjar 或 NPM 模块中 ESM 模块的位置,这与它们相同。但是,NPM 模块中有一些约定可以实现自动化:大多数模块都有一个包含“module”字段的package.json。例如,从 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 样式。那么,一些带有内容和一两个按钮的选项卡怎么样?听起来不错。首先是<header/>,其中包含index.html中的选项卡链接

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

第二个(默认情况下处于非活动状态)选项卡称为“流”,因为部分示例将探讨使用服务器发送事件流。选项卡内容在<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>

请注意,其中一个选项卡是“活动”的,并且两者都有与标题中的data-bs-target属性匹配的 id。这就是我们需要一些 JavaScript 的原因——处理选项卡上的点击事件,以便显示或隐藏正确的内容。Bootstrap 文档有很多不同选项卡样式和布局的示例。这里基本功能的一个好处是它们可以在狭窄的设备(如手机)上自动呈现为下拉菜单(对<nav/>中的类属性进行一些小的更改——您可以查看Petclinic了解如何操作)。在浏览器中,它看起来像这样

tabs

当然,如果您点击“流”选项卡,它会显示一些不同的内容。

使用 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()
		);
	}

	...
}

因此,我们通过端点映射上的produces属性,由 Spring 渲染了一系列消息

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

data:1:1639472866461

data:2:1639472871461

...

HTMX 可以将这些消息注入到我们的页面中。以下是添加到“stream”选项卡的index.html中的方法

<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 还可以做更多的事情。端点可以返回常规的 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获取动态内容,它还设置了两个空 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>

然后,我们可以通过添加导入映射将其导入我们的页面

<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 就会按照要求执行操作(替换由“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

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

以及在index.html中的导入映射

<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中,我们添加一个导入映射和一些 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与Gradle,或XML与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中的导入映射中(使用手动资源路径,因为NPM包中的“模块”指向浏览器中无法正常工作的内容)。

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

示例代码除了“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社区中所有即将举行的活动。

查看全部