使用 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 社群的所有近期活動。

檢視全部