这是"从头学服务器组件"系列的第 6 篇文章,也是最后一篇。这个系列的文章的来自于 Dan Abramov 所写的《RSC From Scratch. Part 1: Server Components》这篇长文,为了方便理解和学习,我将这篇长文拆分成了一个系列进行讲解。
回顾
在上一篇文章《从头学服务器组件#5:在导航间保留状态》中,我们为服务端增加了返回 JSX 数据的支持,并使用 React 在客户端进行消费,实现基于 JSX 的结构的页面初始化和页面局部更新。
在最后这篇文章,我们将对现在的代码做一些清理工作,修复一些缺陷,并对服务端进行拆分,为下一波功能添加做好准备。
第 1 步:避免重复工作
再看一下我们初始生成 HTML 的代码:
javascript
async function sendHTML(res, jsx) {
// We need to turn <Router /> into "<html>...</html>" (a string):
let html = await renderJSXToHTML(jsx);
// We *also* need to turn <Router /> into <html>...</html> (an object):
const clientJSX = await renderJSXToClientJSX(jsx);
假设 jsx
值为 <Router url="https://localhost:3000" />
。
首先,我们调用 renderJSXToHTML
,它会在创建 HTML 字符串时递归调用 Router
和其他组件。但我们还需要发送初始客户端 JSX --- 因此之后还要调用 renderJSXToClientJSX
,这会再次调用 Router
和所有其他组件。也就是说,每个组件都调用了两次!这不仅很慢,而且还可能发生错误。例如,如果我们渲染的是一个 Feed
组件,那么前后的调用输出可能是不同的。因此,我们需要重新思考数据的流动方式。
如果我们先生成客户端 JSX 树如何?
javascript
async function sendHTML(res, jsx) {
// 1. Let's turn <Router /> into <html>...</html> (an object) first:
const clientJSX = await renderJSXToClientJSX(jsx);
到这里,我们所有的组件都执行一遍了。然后,将 clientJSX
直接带入 renderJSXToHTML
就能生成 HTML 了!
javascript
async function sendHTML(res, jsx) {
// 1. Let's turn <Router /> into <html>...</html> (an object) first:
const clientJSX = await renderJSXToClientJSX(jsx);
// 2. Turn that <html>...</html> into "<html>...</html>" (a string):
let html = await renderJSXToHTML(clientJSX);
// ...
这样,程序依然能正常运转,同时每个组件也调用一次。
点击这里的 线上 demo 查看效果。
第 2 步:使用 React 来渲染HTML
我们的自定义函数 renderJSXToHTML
最初引入是为了正确执行组件调用。例如,对 async
函数的处理。现在,renderJSXToHTML
接收到的已经时预先计算好的客户端 JSX 树,所以无需再执行处理,保留它也没有意义了。因此,我们删除 renderJSXToHTML
,并使用 React 内置的 renderToString
进行替换。
javascript
import { renderToString } from 'react-dom/server';
// ...
async function sendHTML(res, jsx) {
const clientJSX = await renderJSXToClientJSX(jsx);
let html = renderToString(clientJSX);
// ...
这块代码跟客户端代码有些的相似。尽管我们已经实现了新功能(例如 async
组件),但仍然可以使用现有的 React API(例如 renderToString
或 hydrateRoot
),只不过使用方式不同而已。
在传统的服务器渲染 React 应用程序中,我们可以使用根 <App />
组件调用 renderToString
和 hydrateRoot
。但在我们的方法中,我们首先使用了 renderJSXToClientJSX
计算"服务器"JSX 树,并将其输出传递给 React API。
在传统的服务器渲染 React 应用程序中,组件在服务器和客户端上是以相同方式执行的。但在我们的方法中,像 Router
、 BlogIndexPage
和 Footer
这样的组件实际上仅适用于服务器(至少目前是这样)。
对 renderToString
和 hydrateRoot
来说,Router
、 BlogIndexPage
和 Footer
组件似乎从来就没存在过。因为,等到调用它们时,这些服务端组件已经从 JSX 树上处理掉了,只留下最后的纯客户端产物。
第 3 步:拆分服务端代码
在上一步中,我们将运行组件与生成 HTML 做了解耦:
- 首先,调用
renderJSXToClientJSX
函数执行我们的组件来生成客户端 JSX - 然后,调用
renderToString
函数将客户端 JSX 转换成 HTML
由于这些步骤是独立的,因此它们不必在同一进程中完成,甚至不必在同一台机器上完成。
为了演示这一点,我们将 server.js
拆分为两个文件:
- server/rsc.js:这个服务器用来运行我们的组件。总是对外输出 JSX --- 不输出 HTML。如果我们的组件正在访问数据库,那么在靠近数据中心的位置运行这个服务器是有意义的,这样会降低延迟
- server/ssr.js:这个服务器用来生成 HTML,提供静态资源
我们在 package.json
中启动这两个服务:
json
{
"scripts": {
"start": "concurrently \"npm run start:ssr\" \"npm run start:rsc\"",
"start:rsc": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server/rsc.js",
"start:ssr": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server/ssr.js"
},
}
咱们这个 demo 里,两个服务位于同一台计算机上,但实际上时可以单独进行托管的。
RSC 服务器是渲染我们的组件的服务器,只提供 JSX 输出:
jsx
// server/rsc.js
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
await sendJSX(res, <Router url={url} />);
} catch (err) {
console.error(err);
res.statusCode = err.statusCode ?? 500;
res.end();
}
}).listen(8081);
function Router({ url }) {
// ...
}
// ...
// ... All other components we have so far ...
// ...
async function sendJSX(res, jsx) {
// ...
}
function stringifyJSX(key, value) {
// ...
}
async function renderJSXToClientJSX(jsx) {
// ...
}
另一个是 SSR 服务器。 SSR 服务器也是我们用户会访问的服务器,向 RSC 服务器发出 JSX 请求,然后再将 JSX 作为字符串提供(用于页面之间的导航),或者将其转换为 HTML(用于初始加载):
jsx
// server/ssr.js
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === "/client.js") {
// ...
}
// Get the serialized JSX response from the RSC server
const response = await fetch("http://127.0.0.1:8081" + url.pathname);
if (!response.ok) {
res.statusCode = response.status;
res.end();
return;
}
const clientJSXString = await response.text();
if (url.searchParams.has("jsx")) {
// If the user is navigating between pages, send that serialized JSX as is
res.setHeader("Content-Type", "application/json");
res.end(clientJSXString);
} else {
// If this is an initial page load, revive the tree and turn it into HTML
const clientJSX = JSON.parse(clientJSXString, parseJSX);
let html = renderToString(clientJSX);
html += `<script>window.__INITIAL_CLIENT_JSX_STRING__ = `;
html += JSON.stringify(clientJSXString).replace(/</g, "\\u003c");
html += `</script>`;
// ...
res.setHeader("Content-Type", "text/html");
res.end(html);
}
} catch (err) {
// ...
}
}).listen(8080);
点击这里的 线上 demo 查看效果。
在后续系列,我们将 RSC 和"其他部分"(SSR 和用户计算机)之间保持分离。分离的好处会在后续向这两块添加新功能显现。
(严格来说,技术上可以在同一进程中运行 RSC 和 SSR,但它们的模块环境必须相互隔离。这是一个高级主题,超出了本系列的讨论范围。)
数据流向说明
终于,我们终于实现了一个简易版本的 RSC 实现。看起来我们好像写了很多代码,但实际上真没有多少。
- server/rsc.js:共 160 行代码,其中 80 行都是组件代码
- server/ssr.js:共 60 行代码
- client.js:也是 60 行代码
通读一遍。也为了更好的帮助我们理解数据流向,这里画了 2 张图表。
这是页面首次加载期间的数据流向:
当在页面之间导航时的数据流向:
最后,总结一下图表里用到一些术语:
- React Server (或是 Server )只用来指 RSC 服务器环境。只存在于 RSC 服务器上的组件(这个系列里我们写得所有组件都是)称为服务器组件(Server Components)。
- 而 React Client (或是 Client )是指任何消费 React Server 输出产物的环境。如你所见,SSR 只是一个 React 客户端,浏览器也是如此。目前,我们还不支持在客户端上的组件,也就是所谓的客户端组件(Client Components),后续我们会实现。
总结
作为本系列的最后一篇,我们移除了之前的 renderJSXToHTML
函数实现,并使用 React API renderToString
替代。另外,我们还将服务端代码一份成二,拆分成可单独部署的 RSC 服务与 SSR 服务,把执行服务器组件与生成 HTML 解耦了,也为后续继续添加功能带来便捷。
感谢你的阅读,Happy Coding!