React 服务器组件深度指南

React server components (RSC)是一个令人兴奋的新功能,它将对页面加载性能、bundle大小以及未来我们编写React应用程序的方式产生巨大影响。

什么是React服务器组件?

React服务器组件允许服务器和客户端(浏览器)共同渲染您的React应用程序。用于呈现页面的典型React元素树,通常由不同的React组件组成,这些组件又渲染更多的React组件。RSC使得一些组件可以由服务器呈现,而另一些组件可以由浏览器呈现 🤯

以下是React团队提供的一个快速示例,展示了最终目标是什么:一个React树,其中橙色组件在服务器上呈现,蓝色组件在客户端上呈现。

这难道不是“服务器端渲染”吗?

React服务器组件不是服务器端渲染(SSR)!这有点令人困惑,因为它们的名称中都带有“服务器”,它们都在服务器上工作。但是更容易理解它们是两个独立且正交的功能。使用RSC不需要使用SSR,反之亦然!SSR模拟了一个环境,用于将React树渲染为原始html;它不区分服务器和客户端组件,并且以相同的方式呈现它们!

可以将SSRRSC结合起来使用,这样您就可以使用服务器组件进行服务器端渲染,并在浏览器中正确地对其进行hydrate

但现在,让我们忽略SSR,纯粹专注于RSC。

为什么我们需要这个?

React服务器组件出现之前,所有的React组件都是“客户端”组件 - 它们都在浏览器中运行。当您的浏览器访问一个React页面时,它会下载所有必要的React组件的代码,构建React元素树,并将其渲染到DOM中(如果您使用SSR,则会对DOM进行hydrate)。浏览器允许您的React应用程序是交互式的 - 您可以安装事件处理程序,跟踪状态,根据事件改变您的React树,并高效地更新DOM。那么为什么我们要在服务器上渲染任何东西呢?

服务器上渲染有一些优势,胜过于在浏览器上:

    1. 服务器对您的数据源具有更直接的访问权限 - 无论是数据库、GraphQL端点还是文件系统。服务器可以直接获取您需要的数据,而无需通过某个公共API端点,而且通常更接近您的数据源,因此可以比浏览器更快地获取数据。
    1. 服务器可以廉价地利用“重型”代码模块,例如将markdown渲染为htmlnpm包,因为服务器不需要每次使用这些依赖项时都下载它们 - 不像浏览器必须下载所有使用的代码作为javascript``bundle包。

简而言之,React服务器组件使得服务器和浏览器可以做各自擅长的事情。服务器组件可以专注于获取数据和渲染内容,客户端组件可以专注于有状态的交互,从而实现更快的页面加载、更小的javascript bundle包大小和更好的用户体验。

更深层次挖掘

首先,让我们先对其运作方式有一些直觉认识。

React服务器组件的核心在于实现这种分工 - 让服务器在将事情交给浏览器完成之前,先做它擅长的事情。

考虑一下您页面的React树,其中一些组件要在服务器上呈现,一些组件要在客户端上呈现。以下是一个简化的高层次策略思考方式:服务器可以像往常一样“渲染”服务器组件,将您的React组件转换为原生html元素,如divp。但是,每当它遇到一个意味着在浏览器中呈现的“客户端”组件时,它只是输出一个占位符,并附带填充此空洞的正确客户端组件和属性的说明。然后,浏览器接收到该输出,用客户端组件填充这些空洞,。

这并不是实际运行的方式,我们很快就会深入到那些真正棘手的细节中;

服务器-客户端组件如何划分

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

React团队根据组件所在文件的扩展名进行了定义:如果文件以.server.jsx结尾,则包含服务器组件;如果以.client.jsx结尾,则包含客户端组件。如果两者都没有,那么它包含可以同时用作服务器和客户端组件的组件。

这个定义是实用的 - 对于人类和打包工具来说,很容易区分它们。特别是对于打包工具来说,它们现在可以通过检查文件名来区分客户端组件。正如您很快将看到的,打包工具在RSC工作中扮演着重要角色。

因为服务器组件在服务器上运行,客户端组件在客户端上运行,所以对它们的行为都有许多限制。但是最重要的一点是要记住**客户端组件不能导入服务器组件**!这是因为服务器组件不能在浏览器中运行,并且可能具有浏览器中无法运行的代码;如果客户端组件依赖于服务器组件,那么我们将会将这些非法依赖项拉入浏览器捆绑包中。

这最后一点可能会让人费解;这意味着像这样的客户端组件是非法的:

1
2
3
4
5
6
7
8
9
10
// ClientComponent.client.jsx
// 不允许:
import ServerComponent from './ServerComponent.server'
export default function ClientComponent() {
return (
<div>
<ServerComponent />
</div>
)
}

