准备
- 安装好 node ,版本 >=18.0。
- 了解 react renderToString 和 hydrateRoot 方法
- 了解 babel 和 webpack
启动一个服务器
新建一个src
目录,目录下新建一个server.js
文件,文件里添加下面这些代码:
// src/server.js
const http = require("http");
const PORT = 3000;
const sever = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(`
<html>
<head>
<title>react ssr demo</title>
</head>
<body>
<h1>hello</h1>
</body>
</html>
`);
});
sever.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
上面的代码用 node 启动了一个服务器,返回一个非常简单的 HTML 代码。
运行下面的命令,启动服务器:
node src/server.js
接着在浏览器中访问 http://localhost:3000
,即可看到大标题 hello
。
上面的例子中,是手写了页面内容,接下来换用 react 来写页面。
服务器渲染 react 组件
先安装下 react (在写这篇文章时,安装的 react 版本是 19)。
npm install react react-dom
然后在 src
目录下新建个 App.js
文件,用来当做 react 组件,添加下面这些代码:
// App.js
function App() {
return (
<button
onClick={() => {
alert("hello");
}}
>
click me
</button>
);
}
module.exports = App;
这段代码很简单,定义了一个 react 函数式组件,返回一个可点击的按钮,点击按钮会弹出一个提示框。
在 server.js
文件中导入这个组件:
const http = require("http");
const App = require("./App.js");
react 提供了一个服务端 API: renderToString,这个方法可将 react 节点渲染为一个 HTML 字符串。
组件也是一种 react 节点,我们可以用这个方法把上面的 App 组件渲染为一个 HTML 字符串。
// src/server.js
const http = require("http");
const App = require("./App.js");
const { renderToString } = require("react-dom/server");
const PORT = 3000;
const sever = http.createServer((req, res) => {
const html = renderToString(<App />);
res.writeHead(200, { "Content-Type": "text/html" });
res.end(`
<html>
<head>
<title>react ssr demo</title>
</head>
<body>
<h1>hello</h1>
<div id="root">${html}</div>
</body>
</html>
`);
});
sever.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
重新启动服务器 node src/server.js
,这个时候终端会显示一个报错:
这是因为 <App />
是一种 JSX 语法,node 是不认识这种语法的,<
在 node 眼里只是个比较运算符。
我们需要 babel 工具来把 JSX 语法转换成 node 可以读懂的普通 JavaScript ,因为文章后面还要用 webpack 转换和打包客户端的代码,webpack 也可以处理 node 环境的代码,索性这个地方我们直接用 webpack 和 babel 来处理 server.js
安装 webpack 、babel 和一些要用到的插件:
npm install @babel/core @babel/preset-env @babel/preset-react babel-loader webpack webpack-cli webpack-node-externals
- @babel/core:Babel 的核心包。
- @babel/preset-env:用于转换最新的 JavaScript 语法。
- @babel/preset-react:用于转换 JSX 语法。
- babel-loader:可以在 webpack 中使用 babel。
- webpack:webpack 的核心包。
- webpack-cli:以命令行形式运行 webpack。
- webpack-node-externals:一个插件,用来排除 node_modules 目录下的文件。
使用 webpack 时通常要使用配置文件。在项目根目录新建一个 webpack.config.js
文件,添加如下代码:
// webpack.config.js
const path = require("path");
const nodeExternals = require("webpack-node-externals");
module.exports = {
mode: "production",
externals: [nodeExternals()],
target: "node",
entry: "./src/server.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "server.js",
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: [
[
"@babel/preset-env",
{
modules: "commonjs",
},
],
[
"@babel/preset-react",
{
runtime: "automatic",
},
],
],
},
},
},
],
},
};
不用太关心配置,配置是服务人民的,只需要知道这个配置可以帮我们处理高级的语法,让 node 能看懂、运行。
Good to know
你可能发现了,在写 App 组件的时候,并没有主动导入 react。按照常理来讲,在使用 JSX 语法的时候,应该主动导入 react,否则会报错。
这是因为开启了 "runtime": "automatic"
选项,这个选项可以在我们使用 react 的地方,自动导入 react,开发者不需要在每个 react 组件内都主动导入 react。
至此, webpack 都已准备完毕。执行下面的命令,运行 webpack:
npx webpack -w
如果转换成功, dist
目录下会生成 server.js
文件。-w
选项可以监听文件的变化,与 src/server.js
相关的文件一旦被修改, webpack 将自动执行。
使用 dist
目录下的 server.js
文件重新启动服务器:
node src/server.js
node dist/server.js
刷新下页面,这个时候就显示了我们用 react 写的按钮组件:
但是,当我们点击按钮的时候,跟预想的不一样,没有出现提示框,Why???
为了搞懂原因,我们先查看下网页源代码:
<html>
<head>
<title>react ssr demo</title>
</head>
<body>
<div id="root"><button>click me</button></div>
</body>
</html>
button
元素上的 onclick
事件没了,严重怀疑 react 在服务端把 onclick
代码移除了。
回到 const html = renderToString(<App />)
这段代码,把变量 html
打印出来看看。
const html = renderToString(<App />);
console.log(html);
重新启动服务器,刷新页面 http://localhost:3000
,服务端控制台会打印出字符串 <button>click me</button>
,点击事件的那些代码确实被 renderToString
方法删掉了。
INFO
renderToString
函数只会生成没有交互的 HTML 字符串,像按钮的点击、输入框的输入等等、和客户端交互相关的代码,服务端没法处理,索性就移除了这些代码。
移除归移除,按钮还是要有点击事件的,接下来我们要用 react 另一种方法,为这些没有交互的 HTML 代码,加上交互。
激活服务端渲染的 HTML
react 提供了 hydrateRoot 方法,它可以激活服务端生成的 HTML,让这些 HTML 具有交互性,比如点击、输入等。
我们在 src
目录下再新建一个 client.js
文件,顾名思义,这个文件只能在客户端运行,添加如下代码:
// src/client.js
const { hydrateRoot } = require("react-dom/client");
const App = require("./App.js");
hydrateRoot(document.getElementById("root"), <App />);
hydrateRoot 方法会遍历服务端生成的 DOM 结构,尝试将每个 DOM 节点与 react 虚拟 DOM 节点一一关联,将事件和状态管理附加在服务端生成的 DOM 上。
在我们这个项目里,“react 虚拟 DOM 节点”可以粗糙地理解为带有点击事件的 App
组件,“服务端生成的 DOM” 是没有点击事件的、干巴巴的字符串 <button>click me</button>
,组件里的点击事件被附加在这个服务端生成的 DOM 上面。
现在有个待处理的问题, client.js
文件里面有 require
、<App />
等语法,客户端是没法运行的,需要转换一下,这里还是要用到 webpack, 将 client.js
打包成可以在客户端运行的代码。
将 webpack.config.js
修改为如下代码:
// webpack.config.js
const path = require("path");
const nodeExternals = require("webpack-node-externals");
const baseConfig = {
mode: "production",
output: {
path: path.resolve(__dirname, "dist"),
filename: "[name].js",
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: [
[
"@babel/preset-env",
{
modules: "commonjs",
},
],
[
"@babel/preset-react",
{
runtime: "automatic",
},
],
],
},
},
},
],
},
};
const clientConfig = {
...baseConfig,
entry: {
client: "./src/client.js",
},
};
const serverConfig = {
...baseConfig,
entry: {
server: "./src/server.js",
},
target: "node",
externals: [nodeExternals()],
};
module.exports = [clientConfig, serverConfig];
这些乱七八糟的配置也不用太在意细节,只需要知道,它可以让 webpack 同时处理服务端和客户端的脚本。重新启动下 webpack,dist
目录下会生成一个 client.js
文件。
为了能让刚生成的 dist/client.js
文件顺利在客户端加载、运行,需要改造 server.js
文件:
// src/server.js
const http = require("http");
const App = require("./App.js");
const path = require("path");
const fs = require("fs");
const { renderToString } = require("react-dom/server");
const PORT = 3000;
const sever = http.createServer((req, res) => {
// prettier-ignore
if (req.url === "/client.js") {
const filePath = path.join(__dirname, "../dist/client.js");
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Internal Server Error");
} else {
res.writeHead(200, { "Content-Type": "application/javascript" });
res.end(data);
}
});
return;
}
const html = renderToString(<App />);
console.log(html);
res.writeHead(200, { "Content-Type": "text/html" });
res.end(`
<html>
<head>
<title>react ssr demo</title>
</head>
<body>
<div id="root">${html}</div>
<script src="/client.js"></script>
</body>
</html>
`);
});
sever.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
重新启动下服务器,刷新下页面,点击按钮,能像预期一样,弹出一个 hello 文案的提示框。
至此,我们已经实现了服务端渲染 React 组件,并且在客户端加上交互。
服务端渲染还有另外一个优点,就是可以在服务端获取数据,因为服务端和后端的项目通常部署在同一个集群,相比于客户端,服务端获取数据会更快、更稳定。
有优点就要发挥出来,让我们在 server.js
里获取数据,然后把数据和组件组装起来,渲染完后发送到客户端。
远程数据的获取与传递
我们现在要改造下 App 组件,让它能接受一个字符串,作为按钮的文案:
// src/App.js
function App() {
function App({ text }) {
return (
<button
onClick={() => {
alert("hello");
}}
>
click me
{text}
</button>
);
};
module.exports = App;
按钮的文案是通过调用接口获取的,调用接口的操作我们也放在 App 组件里:
function App({ text }) {
return (
<button
onClick={() => {
alert("hello");
}}
>
{text}
</button>
);
}
// prettier-ignore
App.getServerSideProps = async function () {
// 模拟接口调用,耗时2秒
const data = await new Promise((resolve) => {
setTimeout(() => {
resolve({ text: "click here" });
}, 2000);
});
return {
props: data,
};
};
module.exports = App;
在服务端,我们调用 App 组件的这个 getServerSideProps
方法,并且把返回的数据传递给 App 组件:
const http = require("http");
const App = require("./App.js");
const path = require("path");
const fs = require("fs");
const { renderToString } = require("react-dom/server");
const PORT = 3000;
const sever = http.createServer((req, res) => {
const sever = http.createServer(async (req, res) => {
if (req.url === "/client.js") {
const filePath = path.join(__dirname, "../dist/client.js");
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Internal Server Error");
} else {
res.writeHead(200, { "Content-Type": "application/javascript" });
res.end(data);
}
});
return;
}
const html = renderToString(<App />);
const { props } = await App.getServerSideProps();
const html = renderToString(<App {...props} />);
console.log(html);
res.writeHead(200, { "Content-Type": "text/html" });
res.end(`
<html>
<head>
<title>react ssr demo</title>
</head>
<body>
<div id="root">${html}</div>
<script src="/client.js"></script>
</body>
</html>
`);
});
sever.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
重启服务器,刷新下页面,两秒钟后页面展示出文案为 click here 的按钮,两秒钟正好是服务端获取数据消耗的时间。
现在我们再改造下 App 组件,让它额外接受一个 message
参数,作为点击按钮时弹出的文案:
function App({ text }) {
function App({ text, message }) {
return (
<button
onClick={() => {
alert("hello");
alert(message);
}}
>
{text}
</button>
);
}
App.getServerSideProps = async function () {
// 模拟接口调用,耗时2秒
const data = await new Promise((resolve) => {
setTimeout(() => {
resolve({ text: "click here" });
resolve({ text: "click here", message: "good!" });
}, 2000);
});
return {
props: data,
};
};
module.exports = App;
重启服务器,刷新下页面,点击按钮,很可惜,弹出的文案是 undefined
,而不是 good!
:
这不对劲啊,我明明传了 message
字段进去,为什么还会 undefined
???
让我们回到 “renderToString 函数只会生成没有交互的 HTML 字符串” 这段话,在服务端我们虽然把 message
信息传递给组件了,但是服务端渲染的时候,把点击事件的代码移除了。
真正的点击事件是客户端执行这段代码 hydrateRoot(document.getElementById("root"), <App />)
时加上去的,在执行这段代码时,没有向 App 组件传递参数,在取值 message
时,自然是 undefined
。
现在我们面临另外一个问题,数据是在服务端获取的,点击事件却是在客户端加上的,如何在执行 hydrateRoot(document.getElementById("root"), <App />)
时候,把服务端的 props 也传递给 App 组件呢?
我们可以在服务端渲染页面的时候,顺带把数据塞到页面里面!这样客户端就可以拿到数据,然后传到组件里面了!
先把数据塞到页面里面:
// src/server.js
// ...
const sever = http.createServer(async (req, res) => {
// ...
res.end(`
<html>
<head>
<title>react ssr demo</title>
</head>
<body>
<div id="root">${html}</div>
<script type="application/json" id="__PAGE_PROPS__">
${JSON.stringify(props)}
</script>
<script src="/client.js"></script>
</body>
</html>
`);
});
// ...
然后在客户端脚本里,获取这个数据,传递给组件:
// src/client.js
const { hydrateRoot } = require("react-dom/client");
const App = require("./App.js");
const props = JSON.parse(document.getElementById("__PAGE_PROPS__").textContent);
hydrateRoot(document.getElementById("root"), <App />);
hydrateRoot(document.getElementById("root"), <App {...props} />);
重启服务器,刷新下页面,点击按钮,弹出文案 good!
:
至此,我们已经实现了服务端渲染 react 组件,并且让组件具有交互性,最终成功将服务端 props 传递给客户端组件。
一些基于 react 的服务端渲染的框架(比如 Nextjs),最基础、最核心的东西,大概也是如此,当然这类框架远比我们实现的要复杂得多。