使用 Spring Boot 應用程式進行用戶端開發 - 第 2 部分

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

第 1 部分

使用 SSE Stream 的純 Javascript

在這個簡單的 HTML 替換用例中,Vue 並沒有真正增加很多價值,並且對於 SSE 範例來說根本沒有價值,所以我們將繼續使用 Vanilla Javascript 來實現它。這是一個 stream 標籤

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

和一些 Javascript 來填充它

<script type="module">
	var events = new EventSource("/stream");
	events.onmessage = e => {
		document.getElementById("load").innerHTML = e.data;
	}
</script>

使用 React 的動態內容

大多數使用 React 的人可能做的比一點點邏輯更多,最終會在 Javascript 中包含所有的佈局和渲染。你不必那樣做,而且只使用一點 React 來感受它很容易。您可以將其保留在那裡並將其用作實用程式庫,或者您可以發展到完整的 Javascript 用戶端元件方法。

我們可以開始嘗試,而無需做太多的更改。 如果你想偷看,範例程式碼最終看起來會像 react-webjars 範例。 首先是 pom.xml 中的依賴項

<dependency>
	<groupId>org.webjars.npm</groupId>
	<artifactId>react</artifactId>
	<version>17.0.2</version>
</dependency>
<dependency>
	<groupId>org.webjars.npm</groupId>
	<artifactId>react-dom</artifactId>
	<version>17.0.2</version>
</dependency>

index.html 中的模組對應

<script type="importmap">
	{
		"imports": {
			...
			"react": "/npm/react/umd/react.development.js",
			"react-dom": "/npm/react-dom/umd/react-dom.development.js"
		}
	}
</script>

React 尚未打包為 ESM 組合包(無論如何),因此沒有「模組」元數據,我們必須像這樣硬編碼資源路徑。 資源路徑中的「umd」是指「通用模組定義」,這是較早的模組化 Javascript 嘗試。 如果你眯著眼睛看,它足夠接近,你可以以類似的方式使用它。

完成這些後,您可以導入它們定義的函數和物件

<script type="module">
	import * as React from 'react';
	import * as ReactDOM from 'react-dom';
</script>

因為它們實際上不是 ESM 模組,所以你可以在 HTML <head/> 中的 <script/> 中的「全域」級別執行此操作,例如我們導入 bootstrap 的地方。 然後你可以透過建立一個 React.Component 來定義一些內容。 這是一個非常基本的靜態範例

<script type="module">
	const e = React.createElement;
	class RootComponent extends React.Component {
		constructor(props) {
			super(props);
		}
		render() {
			return e(
				'h1',
				{},
				'Hello, world!'
			);
		}
	}
	ReactDOM.render(e(RootComponent), document.querySelector('#root'));
</script>

render() 方法會傳回一個函數,該函數會建立一個新的 DOM 元素(一個內容為「Hello, world!」的 <h1/>)。 它由 ReactDOM 連接到一個 id="root" 的元素,所以我們最好也添加一個,例如在「test」標籤中

<div class="tab-pane fade" id="test" role="tabpanel">
	<div class="container" id="root"></div>
</div>

如果你執行它,它應該可以工作,並且應該在該標籤中顯示「Hello World」。

Javascript 中的 HTML:XJS

大多數 React 應用程式都使用嵌入在 Javascript 中的 HTML,透過一種名為「XJS」的樣板語言(它可以透過其他方式使用,但實際上現在是 React 的一部分)。 上面的 hello world 範例看起來像這樣

<script type="text/babel">
	class Hello extends React.Component {
		render() {
			return <h1>Hello, {this.props.name}!</h1>;
		}
	}
	ReactDOM.render(
		<Hello name="World"/>,
		document.getElementById('root')
	);
</script>

