React Server Component 的工作原理

8,051 阅读14分钟

React Server Components 是什么

React Server Components(RSC)是一项令人兴奋的新特性,它将对页面加载性能包大小以及React 应用程序的编写方式产生巨大影响。

RSC 使得服务端和客户端(浏览器)可以协同渲染React应用程序,从而实现了部分组件在服务端或客户端两者之间的渲染。下面是 React 团队的一个插图,展示了最终目标:一个 React 树,其中橙色的组件渲染在服务端上,蓝色的组件渲染在客户端上。 image.png

RSC 等同于 SSR ?

  • RSC 和 SSR 不是等同的概念,尽管它们都在服务端运行且名称中均包含“Server”。
  • SSR 是一种技术,通过模拟环境将 React Dom 树渲染成原生 HTML 并返回给客户端。无论是服务器组件还是客户端组件,都会以相同的方式渲染为原始 HTML。
  • 未来可能会支持 RSC 和 SSR 结合使用。

RSC 有什么作用

在 RSC 出现之前,所有的组件都被称为客户端组件。它们在浏览器环境下运行。当我们访问一个 React 应用时,浏览器会下载必要的 React 组件代码、结构和 React Element Tree 的代码,并将它们渲染到浏览器的 DOM 树上。浏览器提供了能够使 React 应用变得可交互的环境(例如增加事件处理程序、追踪状态变化、根据事件处理来改变 React Tree 和高效地更新 DOM)。 但是,既然客户端这么优秀,为什么还需要服务器来渲染呢?

  • 服务器可以直接访问数据源(data sources)。数据源可能是你的数据库、GraphQL 端点或文件系统等等。服务器可以直接获取所需数据而无需通过公共 API 端点,并且通常与数据源更紧密地搭配在一起,因此可以比浏览器更快地获取数据。
  • 服务器可以廉价地利用“重型”代码模块。例如将 Markdown 渲染为 HTML 的 NPM 包,在服务器上使用时不需要每次都下载这些依赖项——不像在浏览器中必须下载所有使用的代码作为 JavaScript 包。

总之,RSC 可以使得服务端和客户端各司其职并充分利用各自的优点:

  • 服务器组件可以专注于获取数据和渲染内容。
  • 客户端组件可以专注于状态交互。

以上两点造就了更快的页面加载更小的 JavaScript 打包大小以及更好的用户体验

下表总结了服务器和客户端组件的不同用例: image.png

高层次的全貌

让我们先来了解一下 RSC 是如何工作的。

"Bob的孩子们喜欢装饰纸杯蛋糕,但是却不太喜欢烘焙。让他们从头开始制作和装饰纸杯蛋糕将是一个可爱的噩梦。Bob需要给他们几袋面粉和糖,几条黄油,让他们使用烤箱,给他们读一大堆说明,花上一整天的时间。然而,他可以更快地完成烘焙部分,如果他提前做了一些工作——先烤纸杯蛋糕,做糖霜,然后把它们交给孩子们,而不是生的材料——他们可以更快地享受装饰的乐趣!更棒的是,Bob根本不必担心孩子们用烤箱的问题。"

React Server Component 的目的是实现更好的任务分工,让服务器先处理它擅长的事情,然后将剩余任务交给浏览器完成。这样一来,服务器需要传输的内容就减少了,不再需要传输整袋面粉和一个烤箱,只需传输12个小蛋糕即可提高效率。

在考虑页面中 React 树时,有些组件在服务器上渲染,而另一些则在客户端渲染。为了实现更高级别的策略,在“Render” server component 时可以采用简化方法将 React 组件转换为原生 HTML 元素(如 divp)。但每当遇到要在浏览器中渲染的 “Client” 组件时,则只输出占位符,并指示使用正确的 client component 和 props 来填充该空缺。随后,浏览器接收输出并使用 client component 填充这些占位符。

以上仅是其工作方式简述,并未涉及具体细节。

区分 server - client 组件

首先,什么是服务器组件?如何区分哪些组件是“用于服务器”的,哪些是“用于客户端”的?

React团队根据编写组件的文件扩展名进行定义:如果文件以“.server.jsx”结尾,则包含服务器组件;如果以“.client.jsx”结尾,则包含客户端组件。如果两者都没有,则该组件既可以作为服务器组件使用,也可以作为客户端组件使用。这个定义很实用——对人和打包工具来说都很容易区分。

