为什么说 React 可不能没有了 React Server Components👦🏽👦🏽👦🏽

27 阅读20分钟

在过去的十年中,React 及其生态系统经历了不断的发展。每个版本都引入了新的概念、优化,有时甚至是范式转变,突破了我们认为 Web 开发可能的界限。

React 服务器组件 (RSC) 是自 React Hooks 以来的最新变化,也许是最重要的变化。然而,这一变化在社区内引起了不同的反应。

对我来说,Linkin Park 的这句话抓住了我们步入 2024 年时围绕 React 演变的情绪

因为一旦你有了关于事物如何运作的理论,每个人都希望下一件事就像第一件事一样

我们已经习惯了我们所熟悉和喜爱的 React,可以理解的是,拥抱范式转变会带来充满犹豫和怀疑的挑战。

这篇文章的目的是引导你了解 React 多年来的渲染演变历程,并帮助你理解为什么 React 服务器组件不仅是不可避免的,而且是构建具有成本效益的高性能 React 应用程序的未来,这些应用程序可以提供卓越的用户体验。

客户端渲染(CSR)

如果你已经在开发游戏中工作了一段时间,你会记得 React 是创建单页应用程序 (SPA) 的首选库。

在典型的 SPA 中,当客户端发出请求时,服务器会向浏览器(客户端)发送单个 HTML 页面。此 HTML 页面通常只包含一个简单的 div 标记,即对 JavaScript 文件的引用。此 JavaScript 文件包含应用程序运行所需的所有内容,包括 React 库本身和应用程序代码。解析 HTML 文件时下载它。

然后,下载的 JavaScript 代码会在您的计算机上生成 HTML,并将其插入到 DOM 的根 div 元素下,您会在浏览器中看到用户界面。

当您看到 HTML 出现在 DOM 检查器中,但未出现在“查看源代码”选项中时,此过程是显而易见的,该选项显示服务器发送到浏览器的 HTML 文件。

这种呈现方法,其中组件代码直接在浏览器(客户端)中转换为用户界面,称为客户端呈现 (CSR)。

下面是客户端渲染的可视化效果:

output.gif

下面是 DOM 检查器与 React SPA 的页面源代码对比:下面是 DOM 检查器与 React SPA 的页面源代码对比:

image.png

CSR 迅速成为 SPA 的标准,并被广泛采用。然而,没过多久,开发人员就开始注意到这种方法的一些固有缺点。

CSR 的弊端

首先,生成主要包含单个 div 标签的 HTML 对于 SEO 来说并不是最佳选择,因为它为搜索引擎提供的内容很少。较大的捆绑包大小和来自深度嵌套组件的 API 响应的网络请求瀑布流可能会导致有意义的内容的呈现速度不够快,以至于爬网程序无法对其进行索引。

其次,让浏览器(客户端)处理所有工作,例如获取数据、计算 UI 和使 HTML 具有交互性,可能会减慢速度。用户可能会在页面加载时看到空白屏幕或加载微调器。随着时间的流逝,此问题往往会恶化,因为添加到应用程序的每个新功能都会增加 JavaScript 包的大小,从而延长用户查看 UI 的等待时间。对于互联网连接速度较慢的用户来说,这种延迟尤为明显。

CSR 为我们今天习惯的交互式 Web 应用程序奠定了基础,但为了增强 SEO 和性能,开发人员开始寻找更好的解决方案。

服务端渲染(SSR)

为了克服 CSR 的缺点,像 Next.js 这样的现代 React 框架转向了服务器端解决方案。这种方法从根本上改变了向用户交付内容的方式。

服务器负责呈现完整的 HTML,而不是发送依赖于客户端 JavaScript 来构建页面的几乎为空的 HTML 文件。然后,将这个完整格式的 HTML 文档直接发送到浏览器。由于 HTML 是在服务器上生成的,因此浏览器能够快速解析和显示它,从而缩短了初始页面加载时间。

下面是服务器端渲染的可视化效果:

output.gif

解决企业社会责任的弊端

服务器端方法有效地解决了与 CSR 相关的问题。

首先,它显着改善了 SEO,因为搜索引擎可以轻松地索引服务器呈现的内容。

其次,浏览器可以立即加载页面 HTML 内容,而不是空白屏幕或加载微调器。

Hydration

SSR 立即提高内容可见性的方法有其自身的复杂性,尤其是在页面的交互性方面。该页面的完整交互性将处于暂停状态,直到 JavaScript 包(包括 React 本身以及您的应用程序特定代码)完全下载并由浏览器执行。

这个重要的阶段称为水化,是最初由服务器提供的静态页面变得生动的地方。在水化期间,React 在浏览器中控制,根据提供的静态 HTML 在内存中重建组件树。它仔细规划了交互式元素在此树中的位置。