但是如果客户端组件不能导入服务器组件,因此不能实例化服务器组件,那么我们怎么会得到一个像这样的React树,其中服务器和客户端组件交替在一起呢?您如何在客户端组件(蓝色点)下面有服务器组件(橙色点)?

虽然您不能从客户端组件导入和渲染服务器组件,但您仍然可以使用composition - 也就是说,客户端组件仍然可以接受仅是不透明的ReactNodesprops,而这些ReactNodes可能正好由服务器组件渲染。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 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可以实例化客户端和服务器组件,
// 我们将一个<ServerComponent/>作为children prop传递给ClientComponent。
import ClientComponent from './ClientComponent.client'
import ServerComponent from './ServerComponent.server'
export default function OuterServerComponent() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
)
}

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

RSC渲染的生命周期

让我们深入了解实际渲染React服务器组件时发生的细节。

1. 服务器接收到渲染请求

由于服务器需要进行一些渲染,使用RSC的页面的生命周期始终从服务器开始,在响应某些API调用以渲染React组件时启动。这个“root”组件始终是一个服务器组件,它可能会呈现其他服务器或客户端组件。服务器根据请求中传递的信息确定要使用的服务器组件和props。这个请求通常以特定URL的页面请求的形式出现,尽管Shopify Hydrogen有更精细的方法,React团队的官方演示有一个原始实现

2. 服务器将根组件元素序列化为JSON

这里的最终目标是将初始根服务器组件渲染成基本html标签和客户端组件“占位符”的树。然后,我们可以将这个树序列化,发送到浏览器,并且浏览器可以完成反序列化工作,将客户端占位符填充为真实的客户端组件,并渲染最终结果。

因此,根据上面的示例 - 假设我们要渲染 <OuterServerComponent/>。我们可以简单地执行 JSON.stringify(<OuterServerComponent />) 来获取序列化的元素树吗?

几乎可以,但还不太准确!😅回想一下React元素实际上是什么 - 一个对象,具有一个类型字段,可以是一个字符串 - 用于基本的html标签,例如 "div" - 或一个函数 - 用于React组件实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 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标签元素 - 时,类型字段引用一个组件函数,而函数是不可JSON序列化的!

为了正确地对所有内容进行JSON字符串化,ReactJSON.stringify() 传递了一个特殊的替换函数,该函数正确处理这些组件函数引用;您可以在 ReactFlightServer.js 中找到它作为resolveModelToJSON()

具体来说,每当它看到要序列化的React元素时,

  • 如果是一个基本html标签(类型字段是一个字符串,如 "div"),那么它已经是可序列化的!不需要特别处理。
  • 如果是一个服务器组件,则调用服务器组件函数(存储在类型字段中)以及它的props,并序列化结果。这实际上“渲染”了服务器组件;这里的目标是将所有服务器组件转换为基本html标签。
  • 如果是一个客户端组件,那么……它实际上已经是可序列化的!类型字段实际上已经指向了一个模块引用对象,而不是一个组件函数。等等,什么?!
“模块引用”对象是什么?

RSCReact元素的类型字段引入了一个新的可能值,称为“模块引用”;它不是组件函数,而是对其的可序列化“引用”。

例如,一个ClientComponent元素可能看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
$$typeof: Symbol(react.element),
// 现在类型字段有一个引用对象,
// 而不是实际的组件函数
type: {
$$typeof: Symbol(react.module.reference),
// ClientComponent是默认导出的...
name: "default",
// 来自这个文件!
filename: "./src/ClientComponent.client.js"
},
props: { children: "oh my" },
}

但这个手法发生在哪里 - 我们是如何将对客户端组件函数的引用转换为可序列化的“模块引用”对象的?

事实证明,是打包工具执行了这个转化!React团队已经发布了对webpack的官方RSC支持,作为webpack加载器或node-register。当服务器组件从*.client.jsx文件导入某些内容时,打包工具不会真正获取到那个内容,而是只获取到一个模块引用对象,其中包含该内容的文件名和导出名称。客户端组件函数从未是构建在服务器上的React树的一部分!

再考虑一下上面的例子,我们尝试序列化 <OuterServerComponent />;我们最终会得到一个JSON树,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
// 带有“模块引用”的ClientComponent元素占位符
$$typeof: Symbol(react.element),
type: {
$$typeof: Symbol(react.module.reference),
name: "default",
filename: "./src/ClientComponent.client.js"
},
props: {
// 传递给ClientComponent的children,即 <ServerComponent />。
children: {
// ServerComponent直接渲染为html标签;
// 请注意,这里根本没有对ServerComponent的引用 -
// 我们直接渲染了 `span`。
$$typeof: Symbol(react.element),
type: "span",
props: {
children: "Hello from server land"
}
}
}
}
可序列化的React树

