在此篇文章中,我将尝试去构建一个简单的应用去实现 React Server Component。
step1 在服务端渲染 react 组件
首先,用 express 去搭建一个服务器,然在尝试在服务端去渲染 React 的组件。 这个页面很简单,就是读取 data
文件夹下的两个文件名展示为页面的两个按钮。 实现起来很简单,重点在于我们直接使用 Raect 提供的 api
javascript
import { renderToPipeableStream } from 'react-dom/server.node'
这会儿有些朋友就要说了,这不就是 SSR 吗? 是的,目前的代码中实现的是,但是 SSR 和 RSC 绝对不是同一个东西,我们只是先从实现这个功能开始。 如果不直接借用 React 提供的 api, 我们还得自己去实现一个 jsxToHtml
函数,麻烦,所以这里直接使用 React 提供的 api 了,由于其中有 async component,所以这里直接使用 renderToPipeableStream
这个 api 了。 现在启动服务,然后访问 http://localhost:5555
应该能看到这样的界面: 在 分支 feat/step1 可以看到完整代码
step2 增加路由
现在页面中有两个按钮,现在我想添加一个功能,就是点击按钮的时候展示出按钮对应文件的内容,现在需要为程序去添加:文件内容展示以及路由的功能。 这个功能实现起来也是相当的简单,只需要改造一下服务的路由部分:
javascript
app.use('/', (req, res) => {
const { pipe } = renderToPipeableStream(<App url={req.url} />, {
// bootstrapScripts: ['/public/main.js'],
onShellReady() {
res.setHeader('content-type', 'text/html');
pipe(res);
}
})
})
这里我们将 url 作为参数传到组件里面去。 然后再实现一个文件展示组件:
javascript
async function FileDetails({ filePath }) {
const fileContent = await readFile(filePath, 'utf8')
return (
<p>
{fileContent}
</p>
)
}
使用 node 提供的 Api 读取文件内容然后展示出来即可。 具体代码直接看源码 feat/step2 分支。
step3 页面跳转保留交互
经过 step2 我们实现了路由的功能,现在应用程序已经可以做到点击对应的按钮展示对应的文件内容。 注意到页面顶部有一个搜索框了吗,如果我们在搜索框里面输入内容,然后点击按钮会发现,展示的文章内容改变了,但是搜书框里面的内容也被清掉了。 我们接下来想要实现的内容就是,在"页面跳转"的时候,仅更新页面上变动的部分,不变的部分保留其状态,对应到这里要实现的效就是切换文章的时候,input 框里面的内容能够保留下来。
step3.1 拦截页面默认的路由行为
如果每次点击按钮都是重新去加载一个页面,那么肯定是无法实现这个功能的,所以第一步要做的事情就是,拦截默认的路由行为,所以需要写一个 js 文件在 client 端去运行。 这里添加了 app/client.js 文件,使用 webpack 打包之后,输出到 build/main.js 目录,然后把 build 目录设置为服务器的静态资源目录:
javascript
app.use('/public', express.static('build'))
这样 client 端就可以通过 http://localhost:5555/public/main.js
加载到文件,在 renderToPipeableStream
的 bootstrapScripts
配置项里配置上这个路径,之后 client 端就会加载这个 js 文件。 然后编写 client.js 文件:
javascript
window.addEventListener('click', function(e) {
e.stopPropagation()
e.preventDefault()
const target = e.target
if (target.href) {
console.log(target.href)
}
})
step3.2 实现获取页面内容的请求
接下来要实现从 client 端获取页面内容,且"增量的"将其渲染在页面上,先实现获取页面内容。 先考虑一下这里获取到什么样的页面内容,才能实现增量渲染? 肯定要借助 React 的能力啊,React 不就干这个事儿的吗,React Tree 前后 diff 找出不同的部分然后渲染。那这里返回 html 肯定是不行的了,只能返回 React 它认识的内容,这里很容易能联想到这个结构:
javascript
{
$$typeof: Symbol.for("react.element"),
type: 'html',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: 'head',
props: {
// ... And so on ...
这是 React.createElement 返回的数据结构。 我们可以在获取页面内容的请求时,服务端返回这样的数据结构,然后使用 React.render 去更新内容,这样肯定是行的通的。 但是我不打算以这样的思路讲下去,因为 React 提供了专门的数据结构和 api 用以将服务端组件渲染出来的数据传递给客户端,它有点类似于 JSON,比起 jsx 更紧凑,也是 React 推荐的做法。安装一下这个包:react-server-dom-webpack
。 这里就整个改掉吧,不在使用 SSR react-dom/server.node
这个包提供的渲染方法了。 接下来整块代码就动的比较多了。
- 服务启动的时候需要开启 react-srever 环境,既增加
--conditions react-server
参数
javascript
nodemon --conditions react-server --experimental-loader ./node-jsx-loader.js ./server/index.js
- 服务启动的时候,要调用 react-server-dom-webpack 提供的注册方法,在 server/index.js 文件:
- 区分了普通页面请求和
.jsx
请求,.jsx请求就认为是在获取服务端组件,使用react-server-dom-webpack
这个包提供的方法去渲染组件
javascript
app.use('/', (req, res) => {
if (req.url.indexOf('.jsx') >= 0) {
const { pipe } = renderToPipeableStream(<App url={req.url.replace('.jsx', '')} />)
pipe(res)
} else {
res.setHeader('content-type', 'text/html');
res.send(
`
<html>
<head></head>
<body>
<div id="root"></div>
<script src="/public/main.js"></script>
</body>
</html>
`
)
}
})
- 客户端使用
react-server-dom-webpack
提供的createFromFetch
去处理 fetch 请求,然后使用 react 的use
方法渲染 server component
整个流程就是,页面初始加载的时候加载一个空的 html 页面,然后初始化 react 的根节点,然后使用 fetch 加载服务端组件得到序列化后的服务端组件,然后使用 use 这个 api 渲染。 然后为了观察 Suspense 的效果,我们手动的把读取文件内容的时间延长,完整的代码请看 feat/step3
分支。 接下来尝试一下,在输入框中输入文字,然后点击文章按钮: 这里还有一个问题是当按钮点击时,并没有改变页面的路由地址,这里要实现起来也很简单,用 hash 路由或者 history api,然后页面初始加载的时候在回显路由内容就行了,不过多赘述了,重点不在这里。
step4 增加对于客户端组件的支持
服务端组件存在很多局限,比如其不支持 setState,其不能添加点击事件等,说直白点,就是服务端组件不能处理用户交互的部分,这部分只能由客户端组件来支持。 接下来我们就来实现客户端组件的部分:给应用程序添加一个功能,点击按钮的时候给当前显示的文章对应的按钮添加高亮的颜色。 首先是文章按钮这部分,需要用客户端组件去替代,React 规定以 "use client"
的开头的视为 Client Component,只在客户端渲染。 Client Component 不会再服务端被执行,从服务端组件返回的结果来看处理方式是将 Client Component 打包成一个单独的 chunk,然后记录这个 chunk 的引用,再到客户端加载其 js 文件。 React 已经提供了相关的 api 去实现这个能力。
javascript
const register = require('react-server-dom-webpack/node-register');
register();
在服务端代码开始执行之前,先执行 React 提供的这个 register 方法,其通过自定义 nodeJs 的 Module.prototype._compile
方法在 nodeJs 每次 require 文件时检测其头部是否有 'use client'
,有的话就做 Client Component 相关逻辑的处理,感兴趣的话可以自行去阅读 源码。 回到本 demo 来说,要实现 Client Componnet 我们要改三个地方:
- Server 端开始执行之前调用 React 提供的 register 方法
- Client 端 webpack 打包配置 React 提供的插件
javascript
const ReactServerWebpackPlugin = require('react-server-dom-webpack/plugin')
plugins: [
new ReactServerWebpackPlugin({isServer: false}),
]
- server 端传入 moduleMap
总体来说,我们借助 react-server-dom-webpack
实现三个事情:
- 根据模块中的
'use client'
生成客户端模块,生成单独的 chunk,React Flight 数据结构中引用这个 chunk。 - 生成 在 react-client-manifest.json,记录客户端模块和它依赖的其他模块。
- 使用 Webpack 的运行时按需加载客户端模块。
代码见分支 feat/step4
其实支持了客户端组件之后,可以优化 step3.1 的拦截页面默认路由行为的逻辑,因为现在支持绑定点击事件了,后面会讲到
step5 结合 SSR
在上一篇文章中,我们一直在强调 Server Component 的存在是为了让开发人员能够比较容易的写出代码维护和性能都还不错的应用程序。 但是目前的编写的应用程序还存在一个问题:既跟 SPA 一样,在初始的时候只是加载了一个空的 html,然后再 main.js 文件中去请求 Server Component 产出的类似 JSON 一样的数据然后再渲染在屏幕上。应用程序具备 SPA 的两个缺点:1 无法 SEO;2 不利于 FCP 指标。 其实我们想要达到的目的应该是:如果是初始页面请求,那就将 Server Component 渲染产出的数据结构转换成 html 在传输到客户端: 在回顾 step4 的内容,Client Component 被打包成了单独的 chunk,在客户端运行时才被加载,这一点其实不是很友好,因为当前 Client Component 总是依赖于 js 文件加载成功才被绘制到屏幕上,在之前的架构模式中,这一点无法避免。 现在在架构中加入了一层 SSR server,用以将 RSC 的渲染结果:数据结构 React Flight 转换成 html。在这一步里,SSR server 可以在服务端就加载 Client Component 的资源将其预渲染成 html,这样客户端在页面请求响应的时候就能先看到 Client Component 的 UI,待 js 文件加载完成之后在进行水合,当然也可以让开发者自己去配置 html 要不要预渲染 Client Component (类似于之前的 dynamic 的 ssr 选项)。
讲到这里对应 RSC 是什么的理解应该更清晰了,在旧的架构中,SSR 层既负责预渲染的工作也负责在服务端获取数据的工作,现在在抽象出一层 RSC server,专职负责执行服务端的相关操作,并输出的新的数据结构 React Flight,原本的 SSR 层可以只关注预渲染的逻辑了。
5.1 实现 Server Component 运行时 (RSC 服务)
javascript
import React from 'react'
import express from 'express';
import { readFileSync } from 'fs';
import path from 'path';
import App from '../app/index.js'
import { renderToPipeableStream } from 'react-server-dom-webpack/server';
const app = express();
app.use('/', (req, res) => {
const manifest = readFileSync(
path.resolve(__dirname, '../build/react-client-manifest.json'),
'utf8'
);
const moduleMap = JSON.parse(manifest);
// server component node 运行时,将 React 组件渲染为 React Flight 流
const { pipe } = renderToPipeableStream(
React.createElement(App, {
url: req.url.replace('.jsx', '')
}),
moduleMap
);
pipe(res)
})
app.listen(5556)
这一步的代码很简单,借助 React 的 api 将 Server Component 渲染成 React Flight 流。 注意,启动服务时一定要加上 --conditions react-server
以及:
javascript
const register = require('react-server-dom-webpack/node-register');
register();
这样才能正确的导入我们在 node server 下需要的 renderToPipeableStream
方法,以及在 server 端正确的处理 Client Component
5.2 实现 Server 端渲染时 (SSR)
在这一步中我们需要把 5.1 产出的 React Flight 流渲染成 html 然后传输到客户端,这里 Client Component 也会被预渲染。
jsx
app.use('/', (req, res) => {
const ssrManifest = readFileSync(
path.resolve(__dirname, '../build/react-ssr-manifest.json'),
'utf8'
)
const ssrModuleMap = JSON.parse(ssrManifest);
// 获取 rsc 流
http.get('http://localhost:5556/', (resRsc) => {
const readable = new Stream.PassThrough();
resRsc.pipe(readable)
let response;
function ClientRoot() {
if (response) return use(response);
response = ReactServerDOMClient.createFromNodeStream(
readable,
ssrModuleMap
);
return use(response);
}
function Template() {
return (
<html>
<meta charSet="utf-8" />
<head>
<title>111</title>
</head>
<body>
{<ClientRoot />}
</body>
</html>
)
}
// 利用 React 提供的 api 在 server 端将 React Flight 流渲染成 html 流
const ssrStream = ReactDOMServer.renderToPipeableStream(
<Template />,
{
bootstrapScripts: ['/public/main.js'],
onShellReady: () => {
ssrStream.pipe(res)
}
}
);
})
})
此时在访问页面: 可以看到,html 不在返回的是一个空白页面,Client Component 都被预渲染了。
5.3 实现 Client 端运行时,为 Client Component hydrate
有 SSR 就一定有 hydrate,否则点击事件这些根本无法生效。 不过现在的 hydrate 跟以往的有些不同,以往需要:
jsx
import { hydrateRoot } from 'react-dom/client';
const domNode = document.getElementById('root');
// App 是整个应用的根节点
const root = hydrateRoot(domNode, <App />);
现在加入了 Server Component,能确定 Server Component 只在服务端执行,Server Component 没有"点击事件"、"状态" 等,它给到客户端的只有 html,也就是说,Server Component 是不需要 hydrate 的,只需要给 Client Component hydrate 就行了。 回顾之前的内容,在客户端获取到 React Flight Stream 之后,只需要使用 React 提供的 use
方法去渲染这个流,React 会将 React Flight 数据转换为 UI,且会自动的处理加载 Client Component js 文件的操作,那其实这里我们也只需要也取到 React Flight Stream 的数据,在客户端也渲染一次就好了。
- 在 server 端渲染 React Flight Stream 是为了 SSR,将其预渲染转为 html.
- 在 client 端渲染 React Flight Stream 是为了给 Client Component hydrate.
5.3.1 将 React Flight Stream 发送到客户端
首先,在 SSR React Flight Stream 的时候,我们在创建一个流,用来将 React Flight Stream 的数据以字符串保存下来:
jsx
http.get('http://localhost:5556/', (resRsc) => {
// ...
let flightResponse = ''
const flightStream = new Stream.Writable({
write: (chunk, encoding, next) => {
flightResponse += chunk;
next();
},
});
resRsc.pipe(flightStream)
// ...
})
发送方式是将其以一个 dom 片段的形式发送给客户端:
jsx
// 将 React Flight 以一个 dom script 片段的形式发送给客户端
function sendReactFlightToClient(flight, res) {
res.write(`
<script>
if (window.clientFizzReactFlight) {
clientFizzReactFlight(${JSON.stringify(flight)})
} else {
window.__initFlight = ${JSON.stringify(flight)}
}
</script>
`)
}
然后就是要选择一个时间去发送,这里我们选择在:ReactDOMServer.renderToPipeableStream onAllReady
的时候。
jsx
const ssrStream = ReactDOMServer.renderToPipeableStream(
<Template />,
{
bootstrapScripts: ['/public/main.js'],
onShellReady: () => {
ssrStream.pipe(res)
},
: () => {
sendReactFlightToClient(flightResponse, res)
}
}
);
5.3.2 在客户端解析 React Flight 数据
在客户端需要用 js 去处理解析 React Flight 数据的逻辑,也就是上面那段代码中的 bootstrapScripts 字段对应的 js 文件。
javascript
import React, { use } from 'react'
import { createRoot } from 'react-dom/client';
import { createFromFetch, createFromReadableStream } from 'react-server-dom-webpack/client';
function App(props) {
if (!props?.content) return null;
return use(props.content)
}
const root = createRoot(document.getElementById('root'));
// 因为不知道是服务端传递过来的 React Flight 数据先到
// 还是 main.js 先执行
// 所以服务端传递过来的 script 片段会判断如果 clientFizzReactFlight 方法
// 已经存在就调用这个方法,否则把数据存储到 __initFlight 变量中
window.clientFizzReactFlight = function(str) {
const rscStream = createFromReadableStream(new Response(str).body)
root.render(React.createElement(App, {
content: rscStream
}))
}
if (window.__initFlight) {
clientFizzReactFlight(window.__initFlight)
}
需要注意的是,服务端传递过来的是字符串,需要使用 Response
和 createFromReadableStream
api 将其转为一个流之后才能使用 use
去渲染。 做完这个操作后,Client Component 中的点击事件就能生效了
5.4 再次考虑页面路由
到这里我们已经将 RSC 和 SSR 相结合了。 然后考虑一下页面路由跳转的时候,比如点击了 text1.txt 按钮,在之前是直接去获取 React Flight Stream 然后渲染到页面中,现在引入了 SSR,那有没有必要在这种时候去 SSR 呢? 我觉得是没必要,SSR 的主要目的是解决首屏白屏问题和 SEO 的问题,这里页面路由跳转不存在这两个问题,所以我们还是采用之前的方式。 只是现在我们已经实现了 Client Component 了,所以没必要再在客户端全局去监听 click 事件了,最终的代码在 feat/step5 分支。
总结
至此,尝试实现 Server Component 的 demo 已经结束,总体下来实现了以下功能:
- 支持 Server Component
- 支持 Client Component
- 支持路由跳转
- 与 SSR 结合使用,优化首屏渲染
现在能在网上搜索到了 React Server Component 深入一点讲解的文档很少, React 提供的 react-server-dom-webpack
包目前仍是实验性质,也没有文档;在 结合 SSR 这一块的内容,实际上 React 现在并没有给出实践指导,Next.js 虽然已经成熟的实践,但是也没有详细的文章讲解这一块是如何去做的,而翻看 next.js 的源码工作量实在太大了。 所以我是根据 react-server-dom-webpack
提供的单元测试用例,在结合搜索到的一些其他信息制定的方案,也许到 Server Component 真正推出的那一天,React 团队会给出更好的实践。 demo 其实也还存在一些可以改进的问题:
- 路由跳转时,地址栏 url 并没有跟随一起改变。
- "切换路由" 时,在 rsc 端,目前实际上是从根节点开始都执行了一遍,这里感觉可以优化成只执行"切换"那部分的节点,文章列表那部分的内容其实是没动的,动的只是下面的文件名和内容。(关于这一点,后面应该会仔细的研究一下 next.js 的路由系统。)
- 目前是在 onAllReady 中才将 React Flight Stream 推到客户端,要等到页面所有异步操作结束才会触发这个勾子,也就是说,目前得整个页面加载完毕,Client Component js文件才会开始加载,导致 Client Component 能够具有操作性的时间特别慢。最理想的情况应该是 React Flight Stream 来一点就往客户端推一点,然后客户端增量渲染。
其实如果是以要应用到生产环境中的标准去考虑,问题还有很多很多......下一篇,我会带着我做这个 demo 产生的一些疑问,到目前唯一将 Server Component 标记为稳定的 next.js 中去寻找答案。
系列进度: React Server Component 第一篇:React Server Component 为何要出现,解决什么问题?(done) React Server Component 第二篇:尝试实现 React Server Component (done) React Server Component 第三篇:了解 next.js,看 next.js 是如何结合 SSR 和 RSC 的(努力中)
参考文章
timtech.blog/posts/react... Client Components Rendering on Client Side or Server Side Why do Client Components get SSR'd to HTML