在《源码篇 | 手写 RSC(下)》中,我们实现了 React RSC,最终的效果如下:
本篇并不会拓展新的功能,而是会在此基础上进行优化,并讲解 RSC 与 Next.js 实现的基本原理。
如果没有实现之前的代码,可以运行:
# 下载指定分支的代码
git clone -b react-rsc-8 git@github.com:mqyqingfeng/next-app-demo.git
# 进入目录并安装依赖项
cd next-app-demo && npm i
# 启动
npm start
查看我们的 generator.tsx
中的 htmlGenerator 函数代码:
export async function htmlGenerator(url) {
let jsx = <Router url={url} />
let html = await renderJSXToHTML(jsx);
const clientJSX = await renderJSXToClientJSX(jsx);
// ...
}
运行 renderJSXToHTML 时我们递归调用了 Router 和子组件,运行 renderJSXToClientJSX 时,我们又递归调用了 Router 和子组件,这就造成了两次重复调用,如果数据变化(比如 feeds)就会产生问题,所以最好的解决方法是使用 clientJSX 渲染最终的 HTML。修改代码如下:
export async function htmlGenerator(url) {
let jsx = <Router url={url} />
const clientJSX = await renderJSXToClientJSX(jsx);
let html = await renderJSXToHTML(clientJSX);
// ...
}
我们自定义的 renderJSXToHTML 其实对应的就是 React 的 renderToString 函数,我们直接修改为使用 renderToString。修改 generator.tsx
中的 htmlGenerator 函数代码:
import { renderToString } from 'react-dom/server';
export async function htmlGenerator(url) {
let jsx = <Router url={url} />
const clientJSX = await renderJSXToClientJSX(jsx);
let html = await renderToString(clientJSX);
// ...
}
在优化一中,我们已经将组件运行和生成 HTML 解耦:
首先,renderJSXToClientJSX 生成客户端 JSX 对象,再调用 renderToString 将客户端 JSX 转换为 HTML。
因为步骤相互独立,所以我们完全可以拆分为两个服务:
现在让我们开始修改代码吧。
新建 server/rsc.ts
和 server.ssr.ts
,为了能够同时运行,我们安装 concurrently:
npm i concurrently
修改 package.json
,代码如下:
{
"scripts": {
"start": "concurrently \"npm run start:ssr\" \"npm run start:rsc\"",
"start:rsc": "tsx watch ./server/rsc.ts",
"start:ssr": "tsx watch ./server/ssr.ts"
}
}
其中server/rsc.ts
代码如下:
import express from "express";
import { jsxGenerator } from "../generator";
const app = express();
app.get("/:route(*)", async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const clientJSXString = await jsxGenerator(url);
res.setHeader("Content-Type", "application/json");
res.end(clientJSXString);
});
app.listen(3001, (err) => {
if (err) return console.error(err);
return console.log(`Server is listening on 3001`);
});
server/ssr.ts
代码如下:
import express from "express";
import { readFile } from "fs/promises";
import { renderToString } from "react-dom/server";
import { parseJSX } from "../utils";
const app = express();
app.get("/:route(*)", async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
// client.js
if (url.pathname === "/client.js") {
const content = await readFile("./client.js", "utf8");
res.setHeader("Content-Type", "text/javascript");
res.end(content);
return;
}
// 获取客户端 JSX 对象
const response = await fetch("http://127.0.0.1:3001" + url.pathname);
if (!response.ok) {
res.statusCode = response.status;
res.end();
return;
}
const clientJSXString = await response.text();
// 获取客户端 JSX 对象
if (url.searchParams.has("jsx")) {
res.setHeader("Content-Type", "application/json");
res.end(clientJSXString);
}
// 获取 HTML
else {
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>`;
html += `
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.2.0",
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev"
}
}
</script>
<script type="module" src="/client.js"></script>
`;
res.setHeader("Content-Type", "text/html");
res.end(html);
}
});
app.listen(3000, (err) => {
if (err) return console.error(err);
return console.log(`Server is listening on 3000`);
});
utils.js
新增 parseJSX 函数:
export function parseJSX(key, value) {
if (value === "$RE") {
return Symbol.for("react.element");
} else if (typeof value === "string" && value.startsWith("$$")) {
return value.slice(1);
} else {
return value;
}
}
运行 npm start
,此时效果应该是不变的:
现在让我们重新看下实现的原理。当页面初始加载时:
当用户访问 /
的时候,请求首先会到 SSR server 上,然后 SSR server 请求 RSC server,RSC server 返回 /
的 React 树,SSR server 获取到 React 树后,会根据 React 树渲染 HTML,最后将 HMTL 返回给用户。
当后续发生导航时:
当用户发生导航行为时,客户端会拦截浏览器的默认跳转,改为客户端请求目标路径的数据。请求首先会到 SSR server,SSR server 根据其中的 ?jsx 参数判断出是获取客户端 JSX 对象,然后请求 RSC server,SC server 返回 /
的 React 树,SSR server 获取到 React 树后,将 React 树返回给客户端,客户端根据 React 树修改 DOM。
理解这个过程,有助于我们学习 Next.js。比如我们在 《渲染篇 | 服务端组件和客户端组件》讲到组件的渲染原理时:
在服务端:
Next.js 使用 React API 编排渲染,渲染工作会根据路由和 Suspense 拆分成多个块(chunks),每个块分两步进行渲染:
RSC payload 中包含如下这些信息:
- 服务端组件的渲染结果
- 客户端组件占位符和引用文件
- 从服务端组件传给客户端组件的数据
在客户端:
你会发现,这个架构设计其实十分类似。不过目前客户端组件的实现还没有讲到,但单看服务端组件的部分,是不是对 Next.js 的实现有了更多的理解了?
此外,我们在《缓存篇 | Caching(上)》时讲到 Next.js 中的四种缓存机制:
现在再看其中的 RenderToPayload 和 RenderToHTML 是不是似曾相识?
按照我们目前的实现方式,所谓“全路由缓存”,就是在服务端缓存目标路由的客户端 JSX 对象和 HTML。
在后续导航的时候,目标路由的客户端 JSX 对象会发送给客户端,客户端根据这个客户端 JSX 对象进行更新,所谓“路由缓存”,其实就是将返回的客户端 JSX 对象缓存在浏览器中。
现在让我们顺手实现一下“路由缓存”。修改 client.js
,代码如下:
import { hydrateRoot } from 'react-dom/client';
let currentPathname = window.location.pathname;
const root = hydrateRoot(document, getInitialClientJSX());
// 客户端路由缓存
let clientJSXCache = {}
clientJSXCache[currentPathname] = getInitialClientJSX()
function getInitialClientJSX() {
const clientJSX = JSON.parse(window.__INITIAL_CLIENT_JSX_STRING__, parseJSX);
return clientJSX;
}
async function navigate(pathname) {
currentPathname = pathname;
if (clientJSXCache[pathname]) {
root.render(clientJSXCache[pathname])
return
} else {
const clientJSX = await fetchClientJSX(pathname);
clientJSXCache[pathname] = clientJSX
if (pathname === currentPathname) {
root.render(clientJSX);
}
}
}
// 其他保持不变
实现并不复杂。页面初始加载的时候,将页面的客户端 JSX 对象保存在缓存中。导航的时候,如果没有命中缓存,则触发请求,然后将返回的结果保存在缓存中,如果命中缓存,则直接缓存中的数据。
效果如下:
因为有了客户端路由缓存,所以只会触发一次 earth?jsx
和 hello?jsx
请求,后续点击的时候,使用的都是缓存中的数据。
现在是不是对 Next.js 的缓存有了更加深入的理解了?
- 功能实现:优化了 RSC 实现和实现客户端路由缓存
- 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/react-rsc-9
- 下载代码:
git clone -b react-rsc-9 git@github.com:mqyqingfeng/next-app-demo.git