Published on

译: 展望React服务端组件未来的构建

Authors
  • avatar
    Name
    Deng Hua
    Twitter

本文翻译至:Why React Server Components Are Breaking Builds to Win Tomorrow

目录

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

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

对我来说,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 - Client Side Rendering)

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

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

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

就像,当您看到HTML出现在开发者工具的DOM检查器中,但没有出现在“查看源代码”选项中(该选项显示服务器发送到浏览器的 HTML 文件)时,就非常明显。

这种将组件代码直接转换为浏览器(客户端)内的用户界面的渲染方法称为客户端渲染(CSR)。

这是客户端渲染的可视化:

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

compare

CSR 很快成为 SPA 的标准并得到广泛采用。然而,不久之后,开发人员就开始注意到这种方法的一些固有缺陷。

CSR的缺点

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

其次,让浏览器(客户端)处理所有工作,例如获取数据、计算布局与UI以及和HTML交互,可能会减慢速度。页面加载时,用户可能会看到空白屏幕或加载旋转图标。随着时间的推移,这个问题往往会变得更糟,因为添加到应用程序的每个新功能都会增加JavaScript包的大小,从而延长用户首屏的等待时间。对于互联网连接速度较慢的用户来说,这种延迟尤其明显。

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

服务器端渲染 (SSR - Server Side Rendering)

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

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

这是服务器端渲染的可视化:

解决了CSR的缺点

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

首先,它显着改善了 SEO,因为搜索引擎可以轻松索引服务器呈现的内容。其次,浏览器可以立即加载页面 HTML 内容,而不是出现空白屏幕或加载骨架框。

水合 (Hydration)

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

这个重要的阶段称为水合作用(hydration),是最初由服务器提供的静态页面被赋予生命的阶段。在水合过程中,React 控制浏览器,根据所提供的静态 HTML 重建内存中的组件树。它会仔细规划树中交互元素的放置。

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

水合(Hydrate),它可以被比喻为用交互性和动态行为的“水”来“浇灌”最初“干”的HTML(从服务器发送)。在 react 内部,这涉及注册事件处理程序并使服务器返回的静态页面变为动态的,使其能够响应用户交互。React 中的水合至关重要,因为它弥合了服务器渲染内容的效率与客户端应用程序的动态功能之间的差距。

SSG 和 SSR

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

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

另一方面,SSR 按需渲染页面以响应用户请求。它适用于社交媒体源等个性化内容,其中 HTML 取决于登录用户。通常,您会看到两者统称为服务器端渲染或 SSR。

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

SSR的缺点

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

SSR的第二个问题,为了成功实现水合作用,React向服务器渲染的HTML添加交互功能,浏览器中的组件树必须与服务器生成的组件树完全匹配。这意味着组件的所有JavaScript都必须先加载到客户端,然后才能开始对其中任何组件进行Hydrating。

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

这三个问题 ———— 必须加载整个页面的数据、加载整个页面的 JavaScript 以及水合整个页面,也创建了一个从服务器到客户端的全有或全无的瀑布问题,其中每个问题都必须在进行下一个之前先解决。如果应用程序的某些部分比其他部分慢(现实应用程序中经常出现这种情况),那么这种方法的效率就会很低。

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

Suspense for Server-side Rendering

React 18 为 SSR 引入了 Suspense,以解决传统 SSR 的性能缺陷。这种新架构允许您使用 <Suspense> 组件来解锁两个主要的 SSR 功能:

  1. 服务器上的 HTML 流式传输
  2. 客户端可以选择性的水合

服务器上的 HTML 流式传输

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

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

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

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

下面是使用 <Suspense> 的 HTML 流的可视化:

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

客户端选择性的水合

虽然我们现在可以加快初始HTML的投送速度,但我们仍然面临另一个挑战。在加载main content的 JavaScript 之前,客户端应用程序水合作用无法启动。如果main content的 JavaScript 包很大,则可能会严重延迟该过程。

为了缓解这种情况,可以使用代码分割。代码分割意味着您可以将特定的代码段标记为不立即需要加载,从而指示您的打包程序将它们分隔成单独的 <script> 标记。

使用 React.lazy 进行代码分割使您能够将main content的代码与主要 JavaScript 包分开。因此,包含 React 的 JavaScript 以及整个应用程序的代码(不包括main content)现在可以由客户端独立下载,而无需等待main content的代码。

这一点至关重要,因为通过将main content的代码包含在 <Suspense> 中,您已经向 React 表明它不应阻止页面的其余部分不仅流式传输,而且还阻止水合。这个称为选择性水合的功能允许在完全下载其余 HTML 和 JavaScript 代码之前对可用的部分进行水合。

从用户的角度来看,最初拿到的是以 HTML 形式传输的非交互式内容。然后告诉 React 进行水合。但是main content的 JavaScript 代码还未加载,不过没关系,因为我们可以有选择地合并其他组件。

一旦加载了代码,main content就会被水化。

由于选择性水合作用,大量的 JS 不会妨碍页面的其余部分变得具有交互性。

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

