前言
由于一些原因,我们希望使用 React 或者是其它基于 Virtual DOM 实现的 Web 应用能够在用户访问的时候先输出静态版本的页面,等到整个应用加载完毕之后再切换到实时渲染的版本。这些原因包括性能因素:尽可能快地让用户访问到页面的内容,减少用户等待时间;SEO 因素:让搜索引擎爬虫能够更加容易解析页面的内容;无障碍访问:让辅助设备能够解析内容。
服务端渲染
针对使用 React 实现的应用,React 提供了 Server React DOM APIs 来实现 Server-Side Rendering (SSR)。但是这种解决方案存在着局限性:我们需要一个 Node.js 服务来调用这些 API,在前后端分离的大背景下,如果我们的后端服务本身是使用 Node.js 实现的,这种解决方式是很便捷的。但是如果服务端使用其它技术实现,那么还可能需要单独启动一个 Node.js 服务,或者是使用无头浏览器(Headless Browser)来辅助解决。那么有没有更加轻量的解决方案呢?
Webpack 预渲染
HtmlWebpackPlugin
答案是有的。在搭建 React 开发脚手架的时候,HtmlWebpackPlugin 是一个无法绕开的 webpack 插件,它能帮我们生成 HTML
文件并将编译生成的 JavaScript
和 CSS
放入到 HTML
文件中的相应位置,插入 title
和 meta
等标签。最关键的是 HtmlWebpackPlugin 还支持模板功能。webpack 会在编译完时为我们渲染 ejs 模板。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
/>
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
</head>
<body>
<div id="app">
<ul>
<% for (let i = 0; i < menus.length; i += 1) { %>
<% let menu = menus[i] %>
<li><%= menu %></li>
<% } %>
</ul>
</div>
</body>
</html>
{
plugins: [
new HtmlWebpackPlugin({
minify: false,
template: "./path-of-the-template/index.ejs",
templateParameters: {
menus: ["Home", "About", "Contact"],
},
}),
];
}
webpack 会最终生成下面的页面。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
/>
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<script defer src="/static/js/572.b4d74bdee5412c82202b.js"></script>
</head>
<body>
<div id="app">
<ul>
<li>Home</li>
<li>About</li>
<li>Contact</li>
</ul>
</div>
</body>
</html>
结合上边的例子,我们能想到的一个思路就是将首屏的结构写成模板,并在编译时将动态的内容通过变量传递给模板,最终生成接近 React 会为我们渲染的内容。这里存在着一个问题:如果我们模板中有需要向服务端请求的内容,那么如何能够在 webpack 编译模板之前请求接口将内容保存到模板变量呢?
编译期请求服务
HtmlWebpackPlugin 提供了 Events 让我们能够使用 webpack 的 tapable 机制监听到插件的不同阶段、修改数据并返回给 HtmlWebpackPlugin。
通过 HtmlWebpackPlugin 提供的流程图可以看出 HtmlWebpackPlugin 编译模板是在第 8 步。对应的 Event 是 afterTemplateExecution
。因此只需要监听 afterTemplateExecution
之前的几个 Events,在回调中请求数据,将变量写入到 templateParameters
即可。
插件编写
在理解了 HtmlWebpackPlugin 的 Events 机制之后,结合文档提供的例子,我们可以编写如下插件,在模板的编译前期完成接口的请求、数据的封装和模板变量的植入。
const HtmlWebpackPlugin = require("html-webpack-plugin");
class HtmlWebpackTemplatePlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
compiler.hooks.compilation.tap("HtmlWebpackTemplatePlugin", (compilation) => {
HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync("HtmlWebpackTemplatePlugin", (data, cb) => {
this.options
.func(data)
.then((resp) => {
cb(null, resp);
})
.catch(() => {
cb(null, data);
});
});
});
}
}
module.exports = HtmlWebpackTemplatePlugin;
再来看一下,回调函数里边的 data
有哪些内容。
data.plugin.options.templateParameters
对应着模板变量,只需要将变量写入到此处即可。
插件使用
{
plugins: [
new HtmlWebpackPlugin({
minify: false,
template: "./path-of-the-template/index.ejs",
templateParameters: {},
}),
new HtmlWebpackTemplatePlugin({
func: (data) =>
new Promise((resolve) => {
rxjs
.from(fetch(`${process.env.API_URI}/categories`).then((response) => response.json()))
.pipe(
rxjs.switchMap((categories) =>
rxjs
.forkJoin(
(categories || []).map((category) =>
fetch(`${process.env.API_URI}/category/${category.id}/sites`).then((response) => response.json())
)
)
.pipe(
rxjs.map((sites) => sites.map((site, index) => ({ ...categories[index], sites: site }))),
rxjs.map((sites) => [categories, sites])
)
)
)
.subscribe(([categories, sections]) => {
data.plugin.options.templateParameters.categories = categories;
data.plugin.options.templateParameters.sections = sections;
resolve(data);
});
}),
}),
];
}
通过上边的例子可以看出在访问页面时,浏览器首先请求到的是预渲染的内容,等到页面的 scripts
加载执行完毕之后,React 生成的代码会 mount
到 root
结点上,然后替换掉静态的内容。
结语
这种方法也有局限性,那就是只适合数据实时性不强的应用,这就意味着当数据变动时需要重新编译页面。一种更好的解决方案就是将 HTML
模板的静态内容由服务端进行实时输出,例如:Spring 可以利用 Thymeleaf 将内容输出。这样浏览器优先请求到的内容就是服务端生成的内容,等到 React
应用就绪后可以接管页面的渲染。