然后,React 继续将必要的 JavaScript 逻辑绑定到这些元素。这涉及初始化应用程序状态、为单击和鼠标悬停等操作附加事件处理程序,以及设置完全交互式用户体验所需的任何其他动态功能。

SSR 和 SSG

更深入地研究,服务器端解决方案可分为两种策略:静态站点生成 (SSG) 和服务器端渲染 (SSR)。

SSG 在构建时发生,当应用程序部署在服务器上时。这会导致页面已呈现并准备好投放。它非常适合不经常更改的内容,例如博客文章。

另一方面,SSR 会根据用户请求按需呈现页面。它适用于社交媒体提要等个性化内容,其中 HTML 取决于登录用户。通常,你会看到这两者统称为服务器端渲染或 SSR。

服务器端渲染 (SSR) 是对客户端渲染 (CSR) 的重大改进,提供了更快的初始页面加载和更好的 SEO。然而,SSR 也带来了一系列挑战。

SSR 的缺点

SSR 的一个问题是组件无法开始渲染,然后在加载数据时暂停或“等待”。如果组件需要从数据库或其他源(如 API)获取数据,则必须在服务器开始呈现页面之前完成此获取。这可能会延迟服务器对浏览器的响应时间,因为服务器必须先收集所有必要的数据,然后才能将页面的任何部分发送到客户端。

SSR 的第二个问题是,为了成功冻结,React 为服务器渲染的 HTML 添加了交互性,浏览器中的组件树必须与服务器生成的组件树完全匹配。这意味着,必须先在客户端上加载组件的所有 JavaScript,然后才能开始冻结其中任何一个组件。

SSR 的第三个问题与水合作用本身有关。React 在一次传递中对组件树进行水合,这意味着一旦它开始水化,它就不会停止,直到它完成整个树。因此,必须先对所有组件进行水合,然后才能与其中任何一个组件进行交互。

这三个问题 - 必须加载整个页面的数据,加载整个页面的 JavaScript 以及冻结整个页面 - 创建了一个从服务器到客户端的全有或全无瀑布问题,其中每个问题都必须在移动到下一个问题之前得到解决。如果应用的某些部分比其他部分慢,则效率低下,这在实际应用中很常见。

由于这些限制,React 团队引入了一种新的和改进的 SSR 架构。

Suspense 对于服务器端渲染的使用

React 18 为 SSR 引入了悬念,以解决传统 SSR 的性能缺陷。此新体系结构允许您使用该 <Suspense> 组件来解锁两个主要的 SSR 功能:

  1. 服务器上的 HTML 流式处理

  2. 对客户进行选择性补水 (Selective hydration)

服务器上的 HTML 流式处理

正如我们在上一节中所讨论的,传统上,SSR 一直是全有或全无的事情。服务器呈现完整的 HTML,然后将其发送到客户端。客户端显示此 HTML,只有在加载完整的 JavaScript 包后,React 才会继续冻结整个应用程序以增加交互性。

下面是上述过程的可视化:

output.gif

然而,有了 React 18,我们有了新的可能性。通过将页面的一部分(例如主要内容区域)包装在 React 组件中,我们指示 React Suspense 它不需要等待获取主要部分数据即可开始流式传输页面其余部分的 HTML。React 将发送一个像加载微调器一样的占位符,而不是完整的内容。

一旦服务器准备好了主要部分的数据,React 就会通过正在进行的流发送额外的 HTML,并附有一个内联 <script> 标签,其中包含正确定位该 HTML 所需的最少 JavaScript。因此,即使在客户端加载完整的 React 库之前,主部分的 HTML 对用户可见。

以下是 HTML 流的可视化 <Suspense>

output.gif

这就解决了我们的第一个问题。在显示任何内容之前,您不必获取所有内容。如果某个特定部分延迟了初始 HTML,则可以在以后将其无缝集成到流中。这就是如何 <Suspense> 促进服务器端 HTML 流的本质。

对客户进行选择性补水 (Selective hydration)

虽然我们现在可以加快初始 HTML 交互速度,但我们仍然面临另一个挑战。在加载主要部分的 JavaScript 之前,客户端应用冻结无法启动。如果主要部分的 JavaScript 包很大,这可能会大大延迟该过程。

为了缓解这种情况,可以使用代码拆分。代码拆分意味着您可以将特定的代码段标记为加载时不需要立即使用,从而向打包程序发出信号,将它们隔离到单独的 <script> 标签中。

使用 React.lazy 进行代码分割可以让你将主要部分的代码从主 JavaScript 包中分离出来。因此,包含 React 和整个应用程序的代码(不包括主要部分的代码)现在可以由客户端独立下载,无需等待主要部分的代码。

