使用 Spring Boot 應用程式進行用戶端開發

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

本文探討了 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 網站上有一篇教學文章。還有關於 Angular 的內容,透過 Spring 網站上的另一篇教學文章和相關的入門內容 在這裡。如果您對 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 很容易實現相同的目標。所有範例實際上都有一個靜態首頁(甚至沒有渲染為範本),但它們都有一些動態內容,我們選擇了 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。我們可以像這樣在 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 來定位 classpath 中資源的版本和確切位置,並且瀏覽器會將該樣式表加入到頁面中。

展示一些 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 內部 ESM 模組的位置,或者一個等效的 NPM 模組。但是,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 元數據,因此它可以從 classpath 解析每個 Webjar 的版本,並且我們不需要 webjars-locator-core 依賴項(有一個 Spring Framework 中的開放問題 來新增此功能)。

因此,在範例中,import map 如下所示

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

您只需要知道 NPM 模組名稱,resolver 就會找出如何找到一個解析為 ESM 封裝的資源。如果有的話,它會使用一個 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",並且兩者都具有與標頭中的 data-bs-target 屬性匹配的 ID。這就是我們需要一些 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 函式庫抓取並用於增強頁面。這些屬性表示 "當用戶點擊此按鈕時,將 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 元件(在 Framework 上 這裡 請求作為一個功能),其中 @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 提取動態內容,並且它還設定了 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>

然後我們可以透過新增匯入映射將其匯入到我們的頁面中

<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 中,我們加入一個匯入對應 (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 與 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 中的匯入對應(import map)(使用手動資源路徑,因為 NPM 捆綁包中的 "module" 指向瀏覽器無法運作的內容)

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

然後我們可以編寫一個元件並將其 "mount" 在一個已命名的元素中(這是 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>

因此,範本化 (templating) 發生在客戶端,並且是由 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 hook 作為 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(或其他函式庫,甚至普通的 Javscript)替換我們的 Turbo(和 HTMX)程式碼,但我們可以從範例中看到,它不可避免地涉及一些樣板 Javascript。

第二部分

取得 Spring 電子報

透過 Spring 電子報保持聯繫

訂閱

領先一步

VMware 提供培訓和認證,以加速您的進度。

了解更多

獲得支援

Tanzu Spring 在一個簡單的訂閱中提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位檔案。

了解更多

即將舉辦的活動

查看 Spring 社群中所有即將舉辦的活動。

查看全部