认识 React Server Component


一、前言

如果你看了今年的 Next.js Conf 2023 ,一定不会对下面这张图感到陌生,在 Next.js v14 推出的诸多新特性中,Server Actions 在社区中引起了广泛的讨论。

Untitled

Server Actions 是指与在服务器上运行的组件一起创建的函数,用于执行数据变更等任务。作为一个前端,如果你第一次看到这种编程范式,一定会满脸问号,社区中也因此也出现了发布会各种梗图。

Untitled

不过今天的话题不是 Server Actions,而是衍生出 Server Actions 的 React Server Component。早在 2020 年 React 便在 Introducing Zero-Bundle-Size React Server Components 中介绍了 React Server Component 的相关概念,直到 2023 年 5 月才在 Next.js v13.4 中作为一个稳定版本的特性被发布。许多人对 React Server Component 还不太了解,可能会有很多疑问,比如它是什么,解决了什么问题,有什么好处以及跟 Server Side Rendering 有什么区别,本文将解答这些疑问。

二、React 中的数据获取问题

为了更深入地理解 React Server Component 的工作原理,我们先来探讨一下在 React 中进行数据获取的流程。以 Shopify 商店的用户订单页面为例,当用户访问订单页面时,页面首先会显示一些导航按钮和链接,以及订单列表的框架,提示用户数据正在加载。随后,页面获取到订单数据并渲染出订单列表,完成整个页面的渲染过程。

Untitled

Untitled

接下来看下 React 如何完成这个页面的渲染工作。

客户端渲染中的数据获取

最初,React App 采用的都是客户端渲染,用户访问的时候会首先得到这样一个 HTML 文件:

<!DOCTYPE htlm>
<html>
  <body>
    <div id="root"></div>
    <script src="/static/js/app.js"></script>
  </body>
</html>

app.js 脚本包含了应用运行所需的所有代码内容,下载并解析 JS 后,React 便为整个应用创建 DOM 节点并挂载到 <div id="root"> 上。但这里存在一个很糟糕的用户体验问题,完成这些工作是需要一些时间的,在此之前,用户只能看着一个空白的页面发呆,并且这个问题还会随着代码量的增长变得更加明显,用户等待的时间也会延长。客户端渲染的流程可以用下图表示:

Untitled

服务端渲染中的数据获取

与客户端渲染相比,服务端渲染(SSR) 专注于初始页面的加载,在服务器预渲染生成一个完整的 HTML 文档,HTML 文档发送到客户端后,同样需要下载 JS 包,接着使用下载好的 Javascript 进行 Hydrate 才能像典型的 React 应用一样运行。因此使用 SSR,用户不必在下载和解析 JS 的时候只能看到一个空白的页面。服务端渲染流程如下:

Untitled

这极大改善了首屏空白的问题,起码用户在等待完成所有内容的渲染之前,能够看到骨架图,知道页面正在正常工作,减轻用户的焦虑,并且能够进行一些导航的操作,更快地到达自己想要的最终路径。

SSR 遗留的问题

尽管 React 通过一定的策略对 SSR 进行的优化,比如通过 Suspense 允许客户端选择性进行 Hydrate,通过 <Suspense> 告诉服务器降低组件渲染和 Hydration 的优先级,从而不影响一些重要组件的 Hydration。但是,SSR 仍然遗留了一些问题:

  • 在显示任何组件之前,必须先向服务器获取整个页面的数据
  • 所有页面的 Javascript 代码都会被下载,即使是通过异步流式传输,随着应用复杂度上升,用户需要下载的代码量也会增加
  • 用户在完成 Hydrate 之前仍无法于页面进行交互
  • 大多数的 Javascript 计算量仍然在客户端进行

对于页面数据的获取,在 React 中我们通常更倾向于组件负责获取他自己的数据,而不是在最顶层获取数据之后一层层往下传递,由于这样代码的构建,还会产生网络瀑布流问题。比如下图的订单详情页面,包含了 Order、Billing 和 Shipping 信息,我们可以用代码简化表示:

function OrderPage({ orderId }) {
  return (
    <OrderDetail orderId={orderId}>
      <BillingDetail orderId={orderId} />
      <ShippingDetail orderId={orderId} />
    </OrderDetail>
  )
}