該元件定義一個自訂元素 <Hello/>,它與元件的類別名稱匹配,並且按照慣例以大寫字母開頭。 <Hello/> 片段是一個 XJS 範本,並且該元件還有一個傳回 XJS 範本的 render() 函數。 大括號用於插值,props 是一個地圖,包含自訂元素的所有屬性(在本例中為「name」)。 最後是 <script type="text/babel">,需要將 XJS 轉換為瀏覽器可以理解的實際 Javascript。 上面的腳本在瀏覽器被教導辨識這個腳本之前不會做任何事情。 我們透過導入另一個模組來做到這一點

<script type="importmap">
{
  "imports": {
    ...
    "react": "/npm/react/umd/react.development.js",
    "react-dom": "/npm/react-dom/umd/react-dom.development.js",
    "@babel/standalone": "/npm/@babel/standalone"
  }
}
</script>
<script type="module">
...
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import '@babel/standalone';
</script>

React 使用者指南建議不要在大型應用程式中使用 @babel/standalone,因為它必須在瀏覽器中做很多工作,並且可以在建置時完成相同的工作,這樣更有效率。 但它很適合嘗試東西,以及用於少量 React 程式碼的應用程式,就像這個應用程式一樣。

基本事件和使用者輸入處理

我們現在可以將主要的「message」標籤遷移到 React。 因此,讓我們修改 Hello 元件並將其附加到不同的元素。 message 標籤可以簡化為一個空元素,準備好接受 React 內容

<div class="tab-pane fade show active" id="message" role="tabpanel">
	<div class="container" id="hello"></div>
</div>

我們可以預期我們需要第二個元件來渲染驗證的使用者名稱,因此讓我們從這個開始,將一些程式碼附加到上面標籤中的元素

ReactDOM.render(
	<div className="container" id="hello">
		<Auth/>
		<Hello/>
	</div>,
	document.getElementById('hello')
);

然後我們可以像這樣定義 Auth 元件

class Auth extends React.Component {
	constructor(props) {
		super(props);
		this.state = { user: 'Unauthenticated' };
	};
	componentDidMount() {
		let hello = this;
		fetch("/user").then(response => {
			response.json().then(data => {
				hello.setState({user: `Logged in as: ${data.name}`});
			});
		});
	};
	render() {
		return <div id="auth">{this.state.user}</div>;
	}
};

在這種情況下,生命週期回調是 componentDidMount,它在元件被啟用時由 React 呼叫,所以我們把我們的初始化程式碼放在這裡。

另一個元件是將「name」輸入傳輸到問候語的元件

class Hello extends React.Component {
	constructor(props) {
		super(props);
		this.state = { name: '', message: '' };
		this.greet = this.greet.bind(this);
		this.change = this.change.bind(this);
	};
	greet() {
		this.setState({message: `Hello ${this.state.name}!`})
	}
	change(event) {
		console.log(event)
		this.setState({name: event.target.value})
	}
	render() {
		return <div>
			<div id="greeting">{this.state.message}</div>
			<input id="name" name="value" type="text" value={this.state.name} onChange={this.change}/>
			<button className="btn btn-primary" onClick={this.greet}>Greet</button>
		</div>;
	}
}

render() 方法必須傳回一個單一元素,因此我們必須將內容包裝在 <div> 中。 另一個值得指出的是,狀態從 HTML 傳輸到 Javascript 不是自動的 - React 中沒有「雙向模型」,你必須將變更監聽器添加到輸入以明確更新狀態。 此外,我們必須在我們想要用作監聽器的所有元件方法上呼叫 bind()(在本例中為 greetchange)。

圖表選擇器

要將其餘的 Stimulus 內容遷移到 React,我們需要編寫一個新的圖表選擇器。 因此,我們可以從一個空的「chart」標籤開始

<div class="tab-pane fade" id="chart" role="tabpanel" data-controller="chart">
	<div class="container">
		<canvas id="canvas"></canvas>
	</div>
	<div class="container" id="chooser"></div>
</div>

並將 ReactDOM 元素附加到「chooser」

ReactDOM.render(
	<ChartChooser/>,
	document.getElementById('chooser')
);

ChartChooser 是封裝在元件中的按鈕列表