这一点至关重要,因为通过将主要部分包裹在 <Suspense> 中,你已经向 React 表明它不应该阻止页面的其他部分不仅是流式传输,而且还包括水合作用。这一特性称为选择性水合,它允许在其余的 HTML 和 JavaScript 代码完全下载之前,随着各个部分变得可用而对它们进行水合。

从用户的角度来看,最初他们获得的是以 HTML 形式流入的非交互式内容。然后你告诉 React 补水。主要部分的 JavaScript 代码还没有,但没关系,因为我们可以有选择地对其他组件进行水合。

加载代码后,主部分将被冻结。

由于选择性水化,沉重的 JS 不会阻止页面的其余部分变得交互。

以下是选择性水合作用的可视化 <Suspense>

output.gif

此外,选择性水合提供了解决第三个问题的方案:需要“水合所有内容才能与任何内容互动”。React 会尽可能早地开始水合过程,使得用户可以与头部和侧边导航等元素互动,无需等待主内容完成水合。这一过程由 React 自动管理。

在多个组件等待水合的场景中,React 根据用户互动来优先处理水合。例如,如果侧边栏即将进行水合,而你点击了主内容区域,React 将在点击事件的捕获阶段同步水合被点击的组件。这确保了组件能够立即响应用户的互动。侧导航稍后会被水合。

下面是基于用户交互的水合作用可视化效果:

output.gif

Suspense 对于服务器端渲染(SSR)的缺点

首先,尽管 JavaScript 代码是异步流式传输到浏览器的,但最终用户必须下载网页的全部代码。随着应用程序增加更多功能,用户需要下载的代码量也在增加。这就引出了一个重要的问题:用户真的需要下载这么多数据吗?

其次,当前的方法要求所有 React 组件在客户端进行水合,而不考虑它们对交互性的实际需求。这个过程可能会低效地消耗资源,并延长用户的加载时间和交互时间,因为他们的设备需要处理和渲染可能根本不需要客户端交互的组件。这就引出了另一个问题:是否所有组件都应该进行水合,即使是那些不需要交互性的组件?

第三,尽管服务器在处理密集型处理任务方面有着更强的能力,但大部分 JavaScript 执行仍然发生在用户的设备上。这可能会降低性能,特别是在那些不是很强大的设备上。这就引出了另一个重要问题:是否应该在用户的设备上完成这么多的工作?

为了应对这些挑战,仅仅采取渐进的步骤是不够的。我们需要朝着更强大的解决方案迈出一大步。

React Server Components (RSC)

React 服务器组件(RSC)代表了 React 团队设计的一种新架构。这种方法旨在利用服务器和客户端环境的优势,优化效率、加载时间和交互性。

这种架构引入了双组件模型,区分了客户端组件和服务器组件。这种区分不是基于组件的功能,而是基于它们执行的位置和它们设计用于交互的特定环境。让我们更仔细地看看这两种类型:

客户端组件 (client components)

客户端组件是我们在之前的渲染技术中一直在使用和讨论的熟悉的 React 组件。它们通常在客户端 (CSR) 上呈现,但也可以在服务器上呈现一次 HTML (SSR),允许用户立即看到页面的 HTML 内容,而不是空白屏幕。

客户端组件在服务器上渲染的想法可能看起来令人困惑,但将它们视为主要在客户端运行、但也可以(且应该)作为优化策略在服务器上执行一次的组件,这种看法是有帮助的。

客户端组件可以访问客户端环境,如浏览器,允许它们使用状态、效果和事件监听器来处理交互性,也可以访问像地理位置或 localStorage 这样的浏览器专有 API,让你为特定用例构建前端,就像我们在引入 RSC 架构之前多年所做的那样。

事实上,术语客户端组件并不表示任何新内容;它只是帮助将这些组件与新引入的服务器组件区分开来。

下面是 Counter 客户端组件的示例:

"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 的需要。此外,它移除了水合步骤,加速了应用程序的加载和交互。

直接访问服务器端资源:其次,通过直接后端访问服务器端资源,如数据库或文件系统,服务器组件能够高效地进行数据获取和渲染,无需客户端处理。利用服务器的计算能力和靠近数据源的优势,它们管理计算密集的渲染任务,并仅将交互式代码片段发送到客户端。

增强的安全性:第三,服务器组件的独家服务器端执行通过将敏感数据和逻辑(包括令牌和 API 密钥)保持在客户端之外,增强了安全性。

改进的数据获取:第四,服务器组件提高了数据获取的效率。通常,在使用 useEffect 在客户端获取数据时,子组件不能开始加载其数据,直到父组件完成加载。这种顺序获取数据的方式通常导致性能不佳。

主要问题不在于往返本身,而是这些往返是从客户端到服务器进行的。服务器组件使应用程序能够将这些顺序往返转移到服务器端。通过将这些逻辑移动到服务器,减少了请求延迟,并提高了总体性能,消除了客户端-服务器瀑布。

