理解React服务端组件

理解React服务端组件

React 团队推出了 React Server Components.

介绍

React 组件有史以来第一次可以专门在服务器上运行.

网上对此有太多令人困惑的事情.许多人对这是什么,它如何工作,有什么好处以及它如何与服务器端渲染等内容相结合有很多疑问.

服务端渲染快速入门

当我在 2021 年第一次开始使用 React 时,大多数 React 设置都使用”客户端”渲染策略.用户将收到一个如下所示的 HTML 文件:

1
2
3
4
5
6
7
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="/static/js/bundle.js"></script>
</body>
</html>

bundle.js 脚本包含我们安装和运行应用程序所需的所有内容,包括 React,其他第三方依赖项以及我们编写的所有代码.

下载并解析 JS 后,React 就会立即采取行动,为整个应用程序召唤所有 DOM 节点,并将其容纳在那个空的<div id="root">中.

这种方法的问题是完成所有这些工作需要时间.当这一切发生时,用户盯着空白的屏幕.随着时间的推移,这个问题往往会变得更糟:我们发布的每一个新功能都会向 JavaScript 包添加更多的字节,从而延长用户必须盯着白屏幕等待的时间.

服务器端渲染旨在改善这种体验.服务器将渲染我们的应用程序以生成实际的 HTML,而不是发送空的 HTML 文件.用户会收到一个完整的 HTML 文档.

该 HTML 文件仍将包含该<script>标签,因为我们仍然需要 React 在客户端上运行,以处理任何交互性.但我们将 React 配置为在浏览器中的工作方式略有不同:它不是从头开始构造所有 DOM 节点,而是采用现有的 HTML.这个过程称为水合.

下载 JS 包后,React 将快速运行我们的整个应用程序,构建 UI 的虚拟草图,并将其”拟合”到真实的 DOM,附加事件处理程序,触发任何效果等等.

简而言之,服务器生成初始 HTML,以便用户在下载和解析 JS 包时不必盯着空白页面.然后,客户端 React 继续服务器端 React 的工作,采用 DOM 并加入交互性.

总结下来,服务端渲染的流程:

  1. 用户访问 myWebsite.com.
  2. Node.js 服务器接收请求,并立即呈现 React 应用程序,生成 HTML.
  3. 新鲜出炉的 HTML 被发送到客户端.

这是实现服务器端渲染的一种可能方法,但不是唯一的方法.另一种选择是在构建应用程序时生成 HTML.

通常,React 应用程序需要进行编译,将 JSX 转换为普通的 JavaScript,并捆绑我们的所有模块.如果在同一过程中,我们为所有不同的路由”预渲染”所有 HTML 会怎么样?

这通常称为静态站点生成 (SSG).它是服务器端渲染的一个子变体.

“服务器端渲染”是一个涵盖性术语,包括几种不同的渲染策略.它们都有一个共同点:初始渲染发生在 Node.js 等服务器运行时,使用 ReactDOMServer API.实际上,何时发生这种情况并不重要,无论是按需发生还是在编译时发生.无论哪种方式,它都是服务器端渲染.

反复横跳

我们来谈谈 React 中的数据获取.通常,我们有两个通过网络进行通信的独立应用程序:

  1. 客户端 React 应用程序
  2. 服务器端 REST API

使用 React Query,SWRApollo 等工具,客户端会向后端发出网络请求,然后后端会从数据库中获取数据并通过网络发送回.

我们可以使用图表可视化此流程:

关于这些图表的注释:

文章里包括许多这样的”网络请求图”.它们旨在可视化数据如何在客户端(浏览器)和服务器(后端 API)之间跨多种不同的渲染策略移动.

底部的数字代表虚构的时间单位.它们不是分钟或秒.事实上,由于大量不同的因素,这些数字差异很大.这些图表旨在让您对概念有一个高层次的理解,它们并未对任何真实数据进行建模.

第一张图显示了使用客户端渲染 (CSR) 策略的流程.它从客户端接收 HTML 文件开始.该文件没有任何内容,但有一个或多个<script>标签.