Untitled

首先 OrderDetial 获取数据,通常这期间 OrderDetail 渲染一个 Spinner,等到 OrderDetail 渲染了 BillingDetailShippingDetail,才在 useEffect 中获取 Billing 和 Shipping 信息。这个过程叫做网络瀑布流,因为它是分阶段进行的。

Untitled

这个流程似乎非常标准,因为我们之前已经创建了许多类似的页面。然而,如果我们回过头来思考一下,我们在服务端已经完成了初始页面的渲染,为什么不顺便在服务端获取页面所需的数据呢?这样就不需要在客户端和服务端之间来回传输数据,从而节省时间。考虑到服务器功能更强大,而且通常更接近数据源,因此在服务器上准备好渲染所需的数据似乎是一个很棒的想法。这个流程应该是这样的:

Untitled

为了实现这个效果,我们需要为 React 提供一段专门在服务器运行的代码,用来对数据库进行查询,但 React 只是一个用户构建 UI 界面的框架,它并没有这样的能力,即使使用 SSR,我们所有的代码还是会在服务端和客户端都运行一遍。

Next.js 的解决方案

针对这个问题,一些像 Next.js 这样的元框架(Meta-Frameworks)给出了自己的解决方案。在使用 Next.js 传统的 Pages Router 时,我们可以通过 getServerSideProps 函数来声明一段只在服务器运行的代码,比如:

export async function getServerSideProps() {
  const res = await fetch('https://api.github.com/repos/vercel/next.js')
  const repo = await res.json()
  return { props: { repo } }
}

export default function Page({ repo }) {
  return repo.stargazers_count
}

当服务器接受到请求时,调用 getServerSideProps 函数,返回一个 props 对象,这个 props 对象将被传递到页面中,页面在服务器上进行渲染,然后在客户端进行 Hydrate。这段代码只会在服务器上运行,也不会被打包到发送给客户端的 Javascript bundle 中。

这其实是一个很巧妙的设计,使用也非常简单,但仍然存在一些问题:

  • 只作用于路由级别,适用于 Root Component,我们无法在其他任何组件中使用这个策略
  • 即使已经通过该策略完成了数据的获取并在服务器完成了渲染,但这些组件仍然需要在客户端再一次进行 Hydrate,有时候我们并不需要做
  • 这是一个非标准化的策略,不同的元框架会有自己的实现,Next.js、Gatsby 和 Remix 都有自己的实现方法

三、React Server Component 介绍

基于这些问题,React 想要推出一个官方的解决方案,需要对服务端拥有更多的掌控,这个解决方案就是 React Server Component。RSC 允许开发人员构建跨服务器和客户端的应用程序,将客户端应用程序的丰富交互性与传统服务器渲染的性能改进相结合。

什么是 RSC

这是一个简单的 RSC 示例:

import {db} from './db';
import SidebarNote from './SidebarNote';

export default async function NoteList({searchText}) {
  // const notes = await (await fetch('http://localhost:4000/notes')).json();

  // WARNING: This is for demo purposes only.
  // We don't encourage this in real apps. There are far safer ways to access
  // data in a real application!
  const notes = (
    await db.query(
      `select * from notes where title ilike $1 order by id desc`,
      ['%' + searchText + '%']
    )
  ).rows;

  return (
    <ul>
      {notes.map((note) => (
        <li key={note.id}>
          <SidebarNote note={note} />
        </li>
      ))}
    </ul>
  );
}

对于一个传统的 React 开发者,对 NoteList 这个组件的代码一定是特殊的,它至少有两个点跟传统的组件范式是冲突的:

  1. 直接在组件中查找数据库并渲染数据
  2. 在渲染的过程中直接使用异步函数

对于第一点,其实演示的效果大于实际意义,我们有很多更加安全的最佳实践去获取数据,并不会真的建议在真实的应用中这样操作。主要在于第二点,我们都知道,在传统 React 中,组件渲染中是不允许出现这样的副作用的,我们需要将副作用放入 useEffect 回调或者其他的事件回调中,防止它们在每次渲染的时候都重复调用。

