RSC 就是套壳 PHP ?带你从零实现 React Server Component

🙋🏻‍♀️ 编者按:本文作者是蚂蚁集团前端工程师迫风,跟随 React 核心开发者 Dan Abramov 的思路,从零开始实现 React Server Component,顺带实现基于 RSC 的 SSR,附完整代码 codesandbox.io/p/sandbox/a... ,欢迎查阅~

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。让我们就跟随 Dan 的思路,从零开始实现 React Server Component。

注:github discussion 里只实现了最基础功能的 RSC,并不包含 Suspense、RSC 与 React Client Component 嵌套等高级能力,目的只是为了理解设计思想。同时在不破坏原文主逻辑的基础上,笔者加入了自己的一些解释和演绎。

网络上讲 RSC 概念的文章很多,这里就不赘述。为了说明 RSC 并不是简单的 PHP 套壳,我们先列一个需求,拿 PHP 的方式实现一遍,然后再一步步构建 RSC 并实现同样的功能,看看不同之处。

需求如下:读取本地磁盘上的文件,并把文件内容展示在页面上。

为此我们在当前目录中创建如下文件,

shell 复制代码
├── index.php
└── posts
    └── hello-world.txt

在 index.php 文件中写入如下内容,

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 文件随便,我们就写如下内容,

shell 复制代码
Hi everyone! This is my first blog post. I <3 React.

如果你的电脑上没有装 php 也不要紧。运行如下 docker run命令,就能立刻把代码跑起来,

shell 复制代码
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 伺服出去就行了。具体代码如下, codesandbox.io/p/sandbox/n...

万事开头难,虽然没有引入任何 React 的概念,但是通过 nodejs 实现了 php 版本同样的功能,为我们接下来实现 RSC 走出了第一步。

步骤 1 - 用 jsx 来渲染 html

在上一步的基础上,我们自然可以想到,是不是可以用 jsx 代替字符串模板来生成 html 的内容?答案是肯定的。具体代码如下, codesandbox.io/p/sandbox/r...

有两个关键点需要理解。首先,我们在 server.js 文件中直接写了 jsx 语法,但是 nodejs 却能运行它,这得益于 nodejs 的 loader 机制,当在 nodejs 里 require 或者 import 另一个模块的时候,我们可以在运行时对该模块提前做下处理,比如把 jsx 格式的文件转译为普通的 js 文件。如下所示,

json 复制代码
"scripts": {
  "start": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server.js"
},

其次,jsx tag 经过编译之后,生成的是一个朴素的 javascript object,就是我们常说的 React element,结构如下,