下载并解析 JS 后,我们的 React 应用程序将启动,创建一堆 DOM 节点并填充 UI.不过,一开始我们没有任何实际数据,所以我们只能以加载状态渲染外壳(页眉,页脚,总体布局).

您可能已经多次见过这种模式.例如,UberEats 首先渲染一个骨架屏,同时获取填充实际餐厅所需的数据:

用户将看到此加载状态,直到网络请求解析并 React 重新渲染,用真实内容替换加载 UI.

让我们看看另一种构建此方案的方法.下图保持相同的一般数据获取模式,但使用服务器端渲染而不是客户端渲染:

在这个新流程中,我们在服务器上执行第一次渲染.这意味着用户收到的 HTML 文件并非完全为空.

这是一个改进,外壳比空白页更好,但最终,它并没有真正以显着的方式改变现状.用户访问我们的应用程序不是为了看加载屏幕,而是为了查看内容(餐厅,酒店列表,搜索结果,消息等).

为了真正了解用户体验的差异,让我们在图表中添加一些网络性能指标.关注两个流程之间,并注意标志发生了什么变化:

每个标志都代表一个常用的 Web 性能指标.详细情况如下:

  1. 第一次绘制-用户不再盯着空白的白色屏幕.总体布局已经渲染出来,但是内容还缺失.这有时称为 FCP(首次内容绘制).
  2. 页面交互- React 已下载,我们的应用程序已渲染/水化.交互式元素现在完全响应.这有时称为 TTI(交互时间).
  3. 内容绘制—页面现在包含用户关心的内容.我们从数据库中提取数据并将其呈现在 UI 中.这有时称为 LCP(最大内容绘制).

通过在服务器上进行初始渲染,我们能够更快地绘制初始”骨架”.这可以让加载体验感觉更快一些,因为它提供了一种进展感,即事情正在发生.

而且,在某些情况下,这将是一个有意义的改进.例如,用户可能只是等待标题加载,以便他们可以单击导航链接.

但这样的流程不觉得有点傻吗?当我查看 SSR 图时,我不禁注意到请求是在服务器上启动的.我们不需要第二次往返网络请求,为什么不在初始请求期间执行数据库工作呢?

换句话说,为什么不做这样的事情呢?

我们不会在客户端和服务器之间来回切换,而是将数据库查询作为初始请求的一部分,将完全填充的 UI 直接发送给用户.

我们需要能够为 React 提供一段专门在服务器上运行的代码,以执行数据库查询.但这并不是 React 的一个选择…即使使用服务器端渲染,我们所有的组件都会在服务器和客户端上渲染.

生态系统针对这个问题提出了很多解决方案.像 Next.js 和 Gatsby 已经创建了自己的方式来专门在服务器上运行代码.

例如,使用 Next.js 的情况如下(使用旧版”Pages”路由器):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 (
<>
<h1>Trending Products</h1>
{data.map((item) => (
<article key={item.id}>
<h2>{item.title}</h2>
<p>{item.description}</p>
</article>
))}
</>
);
}

让我们分解一下:当服务器收到请求时,getServerSideProps 就会调用该函数.它返回一个 props 对象.然后这些 props 被汇集到组件中,该组件首先在服务器上渲染,然后在客户端上进行水化.

这里的聪明之处在于它 getServerSideProps 不会在客户端上重新运行.事实上,这个函数甚至没有包含在我们的 JavaScript 包中.

这种方法在当时是非常超前的.但这样做也有一些缺点:

此策略仅适用于路由级别,适用于树最顶部的组件.我们无法在任何组件中执行此操作.

每个元框架都提出了自己的方法.Next.js 有一种方法,Gatsby 有另一种方法,Remix 还有另一种方法.它尚未标准化.

我们所有的 React 组件将始终在客户端上进行水合,即使它们不需要这样做.

多年来,React 团队一直在悄悄地修补这个问题,试图想出一个官方的方法来解决这个问题.他们的解决方案称为 React Server Components.