class ChartChooser extends React.Component {
	constructor(props) {
		super(props);
		this.state = {};
		this.clear = this.clear.bind(this);
		this.bar = this.bar.bind(this);
	};
	bar() {
		let chart = this;
		this.clear();
		fetch("/pops").then(response => {
			response.json().then(data => {
				data.type = "bar";
				chart.setState({ active: new Chart(document.getElementById("canvas"), data) });
			});
		});
	};
	clear() {
		if (this.state.active) {
			this.state.active.destroy();
		}
	};
	render() {
		return <div>
			<button className="btn btn-primary" onClick={this.clear}>Clear</button>
			<button className="btn btn-primary" onClick={this.bar}>Bar</button>
		</div>;
	}
}

我們還需要 Vue 範例中的圖表模組設定(它在 <script type="text/babel"> 中無法工作)

<script type="module">
	import { Chart, BarController, BarElement, LinearScale, CategoryScale, Title, Legend } from 'chart.js';
	Chart.register(BarController, BarElement, LinearScale, CategoryScale, Title, Legend);
	window.Chart = Chart;
</script>

Chart.js 沒有以你可以導入到 Babel 腳本中的形式發布。 我們在一個單獨的模組中導入它,並且 Chart 必須定義為全域變數,以便我們仍然可以在我們的 React 元件中使用它。

伺服器端片段

要使用 React 渲染「test」標籤,我們可以從標籤本身開始,再次為空以接受來自 React 的內容

<div class="tab-pane fade" id="test" role="tabpanel">
	<div class="container" id="root"></div>
</div>

並綁定到 React 中的「root」元素

ReactDOM.render(
	<Content />,
	document.getElementById('root')
);

然後我們可以將 <Content/> 實現為一個從 /test 端點取得 HTML 的元件

class Content extends React.Component {
	constructor(props) {
		super(props);
		this.state = { html: '' };
		this.fetch = this.fetch.bind(this);
	};
	fetch() {
		let hello = this;
		fetch("/test").then(response => {
			response.text().then(data => {
				hello.setState({ html: data });
			});
		});
	}
	render() {
		return <div>
			<div dangerouslySetInnerHTML={{ __html: this.state.html }}></div>
			<button className="btn btn-primary" onClick={this.fetch}>Fetch</button>
		</div>;
	}
}

React 故意命名 dangerouslySetInnerHTML 屬性,以阻止人們將其與直接從使用者那裡收集的內容一起使用(XSS 問題)。 但是我們從伺服器取得該內容,因此我們可以信任那裡的 XSS 保護並忽略警告。

如果我們使用該 <Content/> 元件和上面範例中的 SSE 載入器,那麼我們可以完全擺脫此範例中的 Hotwired。

使用 Node.js 構建和捆綁

Webjars 很棒,但有時你需要更接近 Javascript 的東西。 對於某些人來說,Webjars 的一個問題是 jar 的大小 - Bootstrap jar 接近 2MB,其中大部分在運行時永遠不會被使用 - 並且 Javascript 工具非常注重減少該開銷,方法是不在你的應用程式中打包整個 NPM 模組,並且透過將資產捆綁在一起,以便它們可以有效地被下載。 Java 工具也存在一些問題 - 特別是關於 Sass,缺乏好的工具,正如我們最近在 Petclinic 中發現的那樣。 所以也許我們應該看看使用 Node.js 工具鏈構建的選項。

你首先需要的是 Node.js。 有很多方法可以獲取它,你可以使用你想要的任何工具。 我們將展示如何使用 Frontend Plugin 來做到這一點。

安裝 Node.js

讓我們將插件添加到 turbo 範例中。(如果您想先看看結果,最終結果是 nodejs 範例)在 pom.xml

