本文来源:支付宝体验科技公众号
🙋🏻♀️ 编者按:本文作者是蚂蚁集团前端工程师迫风,跟随 React 核心开发者 Dan Abramov 的思路,从零开始实现 React Server Component,顺带实现基于 RSC 的 SSR,附完整代码 https://codesandbox.io/p/sandbox/agitated-swartz-4hs4v1?file=%2Fserver%2Fssr.js ,欢迎查阅~
RSC 就是套壳 PHP ?
next.js 13 被调侃为套壳 php (图源网络,侵删)
React Server Component (简称 "RSC") 是 React 18 版本中引入的新特性。无论是废弃 mixins、使用 ES6 Class 声明组件、Suspense 还是极具革命性的 hooks,React 之前版本中的进化还是局限在客户端范畴,这次 RSC 革命性地把 React 的运行时扩展到了服务端,尤其令人惊艳的是在「可组合性」上没有妥协,RSC 与 React Client Component 可以几乎完美地互操作,秉承了 React 的一贯哲学。不少社区的 KOL 认为这将引发下一轮的范式转移。
社区也不是只有一种声音,不少人开始质疑 RSC。比如 RSC 无法简单地使用,官方推荐的最佳实践是与元框架集成在一起使用。当前 (2023 年 8 月) 生产环境 RSC 可用的只有 Vercel 的 Next.js,加之 React 核心成员 Sebastian Markbåge 和 Andrew Clark 都相继加入了 Vercel,社区愈发担心不在 Vercel 上氪金就享受不到正宗味道的 React。此外,RSC 本身也不是没有理解成本的。这取决于你的研发背景。React、Vue 等框架带来的「组件式开发」把前端的门槛大大地降低,人们可以只对框架一知半解就能写出来效果不错的 Admin 后台,也不需要体系化的服务端技能,开发者就质疑:RSC 纯纯是增加心智负担,老子就喜欢 useEffect 一把梭。相比而言,如果是从传统 MVC 时代过来的同学,会一下子找到当年那种味道。
图源网络,侵删
又因为并不熟悉 React,就给 RSC 下定论:无非是套壳 PHP 罢了,并没有什么新颖的东西。
图片来源:imgflip.com,侵删
因为不理解,所以造成偏见。而在这匆忙的时代,人们更愿意接受三分钟弄懂 xxx 的快餐知识。笔者的感受是 RSC 需要一定的理解成本,但并不难,会有那么一个时刻就豁然开朗了。越过了这个奇点,笔者能强烈感受到 RSC 设计的优雅性,蕴含了 Sebastian Markbåge 强烈的个人风格:用最少的 API 组合出最大的潜力。当然优雅并不代表成功,在计算机历史上有数不清虽优雅但死掉的技术。
理解 xxx 最好的方式就是把手弄脏,把手弄脏的最好方式就是 Implement xxx From Scratch。很幸运在 Dan Abramov 离职 Meta 之前,留下了这么一篇 github discussion(https://github.com/reactwg/server-components/discussions/5)。让我们就跟随 Dan 的思路,从零开始实现 React Server Component。
注:github discussion 里只实现了最基础功能的 RSC,并不包含 Suspense、RSC 与 React Client Component 嵌套等高级能力,目的只是为了理解设计思想。同时在不破坏原文主逻辑的基础上,笔者加入了自己的一些解释和演绎。
网络上讲 RSC 概念的文章很多,这里就不赘述。为了说明 RSC 并不是简单的 PHP 套壳,我们先列一个需求,拿 PHP 的方式实现一遍,然后再一步步构建 RSC 并实现同样的功能,看看不同之处。
需求如下:读取本地磁盘上的文件,并把文件内容展示在页面上。
为此我们在当前目录中创建如下文件,
├── index.php └── posts └── hello-world.txt
在 index.php 文件中写入如下内容,
<?php $author = "Jae Doe"; $post_content = @file_get_contents("./posts/hello-world.txt"); ?> <html> <head> <title>My blog</title> </head> <body> <nav> <a href="/">Home</a> <hr> </nav> <article> <?php echo htmlspecialchars($post_content); ?> </article> <footer> <hr> <p><i>(c) <?php echo htmlspecialchars($author); ?>, <?php echo date("Y"); ?></i></p> </footer> </body> </html>
hello-world.txt 文件随便,我们就写如下内容,
Hi everyone! This is my first blog post. I <3 React.
如果你的电脑上没有装 php 也不要紧。运行如下 docker run
命令,就能立刻把代码跑起来,
docker run -it --rm -p 80:80 --name my-apache-php-app -v "$PWD":/var/www/html php:7.2-apache
访问你本机的 80 端口,就能看到如下的页面效果了,此刻不得不赞叹一句:php 老哥稳!
步骤 0 - 基于字符串模板的实现
React 的 jsx 语法需要特殊的编译。但是用字符串模板就不需要了。服务器当然是可以直接响应 html 字符串的。代码逻辑很简单,用 nodejs 启动一个最朴素的 http 服务器,把文件内容读出来,通过模板字符串嵌入到 html 中,而后一股脑把 html 伺服出去就行了。具体代码如下,https://codesandbox.io/p/sandbox/nostalgic-platform-kvog0r?file=%2Fserver.js
万事开头难,虽然没有引入任何 React 的概念,但是通过 nodejs 实现了 php 版本同样的功能,为我们接下来实现 RSC 走出了第一步。
步骤 1 - 用 jsx 来渲染 html
在上一步的基础上,我们自然可以想到,是不是可以用 jsx 代替字符串模板来生成 html 的内容?答案是肯定的。具体代码如下,https://codesandbox.io/p/sandbox/recursing-kepler-yw7dlx?file=%2Fserver.js
有两个关键点需要理解。首先,我们在 server.js 文件中直接写了 jsx 语法,但是 nodejs 却能运行它,这得益于 nodejs 的 loader 机制,当在 nodejs 里 require 或者 import 另一个模块的时候,我们可以在运行时对该模块提前做下处理,比如把 jsx 格式的文件转译为普通的 js 文件。如下所示,
"scripts": { "start": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server.js" },
其次,jsx tag 经过编译之后,生成的是一个朴素的 javascript object,就是我们常说的 React element,结构如下,
{ $$typeof: Symbol.for("react.element"), type: 'html', props: { children: [ { $$typeof: Symbol.for("react.element"), type: 'head', props: { children: { $$typeof: Symbol.for("react.element"), type: 'title', props: { children: 'My blog' } } } }, // ...
对比之下,我们熟悉的 React Client Component 其实产生的也是一个 React element,只不过在客户端上这个 React element 如何最终变成 DOM 元素是 React 运行时来管理的,但这里我们需要自行处理。因为不需要做什么 diff 算法、时间分片,逻辑简单许多,代码里 function renderJSXToHTML(jsx)
蕴含了相应的逻辑,主要是做递归,不再赘述。
实现到这一步,可以认为用 React 的方式成功实现了 php 版本的功能。不要高兴太早。php 老哥一定偷着乐:如果我祭出 include 关键字,阁下该如何应对?
<html> <body> <h2>This is the content of index.php file.</h2> <?php include("another_file.php"); ?> </body> </html>
所以接下来还需要实现 React 的精髓:Component,也就是可组合型。
步骤 2 - 添加 jsx 的组件化能力
目前我们是通过 function renderJSXToHTML(jsx)
来自行处理 jsx 的渲染结果的。那么组件化能力也必然要通过此函数实现。
我们知道组件就是一个产生 jsx 的函数。在服务端上我们不使用 useEffect、useState 这些 hooks,所以组件化也不难,给 renderJSXToHTML 添加 type 为函数的处理逻辑即可。关键代码如下,
else if (typeof jsx.type === "function") { const Component = jsx.type; const props = jsx.props; const returnedJsx = Component(props); return renderJSXToHTML(returnedJsx); }
完整代码实现如下,不再赘述,https://codesandbox.io/p/sandbox/thirsty-frost-8oug3o?file=%2Fserver.js
我们似乎有点沾沾自喜了,但 php 作为老牌 mvc 框架,服务端上的核心能力自然稳如磐石:请问当我祭出路由能力时,阁下又当如何应对?
<?php $request = $_SERVER['REQUEST_URI']; $viewDir = '/views/'; switch ($request) { case '': case '/': require __DIR__ . $viewDir . 'home.php'; break; case '/views/users': require __DIR__ . $viewDir . 'users.php'; break; case '/contact': require __DIR__ . $viewDir . 'contact.php'; break; default: http_response_code(404); require __DIR__ . $viewDir . '404.php'; }
步骤 3 - 加入路由的能力
只要能够读取到请求的 url,实现路由的能力并不是什么难事。完整代码如下,https://codesandbox.io/p/sandbox/trusting-turing-bi5vjr?file=%2Fserver.js
并且得益于我们在上一步实现了组件化,我们还借此重构了一波代码,把列表页和详情页共享的页头页脚抽象为 BlogLayout 组件。
php 老哥这下有点坐不住了,声称它提供了诸如file_get_contents
的扩展能力,直接集成在 php 指令中,提供丰富的 I/O 功能。甩出文档 https://www.php.net/manual/en/funcref.php 直接冲脸。
诚然,回看我们这版的代码确实比不上 php,执行 I/O 操作的语句与组件是割裂的。而在 JS 的生态中,I/O 最直观的表达形式就是 async/await,所以本质是需要实现 async 组件。
步骤 4 - async 组件
差点就被吓住了,仔细想想其实支持 async 组件并不难,需要调用 Component 的时候加上 await 就可以。并由此「传染」开,最终 renderJSXToHTML 函数需变为 async 函数。完整代码如下,https://codesandbox.io/p/sandbox/relaxed-pare-gicsdi?file=%2Fserver.js
async 组件的难点在于同时提供 Suspense 的能力,但这个~~ Dan Abramov 还没写~~不在本文讨论范围内。
借此步骤,我们同时抽象出组件以及组件,其中组件完美封装了 I/O 逻辑。如下所示,
async function Post({ slug }) { let content; try { content = await readFile("./posts/" + slug + ".txt", "utf8"); } catch (err) { throwNotFound(err); } return ( <section> <h2> <a href={"/" + slug}>{slug}</a> </h2> <article>{content}</article> </section> ); }
在此基础上,借助 npm 生态的力量,完全可以由组件自带各种 I/O 逻辑,实现高内聚低耦合的抽象。
到了这一步,我们已成功地利用 React 复刻了 php。令人 "毛骨悚然" 的是,我们还只是利用 jsx 这种简单的语法糖而已,并未涉及 React Server Component。由此可窥 React 强大的抽象能力。
下面请开始 RSC 的表演吧。
步骤 5 - 客户端状态保持
传统的 MVC 框架的最大弱点是无法进行端上状态的保持,除非引入从心智模型上完全割裂的三方库如 jquery。这样写出的代码是难以维护的。
我们单纯利用服务端渲染就可以提供 SPA 才有的客户端状态保持的功能。听起来似乎不可思议。
为了说明问题,在上一版代码中的 BlogLayout 组件中加入一个 input,
<nav> <a href="/">Home</a> <hr /> <input /> {/* 此处加一个 input 组件用来说明问题 */} <hr /> </nav>
我们显然能看到 input 里的输入在路由切换的时候丢失了,
下面我们分步骤实现端上的状态保持。
步骤 5.1 - 路由跳转逻辑转移到客户端
如果每次路由切换都加载新文档,那么无论哪门子 Server Component 都是无法实现端上状态保持的。
就像我们熟悉的 React Router 做的那样,在路由跳转的时候,我们需要禁止浏览器加载新文档。为此我们添加 client.js
文件。并在首屏一并返回给浏览器。
client.js
是一个立即执行的逻辑,它对 click
事件进行劫持:在 click 一个 a 标签时,禁止了默认的行为,作为替代向服务端 fetch 一份 html 插入到 DOM 中。
这是一个非常脏的实现。虽然还未实现端上的状态保持,但得益于这个 click 事件重载,我们起码避免了浏览器刷新。
完整代码如下所示,https://codesandbox.io/p/sandbox/agitated-bush-ql7kid?file=%2Fclient.js
步骤 5.2 - 传输 jsx (序列化的 React element) 而非 html
这是极其关键的一个分水岭。我们不再传输和直接插入 html,我们传输的是 jsx。那么 React 本来基于虚拟 DOM 的各种概念和原理都可以继续适用,只是 jsx 的递归解析从客户端转移到了服务端,React 如何修改 DOM 的决策依然来自于前后 jsx 的差异,也就是所谓的 diff 算法,这个依然发生在客户端。
传输内容改为 jsx 更多的挑战在于技术实现细节。为了让实现更平滑,我们在本步骤实现一个中间态功能:当点击了 a 标签后,仅弹窗展示传输过来的 jsx。
首先我们要在服务端的响应逻辑里区分是首屏渲染 (html) 还是请求传输 jsx。为此我们使用 ?jsx
的约定来告诉服务端。
else if (url.searchParams.has("jsx")) { url.searchParams.delete("jsx"); await sendJSX(res, <Router url={url} />); } else { await sendHTML(res, <Router url={url} />); }
可以先看 sendJSX 一个 naïve 的实现。https://codesandbox.io/p/sandbox/heuristic-bartik-gk8ggy?file=%2Fserver.js%3A1%2C1 。
当点击链接后,观察到传输的 jsx 内容如下,
此实现的问题在于没有对 jsx 进行递归解析,仅仅是简单的 JSON.stringify。
因此需要像 renderJSXToHTML 函数那样对 JSX 进行递归,sendJSX 函数的完整内容可以参见如下代码,https://codesandbox.io/p/sandbox/boring-nightingale-74w7p3?file=%2Fserver.js%3A113%2C1
步骤 5.3 - 利用 jsx 实现客户端上的渲染
为了简便,我们接下来直接使用 react-dom/client
来读取 jsx 并渲染到 dom 上。
事实上,在我们这个简易版本 RSC 中可以完全规避 react-dom/client 的使用,因为传输过来的 jsx 中包含的就是简单的原生 html 标签。只要能做 diff 就行。此处仅仅是为了实现便利考虑。
步骤 5.3.1 - 反序列化 jsx
一个朴素的想法就是基于上一步骤的功能,不是把 jsx 内容 alert 出来,而是反序列化后直接用 reac-dom/client 渲染。一个 naïve 的实现见此,https://codesandbox.io/p/sandbox/vibrant-golick-x09dj7?file=%2Fclient.js
观察到点击链接后就报错了。错误内容如下,
Objects are not valid as a React child (found: object with keys {type, key, ref, props, _owner, _store}). If you meant to render a collection of children, use an array instead.
React element 本质是一个对象,这个实现的问题在于 React 并不会随意渲染一个对象,是基于 网络安全的考量。所以在序列化的时候需要对 $$typeof: Symbol.for("react.element")
做转义,并在反序列化时转回 Symbol。
完整的实现如下,https://codesandbox.io/p/sandbox/silly-silence-v7lq4p?file=%2Fclient.js%3A1%2C1
这个版本已经相当完备了。我们看到服务端生成的 jsx 在客户端上成功地进行渲染,并成功地维持了客户端上 input 中的输入值。但还有一点点瑕疵:首屏的渲染结果并没有保存端上状态的能力。
步骤 5.3.2 - 处理首次 hydrate
代码如下,不再赘述,https://codesandbox.io/p/sandbox/vigorous-lichterman-i30pi4?file=%2Fserver.js%3A1%2C1
本质问题是首屏需要同时做水合 (hydrate) 的初始化。这并非是 RSC 独有的问题。比如在经典的 React + Redux 结合使用的 SSR 场景中,需要 hydrate 时给 Redux provider 赋初始值。
我们总算完成了从零实现 React Server Component 的目标 🎉 🎉 🎉,而且我们还顺带实现了基于 RSC 的 SSR ! 如果你坚持读到了这里,也请给自己一个大大的赞 👍🏻,毕竟画马的最后一步通常是艰难的 😂。
图源网络,侵删
步骤 6 - 代码整理和优化
我们从以下三点对上一步的代码再优化一版。
- sendHTML 和 sendJSX 都需要对 jsx 进行递归解析,此处复用避免重复逻辑。
- 使用 React 官方的 renderToString 来渲染 HTML 而非 in-house 的实现 renderJSXToHTML。
- 把服务分为两个,rsc.js 和 ssr.js。在实际生产中,rsc.js 和 ssr.js 两个服务不在同一个集群上运行都是正常的。rsc.js 负责生成可以被消费的 jsx,ssr.js 直接对客户端提供服务,ssr.js 或者返回客户端首屏 html (initial load),或者向 rsc.js 请求 jsx 并以此响应客户端 (路由跳转)。
完整代码如下,不再赘述,https://codesandbox.io/p/sandbox/agitated-swartz-4hs4v1?file=%2Fserver%2Fssr.js
RSC 是什么 ?
目前 RSC 协议已经稳定,真实生产环境下的 RSC payload 并非是本文中描述的格式,而是一种更适合流式渲染的格式。可以参考这个 Next.js 官方 用 RSC 实现的 Hacker News。社区也有工具 https://rsc-parser.vercel.app/ 来帮助分析 RSC 内容。
如果一定要提炼一句话说明 RSC 是什么,那么笔者的理解是:RSC 是 HTTP 之上的一种传输协议 (wire format)。自然地 RSC 并不是开箱即用的东西,需要一个框架实现这个协议下约定的前后端协作关系,如果你从头做完这些步骤、理解了代码,那么你一定懂我在说什么。否则还是「听君一席话,如听一席话」。
在此之上或许能孕育出下一代前端范式。就好比单独的 http 协议并没什么用,http spec 必须被浏览器和 nginx 等服务端软件所实现,但是在 http 之上的所构建的 www 却带来一个时代的变革。