React 服务端组件简介

从高层次来看,React Server Components 是一种全新开发模式.在这个新世界中,我们可以创建专门在服务器上运行的组件.这使我们能够在 React 组件中执行诸如编写数据库查询之类的操作!

这是”服务器组件”的一个简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 (
<>
<h1>Trending Products</h1>
{data.map((item) => (
<article key={item.id}>
<h2>{item.title}</h2>
<p>{item.description}</p>
</article>
))}
</>
);
}
export default Homepage;

作为一个使用 React 多年的人,这段代码一开始对我来说绝对是疯狂的.😅

函数组件不能异步!而且我们不允许直接在渲染中出现这样的副作用!

要理解的关键是:服务器组件永远不会重新渲染.它们在服务器上运行一次以生成 UI.渲染的值被发送到客户端并锁定到位.就 React 而言,这个输出是不可变的,永远不会改变.

这意味着 React 的 API 的很大一部分与服务器组件不兼容.例如,我们不能使用状态,因为状态可以改变,但服务器组件不能重新渲染.我们不能使用效果,因为效果仅在渲染后在客户端上运行,而服务器组件永远不会到达客户端.

这也意味着我们在规则方面有更多的灵活性.例如,在传统的 React 中,我们需要将副作用放入 useEffect 回调或事件处理程序或其他内容中,以便它们不会在每次渲染时重复.但如果该组件只运行一次,我们就不必担心!

服务器组件本身非常简单,但”React Server Component”范例要复杂得多.这是因为我们仍然有常规的组件,并且它们组合在一起的方式可能非常令人困惑.

在这个新范例中,我们熟悉的”传统”React 组件称为客户端组件.老实说,我不喜欢这个名字.😅

“客户端组件”这个名称意味着这些组件仅在客户端上呈现,但实际上并非如此.客户端组件在客户端和服务器上呈现.

总结如下:

  1. React Server Components 是这个新范例的名称.
  2. 在这个新范例中,我们所了解和喜爱的”标准”React 组件已被重新命名为 Client Components.这是一个旧事物的新名称.
  3. 这种新范例引入了一种新型组件:服务器组件.这些新组件专门在服务器上呈现.他们的代码不包含在 JS 包中,因此他们从不进行水合或重新渲染.

React 服务器组件与服务器端渲染:

让我们澄清另一个常见的困惑:React 服务器组件并不是服务器端渲染的替代品.您不应该将 React Server Components 视为”SSR 版本 2.0”.

相反,应该将其视为两个独立的拼图,完美地拼凑在一起,两种风格相辅相成.

我们仍然依靠服务器端渲染来生成初始 HTML.React Server Components 建立在其之上,允许我们从客户端 JavaScript 包中省略某些组件,确保它们只在服务器上运行.

事实上,甚至可以在没有服务器端渲染的情况下使用 React 服务器组件,尽管在实践中,如果将它们一起使用,您会得到更好的结果.

兼容环境

通常,当新的 React 功能出现时,我们可以通过将 React 依赖项升级到最新版本来开始在现有项目中使用它.

不幸的是,React Server Components 并不是这样工作的.

React 服务器组件需要与 React 之外的一堆东西紧密集成,比如捆绑器,服务器和路由器.

那就是使用 Next.js,使用他们全新的重新架构的”App Router”.

希望将来,更多基于 React 的框架将开始合并 React 服务器组件.核心 React 功能只能在一个特定工具中使用。React 文档有一个”前沿框架”部分,其中列出了支持 React 服务器组件的框架;

指定客户端组件

在这个新的”React 服务器组件”范例中,默认情况下所有组件都被假定为服务器组件.我们必须手动指定客户端组件.

我们通过一个全新的指令来做到这一点:

1
2
3
4
5
6
7
"use client";
import React from "react";
function Counter() {
const [count, setCount] = React.useState(0);
return <button onClick={() => setCount(count + 1)}>Current value: {count}</button>;
}
export default Counter;