<plugins>
	<plugin>
		<groupId>com.github.eirslett</groupId>
		<artifactId>frontend-maven-plugin</artifactId>
		<version>1.12.0</version>
		<executions>
			<execution>
				<id>install-node-and-npm</id>
				<goals>
					<goal>install-node-and-npm</goal>
				</goals>
				<configuration>
					<nodeVersion>v16.13.1</nodeVersion>
				</configuration>
			</execution>
			<execution>
				<id>npm-install</id>
				<goals>
					<goal>npm</goal>
				</goals>
				<configuration>
					<arguments>install</arguments>
				</configuration>
			</execution>
			<execution>
				<id>npm-build</id>
				<goals>
					<goal>npm</goal>
				</goals>
				<configuration>
					<arguments>run-script build</arguments>
				</configuration>
				<phase>generate-resources</phase>
			</execution>
		</executions>
	</plugin>
	...
</plugins>COPY

這裡我們有 3 個執行階段:install-node-and-npm 在本地安裝 Node.js 和 NPM,npm-install 執行 npm install,而 npm-build 執行一個腳本來建置 Javascript 和可能的 CSS。 我們將需要一個最小的 package.json 來執行所有這些。如果您已經安裝了 npm,您可以 npm init 來產生一個新的,或者只是手動建立它

$ cat > package.json
{
	"scripts": { "build": "echo Building"}
}

然後我們可以建置

$ ./mvnw generate-resources

您會看到結果是一個新的目錄

$ ls -d node*
node

npm 像這樣在本地安裝時,有一個快速的方法從命令列執行 npm 很有用。 因此,一旦您安裝了 Node.js,您可以透過在本地建立一個腳本來簡化它

$ cat > npm
#!/bin/sh
cd $(dirname $0)
PATH="$PWD/node/":$PATH
node "node/node_modules/npm/bin/npm-cli.js" "$@"

使其可執行並試用一下

$ chmod +x npm
$ ./npm install

up to date, audited 1 package in 211ms

found 0 vulnerabilities

新增 NPM 套件

現在我們準備好建置一些東西了,讓我們使用到目前為止在 Webjars 中擁有的所有依賴項來設定 package.json

{
    "name": "js-demo",
    "version": "0.0.1",
    "dependencies": {
        "@hotwired/stimulus": "^3.0.1",
        "@hotwired/turbo": "^7.1.0",
        "@popperjs/core": "^2.10.1",
        "bootstrap": "^5.1.3",
        "chart.js": "^3.6.0",
        "@springio/utils": "^1.0.5",
        "es-module-shims": "^1.3.0"
    },
    "scripts": {
        "build": "echo Building"
    }
}

執行 ./npm install (或 ./mvnw generate-resources) 會將這些依賴項下載到 node_modules

$ ./npm install

added 7 packages, and audited 8 packages in 8s

2 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
$ ls node_modules/
@hotwired  @popperjs  @springio  bootstrap  chart.js  es-module-shims

將所有下載和產生的程式碼新增到您的 .gitignore (即 node/node_modules/package-lock.json) 是可以的。

使用 Rollup 建置

Bootstrap 維護者使用 Rollup 來捆綁他們的程式碼,所以這似乎是一個不錯的選擇。 它做得很好的一件事是「tree shaking」,以減少您需要與應用程式一起發布的 Javascript 數量。 隨時試用其他工具。 若要開始使用 Rollup,我們將需要在 package.json 中設定一些開發依賴項和一個新的建置腳本

{
    ...
    "devDependencies": {
        "rollup": "^2.60.2",
        "rollup-plugin-node-resolve": "^2.0.0"
    },
    "scripts": {
        "build": "rollup -c"
    }
}

Rollup 有它自己的設定檔,所以這是一個將本地 Javascript 來源捆綁到應用程式中,並在執行時從 /index.js 提供 Javsacript 的範例。 這是 rollup.config.js

import resolve from 'rollup-plugin-node-resolve';

export default {
	input: 'src/main/js/index.js',
	output: {
	  file: 'target/classes/static/index.js',
	  format: 'esm'
	},
	plugins: [
		resolve({
			esm: true,
			main: true,
			browser: true
		  })
	]
};

因此,如果我們將所有 Javascript 移到 src/main/js/index.js 中,我們在 index.html 中只會有一個 <script>,例如在 <body> 的結尾

