社区精选|React Server Component 从理念到原理

SegmentFault

共 7937字,需浏览 16分钟

 ·

2023-06-22 01:30

今天小编为大家带来的是社区作者 卡颂 的文章,让我们一起来学习React Server Component 从理念到原理。




React Server Component(后文简称 RSC)是 React 近几年最重要的特性。虽然他对 React 未来发展至关重要,但由于:

  • 仍属实验特性
  • 配置比较繁琐,且局限较多

所以虽然体验 Demo 已经发布 3 年了,但仍属于知道的人多,用过的人少。

本文会从以下几个角度介绍 RSC:

  1. RSC 是用来做啥的?
  2. RSC 和其他服务端渲染方案(SSR、SSG)的区别
  3. RSC 的工作原理

希望读者读完本文后对 RSC 的应用场景有清晰的认识。

本文参考了 how-react-server-components-work

链接:https://www.plasmic.app/blog/how-react-server-components-work


什么是 RSC

对于一个 React 组件,可能包含两种类型的状态:

  • 前端交互用的状态,比如加载按钮的显/隐状态
  • 后端请求回的数据,比如下面代码中的data状态用于保存后端数据:
function App() {
  const [data, update] = useState(null);
  
  useEffect(() => {
    fetch(url).then(res => update(res.json()))
  }, [])
  
  return <Ctn data={data}/>;

前端交互用的状态放在前端很合适,但后端请求回的数据逻辑链路如果放在前端则比较繁琐,整个链路类似如下:

  1. 前端请求并加载React业务逻辑代码
  2. 应用执行渲染流程
  3. App 组件 mount,执行 useEffect,请求后端数据
  4. 后端数据返回,App 组件的子组件消费数据

如果我们根据状态类型将组件分类,比如:

  • 只包含交互相关状态的组件,叫客户端组件(React Client Component,简写 RCC)
  • 只从数据源获取数据的组件,叫服务端组件(React Server Component,简写 RSC)

按照这种逻辑划分,上述代码中:
  • App 组件只包含数据,显然属于 SSR
  • App 组件的子组件 Ctn 消费 data,如果他内部包含交互逻辑,应该属于 RCC
    将上述代码改写为:
    function App() {
      // 从数据库获取数据
      const data = getDataFromDB();
      return <Ctn data={data}/>;
    }

    其中:

    • App 组件在后端运行,可以直接从数据源(这里是数据库)获取数据
    • Ctn 组件在前端运行,消费数据

    改造后前端交互用的状态逻辑链路不变,而后端请求回的数据逻辑链路却变短很多:

    1. 后端从数据源获取数据,将RSC数据返回给前端
    2. 前端请求并加载业务逻辑代码(来自步骤0)
    3. 应用执行渲染流程(此时App组件已经包含数据)
    4. App 组件的子组件消费数据

    这就是 RSC 的理念,一句话概括就是 —— 根据状态类型,划分组件类型,RCC 在前端运行,RSC 在后端运行。


    与SSR、SSG的区别

    同样涉及到前端框架的后端运行,RSC 与 SSR、SSG 有什么区别呢?

    首先,SSG 是后端编译时方案。使用 SSG 的业务,后端代码在编译时会生成HTML(通常会被上传 CDN)。当前端发起请求后,后端(或 CDN)始终会返回编译生成的 HTML。

    RSC 与 SSR 则都是后端运行时方案。也就是说,他们都是前端发起请求后,后端对请求的实时响应。根据请求参数不同,可以作出不同响应。

    同为后端运行时方案,RSC 与 SSR 的区别主要体现在输出产物:

    • 类似于 SSG,SSR 的输出产物是 HTML,浏览器可以直接解析
    • RSC 会流式输出一种类 JSON 的数据结构,由前端的 React 相关插件解析

    既然输出产物不同,那么他们的应用场景也是不同的。

    比如,在需要考虑 SEO(即需要后端直接输出 HTML )时,SSR 与 SSG 可以胜任(都是输出 HTML),而 RSC 则不行(流式输出)。

    同时,由于实现不同,同一个应用中可以同时存在 SSG、SSR 以及 RSC。


    RSC的限制

    RSC 规范是如何区分 RSC 与 RCC 的呢?根据规范定义:

    • 带有 .server.js(x) 后缀的文件导出的是 RSC
    • 带有 .client.js(x) 后缀的文件导出的是 RCC
    • 没有带 server 或 client 后缀的文件导出的是通用组件

    所以,我们上述例子可以导出为 2 个文件:

    // app.server.jsx
    function App() {
      // 从数据库获取数据
      const data = getDataFromDB();
      return <Ctn data={data}/>;
    }

    // ctn.client.jsx
    function Ctn({data}) {
      // ...省略逻辑

    对于任意应用,按照RSC规范拆分组件后,能得到类似如下的组件树,其中 RSC 和 RCC 可能交替出现:

    但是需要注意:RCC 中是不允许 import RSC 的。也就是说,如下写法是不支持的:

    // ClientCpn.client.jsx

    import ServerCpn from './ServerCpn.server'
    export default function ClientCpn() {
      return (
        <div>
          <ServerCpn />
        </div>
      )

    这是因为,如果一个组件是 RCC,他运行的环境就是前端,那么他的子孙组件的运行环境也是前端,但 RSC 是需要在后端运行的。

    那么上述 RSC 和 RCC 交替出现是如何实现的呢?

    答案是:通过 children。

    改写下 ClientCpn.client.jsx:

    // ClientCpn.client.jsx

    export default function ClientCpn({children}) {
      return (
        <div>{children}</div>
      )

    在 OuterServerCpn.server.jsx 中引入 ClientCpn 与 ServerCpn:

    // OuterServerCpn.server.jsx
    import ClientCpn from './ClientCpn.client'
    import ServerCpn from './ServerCpn.server'
    export default function OuterServerCpn() {
      return (
        <ClientCpn>
          <ServerCpn />
        </ClientCpn>
      )

    组件结构如下:

    解释下这段代码,首先 OuterServerCpn 是 RSC,则他运行的环境是后端。他引入的 ServerCpn 组件运行环境也是后端。

    ClientCpn 组件虽然运行环境在前端,但是等他运行时,他拿到的 children props 是后端已经执行完逻辑(已经获得数据)的 ServerCpn 组件。


    RSC 协议详解

    我们可以将 RSC 看作一种 rpc(Remote Procedure Call,远程过程调用)协议的实现。数据传输的两端分别是 React 后端运行时与 React 前端运行时。

    一款 rpc 协议最基本的组成包括三部分:

    • 数据的序列化与反序列化
    • id 映射
    • 传输协议

    以上面的 OuterServerCpn.server.jsx 举例:

    // OuterServerCpn.server.jsx
    import ClientCpn from './ClientCpn.client'
    import ServerCpn from './ServerCpn.server'
    export default function OuterServerCpn() {
      return (
        <ClientCpn>
          <ServerCpn />
        </ClientCpn>
      )
    }

    // ClientCpn.client.jsx
    export default function({children}) {
      return <div>{children}</div>;
    }

    // ServerCpn.server.jsx
    export default function() {
      return <div>服务端组件</div>;

    这段组件代码转化为 RSC 数据后如下(不用在意数据细节,后文会解释):

    M1:{"id":"./src/ClientCpn.client.js","chunks":["client1"],"name":""}
    J0:["$","div",null,{"className":"main","children":["$","@1",null,{"children":["$","div",null,{"children":"服务端组件"}]}]}

    接下来我们从上述三个角度分析这段数据结构的含义。

    数据的序列化与反序列化

    RSC 是一种按行分隔的数据结构(方便按行流式传输),每行的格式为:

    [标记][id]: JSON数据

    其中:

    • 标记代表这行的数据类型,比如J代表组件树,M 代表一个 RCC 的引用,S 代表 Suspense
    • id 代表这行数据对应的 id
    • JSON 数据保存了这行具体的数据

    RSC 的序列化与反序列化其实就是 JSON 的序列化与反序列化。反序列化后的数据再根据标记不同做不同处理。

    比如,对于上述代码中第二行数据:

    J0:["$","div",null,{"className":"main","children":["$","@1",null,{"children":["$","div",null,{"children":"服务端组件"}]}]}

    可以理解为,这行数据描述了一棵组件树(标记 J),id 为 0,组件树对应数据为:

    按照这种逻辑划分,上述代码中:

    App组件只包含数据,显然属于SSR
    App组件的子组件Ctn消费data,如果他内部包含交互逻辑,应该属于RC

    当前端反序列化这行数据后,会根据上述 JSON 数据渲染组件树。

    id 映射

    所谓 id 映射,是指 对于同一个数据,如何在 rpc 协议传输的两端对应上?

    在 RSC 协议的语境下,是指 对于同一个组件,经由 RSC 在 React 前后端运行时之间传递,是如何对应上的。

    还是考虑上面的例子,回顾下第二行 RSC 对应的数据:

    [
      "$","div",null,{
        "className":"main","children":[
          "$","@1",null,{
            "children":["$","div",null,{
              "children":"服务端组件"}]
            }
          ]
        }

    这段数据结构有些类似 JSX 的返回值,把他与组件层级放到一张图里对比下:

    可以发现,这些信息已经足够前端渲染 <OuterServerCpn/>、<ServerCpn/>组件了,但是 <ClientCpn/> 对应的数据 @1 是什么意思呢?

    这需要结合第一行 RSC 的数据来分析:

    M1:{"id":"./src/ClientCpn.client.js","chunks":["client1"],"name":""}

    M 标记代表这行数据是一个 RCC 的引用,id 为 1,数据为:

    {
      "id":"./src/ClientCpn.client.js",
      "chunks":["client1"],
      "name":""

    第二行中的 @1 就是指引用 id 为 1 的 RCC,根据第一行 RSC 提供的信息,React 前端运行时知道 id 为 1 的 RCC 包含一个名为 client1的chunk,路径为 "./src/ClientCpn.client.js"。

    于是 React 前端运行时会向这个路径发起 JSONP 请求,请求回 <ClientCpn/> 组件对应代码:

    如果应用包裹了 <Suspense/>,那么请求过程中会显示 fallback 效果。

    可以看到,通过协议中的:

    • M[id],定义 id 对应的 RCC 数据
    • @[id],引用 id 对应的 RCC 数据

    就能将同一个 RCC 在 React 前后端运行时对应上。

    那么,为什么 RCC 不像 RSC 一样直接返回数据,而是返回引用 id 呢?

    主要是因为 RCC 中可能包含前端交互逻辑,而有些逻辑是不能通过 RSC 协议序列化的(底层是 JSON 序列化)。

    比如下面的 onClick props 是一个函数,函数是不能通过 JSON 序列化的:

    <button onClick={() => console.log('hello')}>你好</button

    这里我们再梳理下 RSC 协议中 id 映射的完整过程:

    1. 业务开发时通过 .server | client 后缀区分组件类型
    2. 后端代码编译时,所有 RCC(即 .client 后缀文件)会编译出独立文件(这一步是 react-server-dom-webpack 插件做的,对于 Vite,也有人提了Vite插件的实现 PR )
    3. React 后端返回给前端的RSC数据中包含了组件树(J 标记)等按行表示的数据
    4. React 前端根据 J 标记对应数据渲染组件树,遇到引用 RCC(形如 M[id])时,根据 id 发起 JSONP 请求
    5. 请求返回该 RCC 对应组件代码,请求过程的 pending 状态由 <Suspense/> 展示


    传输协议

    RSC 数据是以什么格式在前后端间传递呢?

    不同于一些 rpc 协议会基于 TCP 或 UDP 实现,RSC 协议直接基于 HTTP 协议实现,其 Content-Type 为 text/x-component。


    总结

    本文从理念、原理角度讲解了 RSC,过程中回答了几个问题。

    Q:RSC 和其他服务端渲染方案有什么区别?

    A:RSC 是服务端运行时的方案,采用流式传输。

    Q:为什么需要区分 RSC 与 RCC(通过文件后缀)?

    A:因为 RSC 需要在后端获取数据后流式传输给前端,而 RCC 在后端编译时编译成独立文件,前端渲染时再以 JSONP 的形式请求该文件

    Q:为什么 RCC 中不能 import RSC?

    A:因为他们的运行环境不同(前者在前端,后者在后端)

    由于配置繁琐,并不推荐在现有 React 项目中使用 RSC。想体验 RSC 的同学,可以使用 Next.js 并开启 App Router:

    在这种情况下,组件默认为 RSC。

    参考资料

    [1]体验 Demo: https://github.com/reactjs/server-components-demo

    [2]how-react-server-components-work: https://www.plasmic.app/blog/how-react-server-components-work

    [3]react-server-dom-webpack: https://www.npmjs.com/package/react-server-dom-webpack

    [4]Vite 插件的实现 PR: https://github.com/facebook/react/pull/26926

    点击左下角阅读原文,到 SegmentFault 思否社区 和文章作者展开更多互动和交流,公众号后台回复“ 入群 ”即可加入我们的技术交流群,收获更多的技术文章~

    - END -



    往期推荐



    社区精选|与众不同的夜间开关交互效果


    社区精选|未来全栈框架会卷的方向


    社区精选|Vue 3 中依赖注入与组件定义相关的那点事儿

    浏览 15
    点赞
    评论
    收藏
    分享

    手机扫一扫分享

    举报
    评论
    图片
    表情
    推荐
    点赞
    评论
    收藏
    分享

    手机扫一扫分享

    举报