react ssr 极简实现
思路
react 的 ssr 最低最低也要把下面几个操作完成:
- 转化 jsx:把 jsx 或者 tsx 代码转化为普通代码
- 模板注入:使用 react-dom 的服务器端渲染功能把组件渲染成静态文本,然后丢到 HTTP 响应报文中
- 吸水反应:在客户端添加逻辑挂载,react 称挂载过程是 hydrate 吸水。
实现
实现时用到的依赖:
{
"dependencies": {
"@types/react": "^18.0.14",
"@types/react-dom": "^18.0.5",
"webpack-cli": "^4.10.0",
"webpack-node-externals": "^3.0.0",
"express": "^4.18.1",
"nodemon": "^2.0.18",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"ts-loader": "^9.3.1",
"typescript": "^4.7.4",
"webpack": "^5.73.0"
}
}
可以直接全部装上,或者写到哪里装到哪里。
注意此用例使用的是 react18,并不兼容低版本的 react ssr。
先写点源码
既然是服务器端渲染,就要先装个好用的服务器,我用的是 express。
创建以下的文件:
root
├── src
│ ├── App.tsx # 根元素
│ ├── client.tsx # 客户端代码
│ ├── HelloWorld.tsx # 一个用例组件
│ └── server.js # 服务器端代码
└── tsconfig.json
嗯,再来看看各个文件我都写了啥:
tsconfig.json,配置下 jsx 的转化,这段代码是我从 webpack 官网嫖下来的:
{
"compilerOptions": {
"outDir": "dist",
"noImplicitAny": true,
"module": "esnext",
"target": "ESNext",
"jsx": "react-jsx",
"allowJs": true,
"moduleResolution": "node"
}
}
其实 outDir
选项没啥用,但是不配置会报错我的 js 文件。
然后是 src 内的文件:
/** @file App.tsx */
import HelloWorld from "./HelloWorld";
export default function App() {
return (
<html>
<head>
<meta charSet="UTF-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<h1>react ssr</h1>
<HelloWorld />
<script src="/client.js"></script>
</body>
</html>
);
}
App.tsx 很简单,就是一个完整的页面组件,不过引入了客户端代码,这个在服务器端没什么用处,但是在客户端用来挂载 react 组件的逻辑。
/** @file client.tsx */
import * as ReactDOMClient from "react-dom/client";
import App from "./App";
ReactDOMClient.hydrateRoot(document, <App />);
client.tsx 是客户端代码,构建后会产生 client.js 文件,也就是 App.tsx 中引入的客户端代码。其实这个代码没几行,就是挂载 react 组件逻辑而已。
/** @file HelloWorld.tsx */
export default function HelloWorld() {
function handleClick() {
alert("something");
}
return <button onClick={handleClick}>hello world</button>;
}
HelloWorld.tsx 文件就是一个用例组件,用来测试模块化代码有没有成功引入,还有客户端有没有成功进行 react 的逻辑挂载。
最后是 server.js:
/** @file server.js */
import express from "express";
import App from "./App.tsx";
import { renderToPipeableStream } from "react-dom/server";
const server = express();
server.use(express.static("dist/static"));
server.get("/", (_, res) => {
renderToPipeableStream(<App />).pipe(res);
res.set("content-type", "text/html");
});
server.listen(80, () => {
console.log(`Example app listening on port 80`);
});
在 server.js 中,其实我就做了两件事,一个是解析 root/dist/static
文件夹为静态访问,二个就是路由根路径,使用 react 进行 ssr。eszy,基本没啥逻辑。
转化 jsx
现在万事俱备只欠东风,我们把项目里的 jsx 元素都解析一下就行了。
为了方便,使用 webpack 来充当构建工具,babel 就不用了,直接用 ts 来解析 jsx。
在项目根路径新建 webpack.config.js 文件如下:
const path = require("path");
const nodeExternals = require("webpack-node-externals");
module.exports = () => {
// tsx 配置
const tsx = {
resolve: {
extensions: [".js", ".jsx", "ts", ".tsx"], // 解析 tsx 文件
},
module: {
rules: [
{
test: /\.(tsx?|jsx?)$/, // js ts 都走一遍 ts 进行编译
use: "ts-loader",
exclude: /node_modules/,
},
],
},
};
// 服务器端代码构建
const server = {
...tsx,
mode: "development",
entry: {
index: "./src/server.js",
},
output: {
filename: "server.js",
path: path.join(__dirname, "dist"),
clean: true,
},
externalsPresets: { node: true }, // 不打包 node 模块,什么 fs path 那些
externals: [nodeExternals()], // 不打包 node_modules 内的模块,什么 express react react-dom 那些
};
// 客户端代码构建;
const client = {
...tsx,
mode: "development",
entry: {
client: "./src/client.tsx",
},
output: {
filename: "client.js",
path: path.join(__dirname, "dist/static"), // 输出到静态目录
},
};
return [server, client];
};
服务器端代码就是用 ts 解析一下,不然不能引入 jsx 文件。客户端代码需要单独打包,因为要引入 react 等相关依赖。
服务器端也可以不使用 jsx:
首先不引入开发的 .tsx 代码,直接引入打包好的 .js 组件代码,这样就不用对服务器端代码进行转化了。
然后使用
renderToPipeableStream(React.createElement(App));
来代替:
renderToPipeableStream(<App />);
即可。
接下来干什么?当然是构建目标代码了,来到 package.json 中,在 script 内添加如下命令:
{
"scripts": {
"build": "webpack --config webpack.config.js --watch",
"server": "nodemon ./dist/server.js",
"test": "echo \"Error: no test specified\" && exit 1"
}
}
build
命令使用 webpack 构建了服务器和客户端代码,server
命令启动我们的服务器并进行服务器热重载,方便我们开发用。
然后我们先打开一个终端进行 build:
npm run build
此时可以看到项目里出现了 dist 文件夹和相关代码,然后我们 再新建一个终端 使用 server 命令启动服务器:
npm run server
不出意外的话,打开浏览器访问 localhost 就能访问页面了,什么点击事件也能用。
总结和一些思考
总结
react 的 ssr 并没有什么难点,就只是把 思路 中提及实现出来。
-
转化 jsx
这部分在上述实现中我用到了 typescript,实际上 react 官方更建议使用 babel,babel 为转化 jsx 实现了一个预设 @babel/preset-react,可以很轻松的实现 jsx 转化。还可以用 babel 的预设 @babel/preset-typescript 来支持 ts,从而实现 tsx 转化。
-
模板注入
我在文中的实现其实忽略了这部分,因为我实现的 App.tsx 组件包含了一个完整的 html 结构。这种操作方式其实也没什么不妥,不过生态库视乎对此没有完善的支持。
其实关于客户端逻辑挂载和样式表等其他资源,App.tsx 完全可以使用插槽的方式提供开发接口,让组件关注的逻辑缩小,让服务器端来执行相关客户端逻辑注入。
-
吸水反应
在没有完成上述的实现之前,我一直以为 吸水 操作会是实现的难点,结果就是我真的才写了一行代码
ReactDOMClient.hydrateRoot(document, <App />);
就完成这个操作,所以上文中我都找不到地方提及它的使用。
React 18:
react 18 并不仅仅改动了 react 库,还对 react-dom 库也进行了改动,重构了部分的接口,同时也提供了一些新的接口。renderToPipeableStream
让首屏渲染快了不少,我尝试渲染了一个 20w 项的列表进行测试,大致如下:
<ul>
{Array.from({ length: 200000 }).map((_, i) => (
<li key={i}>列表渲染抗压</li>
))}
</ul>
当我使用 renderToString
进行渲染时,首屏渲染大概花了七八秒,这期间屏幕处于一个请求的状态。打开 chrome 的性能测试,看到页面就进行了一次渲染,是在整个 http 响应结束之后。
而当我使用 renderToPipeableStream
进行渲染是,首屏在两秒内就完成渲染,感官上带来的效果极其明显。打开 chrome 性能测试,发现页面进行了多次渲染,浏览器在获取到一部分代码后,就立刻开始了渲染,渲染被分割成了多次,逐步完善了整个页面
一些思考
-
我能否在局部对数据进行转译并引入?
我理想的情况是我能够编写一个
getJSX
函数,使用大致如下:server.get("/", (req, res) => { res.send(getJSX("./App.tsx")); });
getJSX 应该在内部引入 App.tsx 文件,因为没有转译,我只能通过 fs 文件系统拿到一段流,这期间我可以工具对其进行转译成 CommonJS 模块代码,问题是,我如何引入这段代码?
一开始我设想时想到这个问题,在 NodeJS 环境中,我如何把一段流,当成代码进行执行?我也是傻,花了半天时间我才想到 eval 函数,然后找到 NodeJS 环境中的 node-eval 库,最后顺藤摸瓜找到了 NodeJS 的 vm 模块,vm 模块可以使用虚拟机来执行相关代码并获取输出,所以这个设想是成立的。
-
express 不支持在 webpack 中打包。
我花了大概两三天的时间实现上述 react 的 ssr 并写下这篇记录,虽然实现上并没有我想象的那么难,但是我并不是很满意。
期间遇到一个问题 “为啥 express 不能使用 webpack 进行打包?”。我想使用 webpack 对 express 进行捆绑打包,这样得到的 server.js 仅依赖 NodeJS 环境就能运行,此时部署在服务器就非常方便,因为我不用安装 express 包来提供环境,只需要一条
node server.js
就可以运行。然而 express 内部并没有完美支持 webpack 打包,当我在配置文件删除externals: [nodeExternals()],
时,再次打包就会产生警告:WARNING in ./node_modules/express/lib/view.js 81:13-25 Critical dependency: the request of a dependency is an expression @ ./node_modules/express/lib/application.js 22:11-28 @ ./node_modules/express/lib/express.js 18:12-36 @ ./node_modules/express/index.js 11:0-41 @ ./src/server.js 3:0-30 7:15-22 8:11-25 1 warning has detailed information that is not shown. Use 'stats.errorDetails: true' resp. '--stats-error-details' to show it. webpack 5.73.0 compiled with 1 warning in 3630 ms
运行这个 server.js 将得到一个错误并终止程序,express 在 webpack 中并不能正常运行,这是很烦躁的事情。
很遗憾的是,这个问题我没有找到完美的解决方案,所有方案的目的地都指向 webpack-node-externals 这个库,就是让我把 express 设置成 外部扩展。