特别是对于打包工具来说,它们现在可以通过检查客户端组件的文件名来加以区别处理。正如我们即将看到的那样,在使RSC(Server Component)工作方面,打包工具发挥着重要作用。 因为server component运行在服务器上,客户端组件运行在客户端上,所以每个组件可以做的事情都有很多限制。但是要记住的最重要的一点是客户端组件不能导入server component!这是因为server component 不能在浏览器中运行,并且可能存在无法在浏览器中正常工作的代码;如果客户端组件依赖于 server component,那么我们最终会将这些非法依赖项打包到浏览器中。 最后一点可能有些费解:这意味着像下面这样的客户端组件是不合法的:

// ClientComponent.client.jsx
// NOT OK:
import ServerComponent from './ServerComponent.server'
export default function ClientComponent() {
  return (
    <div>
      <ServerComponent />
    </div>
  )
}

如果客户端组件无法导入服务器组件,就无法实例化服务器组件。那么我们该如何在React树中将这样的服务器和客户端组件交织在一起呢?又该如何将橙色点的服务器组件置于蓝点的客户端组件之下?

A React tree with server components (orange) and client components (blue)

虽然客户端组件无法直接导入和渲染服务器端组件,但可以使用组合的方式。也就是说,客户端组件仍然可以接收不透明的 React 节点,并且这些节点可能正好由服务器端组件渲染。例如:

// ClientComponent.client.jsx
export default function ClientComponent({ children }) {
  return (
    <div>
      <h1>Hello from client land</h1>
      {children}
    </div>
  )
}

// ServerComponent.server.jsx
export default function ServerComponent() {
  return <span>Hello from server land</span>
}

// OuterServerComponent.server.jsx
// OuterServerComponent can instantiate both client and server
// components, and we are passing in a <ServerComponent/> as
// the children prop to the ClientComponent.
import ClientComponent from './ClientComponent.client'
import ServerComponent from './ServerComponent.server'
export default function OuterServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

这一限制将对你如何组织组件以更好地利用RSC产生重大影响。

RSC 渲染流程

1. Server 接收一个 render 请求

由于 Server 需要进行预渲染,因此 RSC 的渲染流程始终从服务器开始。它响应一些 API 调用来渲染 React 组件。应用的根组件总是一个 server component,可以渲染其他 server 或 client 组件。服务器根据传递到请求中的信息确定使用哪个 server component 和什么 props。通常以特定 URL 的页面请求形式出现,但 Shopify Hydrogen 提供了更细粒度的方法(Server props),React 团队也有一个原始实现的官方演示)。

2. Server 将 root component 序列化为 JSON

我们的最终目标是将初始根服务器组件渲染为基本 HTML 标记客户端组件“占位符”的。然后,我们可以对该树进行序列化并发送给浏览器。浏览器会反序列化该树,并使用真正的客户端组件填充占位符,从而渲染最终结果。

因此,以前面提到的例子为例,如果我们想要渲染 这个组件,则仅使用 JSON.stringify() 无法获得一个序列化的元素树。这是因为 React 元素不像 HTML 标签那样简单明了,它是一个封装好的对象:

// React element for <div>oh my</div>
> React.createElement("div", { title: "oh my" })
{
  $$typeof: Symbol(react.element),
  type: "div",
  props: { title: "oh my" },
  ...
}

// React element for <MyComponent>oh my</MyComponent>
> function MyComponent({children}) {
    return <div>{children}</div>;
  }
> React.createElement(MyComponent, { children: "oh my" });
{
  $$typeof: Symbol(react.element),
  type: MyComponent  // reference to the MyComponent function
  props: { children: "oh my" },
  ...
}

当你使用一个组件元素而不是基本的 HTML 标签元素时,type 字段引用了一个组件函数。这些函数无法直接进行 JSON 序列化。 为了正确地对所有内容进行 JSON-Stringify,React 会向 JSON.stringify() 传递一个特殊的替换函数以正确处理这些组件函数引用。 具体来说,在序列化 React 元素时:

  • 如果它被用于基本的 HTML 标签(type 字段是字符串,如 "div"),那么它已经可以被序列化,无需做其他事情。
  • 如果它被用于服务器端组件,则调用其 props 中存储的服务器端组件函数(存储在 type 字段中),并将结果序列化。这有效地“渲染”了服务器端组件;目标是将所有服务器端组件转换成基本的 HTML 标签。
  • 如果它被用于客户端组件,则实际上已经可以被序列化。type 字段实际上指向一个模块引用对象,而不是一个组件函数。

