我们一起理解 React 服务端组件
有件事让我感觉自己真的老了:React 今年已经 10 岁了。
自从 React 首次被引入以来,经历了几次演变。 React 团队并不羞于改变:如果他们发现了更好的问题解决方案,就会采用。
React 团队推出了 React 服务端组件(React Server Components),这是最新的编写范式。 React 组件有史以来第一次可以专门在服务器上运行。
网上对这个概念有太多不理解。许多人对服务端组件是什么、如何工作、有什么好处以及是如何与服务器端渲染等内容结合使用存在很多疑问。
我一直在使用 React 服务端组件进行大量实验,也回答了我自己产生的很多问题。我必须承认,我对这些东西比我预想的要兴奋得多,因为它真的很酷!
今天,我将帮助你揭开 React 服务端组件的神秘面纱,回答你可能对 React 服务端组件存在的许多问题!
服务端渲染快速入门
由于实际场景中,React 服务端组件通常与服务端渲染(Server Side Rendering,简称 SSR)配合使用,因此预先了解服务端渲染的工作原理会很有帮助。当然,如果你已经很熟悉 SSR 了,则可以跳过本节的学习。
在我 2015 年第一次使用 React 时,那时候的大多数 React 项目都还采用“客户端渲染”策略。
在客户端渲染模式下,用户会先收到下面这样一个比较简单的网页。
bundle.js 包含整个项目初始化和运行阶段的所有代码。包括 React、其他三方依赖以及我们自己的业务代码。
JS 文件下载并解析后,React 会立即介入,准备好渲染应用所需要的 DOM 节点,并插入到空的 里。到这里,用户就得到可以交互的页面了。
虽然这个空的 HTML 文档会很快接收,但 JS 文件的下载和解析是需要一些时间的,另外随着我们项目规模的扩大,JS 文件本身的体积可能也在不断变大。
在客户端接收到 HTML 文档,到 JS 文件处理结束的中间阶段,用户通常会面临白屏问题,这种体验就比较糟糕了。
服务端渲染就能有效的避免这种体验。服务端渲染会将我们首屏要展示的 HTML 内容在服务端预先生成,再发送到客户端。这样,客户端在接收到 HTML 时,就能渲染首屏内容,也就不会遇到白屏问题了。
当然,服务端渲染的 HTML 网页同样会包含 标签,因为发送的首屏内容还需要交由 React 托管,附加交互能力。具体来说:与客户端从头构建 DOM 不同,服务端渲染模式下,React 会利用现有的 HTML 结构进行构建,并为 DOM 节点附加交互能力,以便响应用户操作。这个过程被称为“水合(hydration)”。
我很喜欢 React 核心团队成员 Dan Abramov 对这一过程的通俗解释:
水合(Hydration)就类似使用交互和事件处理程序的“水”浇到“干”的 HTML 上。
JS 包下载后,React 将快速运行我们的整个应用程序,构建 UI 的虚拟草图,并将其“拟合”到真实的 DOM 节点、附加事件处理程序、触发 effect 等。
简而言之,SSR 就是服务器生成初始 HTML,这样用户在等待 JS 处理过程中,不会看到白屏。另外,客户端 React 会接手服务器端 React 的工作,为 DOM 加入交互能力。
💡 关于静态站点生成
当我们谈论服务器端渲染时,我们通常想到的可能是下面的流程:
这是实现服务器端渲染的一种可能方法,但不是唯一的方法。另一种选择是在构建(build)应用程序时生成 HTML。
通常,React 应用程序需要进行编译,将 JSX 转换为普通的 JavaScript,并打包我们的所有模块。如果在这一过程中,我们为所有不同的路由“预渲染”所有 HTML 如何?
这种做法通常称为静态站点生成 (static site generatio,简称 SSG),它是服务器端渲染的一个变体。
在我看来,“服务器端渲染”是一个通用术语,包括几种不同的渲染策略。不过,都有一个共同点:初始渲染都是使用 ReactDOMServer API,发生在 Node.js 等服务器运行时环境。
现有渲染方案分析
本节我们再来谈谈 React 中的数据获取。通常,我们有两个通过网络进行通信的独立应用程序:
- 客户端 React 应用程序
- 服务器端 REST API
在客户端我们使用类似 React Query、SWR 或 Apollo 这样的工具向后端发起网络请求,从后端数据库中获取数据并通过网络发送回来。
我们可以将这一过程可视化成下面这样。
图片
这里就展示了客户端渲染 (CSR) 的工作流程。从客户端接收到 HTML 开始。这个 HTML 文档不包含任何内容,但会有一个或多个 标签。
JS 文件下载并解析好后,React 应用程序将启动,创建一堆 DOM 节点并填充 UI。不过,一开始我们没有任何实际数据,因此往往会使用一个骨架屏来表示处于加载状态中,这一阶段称为“Render Shell”,也就是“渲染骨架屏”。
这种模式很常见了。以 UberEats 网站举例,在获取到实际数据前,会展示下面的加载效果。
图片
在获取实际数据并替换当前内容前,用户会一直看到这个加载页面。
以上就是典型的客户端渲染方案。再来看看服务端渲染方案的执行流程。
图片
可以看到,“Render Shell”阶段被放在了服务端,也就是说用户收到就不是空白 HTML 了,这是比客户端渲染好一点的地方,至少没有白屏了。
为了方便比较,我们在图标中有增加了一些常用网络性能指标。看看在这两个流程之间切换,有哪些指标发生了改变。
图片
图表中这些 Web 性能指标的介绍如下:
通过在服务器上进行初始渲染,我们能够更快地绘制初始“Shell”页面,即“骨架屏”页面。体验上会感觉更快一些,因为它提供了一种响应标识,告诉你页面正在渲染。
某些情况下,这将是一个有意义的改进。但这样的流程会感觉有点傻,用户访问我们的应用程序不是为了查看加载屏幕,而是为了查看内容。
当再次查看 SSR 图时,我不禁想到如果把数据库请求也放在服务器上执行,那么我们不就可以避免客户端网页的网络请求了吗?
换句话说,也就是下面这样。
图片
我们不会在客户端和服务器之间来回切换,当数据库查询结果作为初始请求的一部分时,在客户端接收到的 HTML 文档中,就包含用户向看到的内容了。
不过,我们该怎么做呢?
React 并没有提供这方面渲染方案的支持,不过生态系统针对这个问题提出了很多解决方案。像 Next.js 和 Gatsby 这样的元框架(Meta Frameworks)就创造了自己的方式来专门在服务器上运行代码。
以 Next.js 为例(使用旧的 Pages Router 模式):
import db from 'imaginary-db';
// This code only runs on the server:
export async function getServerSideProps() {
const link = db.connect('localhost', 'root', 'passw0rd');
const data = await db.query(link, 'SELECT * FROM products');
return {
props: { data },
};
}
// This code runs on the server + on the client
export default function Homepage({ data }) {
return (
Trending Products
{data.map((item) => (
{item.title}
{item.description}
))}
);
}
这里简单介绍下:当服务器收到请求时,会先调用 getServerSideProps 函数,它返回一个 props 对象。接着,这些 props 被传给组件,这个组件会先使用这些 props 在服务器上进行一次渲染,然后将结果发送到客户端,最后在客户端进行水合。
getServerSideProps 是一个特殊的函数,只在服务器端执行,函数本身也不会包含在发送给客户端的 JavaScript 文件中。
这种方法在当时是非常超前的,但也有一些缺点:
当然,React 团队也意识到了这个问题,并一直尝试给出一个官方方案。最终,方案确定了下来,也就是我们看到的 React Server Components,即 React 服务端组件,简称 RSC。
React 服务端组件介绍
React 服务端组件是一个全新的渲染模式,在这个模式下,组件完全在服务器上运行,让我们可以组件中做类似查询数据库的后端操作。
下面是一个“服务端组件”的简单示例。
import db from 'imaginary-db';
async function Homepage() {
const link = db.connect('localhost', 'root', 'passw0rd');
const data = await db.query(link, 'SELECT * FROM products');
return (
Trending Products
{data.map((item) => (
{item.title}
{item.description}
))}
);
}
export default Homepage;
如果你已经写了很多年的 React,这样的代码一定会让你感觉奇怪 😅。
我就是其中之一。当我看到这种写法时,本能地惊叹道。 “函数组件不能异步呀!而且我们不能直接在渲染中出现这样的副作用!”
这里要理解的关键点是:服务端组件只会渲染一次,永远不会重新渲染。它们在服务器上运行一次生成 UI,并将渲染的值发送到客户端并原地锁定,输出永远不会改变。
这表示 React 的 API 的很大一部分与服务端组件是不兼容的。例如,我们不能使用 useSate(),因为状态可以改变,但服务端组件不支持重新渲染。我们不能使用 useEffect(),因为它只在渲染后在客户端上运行,而服务端组件是不会发送到客户端的。
不过,由于服务端环境限制,也给服务端组件的编写带来一定灵活性。例如:在传统客户端 React 中,我们需要将副作用放入 useEffect() 回调或事件处理程序中,避免每次渲染时重复调用。但如果组件本身只运行一次,我们就不必担心这个问题了!
服务端组件本身非常简单,但“React 服务端组件”模式要复杂得多。这是因为我们还要支持以前的常规组件,混用就会带来混乱。
为了与新的“React 服务端组件”做区分,传统 React 组件被称为“客户端组件(Client Component)”。老实说,我不是很喜欢这个名字。
“客户端组件”听起来好像这些组件只在客户端上渲染,实际上并非如此——客户端组件在客户端和服务器端都会渲染。
图片
我知道所有这些术语都非常令人困惑,所以我做了一下总结:
- React 服务端组件(React Server Components)是这个新模式的名称
- 我们所了解的“标准”React 组件被重新命名为客户端组件(Client Component),这是对旧事物的一个新称呼
- 这个新模式引入了一个新的类型组件:服务端组件(Server Component),这些组件专门在服务器上渲染,其代码也不会包含在发送给客户端的 JS Bundle 中,因此也不会参与水合或重新渲染
💡 服务端组件与服务器端渲染
这里必须要澄清一下:React 服务端组件并不是服务器端渲染的替代品。你不应该把 React Server Components 理解成“SSR 的 2.0 版本”
这 2 者更像是可以拼凑在一起的拼图,相辅相成。
我们仍然需要服务器端渲染来生成初始 HTML。React Server Components 则是建立在基础之上,让我们从客户端 JavaScript 包中省略这些组件,确保它们只在服务器上运行。
事实上,你也可以在没有服务器端渲染的情况下使用 React 服务端组件。实践中它们通常一起使用,来得到更好的结果。如果你想查看示例,React 团队已经构建了一个没有 SSR 的最小 RSC demo[2]。
在使用服务端组件之前
通常,当新的 React 功能出现时,我们可以通过将 React 依赖项升级到最新版本来使用,类似 npm install react@latest 就可以了,不过服务端组件不是这样。
我的理解是:服务端组件需要与 React 之外的一些系统紧密配合才能使用,比如打包工具(bundler)、服务器、路由之类的。
当我写这篇文章时,Next.js 13.4+ 通过引入全新的重新架构“App Router” 来支持服务端组件的使用。
当然,在可以遇见的将来,会有越来越多的基于 React 的框架会支持这一特性。React 官方文档有一个 “Bleeding-edge frameworks”[3] 的部分,其中列出了支持 React 服务端组件的框架列表。
使用客户端组件
在 Next.js App Router 架构下,默认所有组件都会被看作服务端组件,客户端组件需要特别声明,这需要通过一个新的指令说明。
'use client';
import React from 'react';
function Counter() {
const [count, setCount] = React.useState(0);
return (
setCount(count + 1)}>
Current value: {count}
);
}
export default Counter;
注意,这里顶部的 'use client',这就是在告诉 React 这是一个客户端组件,应该包含在 JS Bundle 中,以便在客户端上重新渲染。
这种声明方式借鉴了 JavaScript 的严格模式声明——'use strict'。
在 App Router 架构下,所有组件默认被看作是服务端组件,无需任何声明。当然,你可能会想到服务端组件是不是使用 'use server'——NO,不是!'use server' 其实是用在 Server Actions,而非服务端组件上的,不过这块内容超出了本文范围就不讲了,有兴趣的同学可以私下学习。
💡 哪些组件应该是客户端组件?
这里你可能就有疑问了:我该怎么知道一个组件应该是服务端组件还是客户端组件呢?
这里可以给大家一个一般规则:如果一个组件可以是服务端组件,那么它就应该是服务端组件。服务端组件往往更简单且更容易推理,还有一个性能优势,即服务端组件不在客户端上运行,所以它们的代码不包含在我们的 JavaScript 包中。因此,React 服务端组件对改进页面交互指标(TTI)有所帮助。
不过,这不意味着我们要尽可能把作为组件都改成服务端组件,不合理也不可能。在 RSC 之前,每个 React 应用程序中的 React 组件都是客户端组件。
当你开始使用 React 服务端组件时,你会发现它写起来这非常直观。而我们的一些组件由于需要状态或 Effect,只能在客户端上运行。你可以通过在组件顶部添加 'use client' 指令指定当前组件是客户端组件,否则默认就是服务端组件。
客户端边界
当我熟悉 React 服务端组件时,我遇到的第一个问题是:如果组建 props 改变了,会发生什么?
假设,我们有一个像这样的服务端组件:
function HitCounter({ hits }) {
return (
Number of hits: {hits}
);
}
如果在初始服务器端渲染中, hits 等于 0 。然后,这个组件将生成以下结果。
Number of hits: 0
但是,如果 hits 的值发生变化会怎样?假设它是一个状态变量,从 0 更成了 1。HitCounter 这个时候就需要重新渲染,但它不能重新渲染,因为它是服务端组件!
这里的问题是,如果没有上下文环境,只是孤立的考虑服务端组件并没有真正的意义。我们必须扩大范围,从更高的角度审视,考虑我们应用程序的结构。
假设我们有如下的组件树结构:
图片
如果所有这些组件都是服务端组件,那么就不会存在上面的问题,因为所有组件都不会重新渲染,props 也就没有改变的可能性。
但假设 Article 组件拥有 hits 状态变量。为了使用状态,我们需要将其转换为客户端组件:
图片
你观察到这里的问题了吗?当 Article 重新渲染时,任何下属子组件也会重新渲染,包括 HitCounter 和 Discussion。但是,如果这些是服务端组件,是无法重新渲染的。
为了避免这类矛盾场景的出现,React 团队添加了一条规则:客户端组件只能导入其他客户端组件。'use client' 指令表示 HitCounter 和 Discussion 的这些实例将自动成为客户端组件。
我在使用 React 服务端组件时遇到的最大的“啊哈(ah-ha)”时刻之一,是意识到服务端组件的这种新模式其实就是关于创建客户端边界的(client boundaries)。在实践中,总会遇到下面的场景:
图片
当我们将 'use client' 指令添加到 Article 组件时,我们创建了一个“客户端边界”。边界内的所有组件都隐式成为客户端组件。即使像 HitCounter 这样的组件没有使用 'use client' 指令,在这种特殊情况下它们仍然会在客户端上进行水合和渲染。
也就是说,我们不必将 'use client' 添加到每个客户端上运行的组件,只需要在创建新的客户端边界的组件上添加即可。
解决服务端组件带来的限制问题
当我第一次了解到客户端组件无法渲染服务端组件时,它对我来说感觉非常限制。如果我需要在应用程序中使用高层状态怎么办?那所有组件岂不是都成为客户端组件了?
事实证明,在许多情况下,我们可以通过重构组件来解决这个限制。
这是一件很难解释的事情,所以让我们先举个例子说明:
'use client';
import { DARK_COLORS, LIGHT_COLORS } from '@/constants.js';
import Header from './Header';
import MainContent from './MainContent';
function Homepage() {
const [colorTheme, setColorTheme] = React.useState('light');
const colorVariables = colorTheme === 'light'
? LIGHT_COLORS
: DARK_COLORS;
return (
);
}
在这段代码中,我们需要使用 React 状态允许用户在深色/浅色模式之间切换。这类功能通常需要在应用程序树的较高层级设置,以便我们可以将 CSS 变量 token 应用到 上。
为了使用状态,我们需要让 Homepage 成为客户端组件。由于这是我们应用程序的顶部,表示其他所有组件 - Header 和 MainContent - 也将隐式成为客户端组件。
为了解决这个问题,让我们将主题管理提取到单独的组件文件中:
// /components/ColorProvider.js
'use client';
import { DARK_COLORS, LIGHT_COLORS } from '@/constants.js';
function ColorProvider({ children }) {
const [colorTheme, setColorTheme] = React.useState('light');
const colorVariables = colorTheme === 'light'
? LIGHT_COLORS
: DARK_COLORS;
return (
{children}
);
}
返回 HomaPage,就可以像这样重新组织了:
// /components/Homepage.js
import Header from './Header';
import MainContent from './MainContent';
import ColorProvider from './ColorProvider';
function Homepage() {
return (
);
}
现在就可以从 Homepage 中删除 'use client' 指令了,因为它不再使用状态或任何其他客户端 React 功能,也就表示 Header 和 MainContent 不再需要被迫转换成客户端组件了!
当然,你可能会有疑问了。ColorProvider 是一个客户端组件,是 Header 和 MainContent 的父组件。不管怎样,它仍然处在树结构的较高层级,是吧?
确实。不过,Header 和 MainContent 是在 Homepage 中引入的,这表示它们的 props 只受到 HomaPage 影响。也就是说,客户端边界只对边界顶部组件的内部有影响,对同处于一个父组件下的其他组件没有影响。
请记住,我们试图解决的问题是服务端组件无法重新渲染的问题,因此无法为它们的任何子组件设置新的 props。Homepage 决定 Header 和 MainContent 的 props 是什么,并且由于 Homepage 本身是一个服务端组件,那么同属于服务端组件的 Header、MainContent 自然就没有 props 会改变的担忧。
不得不承认的是,理解服务端组件架构确实是一件费脑筋的事情。即使有了多年的 React 经验,我仍然觉得这很令人困惑,需要相当多的练习才能培养对这种新架构的直觉。
更准确地说,'use client' 指令是在文件/模块级别下工作的。客户端组件中导入的任何模块也必须是客户端组件。毕竟,当打包工具打包我们的代码时,也是依据这些导入声明一同打包的!
浅析底层实现
现在让我们从一个较低的层面来看服务端组件的实现。当我们使用服务端组件时,输出是什么样的?实际生成了什么?
让我们从一个超级简单的 React 应用程序开始:
function Homepage() {
return (
Hello world!
);
}
在 Next.js App Router 模式下,所有组件默认都是服务端组件。也就是说,Homepage 就是服务端组件,会在服务端渲染。
当我们在浏览器中访问此应用程序时,我们将收到一个 HTML 文档,如下所示:
Hello world!
self.__next['$Homepage-1'] = {
type: 'p',
props: null,
children: "Hello world!",
};
我们看到 HTML 文档包含由 React 应用程序生成的 UI,即“Hello world!”段落。其实这属于服务器端渲染结果,跟 React 服务端组件没有关系。
再往下,是一个 标签来加载我们的 JS 包。这个脚本中包括 React 等依赖项,以及我们应用程序中使用的所有客户端组件代码。由于我们的 Homepage 是服务端组件,所以这个组件的代码不包含在这个 JS 包中。
最后,第二个 标签,其中包含一些内联 JS:
self.__next['$Homepage-1'] = {
type: 'p',
props: null,
children: "Hello world!",
};
这里就比较有趣了。本质上这里所做的就是告诉 React——“嘿,我知道你看不到 Homepage 组件代码,但不用担心:这就是它渲染的内容”。通常来说,当 React 在客户端上水合时,这种做法会加速整个渲染进程,因为部分组件(服务端组件)已经在后端渲染出来了,其组件代码也不会包含在 JS 文件中。
我们会将服务器生成的虚拟表示发送回去,当 React 在客户端加载时,它会重用这这部分虚拟描述,而不是重新生成它。
这就是上面的 ColorProvider 能够工作的原因。 Header 和 MainContent 的输出通过 children 属性传递到 ColorProvider 组件。ColorProvider 可以根据需要重新渲染,但数据是静态的,在服务器就锁定了。
如果你想了解服务端组件如何序列化并通过网络发送的,可以使用 Alvar Lagerlöf 开发的 RSC Devtools[4] 进行查看。
💡 服务端组件不需要服务器
我们有一道,服务器端渲染其实是很多不同渲染策略的总称。包括:
React Server Components 与上述这 2 渲染策略都是兼容的。当服务端组件在 Node.js 调用渲染时,会返回的当前组件的 JavaScript 对象表示。这个操作可以在构建时,也可以在请求时。
也就是说,在没有服务器的情况下使用 React 服务端组件!我们可以生成一堆静态 HTML 文件并将它们托管在某个地方,事实上,这就是 Next.js App Router 中默认就是这个策略——除非我们真的需要推迟到“请求”阶段,否则所有这些工作都会在构建期间提前发生。
服务端组件的好处
React 服务端组件比较酷的一点就在于:它是 React 中运行服务器专有代码的第一个“官方”方案。另外,自 2016 年以来,我们已经能够在 Next.js 的 App Router 模式下使用服务端组件了!
不过,这种方案引入之后,编写 React 代码的方式变得很不一样了,因为我们需要编写专用于服务端的 React 的代码了。
这样带来的一个最明显好处就是性能了。服务端组件不包含在我们发送给客户端的 JS 包中,这样就减少了需要下载的 JS 代码数量以及需要水合的组件数量:
图片
不过,这对我来说可能是最不令人兴奋的事情。毕竟,大多数 Next.js 应用程序在“页面可交互(Page Interactive)”方面已经做得足够快了。
如果你遵循语义 HTML 原则,那么你的大部分应用程序甚至在 React 水合之前就可以运行。比如:跳转链接、提交表单、展开和折叠手风琴(使用 和 )等。者对于大多数项目来说,React 只需要几秒钟的时间来进行水合就很不错了。
不过,React 服务端组件真正的优势在于,我们不再需要在功能与打包文件尺寸上妥协了!
例如,大多数技术博客都需要某种语法高亮库。在我的博客里,我使用 Prism。代码片段如下所示:
function exampleJavaScriptFunction(param) {
return "Hello world!"
}
一个流行语法高亮库,通常会支持很多流行的编程语言,有几兆字节,放到 JS 包中实在太大。因此,我们必须做出妥协,删除非必须语言和功能。
但是,假设我们在服务端组件中进行语法突出显示。在这种情况下,我们的 JS 包中实际上不会包含高亮库代码。因此,我们不必做出任何妥协,另外我们还可以使用所有的附加功能。
Bright[5] 就是支持在服务端组件中使用的现代语法高亮库。
图片
这是让我对 React 服务端感到兴奋的一个地方。原本包含在 JS 包中成本太高的东西现在可以在服务器上运行,而不必在包含在 JS 包中了,这也带来了更好的用户体验。
这也不仅仅是性能和用户体验。使用 RSC 一段时间后,我开始真正体会到服务端组件是多么简单易用。我们永远不必担心依赖数组、过时的闭包、记忆或由事物变化引起的任何其他复杂的东西。
我真的很高兴看到未来几年事情将如何发展,因为社区将利用这种新模式继续创造出像 Bright 这样新的解决方案。对于成为一名 React 开发者来说,这很令人激动!
完整图表
React 服务端组件是一项令人兴奋的方案,但它实际上只是“现代 React”难题的一部分。
当我们将 React 服务端组件与 Suspense 和新的 Streaming SSR 架构结合起来时,事情变得更加有趣。它允许我们做下面这样疯狂的事情:
图片
简单来说,内置 Suspense 组件能够利用 Streaming SSR + React 服务端组件架构实现局部组件更新。这样每块内容都可以单独渲染、处理,能更快响应用户,带来更好地浏览体验。
不过这部分知识超出了本文范围,你可以在 Github[6] 上了解有关此架构的更多信息。
参考资料
[1]
Making Sense of React Server Components: https://www.joshwcomeau.com/react/server-components/
[2]最小 RSC demo: https://github.com/reactjs/server-components-demo
[3]“Bleeding-edge frameworks”: https://react.dev/learn/start-a-new-react-project#bleeding-edge-react-frameworks
[4]RSC Devtools: https://www.alvar.dev/blog/creating-devtools-for-react-server-components
[5]Bright: https://bright.codehike.org/
[6]Github: https://github.com/reactwg/react-18/discussions/37