在这个过程的最后,我们希望得到的是一个React树,在服务器上看起来更像这样,以便发送到浏览器“完成”:

所有props必须是可序列化的
因为我们将整个React树序列化为JSON,所以您传递给客户端组件或基本html标签的所有props都必须是可序列化的。这意味着从服务器组件传递事件处理程序作为props是不允许的!

1
2
3
4
// 不允许:服务器组件不能将函数作为props传递给其子组件,因为函数不可序列化。
function SomeServerComponent() {
return <button onClick={() => alert('OHHAI')}>Click me!</button>
}

但是,在RSC过程中有一件事情需要注意,当我们遇到一个客户端组件时,我们从不调用客户端组件函数,或“深入”到客户端组件。因此,如果您有一个客户端组件实例化另一个客户端组件:

1
2
3
4
5
6
7
8
function SomeServerComponent() {
return <ClientComponent1>Hello world!</ClientComponent1>;
}

function ClientComponent1({children}) {
// 将函数作为prop从客户端传递给客户端组件是可以的
return <ClientComponent2 onChange={...}>{children}</ClientComponent2>;
}

在这个RSC JSON树中,ClientComponent2根本不会出现;相反,我们只会看到一个具有模块引用和ClientComponent1props的元素。因此,ClientComponent1ClientComponent2传递事件处理程序作为props是完全合法的。

3. 浏览器重建React树

浏览器接收来自服务器的JSON输出,现在必须开始重建React树以在浏览器中渲染。每当我们遇到一个类型为模块引用的元素时,我们都希望将它替换为对真实客户端组件函数的引用。

这项工作再次需要我们的打包工具的帮助;正是我们的打包工具在服务器上将客户端组件函数替换为模块引用,并且现在我们的打包工具知道如何在浏览器中将这些模块引用替换为真实的客户端组件函数。

重建的React树将如下所示 - 只包含本机标签和客户端组件:

然后,我们只需像往常一样将这个树渲染并提交到DOM中!

这是否与Suspense一起使用呢?

是的!在上述所有步骤中,Suspense都起着重要的作用。

在本文中,我们故意忽略了Suspense,因为Suspense本身就是一个庞大的主题。但是简要地说 - Suspense允许您在需要尚未准备好的内容(获取数据、延迟导入组件等)时,从您的React组件中抛出Promise。这些Promise会在“Suspense boundary”处捕获 - 每当从渲染Suspense子树中抛出Promise时,React会暂停渲染该子树,直到Promise解决,然后再次尝试。

当我们在服务器上调用服务器组件函数以生成RSC输出时,这些函数可能会在获取所需数据时抛出Promise。当我们遇到这样一个抛出的Promise时,我们会输出一个占位符;一旦Promise解决,我们再次尝试调用服务器组件函数,并在成功时输出完成的块。实际上,我们正在创建一个RSC输出的流,当Promise被抛出时暂停,当它们被解决时,流式传输额外的块。

类似地,在浏览器中,我们正在从上面的fetch()调用中向下流式传输RSC JSON输出。这个过程也可能会在输出中遇到一个suspense占位符时抛出Promise(服务器遇到抛出的Promise时),并且在流中尚未看到占位符内容时(一些细节在此)。或者,如果它遇到一个客户端组件模块引用,但浏览器中尚未加载该客户端组件函数 - 在这种情况下,打包运行时将必须动态获取必要的块。

多亏了Suspense,您可以在服务器上流式传输RSC输出,因为服务器组件获取其数据,您可以在浏览器中逐步渲染数据,因为它们变得可用,并且在必要时动态获取客户端组件包。

RSC传输格式

那么服务器向浏览器流式传输的是什么数据呢?

这是一个简单的格式,每行都有一个带有ID标记的JSON块。以下是我们的<OuterServerComponent/>示例的RSC输出:

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

在上面的片段中,以M开头的行定义了一个客户端组件模块引用,其中包含查找客户端包中的组件函数所需的信息。以J开头的行定义了一个实际的React元素树,其中包含诸如@1之类的内容,这些内容引用了M行定义的客户端组件。

这种格式非常适合流式传输 - 一旦客户端读取了一整行,它就可以解析一小段JSON并取得一些进展。如果服务器在渲染时遇到了suspense,你会看到多个对应于每个解析的块的J行。

例如,让我们将我们的示例变得更有趣一些…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 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流是什么样子呢?

1
2
3
4
5
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的行 - 定义了另一个应该替换到@3位置的React元素树(请注意,J3children引用了M4中定义的Tweet组件)。