但是,如果服务端组件只会渲染一次,我们就不用担心这个问题了。实际上,RSC 确实也是这样设计的,服务器组件永远不会重新渲染,它们就像一个普通的函数一样,在服务器上运行一次来生成 UI 并流式传输到客户端 React 组件树中,并与其他服务器组件和客户端组件交错。

这意味着,传统的 React API 有一部分是与服务器组件不兼容的,比如我们无法在服务器组件中使用 state 和 useEffect 等,因为 state 可以改变,而 effect 仅在渲染后在客户端上运行。在这种新的组件范式下,传统的 React 组件称为客户端组件(React Clien Components)。

RSC 的主要特性

  • 仅在服务器运行,对 bundle size 没有任何影响。服务器组件的代码永远不会被下载到客户端
  • 可以访问服务端数据源,比如数据库,文件系统或者(微)服务。
  • 可以与客户端组件无缝集成,RSC 可以在服务器上加载数据并将其作为 Props 传递给客户端组件,使客户端组件处理交互部分的渲染
  • RSC 可以动态选择要渲染的客户端组件,从而允许客户端仅下载渲染页面所需的最少量的代码
  • RSC 在重新加载时保留客户端状态。意味着重新获取 RSC Tree 时,客户端的状态、焦点甚至正在进行的动画都不会中断或者重置。RSC 采用了一种巧妙的更新策略。在服务器组件更新时,React 会确定哪些部分需要被更新,并只发送必要的改变到客户端。这样,客户端不需要重新加载完整的页面,而只是在必要的地方进行局部更新。这个过程中,React 会保留已有的客户端状态,包括用户界面状态和交云状态,如输入框内的文本、选中的项、当前的滚动位置等
  • RSC 是逐步渲染的,并以增量的方式将 UI 的渲染单元流式传输到客户端。与 Suspense 相结合,开发人员可以精心设计加载状态,并在等待页面其余部分加载时快速显示重要的内容
  • 能够在 RSC 和客户端组件之间共享代码

RSC 的作用

减少 Bundle 体积

RSC 可以减少客户端 Javascript Bundle 的大小从而提高加载性能。一般来说,客户端会下载并执行所有的代码和数据依赖项,以至于我们需要拆分代码和按需加载来减少不必要的资源加载。但是 RSC 会在服务器解析和运行代码,只将处理结果以及客户端组件发送到客户端,意味着在 RSC 中依赖比较重的库去实现一些特性的时候,可以减少很多负担。

防止数据获取瀑布流

RSC 可以与客户端组件无缝集成,在 React 组件树中与客户端组件交错呈现。通过将一些单纯渲染数据的组件移到服务端,有助于防止客户端数据获取的瀑布流问题。

Untitled

如何使用 RSC

一般来说,如果一个组件可以是服务器组件,那么它就应该是服务器组件。服务器组件往往更简单和容易理解,并且服务器组件的代码不会包含在 js bundle 中,因此,在 Next.js v13.4 之后的 App 路由中,默认情况下所有组件都是服务器组件。

需要注意的是,这并不意味着我们要尽可能地消除客户端组件或者取代客户端组件,对于需要使用 state 或 effect,以及浏览器 API 的组件,就可以指定为客户端组件。正常运行的应用程序利用 RSC 进行动态数据提取,并利用客户端组件进行丰富的交互性,在 RSC 和客户端组件之间取得适当的平衡,是 React 开发者需要关注的一个方面,虽然 RSC 并不能完全解决用户使用过多的客户端 Javascript 的问题,但确实有能力让开发者有能力选择在何时将计算权重放在用户终端。

在最新的规范中,在文件的顶部使用字符串 'use client' 来表示该文件是一个客户端组件并且应该包含在 js bundle 中,以便能够在客户端运行。这跟进入严格模式的 'use strict' 指令类似,不过 javascript 并不认识 'use client' ,识别该指令的工作是由 bundler 完成的。

如何实现 RSC 和客户端组件在同一个 React 树中呈现

思考这样一个场景,假设我们有一个 Blog 网站,可以通过按钮切换不同的样式主题,比如组件树结构如‘’下:

Untitled

'use client'

