React server components (RSC)
是一个令人兴奋的新功能,它将对页面加载性能、bundle
大小以及未来我们编写React应用程序的方式产生巨大影响。
什么是React服务器组件?
React
服务器组件允许服务器和客户端(浏览器)共同渲染您的React
应用程序。用于呈现页面的典型React
元素树,通常由不同的React
组件组成,这些组件又渲染更多的React组件。RSC
使得一些组件可以由服务器呈现,而另一些组件可以由浏览器呈现 🤯
以下是React团队提供的一个快速示例,展示了最终目标是什么:一个React
树,其中橙色组件在服务器上呈现,蓝色组件在客户端上呈现。
这难道不是“服务器端渲染”吗?
React
服务器组件不是服务器端渲染(SSR
)!这有点令人困惑,因为它们的名称中都带有“服务器”,它们都在服务器上工作。但是更容易理解它们是两个独立且正交的功能。使用RSC
不需要使用SSR
,反之亦然!SSR模拟了一个环境,用于将React
树渲染为原始html
;它不区分服务器和客户端组件,并且以相同的方式呈现它们!
可以将SSR
和RSC
结合起来使用,这样您就可以使用服务器组件进行服务器端渲染,并在浏览器中正确地对其进行hydrate
。
但现在,让我们忽略SSR,纯粹专注于RSC。
为什么我们需要这个?
在React
服务器组件出现之前,所有的React
组件都是“客户端”组件 - 它们都在浏览器中运行。当您的浏览器访问一个React
页面时,它会下载所有必要的React
组件的代码,构建React
元素树,并将其渲染到DOM
中(如果您使用SSR
,则会对DOM
进行hydrate
)。浏览器允许您的React
应用程序是交互式的 - 您可以安装事件处理程序,跟踪状态,根据事件改变您的React
树,并高效地更新DOM
。那么为什么我们要在服务器上渲染任何东西呢?
服务器上渲染有一些优势,胜过于在浏览器上:
- 服务器对您的数据源具有更直接的访问权限 - 无论是数据库、
GraphQL
端点还是文件系统。服务器可以直接获取您需要的数据,而无需通过某个公共API
端点,而且通常更接近您的数据源,因此可以比浏览器更快地获取数据。
- 服务器对您的数据源具有更直接的访问权限 - 无论是数据库、
- 服务器可以廉价地利用“重型”代码模块,例如将
markdown
渲染为html
的npm
包,因为服务器不需要每次使用这些依赖项时都下载它们 - 不像浏览器必须下载所有使用的代码作为javascript``bundle
包。
- 服务器可以廉价地利用“重型”代码模块,例如将
简而言之,React
服务器组件使得服务器和浏览器可以做各自擅长的事情。服务器组件可以专注于获取数据和渲染内容,客户端组件可以专注于有状态的交互,从而实现更快的页面加载、更小的javascript bundle
包大小和更好的用户体验。
更深层次挖掘
首先,让我们先对其运作方式有一些直觉认识。
React
服务器组件的核心在于实现这种分工 - 让服务器在将事情交给浏览器完成之前,先做它擅长的事情。
考虑一下您页面的React
树,其中一些组件要在服务器上呈现,一些组件要在客户端上呈现。以下是一个简化的高层次策略思考方式:服务器可以像往常一样“渲染”服务器组件,将您的React
组件转换为原生html
元素,如div
和p
。但是,每当它遇到一个意味着在浏览器中呈现的“客户端”组件时,它只是输出一个占位符,并附带填充此空洞的正确客户端组件和属性的说明。然后,浏览器接收到该输出,用客户端组件填充这些空洞,。
这并不是实际运行的方式,我们很快就会深入到那些真正棘手的细节中;
服务器-客户端组件如何划分
首先 - 什么是服务器组件呢
?如何确定哪些组件是“用于服务器”的,哪些是“用于客户端”的呢?
React
团队根据组件所在文件的扩展名进行了定义:如果文件以.server.jsx
结尾,则包含服务器组件;如果以.client.jsx
结尾,则包含客户端组件。如果两者都没有,那么它包含可以同时用作服务器和客户端组件的组件。
这个定义是实用的 - 对于人类和打包工具来说,很容易区分它们。特别是对于打包工具来说,它们现在可以通过检查文件名来区分客户端组件。正如您很快将看到的,打包工具在RSC
工作中扮演着重要角色。
因为服务器组件在服务器上运行,客户端组件在客户端上运行,所以对它们的行为都有许多限制。但是最重要的一点是要记住**客户端组件不能导入服务器组件
**!这是因为服务器组件不能在浏览器中运行,并且可能具有浏览器中无法运行的代码;如果客户端组件依赖于服务器组件,那么我们将会将这些非法依赖项拉入浏览器捆绑包中。
这最后一点可能会让人费解;这意味着像这样的客户端组件是非法的:
1 | // ClientComponent.client.jsx |
但是如果客户端组件不能导入服务器组件,因此不能实例化服务器组件,那么我们怎么会得到一个像这样的Reac
t树,其中服务器和客户端组件交替在一起呢?您如何在客户端组件(蓝色点
)下面有服务器组件(橙色点
)?
虽然您不能从客户端组件导入和渲染服务器组件,但您仍然可以使用composition
- 也就是说,客户端组件仍然可以接受仅是不透明的ReactNodes
的props
,而这些ReactNodes
可能正好由服务器组件渲染。例如:
1 | // ClientComponent.client.jsx |
这个限制将对您如何组织组件以更好地利用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 | // React element for <div>oh my</div> |
当您有一个组件元素 - 而不是基本html
标签元素 - 时,类型字段引用一个组件函数,而函数是不可JSON
序列化的!
为了正确地对所有内容进行JSON
字符串化,React
向 JSON.stringify()
传递了一个特殊的替换函数,该函数正确处理这些组件函数引用;您可以在 ReactFlightServer.js
中找到它作为resolveModelToJSON()
。
具体来说,每当它看到要序列化的React
元素时,
- 如果是一个基本
html
标签(类型字段是一个字符串,如"div"
),那么它已经是可序列化的!不需要特别处理。 - 如果是一个服务器组件,则调用服务器组件函数(存储在类型字段中)以及它的
props
,并序列化结果。这实际上“渲染”了服务器组件;这里的目标是将所有服务器组件转换为基本html
标签。 - 如果是一个客户端组件,那么……它实际上已经是可序列化的!类型字段实际上已经指向了一个模块引用对象,而不是一个组件函数。等等,什么?!
“模块引用”对象是什么?
RSC
为React
元素的类型字段引入了一个新的可能值,称为“模块引用”
;它不是组件函数,而是对其的可序列化“引用”。
例如,一个ClientComponent
元素可能看起来是这样的:
1 | { |
但这个手法发生在哪里 - 我们是如何将对客户端组件函数的引用转换为可序列化的“模块引用”对象的?
事实证明,是打包工具执行了这个转化!React
团队已经发布了对webpack
的官方RSC
支持,作为webpack
加载器或node-register
。当服务器组件从*.client.jsx
文件导入某些内容时,打包工具不会真正获取到那个内容,而是只获取到一个模块引用对象,其中包含该内容的文件名和导出名称。客户端组件函数从未是构建在服务器上的React
树的一部分!
再考虑一下上面的例子,我们尝试序列化 <OuterServerComponent />
;我们最终会得到一个JSON
树,像这样:
1 | { |
可序列化的React树
在这个过程的最后,我们希望得到的是一个React
树,在服务器上看起来更像这样,以便发送到浏览器“完成”:
所有props必须是可序列化的
因为我们将整个React
树序列化为JSON
,所以您传递给客户端组件或基本html
标签的所有props
都必须是可序列化的。这意味着从服务器组件传递事件处理程序作为props
是不允许的!
1 | // 不允许:服务器组件不能将函数作为props传递给其子组件,因为函数不可序列化。 |
但是,在RSC
过程中有一件事情需要注意,当我们遇到一个客户端组件时,我们从不调用客户端组件函数,或“深入”到客户端组件。因此,如果您有一个客户端组件实例化另一个客户端组件:
1 | function SomeServerComponent() { |
在这个RSC JSON
树中,ClientComponent2
根本不会出现;相反,我们只会看到一个具有模块引用和ClientComponent1
的props
的元素。因此,ClientComponent1
向ClientComponent2
传递事件处理程序作为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 | M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""} |
在上面的片段中,以M
开头的行定义了一个客户端组件模块引用,其中包含查找客户端包中的组件函数所需的信息。以J开头的行定义了一个实际的React
元素树,其中包含诸如@1
之类的内容,这些内容引用了M
行定义的客户端组件。
这种格式非常适合流式传输 - 一旦客户端读取了一整行,它就可以解析一小段JSON
并取得一些进展。如果服务器在渲染时遇到了suspense
,你会看到多个对应于每个解析的块的J
行。
例如,让我们将我们的示例变得更有趣一些…
1 | // Tweets.server.js |
在这种情况下,RSC流是什么样子呢?
1 | M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""} |
J0
行现在有一个额外的子项 - 新的Suspense
,其中的children
指向引用 @3
。值得注意的是,此时@3
尚未定义!当服务器完成加载tweets
时,它会输出M4
的行 - 定义了到Tweet.client.js
组件的模块引用 - 和J3
的行 - 定义了另一个应该替换到@3
位置的React
元素树(请注意,J3
的children
引用了M4
中定义的Tweet
组件)。
这里还有一件事要注意的是,打包工具自动将ClientComponent
和Tweet
放入了两个独立的构建包中,这允许浏览器推迟下载Tweet
捆绑包!
使用RSC格式
如何将这个RSC
流转换为浏览器中的实际React
元素?react-server-dom-webpack
包含了接收RSC
响应并重新创建React
元素树的入口点。以下是您的根客户端组件可能看起来像的简化版本:
1 | import { createFromFetch } from 'react-server-dom-webpack' |
您要求react-server-dom-webpack
从API端点读取RSC响应。然后,response.readRoot()
返回一个React
元素,该元素随着响应流的处理而更新!在读取任何流之前,它会立即抛出一个Promise
- 因为尚未准备好任何内容。然后,当它处理第一个J0
时,它会创建一个相应的React
元素树并解决抛出的Promise
。React
恢复渲染,但当它遇到尚未准备好的@3
引用时,另一个Promise
被抛出。一旦它读取了J3
,该Promise
就会被解决,React
再次恢复渲染,这次是完全的。因此,当我们流式传输RSC
响应时,我们将继续更新和渲染我们拥有的元素树,在悬挂点定义的块中,直到我们完成。
为什么不直接输出纯HTML呢?
为什么要发明一个全新的传输格式呢?客户端的目标是重建React
元素树。与从html
创建React
元素相比,使用此格式更容易实现这一目标,因为我们不得不解析HTML
来创建React
元素。请注意,重建React
元素树很重要,因为这样可以让我们以最小的DOM
提交合并对React树的后续更改。
这是否比只从客户端组件获取数据更好?
如果我们无论如何都需要向服务器发出API
请求来获取这些内容,那么这真的比仅请求获取数据然后完全在客户端进行渲染更好吗?
最终,这取决于您要渲染到屏幕上的内容。使用RSC
,您可以获得直接映射到用户所看到内容的去规范化的“处理过”的数据,因此,如果您只渲染了您要获取的数据的一小部分,或者如果渲染本身需要大量的JavaScript
,而您希望避免将其下载到浏览器中,那么您就会获胜。如果渲染需要多个相互依赖的数据获取步骤,那么最好的方式是在服务器上进行获取 - 那里数据的延迟要低得多 - 而不是从浏览器中进行获取。
但是…服务器端渲染呢?
在React 18
中,可以将SSR
和RSC
结合起来,这样您就可以在服务器上生成html
,然后在浏览器中使用RSC
进行hydrate
。
更新服务器组件渲染内容
如果您需要您的服务器组件渲染新内容——例如,如果您正在从一个产品的页面切换到另一个产品的页面,会怎样?
同样,由于渲染发生在服务器端,这需要向服务器发出另一个API
调用以获取以RSC
传输格式提供的新内容。好消息是,一旦浏览器接收到新内容,它就可以构造一个新的React
元素树,并与先前的React
树进行常规的差异处理,以确定对DOM
所需的最小更新,同时保留客户端组件中的状态和事件处理程序。对于客户端组件来说,这个更新与在浏览器中完全发生时没有什么不同。
目前,您必须从根服务器组件重新渲染整个React
树,尽管在未来,可能会有可能针对子树执行此操作。
为什么需要使用meta-framework 来实现RSC?
React
团队表示,RSC
最初是通过Next.js
或Shopify Hydrogen
等框架采用的,而不是直接在普通的React
项目中使用。但是为什么?框架对您有什么好处?
您不必这样做,但这会让您的生活更轻松。框架提供了更友好的封装和抽象,因此您永远不必考虑在服务器端生成RSC
流,并在浏览器中使用它。框架还支持服务器端渲染,并且它们正在努力确保如果您使用服务器组件,则可以正确地hydrate
生成的html
。
正如您所看到的,您还需要从构建工具那里获得合作,以正确地在浏览器中传送和使用客户端组件。已经有了webpack
集成,Shopify
正在开发vite
集成。这些插件需要成为React
仓库的一部分,因为RSC
所需的许多部件并没有作为公共npm
包发布。但是一旦开发完成,这些部件应该可以在不涉及框架的情况下使用。
RSC准备好了吗?
React
服务器组件现在在Next.js
中作为实验性功能提供,并在Shopify Hydrogen
的当前开发者预览版本中提供,但两者都还没有准备好供生产使用。
但毫无疑问,React
服务器组件将成为React
未来的重要组成部分。它是React
对更快的页面加载速度、更小的javascript bundle
包和更短的交互时间的答案——它是如何使用React
构建多页面应用程序的更全面的论文。它可能还没有准备好,但很快就是开始关注它的时候了。