缓存:第五,服务器上的渲染使得结果可以缓存,在后续请求和不同用户之间重用。这种方法可以通过减少每个请求所需的渲染和数据获取量,显著提高性能并降低成本。

更快的初始页面加载和首次内容绘制:第六,使用服务器组件,初始页面加载和首次内容绘制(FCP)得到了显著改善。通过在服务器上生成 HTML,页面可以立即渲染,无需下载、解析和执行 JavaScript 的延迟。

改善的 SEO:第七,在搜索引擎优化(SEO)方面,服务器渲染的 HTML 完全可被搜索引擎爬虫访问,增强了你的页面的可索引性。

高效的流式传输:最后,流式传输。服务器组件允许将渲染过程分解成可管理的块,这些块一旦准备好就立即流式传输到客户端。这种方法允许用户更早地看到页面的部分内容,无需等待服务器完成整个页面的渲染。

这是 ProductList 页面服务器组件的示例:

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 服务器组件范式中,值得注意的是,默认情况下,Next.js 应用中的每个组件都被视为服务器组件。

要定义客户端组件,我们必须在文件顶部包含一个指令 —— 换句话说,一个特殊的指令:“use client”。这个指令充当我们从服务器过渡到客户端的通行证,它允许我们定义客户端组件。

它向打包器发出信号,表明这个组件以及它导入的任何组件都是为客户端执行而设计的。结果,该组件获得了对浏览器 API 的完全访问权限以及处理交互性的能力。

React Server 组件渲染生命周期

让我们假设 Next.js 作为 React 框架来探索 RSC 渲染生命周期。

对于 React 服务器组件 (RSC),重要的是要考虑三个元素:浏览器(客户端)和服务器端的 Next.js(框架)和 React(库)。

初始加载顺序

如下动图所示:

output.gif

  1. 当你在浏览器请求页面时,Next.js 应用程序路由器会将请求的 URL 与服务器组件匹配。然后,Next.js 指示 React 渲染该服务器组件。

  2. React 渲染服务器组件以及任何同样是服务器组件的子组件,将它们转换成一种称为 RSC 负载的特殊 JSON 格式。如果任何服务器组件挂起,React 会暂停渲染那个子树,并发送一个占位符值。

  3. 同时,客户端组件会被准备好,并附带指令,用于生命周期的后期阶段。

  4. Next.js 使用 RSC 负载和客户端组件 JavaScript 指令在服务器上生成 HTML。这个 HTML 被流式传输到你的浏览器,以便立即显示路由的快速、非交互式预览。

  5. 此外,Next.js 在 React 渲染每个 UI 单元时流式传输 RSC 有效负载。

  6. 在浏览器中,Next.js 处理流式处理的 React 响应。React 使用 RSC 有效负载和客户端组件指令来逐步渲染 UI。

  7. 加载所有客户端组件和服务器组件的输出后,将向用户显示最终的 UI 状态。

  8. 客户端组件经过水化处理,将我们的应用程序从静态显示转变为交互式体验。

这是初始加载序列。接下来,让我们看一下刷新应用程序部分的更新顺序。

更新顺序

如下图所示:

output.gif

  1. 浏览器请求重新获取特定 UI,例如完整路由。

  2. Next.js 处理请求并将其与请求的服务器组件匹配。Next.js 指示 React 渲染组件树。React 渲染组件,类似于初始加载。

  3. 但是,与初始序列不同的是,没有用于更新的 HTML 生成。Next.js 逐步将响应数据流式传输回客户端。

  4. 收到流式处理响应后,Next.js 会使用新输出触发路由的重新呈现。

  5. React 协调(合并)新渲染的输出与屏幕上现有的组件。由于 UI 描述是一种特殊的 JSON 格式而不是 HTML,React 可以在保留关键 UI 状态(如焦点或输入值)的同时更新 DOM。

这是 Next.js 中 App Router 的 RSC 渲染生命周期的本质。

在 React 服务器组件架构中,服务器组件负责数据获取和静态渲染,而客户端组件则负责渲染应用程序的交互元素。

归根结底,RSC 架构使 React 应用能够利用服务器和客户端渲染的最佳方面,同时使用单一语言、单一框架和一套协调的 API。RSC 改进了传统的渲染技术,同时也克服了它们的局限性。

总结

React 服务器组件(RSC)架构通过结合服务端渲染的高效数据处理和客户端渲染的丰富交互性,为 React 应用提供了性能上的显著提升。它减少了客户端需要下载和处理的 JavaScript 代码量,加快了应用的加载速度,同时优化了数据获取策略,直接从服务器端高效访问后端资源。这些优势使得 RSC 成为提高 React 应用性能和用户体验的关键技术。

参考资料

最后分享两个我的两个开源项目,它们分别是:

这两个项目都会一直维护的,如果你也喜欢,欢迎 star 🚗🚗🚗