顶部的独立字符串’use client’是我们如何向 React 发出信号,表明此文件中的组件是客户端组件,它们应该包含在我们的 JS 包中,以便它们可以在客户端上重新渲染.

这似乎是一种非常奇怪的方式来指定我们正在创建的组件的类型,但是这种事情有一个先例:在 JavaScript 中选择进入”严格模式”的”use strict”指令.

我们不在’use server’服务器组件中指定该指令;在 React Server Components 范例中,组件默认被视为服务器组件.事实上,’use server’它用于服务器操作,这是一个完全不同的功能,超出了本文章的范围.

哪些组件应该是客户端组件?

您可能想知道:我应该如何决定给定组件应该是服务器组件还是客户端组件?

作为一般规则,如果一个组件可以是服务器组件,那么它就应该是服务器组件.服务器组件往往更简单且更容易推理.还有一个性能优势:因为服务器组件不在客户端上运行,所以它们的代码不包含在我们的 JavaScript 包中.React Server Components 范例的好处之一是它有可能改进页面交互(TTI) 指标.

也就是说,我们也不应该把消除尽可能多的客户端组件作为我们的使命!我们不应该尝试针对最少的客户端组件进行优化.值得记住的是,到目前为止,每个 React 应用程序中的每个 React 组件都是客户端组件.

当您开始使用 React Server 组件时,您可能会发现这非常直观.我们的一些组件需要在客户端上运行,因为它们使用状态变量或效果.您可以在这些组件上添加’use client’指令.否则,您可以将它们保留为服务器组件.

边界

当熟悉 React Server 组件时,遇到的第一个问题是:当 props 改变时会发生什么?

例如,假设我们有一个像这样的服务器组件:

1
2
3
function HitCounter({ hits }) {
return <div>Number of hits: {hits}</div>;
}

假设在初始服务器端渲染中,hits 等于 0.然后,该组件将生成以下标记:

1
<div>Number of hits: 0</div>

但如果值发生 hits 变化会发生什么?假设它是一个状态变量,并且它从 0 变为 1.HitCounter 需要重新渲染,但它不能重新渲染,因为它是服务器组件!

问题是,服务器组件孤立起来并没有真正的意义.我们必须缩小范围,采取更全面的观点,考虑我们应用程序的结构.

假设我们有以下组件树:

如果所有这些组件都是服务器组件,那么这一切都有意义.所有道具都不会改变,因为所有组件都不会重新渲染.

但我们假设该 Article 组件拥有 hits 状态变量.为了使用状态,我们需要将其转换为客户端组件:

您看到这里的问题了吗?Article 重新渲染时,任何拥有的组件也将重新渲染,包括 HitCounter 和 Discussion.但是,如果这些是服务器组件,则它们无法重新渲染.

为了防止这种不可能的情况,React 团队添加了一条规则:客户端组件只能导入其他客户端组件.该’use client’指令意味着 HitCounter 和 Discussion 的这些实例将成为客户端组件.

我在使用 React Server Components 时遇到的最大的”啊哈”时刻之一是意识到这个新范例就是创建客户端边界.在实践中,最终会发生以下情况:

当我们将’use client’指令添加到 Article 组件时,我们创建了一个”客户端边界”.此边界内的所有组件都隐式转换为客户端组件.即使像这样的组件 HitCounter 没有指令’use client’,在这种特殊情况下它们仍然会在客户端上进行水化/渲染.

这意味着我们不必添加’use client’到需要在客户端上运行的每个文件.实际上,我们只需要在创建新的客户端边界时添加它.

解决方法

当我第一次了解到客户端组件无法渲染服务器组件时,它对我来说感觉非常限制.如果我需要在应用程序中使用高层状态怎么办?这是否意味着一切都需要成为客户端组件?

事实证明,在许多情况下,我们可以通过重组应用程序来更改所有者来解决此限制.

这是一件很难解释的事情,所以让我们举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"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 (
<body style={colorVariables}>
<Header />
<MainContent />
</body>
);
}