function App() {
  const [theme, setTheme] = useState('dark')

  const colorSchema = themeMap[theme]

  return (
    <body style={colorSchema}>
      <Header />
      <MainContent />
    </body>
  )
}

由于 App 使用了 state,它是一个客户端组件。App 重新渲染时, Header 和 MainContent 也需要重新渲染,如果 Header 和 MainContent 都是服务端组件,则无法做到重新渲染。为了防止这种不可能的情况,React 团队为 RSC 增加了一个规则:客户端组件只能导入其他客户端组件。这个规则有两个原因的考虑:

  1. RSC 只能在服务器渲染一次,无法随着客户端状态改变而重新渲染
  2. 如果客户端组件如果导入了 RSC,那么 RSC 的代码也要被发送到客户端,显然这是跟 RSC 的特性矛盾的。

如何绕过限制

我们经常需要在应用顶层使用状态,客户端组件只能导入其他客户端组件这个限制,是否意味着一切都需要成为客户端组件?办法总是有的,否则就没有 RSC 什么事了。

在许多情况下,我们可以通过修改代码组合的方式绕过这个限制。虽然客户端组件无法导入 RSC,但是客户端组件可以接收 ReactNode 为 Props,通过 Props 传递的方式,将 RSC 和客户端组件嵌套在一起。修改上面的代码:

// App.jsx
function App() {
  return (
    <ThemeToggler>
      <Header />
      <MainContent />
    </ThemeToggler>
  )
}

// ThemeToggler.jsx
'use client'

function ThemeToggler({ children }) {
  const [theme, setTheme] = useState('dark')

  const colorSchema = themeMap[theme]

  return (
    <body style={colorSchema}>
      {children}
    </body>
  )
}

在 App 组件中删除了 'use client',现在它是一个服务端组件,把修改 theme 状态的代码移到 ThemeToggler 组件中,并且把 Header 和 MainContent 当成是 ThemeToggler children 传递进去,这样就只有 ThemeToggler 是一个客户端组件了。组件树变成了下图:

Untitled

可以看出,在 RSC 和客户端组件的结合中,组件的父子关系并不重要,App 才是导入和渲染 Header 和 MainContent 的那个,这没有违反 RSC 的限制规则。

RSC 工作原理

至此,我们介绍了 RSC 的一些特点和如何使用 RSC,已经足够帮助我们在支持 RSC 的框架中很好地使用 RSC 了。等等,就不能不用其他的框架,只依赖 React 去使用 RSC 吗?很遗憾,我们并不能像其他的最新 React 特性一样,升级一个最新的 React 版本,就能享受到 RSC 的特性。React 虽然定义了 RSC 的规范,比如通过 'use client' 标记为客户端组件,但是 RSC 规范的落地还要依赖其他一堆东西的紧密集成,比如 bundler,服务器和路由器等,这些东西已经超出了 React 的核心能力,所以目前都是通过 Next.js 等元框架实现 RSC。

虽然 React 没有实现面对开发者的完整 RSC,但是为了统一 RSC 规范,是提供了实现 RSC 的基础能力的,这些能力都收在 react-server-dom-webpack 这个包当中,开发者也能通过这个包自己去实现 RSC。

react-server-dom-webpack 包含三部分的能力:

  • 编译时能力
  • 服务端运行时
  • 客户端运行时

编译时能力

react-server-dom-webpack 的编译时能力主要是用于处理客户端组件的。通过 react-server-dom-webpack/plugin 导出一个 ReactServerWebpackPlugin 插件,这个插件的主要作用:

  • 识别出项目和依赖包中的所有的客户端组件,并打包成单独的 chunk
  • 生成 **react-client-manifest.json,**存放 chunk 的映射信息,供服务器运行时表示客户端组件时使用

我们可以从 plugin 中的 hasUseClientDirective 看到这个插件是如何判断客户端组件的:

function hasUseClientDirective(source: string): boolean {
      if (source.indexOf('use client') === -1) {
        return false;
      }
      let body;
      try {
        body = acorn.parse(source, {
          ecmaVersion: '2024',
          sourceType: 'module',
        }).body;
      } catch (x) {
        return false;
      }
      for (let i = 0; i < body.length; i++) {
        const node = body[i];
        if (node.type !== 'ExpressionStatement' || !node.directive) {
          break;
        }
        if (node.directive === 'use client') {
          return true;
        }
      }
      return false;
    }