<script type="module">
import '/index.js';
</script>

我們現在將保留 CSS,稍後我們可以處理本地建置。 因此,在 index.js 中,我們將所有 <script> 標籤內容合併在一起(或者我們可以將其拆分為模組並匯入它們)

import 'bootstrap';
import '@hotwired/turbo';
import '@springio/utils';
import { Application, Controller } from '@hotwired/stimulus';
import { Chart, BarController, BarElement, PieController, ArcElement, LinearScale, ategoryScale, Title, Legend } from 'chart.js';

Turbo.connectStreamSource(new EventSource("/stream"))
window.Stimulus = Application.start();

Chart.register(BarController, BarElement, PieController, ArcElement, LinearScale, CategoryScale, itle, Legend);

Stimulus.register("hello", class extends Controller {
	...
});

Stimulus.register("chart", class extends Controller {
	...
});

如果我們建置並執行應用程式,它應該可以正常運作,並且 Rollup 會在 target/classes/static 中建立一個新的 index.js,執行 JAR 會在此處將其取出。 由於 Rollup 中「resolve」插件的作用,新的 index.js 具有執行我們的應用程式所需的所有程式碼。 如果任何依賴項被封裝為適當的 ESM 捆綁包,Rollup 將能夠剔除它們未使用的部分。 這至少適用於 Hotwired Stimulus,而大多數其他依賴項都是完整包含的,但結果仍然只有 750K(其中大部分是 Bootstrap)

$ ls -l target/classes/static/index.js
-rw-r--r-- 1 dsyer dsyer 768778 Dec 14 09:34 target/classes/static/index.js

瀏覽器必須下載這一次,這在伺服器是 HTTP 1.1 時是一個優勢(HTTP 2 會稍微改變一些事情),並且這表示執行 JAR 不會因為從未使用過的東西而膨脹。 Rollup 還有其他插件選項可以壓縮 Javascript,我們將在下一節中看到其中一些選項。

使用 Sass 建置 CSS

到目前為止,我們使用了在一些 NPM 函式庫中捆綁的普通 CSS。 大多數應用程式需要自己的樣式表,而開發人員喜歡使用某種形式的模板函式庫和建置時工具來編譯為 CSS。 最流行的此類工具(但不是唯一的工具)是 Sass。 Bootstrap 使用它,實際上將其原始檔封裝在 NPM 捆綁包中,因此您可以將 Bootstrap 樣式擴展並改編為您自己的需求。

我們可以透過為我們的應用程式建置 CSS 來了解其運作方式,即使我們沒有做太多自訂。 從 NPM 中的一些工具依賴項開始

$ ./npm install --save-dev rollup-plugin-scss rollup-plugin-postcss sass

這會導致 package.json 中出現一些新項目

{
    ...
    "devDependencies": {
        "rollup": "^2.60.2",
        "rollup-plugin-node-resolve": "^2.0.0",
        "rollup-plugin-postcss": "^0.2.0",
        "rollup-plugin-scss": "^3.0.0",
        "sass": "^1.44.0"
    },
    ...
}

這表示我們可以更新我們的 rollup.config.js 以使用新工具

import resolve from "rollup-plugin-node-resolve";
import scss from "rollup-plugin-scss";
import postcss from "rollup-plugin-postcss";

export default {
  input: "src/main/js/index.js",
  output: {
    file: "target/classes/static/index.js",
    format: "esm",
  },
  plugins: [
    resolve({
      esm: true,
      main: true,
      browser: true,
    }),
    scss(),
    postcss(),
  ],
};

CSS 處理器會尋找與主輸入檔案相同的位置,因此我們可以在 src/main/js 中建立一個 style.scss 並匯入 Bootstrap 程式碼

@import 'bootstrap/scss/bootstrap';

如果我們真的這樣做,SCSS 中的自訂項目會遵循它。 然後在 index.js 中,我們為此新增匯入,以及 Spring 實用程式函式庫

import './style.scss';
import '@springio/utils/style.css';
...