此外,选择性水合为第三个问题提供了解决方案:“水合一切以与任何事物相互作用”的必要性。 React 尽快开始水合,从而可以与headerside nav等元素进行交互,而无需等待主要内容水合。这个过程由 React 自动管理。

在多个组件等待水合作用的情况下,React 根据用户交互优先考虑水合作用。例如,此时sidenav即将被水合,并且您单击了main content区域,React 将在单击事件的捕获阶段同步水合被单击的组件。这确保组件准备好立即响应用户交互。 sidenav稍后会被水合。

以下是基于用户交互的水合可视化:

SSR Suspense 的缺点

首先,即使 JavaScript 代码异步传输到浏览器,最终用户也必须下载网页的整个代码。随着应用程序添加更多功能,用户需要下载的代码量也会增加。这就引出了一个重要的问题:用户真的应该下载这么多数据吗?

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

第三,尽管服务器具有处理密集任务的卓越能力,但大部分 JavaScript 执行仍然发生在用户设备上。这会降低性能,尤其是在功能不是很强大的设备上。这引出了另一个重要问题:这么多工作应该在用户的设备上完成吗?

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

React Server Components (RSC)

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

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

Client components 客户端组件

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

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

客户端组件可以访问客户端环境(例如浏览器),允许它们使用stateeffects和事件侦听器来处理交互性,还可以访问浏览器专有的 API(例如地理定位或 localStorage),从而允许您构建特定用途的前端正如我们在引入 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 应用程序提供了多种好处。让我们仔细看看这些好处。

Zero-bundle sizes 零束尺寸

首先,就bundle的大小而言,服务器组件不会将代码发送到客户端,从而允许大量依赖项保留在服务器端。消除了这些组件下载、解析和执行 JavaScript 的需求,这对互联网连接速度较慢或设备功能较差的用户有利。此外,它还消除了水合步骤,从而加快了应用程序的加载和交互速度。

直接访问服务器端资源

其次,通过直接后端访问服务器端资源(例如数据库或文件系统),服务器组件可以实现高效的数据获取和呈现,而无需额外的客户端处理。利用服务器的计算能力和与数据源的邻近性,它们管理计算密集型渲染任务,并仅向客户端发送交互式代码片段。

增强安全性

服务器组件的专有服务器端执行通过将敏感数据和逻辑(包括令牌和 API 密钥)远离客户端来增强安全性。

改进的数据获取方式

服务器组件提高了数据获取效率。通常,当使用 useEffect 在客户端获取数据时,子组件在父组件完成加载自己的数据之前无法开始加载其数据。这种顺序获取数据通常会导致性能不佳。

主要问题不是往返本身,而是这些往返是从客户端到服务器的。服务器组件使应用程序能够将这些顺序往返转移到服务器端。通过将此逻辑移至服务器,可以减少请求延迟,提高整体性能,从而消除client-server瀑布。

缓存

在服务器上渲染可以缓存结果,可以在后续请求中以及跨不同用户重用。这种方法可以通过最大限度地减少每个请求所需的渲染和数据获取量来显著提高性能并降低成本。

更快的初始页面加载和首次内容绘制

服务器组件显着改进了初始页面加载和首次内容绘制 (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"。该指令就是一张我们跨越从服务器到客户端边界的门票,并且允许我们定义客户端组件。

它向bundler发出信号,表明该组件及其导入的任何组件均用于客户端执行。因此,该组件可以获得对浏览器 API 的完全访问权限以及处理交互性的能力。

React 服务器组件渲染生命周期

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

Vercel 与 Next.js 13 是第一个支持 React Server Components (RSC) 架构的版本。

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

Initial loading sequence (初始加载顺序):

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

  2. React 渲染服务器组件和任何也是服务器组件的子组件,将它们转换为称为 RSC负载的特殊 JSON 格式。如果任何服务器组件挂起,React 会暂停该子树的渲染并发送一个占位符值。(视频中第5s)

  3. 与此同时,客户端组件已准备好在后续阶段使用的指示。(视频中第7s)

  4. Next.js 使用 RSC负载和客户端组件 JavaScript 指令在服务器上生成 HTML。 这些 HTML 会实时传输到您的浏览器,以立即显示路由的快速、非交互式预览。(视频中第11s)

  5. 除此之外,Next.js 在 React 渲染每个UI单元时也会传输流式的RSC负载。(视频中第18s)

  6. 在浏览器中,Next.js 处理流式传输的 React 响应。 React 使用 RSC 负载和客户端组件指令来逐步呈现 UI。(视频中第19s)

  7. 一旦加载了所有客户端组件和服务器组件,最终的 UI 状态就会呈现给用户。(视频中第21s)

  8. 客户端组件经历水合作用,将我们的应用程序从静态显示转变为交互式体验。(视频中第25s)

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

Update sequence 更新顺序

  1. 浏览器请求重新获取特定 UI,例如全部route。

  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 Server Components 架构中,服务器组件负责数据获取和静态渲染,而客户端组件则负责渲染应用程序的交互元素。

最重要的是,RSC 架构使 React 应用程序能够利用服务器和客户端渲染的最佳部分,同时使用单一语言、单一框架和一组内聚的 API。 RSC 改进了传统渲染技术,同时也克服了其局限性。