json 复制代码
{
  $$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 关键字,阁下该如何应对?

php 复制代码
<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 为函数的处理逻辑即可。关键代码如下,

javascript 复制代码
else if (typeof jsx.type === "function") {
  const Component = jsx.type;
  const props = jsx.props;
  const returnedJsx = Component(props);
  return renderJSXToHTML(returnedJsx);
}

完整代码实现如下,不再赘述, codesandbox.io/p/sandbox/t...

我们似乎有点沾沾自喜了,但 php 作为老牌 mvc 框架,服务端上的核心能力自然稳如磐石:请问当我祭出路由能力时,阁下又当如何应对?

php 复制代码
<?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,实现路由的能力并不是什么难事。完整代码如下, codesandbox.io/p/sandbox/t...

并且得益于我们在上一步实现了组件化,我们还借此重构了一波代码,把列表页和详情页共享的页头页脚抽象为 BlogLayout 组件。

php 老哥这下有点坐不住了,声称它提供了诸如file_get_contents 的扩展能力,直接集成在 php 指令中,提供丰富的 I/O 功能。甩出文档 www.php.net/manual/en/f... 直接冲脸。

诚然,回看我们这版的代码确实比不上 php,执行 I/O 操作的语句与组件是割裂的。而在 JS 的生态中,I/O 最直观的表达形式就是 async/await,所以本质是需要实现 async 组件。

步骤 4 - async 组件

差点就被吓住了,仔细想想其实支持 async 组件并不难,需要调用 Component 的时候加上 await 就可以。并由此「传染」开,最终 renderJSXToHTML 函数需变为 async 函数。完整代码如下, codesandbox.io/p/sandbox/r...

async 组件的难点在于同时提供 Suspense 的能力,但这个~~ Dan Abramov 还没写~~不在本文讨论范围内。

借此步骤,我们同时抽象出 组件以及 组件,其中 组件完美封装了 I/O 逻辑。如下所示,

jsx 复制代码
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,

jsx 复制代码
<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 事件重载,我们起码避免了浏览器刷新。

完整代码如下所示, codesandbox.io/p/sandbox/a...

步骤 5.2 - 传输 jsx (序列化的 React element) 而非 html

这是极其关键的一个分水岭。我们不再传输和直接插入 html,我们传输的是 jsx。那么 React 本来基于虚拟 DOM 的各种概念和原理都可以继续适用,只是 jsx 的递归解析从客户端转移到了服务端,React 如何修改 DOM 的决策依然来自于前后 jsx 的差异,也就是所谓的 diff 算法,这个依然发生在客户端。

传输内容改为 jsx 更多的挑战在于技术实现细节。为了让实现更平滑,我们在本步骤实现一个中间态功能:当点击了 a 标签后,仅弹窗展示传输过来的 jsx。

首先我们要在服务端的响应逻辑里区分是首屏渲染 (html) 还是请求传输 jsx。为此我们使用 ?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 的实现。codesandbox.io/p/sandbox/h...

当点击链接后,观察到传输的 jsx 内容如下,

此实现的问题在于没有对 jsx 进行递归解析,仅仅是简单的 JSON.stringify。

因此需要像 renderJSXToHTML 函数那样对 JSX 进行递归,sendJSX 函数的完整内容可以参见如下代码, codesandbox.io/p/sandbox/b...

步骤 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 的实现见此, codesandbox.io/p/sandbox/v...

观察到点击链接后就报错了。错误内容如下,

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。

完整的实现如下, codesandbox.io/p/sandbox/s...

这个版本已经相当完备了。我们看到服务端生成的 jsx 在客户端上成功地进行渲染,并成功地维持了客户端上 input 中的输入值。但还有一点点瑕疵:首屏的渲染结果并没有保存端上状态的能力。

步骤 5.3.2 - 处理首次 hydrate

代码如下,不再赘述, codesandbox.io/p/sandbox/v...

本质问题是首屏需要同时做水合 (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 并以此响应客户端 (路由跳转)。

完整代码如下,不再赘述, codesandbox.io/p/sandbox/a...

RSC 是什么 ?

目前 RSC 协议已经稳定,真实生产环境下的 RSC payload 并非是本文中描述的格式,而是一种更适合流式渲染的格式。可以参考这个 Next.js 官方 用 RSC 实现的 Hacker News。社区也有工具 rsc-parser.vercel.app/ 来帮助分析 RSC 内容。

如果一定要提炼一句话说明 RSC 是什么,那么笔者的理解是:RSC 是 HTTP 之上的一种 传输协议 (wire format)。自然地 RSC 并不是开箱即用的东西,需要一个框架实现这个协议下约定的前后端协作关系,如果你从头做完这些步骤、理解了代码,那么你一定懂我在说什么。否则还是「听君一席话,如听一席话」。

在此之上或许能孕育出下一代前端范式。就好比单独的 http 协议并没什么用,http spec 必须被浏览器和 nginx 等服务端软件所实现,但是在 http 之上的所构建的 www 却带来一个时代的变革。

相关推荐
一條狗6 小时前
隨筆20241226 ExcdlJs 將數據寫入excel
react.js·typescript·electron
一条不想当淡水鱼的咸鱼6 小时前
taro中实现带有途径点的路径规划
javascript·react.js·taro
GISer_Jing7 小时前
React基础知识(总结回顾一)
前端·react.js·前端框架
赵大仁7 小时前
深入解析 Vue 3 的核心原理
前端·javascript·vue.js·react.js·ecmascript
web Rookie10 小时前
React 高阶组件(HOC)
前端·javascript·react.js
web Rookie12 小时前
React 中 createContext 和 useContext 的深度应用与优化实战
前端·javascript·react.js
男孩1212 小时前
react高阶组件及hooks
前端·javascript·react.js
outstanding木槿16 小时前
JS中for循环里的ajax请求不数据
前端·javascript·react.js·ajax
zhenryx19 小时前
微涉全栈(react,axios,node,mysql)
前端·mysql·react.js
六卿1 天前
react防止页面崩溃
前端·react.js·前端框架