在此设置中,我们需要使用 React 状态来允许用户在深色模式/浅色模式之间切换.这需要在应用程序树的高层发生,以便我们可以将 CSS 变量标记应用到标签<body>.

为了使用状态,我们需要创建 Homepage 一个客户端组件.由于这是我们应用程序的顶部,这意味着所有其他组件 Header 和 MainContent 也将隐式成为客户端组件.

为了解决这个问题,让我们将颜色管理内容提取到它自己的组件中,移动到它自己的文件中:

1
2
3
4
5
6
7
8
// /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 <body style={colorVariables}>{children}</body>;
}

回到 Homepage,我们像这样使用这个新组件:

1
2
3
4
5
6
7
8
9
10
11
12
// /components/Homepage.js
import Header from "./Header";
import MainContent from "./MainContent";
import ColorProvider from "./ColorProvider";
function Homepage() {
return (
<ColorProvider>
<Header />
<MainContent />
</ColorProvider>
);
}

我们可以删除该’use client’指令,Homepage 因为它不再使用状态或任何其他客户端 React 功能.这意味着 Header 和 MainContent 不会再隐式转换为客户端组件!

但等一下. ColorProvider 是一个客户端组件,是 Header 和 MainContent 的父组件.不管怎样,它仍然在树上更高的位置,对吧?

我们试图解决的问题是服务器组件无法重新渲染,因此无法为它们的任何 props 赋予新值.

这是一件费脑筋的事情.即使有了多年的 React 经验,我仍然觉得这很令人困惑.需要相当多的练习才能培养对此的直觉.

更准确地说,该’use client’指令在文件/模块级别工作.客户端组件文件中导入的任何模块也必须是客户端组件.毕竟,当捆绑器捆绑我们的代码时,它将遵循这些导入!

更改颜色主题?

在上面的示例中,您可能已经注意到无法更改颜色主题.setColorTheme 从未被调用过.

我想让事情尽可能少,所以我遗漏了一些东西.一个完整的示例将使用 React 上下文来使 setter 函数可供任何后代使用.只要使用上下文的组件是客户端组件,一切就都很好!

原理剖析

让我们从一个较低的层面来看这个问题.当我们使用服务器组件时,输出是什么样的?实际生成了什么?

让我们从一个超级简单的 React 应用程序开始:

1
2
3
function Homepage() {
return <p>Hello world!</p>;
}

在 React 服务器组件范例中,所有组件默认都是服务器组件.由于我们没有明确将此组件标记为客户端组件(或在客户端边界内呈现它),因此它只会在服务器上呈现.

当我们在浏览器中访问此应用程序时,我们将收到一个 HTML 文档,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<body>
<p>Hello world!</p>
<script src="/static/js/bundle.js"></script>
<script>
self.__next["$Homepage-1"] = {
type: "p",
props: null,
children: "Hello world!",
};
</script>
</body>
</html>

为了更容易理解,我冒昧地在这里重组了一些东西.例如,在 RSC 上下文中生成的真正 JS 使用字符串化 JSON 数组,作为减少此 HTML 文档的文件大小的优化.

我还删除了 HTML 的所有非关键部分(例如<head>).

我们看到 HTML 文档包含由 React 应用程序生成的 UI,即”Hello world!” 段落.这要归功于服务器端渲染,而不是直接归因于 React 服务器组件.

下面,我们有一个<script>加载 JS 包的标签.该捆绑包包括 React 等依赖项,以及我们应用程序中使用的任何客户端组件.由于我们的 Homepage 组件是服务器组件,因此该组件的代码不包含在此捆绑包中.

最后,我们有第二个<script>带有一些内联 JS 的标签:

1
2
3
4
5
self.__next["$Homepage-1"] = {
type: "p",
props: null,
children: "Hello world!",
};

本质上,我们在这里所做的就是告诉 React”嘿,所以我知道你缺少组件 Homepage 代码,但不用担心:这就是它渲染的内容”.

通常,当 React 在客户端上运行时,它会加速渲染所有组件,构建应用程序的虚拟表示.它不能对服务器组件执行此操作,因为代码不包含在 JS 包中.

