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

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

本文来源:支付宝体验科技公众号


🙋🏻‍♀️ 编者按:本文作者是蚂蚁集团前端工程师迫风,跟随 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 却带来一个时代的变革。


目录
打赏
0
0
0
0
148
分享
相关文章
React Server Side Rendering (SSR) 详解
【10月更文挑战第19天】React Server Side Rendering (SSR) 是一种在服务器端渲染 React 应用的技术,通过在服务器上预先生成 HTML 内容,提高首屏加载速度和 SEO。本文从概念入手,逐步探讨 SSR 的实现步骤、常见问题及解决方案,并通过代码示例进行说明。
644 3
【Azure App Service for Windows】 PHP应用出现500 : The page cannot be displayed because an internal server error has occurred. 错误
【Azure App Service for Windows】 PHP应用出现500 : The page cannot be displayed because an internal server error has occurred. 错误
React Server Component 使用问题之想在路由切换时保持客户端状态,如何实现
React Server Component 使用问题之想在路由切换时保持客户端状态,如何实现
React Server Component 使用问题之路由的能力,如何实现
React Server Component 使用问题之路由的能力,如何实现
React Server Component 使用问题之添加jsx的组件化能力,如何操作
React Server Component 使用问题之添加jsx的组件化能力,如何操作
React Server Component 使用问题之怎么使用Docker运行PHP应用
React Server Component 使用问题之怎么使用Docker运行PHP应用
|
7月前
|
🔥揭秘JSF导航:如何轻松驾驭页面跳转与流程控制?🎯
【8月更文挑战第31天】在 JavaServer Faces(JSF)中,导航规则是控制页面跳转和流程的关键。本文详细介绍 JSF 的导航规则,包括转发和重定向等跳转方式,并通过 `faces-config.xml` 文件配置示例展示如何实现不同场景下的页面跳转及流程控制,帮助开发者有效管理应用程序的页面流和用户交互,提升应用质量。
75 0
React Server Side Rendering的神奇之处:如何用SSR提升SEO与首屏加载速度,让你的项目一鸣惊人?
【8月更文挑战第31天】在现代Web开发中,React服务器端渲染(SSR)能显著提升SEO性能和首屏加载速度。通过在服务器端预渲染组件并发送HTML至客户端,SSR不仅优化了首屏加载时间,增强了用户体验,还生成了便于搜索引擎抓取的静态HTML文件,提升了页面排名。此外,SSR还具备提高安全性的优点,能够有效防范XSS攻击。虽然其开发复杂性和服务器负载是潜在劣势,但借助如Next.js等库、编写高效组件及定期维护等最佳实践,可以充分发挥SSR的优势,为未来Web开发注入更强动力。
103 0
React Server Component 使用问题之为什么选择使用 React 官方的 renderToString 来渲染 HTML,如何解决
React Server Component 使用问题之为什么选择使用 React 官方的 renderToString 来渲染 HTML,如何解决
如何排查和解决PHP连接数据库MYSQL失败写锁的问题
通过本文的介绍,您可以系统地了解如何排查和解决PHP连接MySQL数据库失败及写锁问题。通过检查配置、确保服务启动、调整防火墙设置和用户权限,以及识别和解决长时间运行的事务和死锁问题,可以有效地保障应用的稳定运行。
101 25

热门文章

最新文章

AI助理

你好,我是AI助理

可以解答问题、推荐解决方案等