模块引用对象

RSC 引入了一种新的可能值作为 React Element 的 type 字段,称为“模块引用”。这个值不同于之前的 component function,而是一个可序列化的“引用”。 举例来说,client component 元素可能长成这样:

{
  $$typeof: Symbol(react.element),
  // The type field  now has a reference object,
  // instead of the actual component function
  type: {
    $$typeof: Symbol(react.module.reference),
    // ClientComponent is the default export...
    name: "default",
    // from this file!
    filename: "./src/ClientComponent.client.js"
  },
  props: { children: "oh my" },
}

这种变化发生在哪里呢?我们将客户端组件函数的引用转换为可序列化的“模块引用”对象是在哪个环节进行的呢?答案是使用 bundler。React 团队已经发布了对 webpack 官方 RSC 支持,可以作为webpack loadernode-register 使用。当服务器组件从 * .client.jsx 文件中导入内容时,并没有真正得到该文件导出的内容,而是得到一个包含该文件名和导出名的模块引用对象。因此,在Server端构建 React Tree 时,客户端组件不会被包括进去。 再次考虑上面的例子,当我们试图序列化组件时,最终会得到一个JSON树:

{
  // The ClientComponent element placeholder with "module reference"
  $$typeof: Symbol(react.element),
  type: {
    $$typeof: Symbol(react.module.reference),
    name: "default",
    filename: "./src/ClientComponent.client.js"
  },
  props: {
    // children passed to ClientComponent, which was <ServerComponent />.
    children: {
      // ServerComponent gets directly rendered into html tags;
      // notice that there's no reference at all to the
      // ServerComponent - we're directly rendering the `span`.
      $$typeof: Symbol(react.element),
      type: "span",
      props: {
        children: "Hello from server land"
      }
    }
  }
}

可序列化的 React tree

在这个过程结束时,我们希望最终得到一个在服务器上看起来更像 React 树的结构,并将其发送到浏览器以完成页面渲染。 A React tree with server components rendered to native tags, and client components replaced with placeholders

所有 props 必须是可序列化的

当我们将整个 React 树序列化为JSON时,传递给客户端的组件或基本 HTML 标签的所有 props 也必须是可序列化的。因此,你不能将事件处理程序作为 props 从服务器组件传递。

// NOT OK: server components cannot pass functions as a prop
// to its descendents, because functions are not serializable.
function SomeServerComponent() {
  return <button onClick={() => alert('OHHAI')}>Click me!</button>
}

在 RSC 过程中,当遇到客户端组件时,我们不会调用该组件,而是保存对它的引用。因此,可以通过另一个客户端组件来传递事件处理程序。

function SomeServerComponent() {
  return <ClientComponent1>Hello world!</ClientComponent1>;
}

function ClientComponent1({children}) {
  // It is okay to pass a function as prop from client to
  // client components
  return <ClientComponent2 onChange={...}>{children}</ClientComponent2>;
}

在这个 RSC JSON 树中,根本没有出现 Client Component2。相反,我们只能看到一个带有模块引用和 Client Component1 属性的元素。因此,将事件处理程序作为参数传递给ClientComponent2是完全合法的。

3. 浏览器重新构建 React Tree

浏览器接收到 Server 输出的 JSON 后,需要重建 React 树以在浏览器中渲染。当遇到类型为模块引用的元素时,我们希望将其替换为真实客户端组件功能的引用

这项工作需要打包工具(bundler) 的帮助。我们的 bundler 用服务器上的模块引用替换了客户端组件函数,现在它知道如何在浏览器中使用真正的客户端组件函数来替换那些模块引用。

重构后的 React 树只交换了原生标签和客户端组件,看起来像这样: A React tree reconstructed in the browser with only native tags and client components

然后,我们按照惯例将这棵树渲染并提交到DOM中。

RSC 与 Suspense

suspense 在上述所有步骤中都起着至关重要的作用。

在本文中我们没有详细介绍 suspense ,因为它是一个庞大的主题。但是简单来说,当 React 组件需要等待某些尚未准备好的东西(例如获取数据或延迟导入组件)时,suspense 允许你从组件中抛出 promise 。这些 promise 被捕获在“ suspense 边界”处——每当从渲染 suspense 子树抛出 promise 时,React 会暂停渲染该子树直到 promise 解决后再次尝试。