因此,我们发送渲染值,即服务器生成的虚拟表示.当 React 在客户端加载时,它会重新使用该描述,而不是重新生成它.

这就是上面的例子 ColorProvider 能够工作的原因.Header 和 MainContent 的输出通过 prop 传递到组件中.可以根据需要重新渲染,但该数据是静态的,由服务器锁定.

如果您想了解服务器组件如何序列化并通过网络发送的真实表现,请查看开发人员 Alvar Lagerlöf 的 RSC Devtools.

服务器组件不需要服务器

在本文前面,我提到服务器端渲染是许多不同渲染策略的”总括术语”,包括:

  • 静态:HTML 是在部署过程中构建应用程序时生成的.
  • 动态:当用户请求页面时,HTML 是”按需”生成的.

React Server Components 与这些渲染策略中的任何一个兼容.当我们的服务器组件在 Node.js 运行时呈现时,它们返回的 JavaScript 对象将被创建.这可以按需发生,也可以在构建期间发生.

这意味着可以在没有服务器的情况下使用 React Server 组件!我们可以生成一堆静态 HTML 文件并将它们托管在我们想要的任何地方.事实上,这就是 Next.js App Router 中默认发生的情况.除非我们真的需要”按需”发生事情,否则所有这些工作都会在构建期间提前发生.

根本没有 React 吗?

您可能想知道:如果我们的应用程序中不包含任何客户端组件,我们真的需要下载 React 吗?我们可以使用 React Server Components 构建一个真正静态的无 JS 网站吗?

问题是,React 服务器组件仅在 Next.js 框架中可用,并且该框架有一堆需要在客户端上运行的代码,以管理路由等内容.

然而,与直觉相反,这实际上往往会产生更好的用户体验;例如,Next 的路由器将比典型标签<a>更快地处理链接点击,因为它不必加载整个新的 HTML 文档.

结构良好的 Next.js 应用程序在 JS 下载时仍然可以工作,但一旦 JS 加载,它会更快/更好.

优点

React Server Components 是在 React 中运行服务器专有代码的第一个”官方”方式.

最大的区别是我们以前从未有过在组件内运行服务器独占代码的方法.

最明显的好处是性能.服务器组件不包含在我们的 JS 包中,这减少了需要下载的 JavaScript 数量以及需要水合的组件数量:

旧版本Next.js

现代Next.js

老实说,大多数 Next.js 应用程序在”页面交互”计时方面已经足够快了.

如果您遵循语义 HTML 原则,那么您的大部分应用程序甚至在 React 水合之前就应该可以运行.可以跟踪链接,可以提交表格,可以展开和折叠手风琴(使用<details><summary>).对于大多数项目来说,如果 React 需要几秒钟的时间来进行水合就可以了.

我们不再需要在功能与捆绑包大小方面做出同样的妥协!

例如,大多数技术博客都需要某种语法突出显示库.如下所示:

1
2
3
function exampleJavaScriptFunction(param) {
return "Hello world!";
}

一个合适的语法高亮库,支持所有流行的编程语言,将有几兆字节,太大而无法放在 JS 包中.因此,我们必须做出妥协,删除非关键任务的语言和功能.

但是,假设我们在服务器组件中进行语法突出显示.在这种情况下,我们的 JS 包中实际上不会包含任何库代码.因此,我们不必做出任何妥协,我们可以使用所有的附加功能.

包含在 JS 捆绑包中成本太高的东西现在可以在服务器上免费运行,我们的捆绑包没有添加任何代码,并产生更好的用户体验.

这也不仅仅是性能和用户体验.使用 RSC 一段时间后,我开始真正体会到服务器组件是多么简单易用.我们永远不必担心依赖数组,过时的闭包,记忆或由事物变化引起的任何其他复杂的东西.

归根结底,现在还为时尚早.React Server Components 几个月前才刚刚推出测试版!

作者

1uciuszzz

发布于

2023-09-25

更新于

2023-09-25

许可协议

评论