在过去的十年里,React 及其生态系统经历了持续的演变。每个版本都引入了新概念、优化,有时甚至是范式转变,不断推动我们对网页开发可能性的认识。
React Server Components (RSC) 是自 React hooks 以来最新也许是最重要的变化。然而,这一变化在社区内引起了不同的反响。
对我而言,这句 Linkin Park 的歌词捕捉到了我们步入 2024 年时,对 React 进化的情感:
Cause once you got a theory of how the thing works Everybody wants the next thing to be just like the first
因为一旦你有了一个关于事物如何运作的理论,每个人都希望下一件事情能够像第一件一样
我们已经非常习惯了我们所熟悉和喜爱的 React,可以理解的是,接受一个范式转变充满了犹豫和怀疑,这无疑是一个挑战。
这篇博客文章的目的是引导你了解 React 渲染进化多年的历程,并帮助你理解为什么 React 服务器组件不仅是不可避免的,而且也是构建高性能、成本效益高的 React 应用程序、提供卓越用户体验的未来。
客户端渲染 (CSR)
如果你已经在开发领域摸爬滚打了一段时间,你会记得 React 曾是创建单页应用程序(SPAs)的首选库。
在一个典型的单页应用程序(SPA)中,当客户端发出请求时,服务器会向浏览器(客户端)发送一个单一的 HTML 页面。这个 HTML 页面通常只包含一个简单的 div 标签和对一个 JavaScript 文件的引用。这个 JavaScript 文件包含了应用程序运行所需的一切,包括 React 库本身和你的应用程序代码。它会在 HTML 文件被解析时下载。
下载的 JavaScript 代码然后在你的电脑上生成 HTML,并将其插入到根 div 元素下的 DOM 中,你在浏览器中看到用户界面。
当你在 DOM Inspector 中看到 HTML 出现,但在 View Source 选项中看不到时,这一过程就很明显,后者显示的是服务器发送给浏览器的 HTML 文件。
这种在浏览器(客户端)内直接将组件代码转换成用户界面的渲染方法,被称为客户端渲染(CSR)。
这是客户端渲染的可视化展示:
这里是对比 React 单页应用的 DOM 检查器和页面源码的情况:这里是对比 React 单页应用的 DOM 检查器和页面源码的情况:
CSR 很快就成为了 SPAs 的标准,并得到了广泛采用。然而,开发者们不久后就开始注意到这种方法固有的一些缺点。
CSR 的缺点
首先,生成主要包含单个 div 标签的 HTML 对 SEO 并不理想,因为它为搜索引擎索引提供的内容很少。大型的包大小和来自深层嵌套组件的 API 响应的网络请求瀑布可能导致有意义的内容无法被爬虫快速渲染并索引。
其次,让浏览器(客户端)处理所有工作,如获取数据、计算 UI 和使 HTML 互动,可能会减慢速度。用户可能会在页面加载时看到空白屏幕或加载旋转器。随着应用程序新增功能,JavaScript 包的大小增加,这个问题会随着时间的推移而恶化,延长用户等待看到 UI 的时间。对于网速慢的用户来说,这种延迟尤其明显。
CSR 为我们今天所熟悉的交互式网络应用奠定了基础,但为了提升 SEO 和性能,开发者开始寻找更好的解决方案。
服务器端渲染 (SSR)
为了克服 CSR 的缺点,现代 React 框架,如 Next.js,转向了服务器端解决方案。这种方法从根本上改变了内容传递给用户的方式。
服务器负责渲染完整的 HTML,而不是发送一个几乎为空的 HTML 文件,依赖客户端 JavaScript 来构建页面。这个完整形成的 HTML 文档随后直接发送给浏览器。由于 HTML 是在服务器上生成的,浏览器能够迅速解析并显示它,提高了初始页面加载时间。
这是服务器端渲染的可视化表示:
解决 CSR 的缺点
服务器端方法有效解决了与 CSR 相关的问题。
首先,它显著提升了 SEO,因为搜索引擎可以轻松索引服务器渲染的内容。
其次,浏览器可以立即加载页面的 HTML 内容,而不是出现空白屏幕或加载旋转器。
注水
SSR 提升内容可见性的方法有其复杂性,尤其是在页面交互性方面。在 JavaScript 包 ------ 包括 React 本身以及你的应用特定代码 ------ 被浏览器完全下载并执行之前,页面的完全交互性都处于等待状态。
这个重要的阶段,被称为 hydration,是最初由服务器提供的静态页面被赋予生命的时刻。在 hydration 过程中,React 在浏览器中接管控制,根据提供的静态 HTML 在内存中重建组件树。它仔细规划这棵树内交互元素的布局。
然后,React 开始将必要的 JavaScript 逻辑绑定到这些元素上。这包括初始化应用程序状态,附加事件处理程序以响应点击和鼠标悬停等操作,以及设置其他所需的动态功能,以实现完全交互式的用户体验。
SSG 和 SSR
深入来看,服务器端解决方案可以分为两种策略:静态站点生成(SSG)和服务器端渲染(SSR)。
SSG 在构建时发生,即应用程序部署在服务器上时。这导致页面已经渲染并准备好提供服务。它非常适合内容不经常变化的情况,比如博客文章。
SSR 则是根据用户请求按需渲染页面。它适用于像社交媒体动态这样的个性化内容,其中 HTML 取决于登录的用户。通常,你会看到这两者被统称为服务器端渲染或 SSR。
服务器端渲染(SSR)是相对于客户端渲染(CSR)的一大改进,它提供了更快的初始页面加载速度和更好的搜索引擎优化(SEO)。然而,SSR 也带来了它自己的一系列挑战。
SSR 的缺点
SSR 的一个问题是,组件不能开始渲染然后暂停或"等待"数据仍在加载时。如果组件需要从数据库或其他来源(如 API)获取数据,这个获取过程必须在服务器开始渲染页面之前完成。这可能会延迟服务器对浏览器的响应时间,因为服务器必须在页面的任何部分发送给客户端之前,完成收集所有必要的数据。
SSR 的第二个问题是,为了成功的水合作用,即 React 向服务器渲染的 HTML 添加交互性,浏览器中的组件树必须与服务器生成的组件树完全匹配。这意味着,所有组件的 JavaScript 必须在客户端加载完毕后,才能开始对它们进行水合作用。
SSR 的第三个问题与 hydration 本身有关。React 在单次遍历中对组件树进行 hydrate,这意味着一旦开始 hydrate,它不会停止,直到整个树完成。因此,在你可以与其中任何一个组件交互之前,所有组件都必须完成 hydrate。
这三个问题 ------ 需要加载整个页面的数据、加载整个页面的 JavaScript 以及为整个页面进行 hydration ------ 创建了一个从服务器到客户端的全有或全无的瀑布问题,在这个问题中,每个问题必须在转向下一个问题之前得到解决。如果你的应用程序中的某些部分比其他部分慢,这种方式就会很低效,而这在现实世界的应用程序中往往是常见的情况。
因为这些限制,React 团队引入了一种新的、改进的 SSR 架构。
Suspense for 服务端渲染
React 18 引入了用于 SSR 的 Suspense,以解决传统 SSR 的性能缺陷。这种新架构允许你使用 <Suspense>
组件来解锁两个主要的 SSR 功能:
- 服务器上的 HTML 流式传输
- 客户端的选择性水合
服务器上的 HTML 流式传输
正如我们在上一节中讨论的,传统上,SSR 是一种全有或全无的事务。服务器渲染完整的 HTML,然后将其发送给客户端。客户端显示这个 HTML,只有在完整的 JavaScript 包加载之后,React 才开始对整个应用程序进行 hydrate 操作以添加交互性。
以下是上述过程的可视化表示:
然而,有了 React 18,我们有了新的可能性。通过将页面的一部分,比如主内容区域,包裹在 React Suspense
组件中,我们指示 React 不需要等待主部分数据被获取就开始为页面的其余部分流式传输 HTML。React 将发送一个占位符,如加载旋转器,而不是完整内容。
服务器准备好主要部分的数据后,React 通过持续的流发送额外的 HTML,并伴随一个内联的 <script>
标签,其中包含正确定位该 HTML 所需的最少量 JavaScript。因此,即使完整的 React 库还未在客户端加载,主要部分的 HTML 对用户也已可见。
这是带有 <Suspense>
的 HTML 流式传输可视化:
这解决了我们的第一个问题。你不必在展示任何内容之前就获取所有内容。如果某个特定部分延迟了初始 HTML 的加载,它可以稍后无缝地集成到流中。这是 <Suspense>
促进服务器端 HTML 流式传输的本质。
客户端的选择性水合
虽然我们现在可以加快初始 HTML 的传输,但我们仍然面临另一个挑战。在主要部分的 JavaScript 加载之前,客户端应用程序的 hydration 无法开始。如果主要部分的 JavaScript 包很大,这可能会显著延迟这个过程。
为了缓解这个问题,可以使用代码分割。代码分割意味着你可以标记特定的代码段为不是立即加载所必需的,指示你的打包工具将它们隔离到不同的 <script>
标签中。
使用 React.lazy
进行代码分割可以帮助你将主要部分的代码从主要的 JavaScript 包中分离出来。因此,包含 React 和整个应用程序的代码(不包括主要部分)现在可以由客户端独立下载,无需等待主要部分的代码。
这一点至关重要,因为通过将主要部分包裹在 <Suspense>
中,你已经向 React 表明它不应该阻止页面的其他部分不仅仅是流式传输,而且还包括水合作用。这个特性,称为选择性水合,允许在其余的 HTML 和 JavaScript 代码完全下载之前,随着部分内容变得可用,就对其进行水合。
从用户的角度来看,最初他们获得的是以 HTML 形式流式传输的非交互式内容。然后你告诉 React 进行水合作用。主要部分的 JavaScript 代码还没有,但这没关系,因为我们可以选择性地对其他组件进行水合作用。
主要部分在其代码加载后会被填充。
由于选择性 hydration,一个庞大的 JS 代码块不会阻止页面的其他部分变得可交互。
这是带有 <Suspense>
的选择性水合作用的可视化:
此外,选择性水合提供了解决第三个问题的方案:即"为了与任何东西交互必须水合所有东西"的必要性。React 会尽可能早地开始水合,使得用户能够与头部和侧边导航等元素进行交互,而无需等待主内容完成水合。这一过程由 React 自动管理。
在多个组件等待 hydration 的场景中,React 根据用户交互来优先处理 hydration。例如,如果侧边栏即将进行 hydration,而你点击了主内容区域,React 将在点击事件的捕获阶段同步对被点击的组件进行 hydration。这确保了组件能够立即响应用户交互。sidenav
稍后会进行 hydration。
这是基于用户互动的水合作用可视化:
Suspense for SSR 的缺点
首先,即使 JavaScript 代码是异步传输到浏览器的,最终,用户必须下载网页的全部代码。随着应用程序增加更多功能,用户需要下载的代码量也在增长。这就引出了一个重要问题:用户真的需要下载这么多数据吗?
其次,目前的方法要求所有 React 组件在客户端进行 hydration,不管它们是否真的需要交互性。这个过程可能会低效地消耗资源,并延长用户的加载时间和交互时间,因为他们的设备需要处理和渲染可能根本不需要客户端交互的组件。这就引出了另一个问题:所有组件都应该进行 hydration 吗,即使是那些不需要交互性的组件?
尽管服务器在处理密集型任务方面具有更强的能力,但大部分 JavaScript 执行仍然发生在用户的设备上。这可能会减慢性能,特别是在不太强大的设备上。这引出了另一个重要问题:是否应该在用户的设备上完成如此多的工作?
为了应对这些挑战,仅仅采取渐进式的步骤是不够的。我们需要向更强大的解决方案迈出重大的飞跃。
React 服务器组件 (RSC)
React Server Components (RSC) 代表了 React 团队设计的一种新架构。这种方法旨在利用服务器和客户端环境的优势,优化效率、加载时间和交互性。
架构引入了双组件模型,区分客户端组件和服务器组件。这种区分不是基于组件的功能,而是基于它们执行的位置和它们设计用于交互的特定环境。让我们更仔细地看看这两种类型:
客户端组件
客户端组件是我们在之前的渲染技术中使用和讨论的熟悉的 React 组件。它们通常在客户端(CSR)上渲染,但也可以在服务器上(SSR)渲染一次,允许用户立即看到页面的 HTML 内容,而不是空白屏幕。
"客户端组件"在服务器上渲染的概念可能看起来令人困惑,但将它们视为主要在客户端运行、但也可以(且应该)作为优化策略在服务器上执行一次的组件,这样理解会有帮助。
客户端组件可以访问客户端环境,比如浏览器,允许它们使用状态、效果和事件监听器来处理交互性,同时也可以访问像地理位置或 localStorage 这样的浏览器专有 API,让你为特定用例构建前端,就像我们在引入 RSC 架构之前多年所做的那样。
实际上,"客户端组件"这个术语并没有表示任何新内容;它只是帮助将这些组件与新引入的服务器组件区分开来。
这是一个 Counter
客户端组件的例子:
javascript
"use client"
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<h2>Counter</h2>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
服务器组件
Server Components 是一种新型的 React 组件,专门设计用于仅在服务器上运行。与客户端组件不同,它们的代码保留在服务器上,永远不会下载到客户端。这种设计选择为 React 应用程序带来了多重好处。让我们更仔细地看看这些好处。
零捆绑大小。 首先,在包大小方面,服务器组件不会将代码发送到客户端,允许大型依赖项保留在服务器端。这样可以减少网络连接较慢或设备性能较差的用户下载、解析和执行这些组件的 JavaScript 的需要。此外,它还消除了 hydration 步骤,加快了应用程序的加载和交互速度。
直接访问服务器端资源。 通过直接后端访问服务器端资源,如数据库或文件系统,服务器组件能够高效地进行数据获取和渲染,无需额外的客户端处理。利用服务器的计算能力和靠近数据源的优势,它们管理计算密集型的渲染任务,并仅将交互式代码片段发送给客户端。
增强的安全性。 第三,服务器组件的独家服务器端执行通过将敏感数据和逻辑,包括令牌和 API 密钥,保持在客户端之外,从而增强了安全性。
改进的数据获取。 第四,服务器组件提高了数据获取效率。通常,在使用 useEffect
在客户端获取数据时,子组件不能开始加载其数据,直到父组件完成加载自己的数据。这种顺序获取数据通常会导致性能不佳。
主要问题不在于往返本身,而在于这些往返是从客户端到服务器的。服务器组件使应用程序能够将这些顺序往返转移到服务器端。通过将这些逻辑移至服务器,可以减少请求延迟,并提高整体性能,消除客户端-服务器的瀑布式交互。
缓存。 服务器端渲染可以缓存结果,这些结果可以在后续请求中重用,并跨不同用户共享。这种方法可以通过减少每个请求所需的渲染和数据获取量,显著提高性能并降低成本。
初始页面加载和首次内容绘制(FCP)速度更快。 服务器组件显著提升了初始页面加载和首次内容绘制(FCP)。通过在服务器上生成 HTML,页面可以立即渲染,无需下载、解析和执行 JavaScript 的延迟。
改善了 SEO。 第七,关于搜索引擎优化(SEO),服务器渲染的 HTML 对搜索引擎机器人是完全可访问的,增强了页面的可索引性。
高效的流式传输。 最后,就是流式传输。服务器组件允许将渲染过程分割成可管理的块,这些块一旦准备好就会被流式传输到客户端。这种方法允许用户更早地开始看到页面的部分内容,无需等待服务器上的整个页面完成渲染。
这是一个 ProductList
页面服务器组件的例子:
javascript
export default async function ProductList() {
const res = await fetch("https://api.example.com/products");
const products = res.json();
return (
<main>
<h1>Products</h1>
{products.length > 0 ? (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
))}
</ul>
) : (
<p>No products found.</p>
)}
</main>
);
}
"use client"
指令
在 React Server Components 范式中,默认情况下,Next.js 应用中的每个组件都被视为 Server Component。
要定义客户端组件,我们必须在文件顶部包含一个指令 --- 换句话说,一个特殊指令: "use client"
。这个指令作为我们从服务器端跨越到客户端的通行证,并且是允许我们定义客户端组件的关键。
它向打包工具表明,这个组件以及它导入的任何组件都是为客户端执行而设计的。因此,该组件可以完全访问浏览器 API 并具有处理交互性的能力。
"use server" 指令标记了可以从客户端代码调用的服务器端函数。我们将在另一篇文章中介绍 "use server" 和服务器 actions 。
React 服务器组件渲染生命周期
让我们探索一下假设 Next.js 为 React 框架的 RSC 渲染生命周期。
Vercel 结合 Next.js 13 是首个支持 React Server Components (RSC) 架构的。
对于 React 服务器组件(RSC),需要考虑三个要素:浏览器(客户端),以及服务器端的 Next.js(框架)和 React(库)。
初始加载序列
- 当您的浏览器请求一个页面时,Next.js 应用路由器将请求的 URL 与一个服务器组件匹配。然后 Next.js 指示 React 渲染该服务器组件。
- React 渲染服务器组件以及任何同样是服务器组件的子组件,将它们转换成一种称为 RSC 负载的特殊 JSON 格式。如果任何服务器组件挂起,React 会暂停渲染那个子树,并发送一个占位符值。
- 与此同时,客户端组件按照后续生命周期中的指令进行准备。
- Next.js 使用 RSC Payload 和 Client Component JavaScript 指令在服务器上生成 HTML。这个 HTML 被流式传输到你的浏览器,以便立即显示路由的快速、非交互式预览。
- Next.js 在 React 渲染每个 UI 单元时,会并行流式传输 RSC 负载。
- 在浏览器中,Next.js 处理流式的 React 响应。React 使用 RSC 负载和客户端组件指令来逐步渲染 UI。
- 客户端组件和服务器组件的输出全部加载完毕后,最终的 UI 状态将呈现给用户。
- 客户端组件经历水合作用,将我们的应用程序从静态显示转变为交互式体验。
这是初始加载序列。接下来,让我们看看用于刷新应用程序部分的更新序列。
更新序列
- 浏览器请求重新获取特定的 UI,例如完整路由。
- Next.js 处理请求并将其与请求的服务器组件匹配。Next.js 指示 React 渲染组件树。React 渲染组件,类似于初始加载。
- 但与初始序列不同,更新时不会生成 HTML。Next.js 会逐步将响应数据流式传输回客户端。
- 在接收到流式响应后,Next.js 使用新的输出触发路由的重新渲染。
- React 将新渲染的输出与屏幕上现有的组件进行协调(合并)。由于 UI 描述是一种特殊的 JSON 格式而不是 HTML,React 可以在保留关键 UI 状态(如焦点或输入值)的同时更新 DOM。
这是 Next.js 中带有 App Router 的 RSC 渲染生命周期的本质。
在 React 服务器组件架构中,服务器组件负责数据获取和静态渲染,而客户端组件则负责渲染应用程序的交互元素。
底线是 RSC 架构使得 React 应用能够同时利用服务器渲染和客户端渲染的最佳特性,而且是在使用单一语言、单一框架以及一套协同的 API 的情况下。RSC 在传统渲染技术的基础上进行了改进,同时也克服了它们的局限性。