可以看到需要满足两个条件,代码中包含 'use client' 的独立语句,并且 use client 是一个 “directive”。

在 JavaScript 中,“directive” 通常指的是一个简单的表达式语句,它看起来像一个未被赋值的字符串字面量。它们出现在函数或脚本的开头,且对其有特殊的语义效果。最常见的例子是严格模式的指令 “use strict”;

服务端运行时

服务端运行时是针对服务端组件的,跟 SSR 类似。SSR 是通过 JSX 输入,然后通过 renderToString 在服务器生成可以直接在浏览器运行的 html 代码,而对于 RSC,则是通过 renderToPipeableStream 方法生成类 JSON 的序列化数据,这也是 RSC 和 SSR 最大的区别。

renderToPipeableStream 方法返回一个 pipe 方法,pipe 方法以流的方式把数据发送到客户端。

以下面代码为例:

export default function App() {
  return (
    <div className="main">
      <div>hello world</div>
    </div>
  );
}

对于这样一个服务端组件,renderToPipeableStream 生成结果如下:

0:["$","div",null,{"className":"main","children":["$","div",null,{"children":"hello world"}]}]

这个序列化数据主要分为两部分,前面的 0 是 id,id 后面则是一个 json 格式的数据,对于这种跟 React Tree 大同小异的数据格式,一定觉得很熟悉!是的,这相当于把 RSC 序列化成了一个类似 React Tree 的格式。

如果把代码变得再复杂一点,加入另一个 RSC 和客户端组件:

import RSC from './RSC';
import RCC from './RCC';

export default function App() {
  return (
    <div className="main">
      <div>hello world</div>
      <RSC />
      <RCC />
    </div>
  );
}

得到的输出结果是(已格式化):

1:I{"id":"./src/RCC.js","chunks":["client2"],"name":""}
0:[
    "$",
    "div",
    null,
    {
        "className": "main",
        "children": [
            [
                "$",
                "div",
                null,
                {
                    "children": "hello world"
                }
            ],
            [
                "$",
                "div",
                null,
                {
                    "children": "This is RSC"
                }
            ],
            [
                "$",
                "$L1",
                null,
                {}
            ]
        ]
    }
]

从结果可以看到,增加的 RSC 也变成了 React Tree 中的一个节点,而对于 RCC 组件,我们在这个 React Tree 中得到的是一个占位对象,根据占位对象中的字段 $L1,我们可以大胆猜测,这个客户端组件的代码信息在 id 为 1 的数据列中。注意,这里说的是代码信息,并不是这个这个组件的运行结果,因为客户端组件不在服务端运行。

另外,第一列数据的前面的 1:I 除了 id 之外,还多了一个标记 I ,这个标记用于表示序列化的是客户端组件。

客户端运行时

客户端运行时主要用于处理 renderToPipeableStream 推送到客户端的序列化数据。主要提供有两个方法:

  • createFromFetch:通过 fetch Promise 获取序列化数据
  • createFromReadableStream:通过可读流获取序列化数据

这两个方法会返回一个最终需要渲染的 React Element Promise,相当于一个 React lazy 组件,然后挂载到页面上。

总结

在 React Server Component 的规范下,一个 React 应用可以看作是既包含客户端又包含服务端组件的单个组件树,在这种心理模型下,我们认为不再从客户端组件中获取数据,而是在服务端组件中获取数据并通过属性的方式向客户端组件传递数据。

RSC 带来了很多优点,服务端组件不会对 bundle size 产生影响,直接访问服务端资源以及完全自由的选择等。但实际使用的时候,可能也会产生一些不好的体验,比如你需要思考你写的每一个组件到底是服务端组件还是客户端组件,并且在客户端组件开头加上 'use client',否则带来的是频繁地看到报错:你不能在服务器组件中使用 useState !

总的来说,RSC 还是值得期待的,由于它特有的数据传输协议,这一特性将来也有希望移植到 React Native 中,减轻移动设备的计算负担。作为一个开发者,你会基于什么原因选择或者不选择在项目中使用 React Server Component 呢。