当我们调用服务器端组件函数以生成 RSC 输出时,这些函数可能会抛出 promise ,因为它们需要获取所需数据。遇到此类 promise 时,我们输出一个占位符;一旦解决了该 promise ,则尝试再次调用服务器端组件函数,并在成功后输出完成块。实际上,我们正在创建 RSC 输出流,在抛出 promise 期间暂停,并在其解决后流式传输其他块。

在浏览器中,我们正在使用 fetch() 方法从上方流式传输 RSC JSON 输出。如果输出中遇到了服务器抛出的 promise 导致的 suspense 占位符,并且在流中还没有看到占位符内容,则此过程也可能会抛出一个 promise(需要注意一些细节)。另外,如果它遇到客户端组件模块引用但尚未加载该客户端组件函数,则也可以抛出一个 promise —— 在这种情况下,打包程序运行时将必须动态获取所需的块。

由于 suspense 操作,你可以通过服务器流式传输 RSC 输出来提高性能。服务器组件会获取数据并逐步渲染它们,同时根据需要动态获取客户端组件 bundler 。

RSC Wire Format(数据传输格式)

然而,你可能会想知道服务器到底输出了什么。如果在看到“JSON”和“stream”这两个词时感到困惑,那么你的疑虑是正确的,应该持怀疑态度!那么服务器传输给浏览器的数据是什么呢?

实际上,它采用了一种简单的格式,在每行都包含一个带有ID标记的JSON块。以下是我们示例中RSC输出的内容:

M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
J0:["$","@1",null,{"children":["$","span",null,{"children":"Hello from server land"}]}]


在上述片段中,M行以 M 开头,定义了客户端组件模块的引用。该行提供了查找客户端包中组件函数所需的信息。J行以 J 开头,定义了实际的 React 元素树。其中包括由 M 行定义的客户端组件引用(例如 @1)。这种格式非常适合流式传输——一旦客户端读取整个行,它就可以解析 JSON 片段并取得进展。如果服务器在渲染时遇到 suspense boundaries,则会看到多个 J 行对应于每个块的解决。

例如,让我们让我们的示例更有趣一些...

// Tweets.server.js
import { fetch } from 'react-fetch' // React's Suspense-aware fetch()
import Tweet from './Tweet.client'
export default function Tweets() {
  const tweets = fetch(`/tweets`).json()
  return (
    <ul>
      {tweets.slice(0, 2).map((tweet) => (
        <li>
          <Tweet tweet={tweet} />
        </li>
      ))}
    </ul>
  )
}

// Tweet.client.js
export default function Tweet({ tweet }) {
  return <div onClick={() => alert(`Written by ${tweet.username}`)}>{tweet.body}</div>
}

// OuterServerComponent.server.js
export default function OuterServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent />
      <Suspense fallback={'Loading tweets...'}>
        <Tweets />
      </Suspense>
    </ClientComponent>
  )
}

在这种情况下,RSC流是什么样子?

M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
S2:"react.suspense"
J0:["$","@1",null,{"children":[["$","span",null,{"children":"Hello from server land"}],["$","$2",null,{"fallback":"Loading tweets...","children":"@3"}]]}]
M4:{"id":"./src/Tweet.client.js","chunks":["client8"],"name":""}
J3:["$","ul",null,{"children":[["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}],["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}]]}]

在 J0 行中,现在有一个额外的子项——新的 Suspense 边界,其中 children 指向引用 @3。有趣的是,在这一点上,@3 尚未定义!当服务器完成加载 Tweets 时,它会输出 M4 这行——它定义了对 Tweet.client.js 组件的模块引用——以及 J3 —— 它定义了另一个 React 元素树,应该在 @3 所在位置进行交换(请注意,J3 的 children 引用了在 M4 中定义的 Tweet 组件)。

还要注意的另一件事是 bundler 自动将 ClientComponent 和 Tweet 放入两个单独的 bundles 中。这使得浏览器可以推迟下载 Tweet bundle!

使用RSC格式

如何在浏览器中将 RSC 流转换为实际的 React 元素?使用 react-server-dom-webpack 包,它包含一个入口点,可以接收 RSC 响应并重新创建 React 元素树。以下是你的根客户端组件可能看起来像的简化版本:

import { createFromFetch } from 'react-server-dom-webpack'
function ClientRootComponent() {
  // fetch() from our RSC API endpoint.  react-server-dom-webpack
  // can then take the fetch result and reconstruct the React
  // element tree
  const response = createFromFetch(fetch('/rsc?...'))
  return <Suspense fallback={null}>{response.readRoot() /* Returns a React element! */}</Suspense>
}

你需要让 react-server-dom-webpack 从API端点读取 RSC 响应。使用 response.readRoot() 方法可以返回一个React元素,它会随着响应流的处理而更新。在任何流被读取之前,该方法会立即抛出一个promise,因为还没有准备好任何内容。当处理第一个J0时,它创建了相应的 React 元素树并解决了抛出的 promise。React恢复渲染过程,但遇到尚未准备好的 @3引用时,另一个 promise 被抛出。一旦读取 J3 后,该 promise 就会得到解决,并且React再次恢复渲染直至完成。因此,在我们流式传输 RSC 响应时,我们将继续通过 Suspense 边界定义的块更新和渲染已有的元素树直至结束。

为什么不输出纯HTML呢?

为什么要创造一种全新的标记语言格式?因为客户端需要重建 React 元素树。相比于使用 HTML,采用这种格式更容易实现此目标,因为我们不必解析 HTML 就能创建 React 元素。请注意,重建 React 元素树非常关键,因为它使得我们可以通过最少的 DOM 提交合并来处理后续对React树的更改。

这比仅从客户端组件获取数据更好吗?

我们是否真的需要向服务器发出 API 请求来获取内容?相比仅请求数据并在客户端进行渲染,这样做是否更好?

你的最终结果取决于渲染的内容。使用 RSC,你可以获取非规范化的“处理”数据,并将其直接映射到用户所看到的内容。因此,如果只需要渲染少量数据片段或者渲染本身需要大量 JavaScript 以避免下载到浏览器中,则从服务器提取数据会更加有效。另外,如果渲染需要多个依次瀑布式地相互依赖的数据提取,则最好从服务器上进行提取——其中延迟要低得多——而不是从浏览器中提取。

服务器端渲染呢?

React 18中,可以结合SSR和RSC。这样,在服务器上生成HTML后,就能在浏览器中将其与RSC组合使用。请继续关注此话题!

更新 Server Component 内容

如果你需要在查看不同产品页面之间进行切换,让服务器组件渲染新内容该怎么办?

由于渲染是在服务器上完成的,因此需要向服务器发出另一个 API 调用以获取新内容的 RSC Wire Format。一旦浏览器接收到新内容,它就可以构建一个新的 React 元素树,并执行与先前元素树的常规协调差异来确定 DOM 所需的最小更新。同时保留状态和事件处理程序在客户端组件中。

对于客户端组件而言,这种更新与完全在浏览器中进行没有什么区别。目前必须重新渲染整个 React 树,但将来可能会针对子树执行此操作。

为什么需要在 meta-framework 上使用 RSC ?

React 团队表示,RSC最初是通过 Next.js 等框架采用的,而不是直接用于普通的 React 项目。这些框架提供了更友好的包装器和抽象,因此你不必考虑在服务器中生成 RSC流,并在浏览器中使用它。元框架还支持服务器端渲染,如果你使用的是 server component ,则会确保服务器生成的 HTML 能够适当地配合。

为了正确发布和使用客户端组件,在浏览器中需要与 bundler 协作。已经有一个 webpack 集成可用,并且 Shopify 正在致力于 vite 集成。这些插件需要作为 React repo 的一部分,因为许多 RSC所需组件并没有作为公共 npm 包发布。但是一旦开发完成,这些部分应该可以在不涉及 meta-framework 情况下使用,从而使构建应用更轻松。

Is RSC Ready?

React Server Components 现在在 Next.js 的开发者预览版中作为实验功能可用,但它们都还没有准备好投入生产使用。不过,毫无疑问,RSC 将成为 React 未来的重要组成部分。它是 React 对更快的页面加载、更小的 JavaScript 包和更短的交互时间的回答,提供了一个更全面的解决方案,用于构建多页面应用程序。尽管它可能还没有准备好,但很快就会是时候开始关注了。

实践

打造属于你自己的 Mac(Next.js+Nest.js TS全栈项目) 这篇文章介绍了我本人做的一个使用了 RSC 新特性的项目。

参考