这里还有一件事要注意的是,打包工具自动将ClientComponentTweet放入了两个独立的构建包中,这允许浏览器推迟下载Tweet捆绑包!

使用RSC格式

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

1
2
3
4
5
6
7
8
import { createFromFetch } from 'react-server-dom-webpack'
function ClientRootComponent() {
// 从我们的RSC API端点进行fetch()。react-server-dom-webpack
// 然后可以使用fetch结果重新构建React
// 元素树
const response = createFromFetch(fetch('/rsc?...'))
return <Suspense fallback={null}>{response.readRoot() /* 返回一个React元素! */}</Suspense>
}

您要求react-server-dom-webpack从API端点读取RSC响应。然后,response.readRoot()返回一个React元素,该元素随着响应流的处理而更新!在读取任何流之前,它会立即抛出一个Promise - 因为尚未准备好任何内容。然后,当它处理第一个J0时,它会创建一个相应的React元素树并解决抛出的PromiseReact恢复渲染,但当它遇到尚未准备好的@3引用时,另一个Promise被抛出。一旦它读取了J3,该Promise就会被解决,React再次恢复渲染,这次是完全的。因此,当我们流式传输RSC响应时,我们将继续更新和渲染我们拥有的元素树,在悬挂点定义的块中,直到我们完成。

为什么不直接输出纯HTML呢?

为什么要发明一个全新的传输格式呢?客户端的目标是重建React元素树。与从html创建React元素相比,使用此格式更容易实现这一目标,因为我们不得不解析HTML来创建React元素。请注意,重建React元素树很重要,因为这样可以让我们以最小的DOM提交合并对React树的后续更改。

这是否比只从客户端组件获取数据更好?

如果我们无论如何都需要向服务器发出API请求来获取这些内容,那么这真的比仅请求获取数据然后完全在客户端进行渲染更好吗?

最终,这取决于您要渲染到屏幕上的内容。使用RSC,您可以获得直接映射到用户所看到内容的去规范化的“处理过”的数据,因此,如果您只渲染了您要获取的数据的一小部分,或者如果渲染本身需要大量的JavaScript,而您希望避免将其下载到浏览器中,那么您就会获胜。如果渲染需要多个相互依赖的数据获取步骤,那么最好的方式是在服务器上进行获取 - 那里数据的延迟要低得多 - 而不是从浏览器中进行获取。

但是…服务器端渲染呢?

React 18中,可以将SSRRSC结合起来,这样您就可以在服务器上生成html,然后在浏览器中使用RSC进行hydrate

更新服务器组件渲染内容

如果您需要您的服务器组件渲染新内容——例如,如果您正在从一个产品的页面切换到另一个产品的页面,会怎样?

同样,由于渲染发生在服务器端,这需要向服务器发出另一个API调用以获取以RSC传输格式提供的新内容。好消息是,一旦浏览器接收到新内容,它就可以构造一个新的React元素树,并与先前的React树进行常规的差异处理,以确定对DOM所需的最小更新,同时保留客户端组件中的状态和事件处理程序。对于客户端组件来说,这个更新与在浏览器中完全发生时没有什么不同。

目前,您必须从根服务器组件重新渲染整个React树,尽管在未来,可能会有可能针对子树执行此操作。

为什么需要使用meta-framework 来实现RSC?

React团队表示,RSC最初是通过Next.jsShopify Hydrogen等框架采用的,而不是直接在普通的React项目中使用。但是为什么?框架对您有什么好处?

您不必这样做,但这会让您的生活更轻松。框架提供了更友好的封装和抽象,因此您永远不必考虑在服务器端生成RSC流,并在浏览器中使用它。框架还支持服务器端渲染,并且它们正在努力确保如果您使用服务器组件,则可以正确地hydrate 生成的html

正如您所看到的,您还需要从构建工具那里获得合作,以正确地在浏览器中传送和使用客户端组件。已经有了webpack集成,Shopify正在开发vite集成。这些插件需要成为React仓库的一部分,因为RSC所需的许多部件并没有作为公共npm包发布。但是一旦开发完成,这些部件应该可以在不涉及框架的情况下使用。

RSC准备好了吗?

React服务器组件现在在Next.js中作为实验性功能提供,并在Shopify Hydrogen的当前开发者预览版本中提供,但两者都还没有准备好供生产使用。

但毫无疑问,React服务器组件将成为React未来的重要组成部分。它是React对更快的页面加载速度、更小的javascript bundle包和更短的交互时间的答案——它是如何使用React构建多页面应用程序的更全面的论文。它可能还没有准备好,但很快就是开始关注它的时候了。