激进的 React Server Components
Making Sense of React Server Components
翻译自 Making Sense of React Server Components (joshwcomeau.com)
引言
让我感到老了的是:React今年迎来了它的10岁生日!
在React首次向一个困惑的开发者社区推出的十年里,它经历了几次演变。React团队在面对激进变革时毫不羞愧:如果他们发现了更好的解决问题的方案,就会直接采用。
几个月前,React团队发布了React服务器组件(React Server Components),这是最新的范式转变。React组件第一次可以完全在服务器上运行。
网上对此有很多困惑。很多人对这是什么,如何工作,有什么好处,以及如何与服务器端渲染等技术结合在一起有很多问题。
我一直在对React服务器组件做很多实验,并回答了自己的很多问题。我不得不承认,我对这个比我预期的要兴奋得多。这真的很酷!
所以,我今天的目标是帮你解开这些谜团,回答你可能对React服务器组件的很多问题!
目标读者
本教程主要面向已经在使用React,并对React服务器组件感兴趣的开发者。你不需要是React专家,但如果你刚开始学React,可能会感到相当困惑。
服务器端渲染快速入门
为了更好地理解React服务器组件,了解服务器端渲染(SSR)的工作原理会很有帮助。如果你已经熟悉SSR,可以直接跳到下一个标题!
2015年我刚开始使用React时,大多数React设置使用"客户端"渲染策略。用户会收到一个看起来像这样的HTML文件:
那个bundle.js脚本包括我们挂载和运行应用程序所需的一切,包括React、其他第三方依赖项以及我们编写的所有代码。
JS下载并解析完成后,React就会开始行动,为整个应用程序构造所有DOM节点,并将其放在那个空的<div id="root">中。
这种方法的问题是完成所有这些工作需要时间。当这一切发生时,用户只能盯着一个空白的白屏。这个问题随着时间的推移往往会变得更糟:我们发布的每个新功能都会为JavaScript包增加更多KB,延长用户不得不坐着等待的时间。
服务器端渲染旨在改善这种体验。我们不是发送一个空的HTML文件,而是由服务器渲染我们的应用程序来生成实际的HTML。用户收到一个完全成型的HTML文档。
这个HTML文件仍然会包含<script>标签,因为我们仍然需要React在客户端运行以处理任何交互性。但是我们配置React在浏览器中稍微有点不同地工作:React不是从头开始构造所有DOM节点,而是采用现有的HTML。这个过程被称为hydration。
我喜欢React核心团队成员Dan Abramov解释这一点的方式:
Hydration就像用交互性和事件处理程序的"水"来浇灌"干燥"的HTML。
JS包下载完成后,React会快速遍历整个应用程序,构建UI的虚拟图,并将其"装配"到真实DOM中,附加事件处理程序,触发任何效果等等。
这就是SSR的简要概述。服务器生成初始HTML,这样用户就不必在JS包下载和解析时盯着空白的白页了。客户端React接着从服务器端React停止的地方开始,采用DOM并加入交互性。
总括术语
当我们谈论服务器端渲染时,我们通常会想象一个如下所示的流程:
- 用户访问myWebsite.com。
- Node.js服务器接收请求,并立即渲染React应用程序,生成HTML。
- 新鲜出炉的HTML被发送到客户端。
这是实现服务器端渲染的一种可能方式,但不是唯一的方式。另一个选择是在构建应用程序时生成HTML。
通常,React应用程序需要编译,将JSX转换为普通的JavaScript,并打包所有模块。如果在同一过程中,我们为所有不同的路由"预渲染"所有HTML,会怎样?
这通常被称为静态站点生成(SSG)。这是服务器端渲染的一个子变体。
在我看来,"服务器端渲染"是许多不同渲染策略的总括术语。它们都有一个共同点:初始渲染发生在像Node.js这样的服务器运行时中,使用ReactDOMServer API。这实际发生的时间并不重要,无论是按需还是在编译时。无论哪种方式,都是服务器端渲染。
让我们来谈谈React中的数据获取。通常,我们有两个通过网络进行通信的独立应用程序:
- 一个客户端React应用
- 一个服务器端REST API
使用类似React Query、SWR或Apollo的工具,客户端会向后端发出网络请求,后端再从数据库获取数据并通过网络发回。
我们可以用一个图来可视化这个流程:
关于这些图的说明
这篇博文包含了几个这样的"网络请求图"。它们旨在可视化数据在客户端(浏览器)和服务器(后端API)之间的移动,跨越几种不同的渲染策略。
底部的数字代表一个想象的时间单位。它们不是分钟或秒。实际上,这些数字会根据很多不同的因素而有很大差异。这些图表旨在让你对这些概念有一个高层次的理解,它们并没有对任何真实数据进行建模。
这第一个图显示了使用客户端渲染(CSR)策略的流程。它从客户端接收一个HTML文件开始。这个文件没有任何内容,但它确实有一个或多个<script>标签。
一旦JS被下载和解析,我们的React应用就会启动,创建一堆DOM节点并填充UI。但一开始,我们没有任何实际数据,所以我们只能渲染外壳(头部、底部、总体布局)和一个加载状态。
你可能经常看到这种模式。例如,UberEats在获取它需要的数据来填充实际的餐厅列表时,会先渲染一个外壳:
https://www.joshwcomeau.com/images/server-components/ubereats-loading.mp4?v=2
在网络请求解析完成并且React重新渲染,将加载中的UI替换为真实内容之前,用户会看到这个加载状态。
让我们看看我们可以如何设计这个架构的另一种方式。下一个图保持相同的数据获取模式,但使用服务器端渲染而不是客户端渲染:
在这个新流程中,我们在服务器上执行第一次渲染。这意味着用户收到的HTML文件不是完全空的。
这是一个改进——一个外壳比一个空白的白屏要好——但最终,它并没有以显著的方式改变局面。用户访问我们的应用不是为了看到一个加载屏幕,而是为了看到内容(餐厅、酒店列表、搜索结果、消息等)。
为了真正感受用户体验的差异,让我们在图表中添加一些Web性能指标。在这两个流程之间切换,注意旗帜会发生什么变化:
这些旗帜中的每一个都代表一个常用的Web性能指标。以下是分类:
- First Paint - 用户不再盯着空白的白屏。总体布局已经渲染,但内容仍然缺失。这有时被称为FCP(First Contentful Paint,首次内容绘制)。
- Page Interactive - React已经下载,我们的应用程序已经渲染/水合。交互式元素现在完全响应。这有时被称为TTI(Time To Interactive,可交互时间)。
- Content Paint - 页面现在包含用户关心的内容。我们已经从数据库中拉取了数据并在UI中渲染。这有时被称为LCP(Largest Contentful Paint,最大内容绘制)。
通过在服务器上进行初始渲染,我们能够更快地绘制出初始的"外壳"。这可以使加载体验感觉有点更快,因为它提供了一种进度感,一种事情正在发生的感觉。
而且,在某些情况下,这将是一个有意义的改进。例如,也许用户只是在等待头部加载,以便他们可以点击一个导航链接。
但是这个流程感觉有点傻,不是吗?当我看SSR图表时,我不禁注意到请求是从服务器开始的。与其需要第二次往返网络请求,为什么我们不在初始请求期间就完成数据库工作呢?
换句话说,为什么不做类似这样的事情?
我们不在客户端和服务器之间来回弹跳,而是在初始请求的一部分中进行数据库查询,将完全填充的UI直接发送给用户。
但是,我们到底该如何做到这一点呢?
为了让这个工作,我们需要能够给React一段它只在服务器上运行的代码,以进行数据库查询。但在React中,这一直不是一个选项……即使使用服务器端渲染,我们所有的组件也都在服务器和客户端上渲染。
生态系统为这个问题提供了很多解决方案。Next.js和Gatsby等"元框架"创建了自己的方式来只在服务器上运行代码。
例如,下面是使用Next.js(使用旧版的"Pages"路由器)实现这一点的方式:
让我们来分解一下:当服务器收到请求时,getServerSideProps函数被调用。它返回一个props对象。然后这些props被传递到组件中,组件首先在服务器上渲染,然后在客户端上水合。
这里巧妙的地方在于,getServerSideProps不会在客户端重新运行。事实上,这个函数甚至没有包含在我们的JavaScript包中!
这种方法非常超前。老实说,它非常棒。但这有一些缺点:
- 这种策略只在路由级别有效,对于树的最顶层的组件。我们不能在任何组件中这样做。
- 每个元框架都提出了自己的方法。Next.js有一种方法,Gatsby有另一种方法,Remix还有另一种方法。它还没有被标准化。
- 我们所有的React组件总是会在客户端上水合,即使没有必要这样做。
多年来,React团队一直在悄悄地研究这个问题,试图提出一种官方的解决方案。他们的解决方案叫做React服务器组件。
React服务器组件简介
从高层次上说,React服务器组件是一种全新范式的名称。在这个新世界中,我们可以创建只在服务器上运行的组件。这允许我们做一些事情,比如在React组件中直接编写数据库查询!
这里有一个"服务器组件"的快速示例:
作为一个使用React多年的人,这段代码一开始看起来绝对是疯狂的。😅
"但是等等!",我的直觉在尖叫。"函数组件不能是异步的!而且我们不允许在渲染中直接有副作用!"
需要理解的关键是:服务器组件从不重新渲染。它们在服务器上运行一次以生成UI。渲染的值被发送到客户端并锁定到位。就React而言,这个输出是不可变的,永远不会改变。
这意味着React的很大一部分API与服务器组件不兼容。例如,我们不能使用状态,因为状态可以改变,但服务器组件不能重新渲染。我们也不能使用效果,因为效果只在渲染之后在客户端运行,而服务器组件从不到达客户端。
这也意味着在规则方面我们有更大的灵活性。例如,在传统的React中,我们需要将副作用放在useEffect回调或事件处理程序中,以便它们不会在每次渲染时重复。但是如果组件只运行一次,我们就不必担心这个!
服务器组件本身非常简单,但"React服务器组件"范式要复杂得多。这是因为我们仍然有常规的组件,而它们如何组合在一起可能相当混乱。
在这个新范式中,我们熟悉的"传统"React组件被称为客户端组件。老实说,我不太喜欢这个名字。😅
"客户端组件"这个名字暗示这些组件只在客户端渲染,但实际上并非如此。客户端组件在客户端和服务器上都会渲染。
我知道所有这些术语相当令人困惑,所以我会这样总结:
- React服务器组件是这个新范式的名称。
- 在这个新范式中,我们熟知和喜爱的"标准"React组件已经被重新命名为客户端组件。这是一个旧事物的新名字。
- 这个新范式引入了一种新型组件,服务器组件。这些新组件只在服务器上渲染。
兼容的环境
在撰写本文时,React服务器组件仍处于实验阶段。这意味着它们还没有完全准备好用于生产。但它们可以在几个不同的环境中进行试验。
要使用React服务器组件,你需要:
- 一个服务器运行时,比如Node.js。服务器组件不能在浏览器中运行。
- 一个构建工具来处理服务器组件。Create React App目前不支持服务器组件。
目前,有两个主要的环境支持服务器组件:
- Next.js应用程序。Next.js 13引入了对React服务器组件的实验性支持,作为其新的"应用程序路由器"的一部分。
- 新的React服务器组件演示应用。React团队创建了一个小型演示应用程序,展示了如何使用Vite设置服务器组件。
当我写这篇文章时,只有一种方式可以开始使用 React 服务器组件,即使用 Next.js 13.4+,并使用他们全新的重新架构的“应用程序路由器”。
希望将来,更多基于 React 的框架将开始整合 React 服务器组件。感觉很奇怪的是,一个核心 React 功能仅在一个特定的工具中可用!React 文档中有一个“边缘框架”部分,其中列出了支持 React 服务器组件的框架;我计划不时查看该页面,看看是否有新的选项变得可用。
(注:Next.js 14 已经正式支持 App Router 了)
指定客户端组件
在这个新的“React 服务器组件”范例中,默认情况下所有组件都是服务器组件。我们需要“选择加入”客户端组件。
我们通过指定一个全新的指令来实现这一点:'use client';
边界
在React服务器组件范式中,客户端组件和服务器组件不能随意混合搭配。我们需要遵循一个关键规则:
服务器组件只能引入(import)其他服务器组件。换句话说,服务器组件不能引入任何客户端组件。
这条规则源于这样一个事实:服务器组件永远不会发送到客户端。如果一个服务器组件引入了一个客户端组件,这个客户端组件将无法在浏览器中运行!
要在客户端组件和服务器组件之间共享值,我们需要将这些值作为道具(props)传递。服务器组件可以将值传递给客户端组件,但反过来不行。
解决方法
当我第一次得知客户端组件无法呈现服务器组件时,我感觉受到了相当大的限制。如果我需要在应用程序中使用高级状态怎么办?这是否意味着一切都需要成为客户端组件??
事实证明,在许多情况下,我们可以通过重组应用程序以改变所有者来解决这一限制。
这是一件很难解释的事情,所以让我们使用一个例子:
在此设置中,我们需要使用React状态来允许用户在黑暗模式/明亮模式之间切换。这需要在应用程序树的高层发生,以便我们可以将CSS变量令牌应用于标签。
为了使用状态,我们需要创建一个客户端组件。由于这是我们应用程序的顶部,这意味着所有其他组件(并且)也将隐式地成为客户端组件。
为了解决这个问题,让我们将颜色管理内容提取到自己的组件中,并移至自己的文件中:
回到后面,我们像这样使用这个新组件:
我们可以从中删除该指令,因为它不再使用状态或任何其他客户端React功能。这意味着不会再隐式转换为客户端组件!
当涉及到客户端边界时,不过父/子关系并不重要。主页是导入和渲染 Header 和 MainContent 的一方。这意味着主页决定这些组件的 props 是什么。
记住,我们试图解决的问题是 Server Components 不能重新渲染,因此它们不能为任何 props 赋予新的值。有了这个新的设置,Homepage 决定 Header 和 MainContent 的 props 是什么,因为 Homepage 是一个 Server Component,所以没有问题。这是让人困惑的东西。
即使在拥有多年React经验后,我仍然发现这非常混乱😅。这需要相当多的练习来develop这种直觉。
更准确地说,“use client”指令在文件/模块级别上起作用。在Client组件文件中导入的任何模块也必须是Client组件。当bundler将我们的代码捆绑起来时,它将遵循这些导入!
深入了解内部原理
让我们从较低的层面来看这个问题。当我们使用服务器组件时,输出是什么样子?实际上会产生什么?
让我们从一个超级简单的React应用程序开始:
在 React 服务器组件范例中,默认情况下所有组件都是服务器组件。由于我们没有明确地将该组件标记为客户端组件(或在客户端边界内渲染它),因此它将仅在服务器上渲染。
当我们在浏览器中访问此应用程序时,我们将收到一个类似于以下内容的HTML文档:
我们看到我们的 HTML 文档包括了我们 React 应用程序生成的 UI,"Hello world!" 段落。这要归功于服务器端渲染,而不是直接归因于 React 服务器组件。
在下面,我们有一个<script>标签,用于加载我们的JS包。该包包括像React这样的依赖项,以及我们应用程序中使用的任何客户端组件。由于我们的首页组件是一个服务器组件,因此该组件的代码不包括在这个包中。
最后,我们有一个带有一些内联JS的第二个<script>标签:
这是非常有趣的一部分。基本上,我们这里所做的是告诉 React:“嘿,我知道你缺少 Homepage 组件代码,但不要担心:这里是它渲染的结果”。
通常情况下,当 React 在客户端 hydrate 时,它会快速渲染所有组件,建立应用程序的虚拟表示。它不能为 Server Components 做到这一点,因为代码不包含在 JS 包中。
因此,我们将渲染值发送过去,即服务器生成的虚拟表示。当 React 在客户端加载时,它会重用该描述,而不是重新生成它。
这就是 ColorProvider 例子上述工作的原因。Header 和 MainContent 的输出通过 children prop 传递给 ColorProvider 组件。ColorProvider 可以重新渲染任意次数,但这些数据是静态的,由服务器锁定。
这意味着我们的 JS 包变得更小,但 HTML 文件变得更大。相比于在 JS 文件中拥有组件定义,我们在 <script> 标签中内联组件的返回值。平均而言,我们仍将发送较少的总数据量,但需要记住 Server Components 并不是完全免费的。我还应该注意,HTML 文件被分割成块并流式传输,以便浏览器可以快速绘制 UI,而不需要等待 <script> 标签中的所有内容。
如果您想看到 Server Components 序列化和网络传输的真实表示,请查看开发者 Alvar Lagerlöf 的 RSC Devtools。GitHub - alvarlagerlof/rsc-parser: A parser for the React Server components when sent over the network
优势
React 服务器组件是 React 中首个“官方”方式,在服务器上运行专属代码。然而,如我之前提到的那样,这并不是 React 生态系统中新鲜事物;我们从 2016 年起就在 Next.js 中运行服务器专属代码!
最大不同之处在于,我们从未在组件内部运行服务器专属代码。
最明显的益处是性能。服务器组件不包括在我们的 JS 包中,从而减少了需要下载的 JavaScript 数量,并减少了需要 hydrate 的组件数量
这可能是我最不感兴趣的事情。老实说,大多数 Next.js 应用程序在“页面交互”计时方面已经足够快了。
如果您遵循语义 HTML 原则,大多数应用程序在 React 混合之前就可以工作。链接可以被跟踪,表单可以被提交,accordion 可以被展开和折叠(使用 <details> 和 <summary>)。对于大多数项目,如果 React 混合需要几秒钟,那也没问题。
但是我发现真的很酷:我们不再需要在功能和包大小之间做出妥协!
例如,大多数技术博客需要某种语法高亮库。在这个博客上,我使用 Prism。代码片段看起来像这样:
一个适当的语法高亮库,支持所有流行的编程语言,将是几兆字节,太大了,无法塞入 JS 包中。因此,我们不得不做出妥协,削减掉非关键任务的语言和功能。
但是,如果我们在服务器组件中执行语法高亮。在那种情况下,库代码将不会包含在我们的 JS 包中。因此,我们不需要做出任何妥协,我们可以使用所有的功能。
这就是 Bright 背后的主要思想,一个现代的语法高亮包,旨在与 React 服务器组件一起工作。
这是让我对 React 服务器组件感到激动的事情。那些原本由于成本太高无法包含在 JS 包中的内容现在可以免费在服务器上运行,不增加任何 KB 到我们的包中,并提供更好的用户体验。
这不仅仅是关于性能和用户体验。经过一段时间的使用 RSC,我真的很欣赏服务器组件的轻松自在。我们不再需要担心依赖数组、陈旧的闭包、记忆化或其他由于变化引起的复杂问题。
最终,这仍然是非常早期的日子。React 服务器组件仅仅在几个月前从 beta 中发布!我真的很高兴看到未来几年内事情的发展,因为社区继续创新新的解决方案,如 Bright,利用这个新的范式。这是一个激动人心的时刻,作为一个 React 开发者!
React服务器组件是一个重大的范式转变。就我个人而言,我非常渴望看到未来几年事情的发展,因为生态系统会构建更多像Bright这样利用服务器组件的工具。
我有一种感觉,React中的构建将会变得更加酷。😄