並重新建置。 這將導致建立一個新的 index.css (與主輸入 Javascript 相同的檔案名稱),然後我們可以將其連結到 index.html<head>

<head>
	...
	<link rel="stylesheet" type="text/css" href="index.css" />
</head>COPY

就是這樣。 我們有一個 index.js 腳本驅動我們 Turbo 範例的所有 Javascript 和 CSS,現在我們可以移除 pom.xml 中所有剩餘的 Webjars 依賴項。

使用 Node.js 捆綁 React 應用程式

為了完成,我們可以將相同的想法應用於 react-webjars 範例,移除 Webjars 並將 Javascript 和 CSS 提取到單獨的原始檔中。 這樣,我們最終也可以擺脫稍微有問題的 @babel/standalone。 我們可以從 react-webjars 範例開始,並如上所述新增前端插件(或者以其他方式取得 Node.js),並手動或透過 npm CLI 建立 package.json。 我們將需要 React 依賴項,以及 Babel 的建置時工具。 這是我們最終得到的結果

{
    "name": "js-demo",
    "version": "0.0.1",
    "dependencies": {
        "@popperjs/core": "^2.10.1",
        "@springio/utils": "^1.0.4",
        "bootstrap": "^5.1.3",
        "chart.js": "^3.6.0",
        "react": "^17.0.2",
        "react-dom": "^17.0.2"
    },
    "devDependencies": {
        "@babel/core": "^7.16.0",
        "@babel/preset-env": "^7.16.0",
        "@babel/preset-react": "^7.16.0",
        "@rollup/plugin-babel": "^5.3.0",
        "@rollup/plugin-commonjs": "^21.0.1",
        "@rollup/plugin-node-resolve": "^13.0.6",
        "@rollup/plugin-replace": "^3.0.0",
        "postcss": "^8.4.5",
        "rollup": "^2.60.2",
        "rollup-plugin-postcss": "^4.0.2",
        "rollup-plugin-scss": "^3.0.0",
        "sass": "^1.44.0",
        "styled-jsx": "^4.0.1"
    },
    "scripts": {
        "build": "rollup -c"
    }
}

我們需要 commonjs 插件,因為 React 沒有封裝為 ESM,如果不進行一些轉換,匯入將無法運作。 Babel 工具隨附一個設定檔 .babelrc,我們使用它來告訴它建置 JSX 和 React 元件

{
        "presets": ["@babel/preset-env", "@babel/preset-react"],
        "plugins": ["styled-jsx/babel"]
}

有了這些建置工具,我們可以從 index.html 中提取所有 Javascript,並將其放入 src/main/resources/static/index.js 中。 這幾乎是複製貼上,但我們會想要新增 CSS 匯入

import './style.scss';
import '@springio/utils/style.css';

來自 React 的匯入看起來像這樣

import React from 'react';
import ReactDOM from 'react-dom';

您可以使用 npm run build(或 ./mvnw generate-resources)建置它,它應該可以正常運作 - 所有標籤都有一些內容,並且所有按鈕都會產生一些內容。

最後,我們只需要整理 index.html,使其僅匯入 index.jsindex.css,然後 Webjars 專案的所有功能都應該按預期運作。

結論

客戶端開發有很多選擇,而 Spring Boot 對它們沒有太大的影響,因此您可以自由選擇適合您的任何選擇。 本文必然在範圍上受到限制(我們實際上無法從每個角度來看所有內容),但希望能夠突出顯示一些有趣的可能。 我個人發現自己非常依賴 HTMX,最近已將其用於一些迷你專案,但您的里程可能會有所不同。 請在部落格上發表評論或透過 Github 或憤怒的小鳥應用程式傳送意見反應 - 聽聽人們的想法會很有趣。 我們是否應該將本文作為 spring.io 上的教學課程發布?

取得 Spring 電子報

隨時掌握 Spring 電子報

訂閱

取得領先

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

了解更多

取得支援

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

了解更多

即將舉行的活動

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

查看全部