自从 React 被引入开发社区以来的十年里,它经历了几次演变。React 团队在发生根本性变革时并不害羞:如果他们发现了一个更好的问题解决方案,他们就会带着它运行。
几个月前,React 团队推出了 React Server Components
,这是最新的范式转变。有史以来第一次,React 组件可以只在服务器上运行。
网上对此有太多的困惑。很多人对这是什么、它是如何工作的、好处是什么以及它如何与服务器端渲染等东西结合在一起有很多疑问。
我一直在做很多关于 React Server Components 的实验,我已经回答了很多我自己的问题。我不得不承认,我对这些东西比我预期的要兴奋得多。真的很酷!
所以,我今天的目标是帮助你揭开这些东西的神秘面纱,回答你可能对 React 服务器组件的很多疑问!
本教程主要是为已经在使用 React 并且对 React 服务器组件感到好奇的开发人员编写的。你不需要成为 React 专家,但如果你刚刚开始使用 React,这可能会很混乱。
文章目录
- 服务器端渲染快速入门
- 来回弹跳
- React 服务器组件简介
- 兼容环境
- 指定客户端组件
- 边界
- 解决方法
- 优势
服务器端渲染快速入门
要将 React Server Components 置于上下文中,了解服务器端渲染 (SSR) 的工作原理会很有帮助。如果您已经熟悉 SSR,请随时跳到下一个章节!
当我在 2015 年第一次开始使用 React 时,大多数 React 设置都使用“客户端”渲染策略。用户将收到如下所示的 HTML 文件:
<!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。这个过程被称为 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。但是,起初我们没有任何实际数据,因此我们只能呈现具有加载状态的shell(页眉,页脚,常规布局)。
你可能已经看到了很多这种模式。例如,UberEats 优食首先渲染一个外壳,同时获取填充实际餐厅所需的数据: 视频
用户将看到此加载状态,直到网络请求解析并且 React 重新渲染,将加载 UI 替换为真实内容。
让我们看看我们可以构建它的另一种方式。下图保留相同的常规数据获取模式,但使用服务器端呈现而不是客户端呈现:
在这个新流程中,我们在服务器上执行第一个渲染。这意味着用户会收到一个不完全为空的 HTML 文件。
这是一个改进——一个外壳比一个空白的白页要好——但最终,它并没有真正以显着的方式移动针头。用户访问我们的应用不是为了看到加载屏幕,而是在访问以查看内容(餐厅、酒店列表、搜索结果、消息等)。
为了真正了解用户体验的差异,让我们在图表中添加一些 Web 性能指标。在这两个流之间切换,并注意标志会发生什么情况:
这些标志中的每一个都代表一个常用的 Web 性能指标。以下是细分:
- 第一次绘制 — 用户不再盯着空白的白色屏幕。总体布局已呈现,但内容仍然缺失。这有时称为 FCP(首次内容绘制)。
- 页面交互 — React 已经下载,我们的应用程序已经渲染/水合。交互式元素现在完全响应。这有时被称为TTI(交互时间)。
- 内容绘制 — 页面现在包含用户关心的内容。我们已从数据库中提取数据并在 UI 中呈现。这有时称为LCP(最大内容绘制)。
通过在服务器上进行初始渲染,我们能够更快地绘制初始“shell”。这可以使加载体验感觉更快一些,因为它提供了一种进度感,即事情正在发生。
而且,在某些情况下,这将是一个有意义的改进。例如,用户可能只等待标头加载,以便他们可以单击导航
**但是这种流程是不是感觉有点傻?**当我查看 SSR 图时,我不禁注意到请求在服务器上启动。与其要求第二个往返网络请求,为什么我们不在初始请求期间执行数据库工作?
换句话说,为什么不做这样的事情呢?
我们不是在客户端和服务器之间来回弹跳,而是将数据库查询作为初始请求的一部分,将完全填充的 UI 直接发送给用户。
但是,嗯,我们究竟该怎么做?
为了使其正常工作,我们需要能够为 React 提供一块代码,它只在服务器上运行,以执行数据库查询。但这并不是 React 的一个选择…即使使用服务器端渲染,我们所有的组件也会在服务器和客户端上渲染。
生态系统已经为这个问题提出了许多解决方案。 元框架?像Next.js 和 Gatsby 创建了自己的方式,只在服务器上运行代码。
例如,以下是使用Next.js(使用旧版“Pages”路由器)的样子:
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
对象。然后将这些 prop 汇集到组件中,该组件首先在服务器上渲染,然后在客户端上冻结。
这里的聪明之处在于 getServerSideProps
不会在客户端上重新运行。事实上,这个函数甚至没有包含在我们的 JavaScript 包中!
这种方法非常超前。老实说,这真是太糟糕了。但这有一些缺点:
- 此策略仅适用于路由级别,适用于树最顶部的组件。我们不能在任何组件中执行此操作。
- 每个元框架都提出了自己的方法。Next.js有一种方法,Gatsby 有另一种方法,Remix有另一种方法。它尚未标准化。
- 我们所有的 React 组件将始终在客户端上补水,即使它们不需要这样做。
多年来,React 团队一直在悄悄地修补这个问题,试图想出一个官方的方式来解决这个问题。他们的解决方案称为 React Server Components。
React 服务器组件简介
在高层次上,React Server Components 是一个全新范式的名称。在这个新世界中,我们可以创建仅在服务器上运行的组件。这允许我们做一些事情,比如在我们的 React 组件中编写数据库查询!
下面是“服务器组件”的快速示例:
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 与服务器组件不兼容。例如,我们不能使用 state,因为状态可以更改,但服务器组件不能重新呈现。而且我们不能使用效果,因为效果仅在渲染后在客户端上运行,而服务器组件永远不会到达客户端。
这也意味着许多旧规则不适用。例如,在传统的 React 中,我们需要将副作用放在回 useEffect
调或事件处理程序或其他东西中,这样它们就不会在每次渲染时重复。但是,如果组件只运行一次,我们就不必担心了!
服务器组件本身非常简单,但“React Server Components”范式要复杂得多。这是因为我们仍然有常规的 ol’ 组件,并且它们组合在一起的方式可能会非常混乱。
在这种新范式中,我们熟悉的“传统”React 组件称为客户端组件。老实说,我不喜欢这个名字。😅
名称“客户端组件”意味着这些组件仅在客户端上呈现,但实际上并非如此。客户端组件在客户端和服务器上呈现。
我知道所有这些术语都非常令人困惑,所以我总结一下:
- React Server Components 是这种新范式的名称。
- 在这种新范式中,我们所熟悉和喜爱的“标准”React组件已被重新命名为客户端组件。这是一个旧事物的新名称。
- 这种新范例引入了一种新型组件,即服务器组件。这些新组件以独占方式呈现在服务器上。他们的代码不包含在JS捆绑包中,因此他们从不水化或重新渲染。
React 服务器组件与服务器端渲染
让我们澄清另一个常见的混淆:React Server Components 不能替代服务器端渲染。你不应该把 React Server Components 看作“SSR 2.0 版”。
相反,我喜欢把它想象成两个独立的拼图,完美地结合在一起,两种味道相辅相成。
我们仍然依靠服务器端渲染来生成初始 HTML。React Server Components 构建在此之上,允许我们从客户端 JavaScript 捆绑包中省略某些组件,确保它们仅在服务器上运行。
事实上,甚至可以在没有服务器端渲染的情况下使用 React 服务器组件,但在实践中,如果将它们一起使用,您将获得更好的结果。React 团队构建了一个没有 SSR 的最小 RSC 演示。
兼容环境
因此,通常情况下,当一个新的 React 功能出现时,我们可以通过将我们的 React 依赖项提升到最新版本来开始在现有项目中使用它。快 npm install react@latest
点,我们开始比赛了。
不幸的是,React Server Components 不是这样工作的。
我的理解是,React 服务器组件需要与 React 之外的一堆东西紧密集成,比如捆绑器、服务器和路由器。
在我写这篇文章的时候,只有一种方法可以开始使用 React Server 组件,那就是使用 Next.js 13.4+,使用他们全新的重新设计的“应用程序路由器”。
希望在未来,更多基于 React 的框架将开始合并 React Server 组件。核心 React 功能仅在一个特定工具中可用,这感觉很尴尬!React 文档有一个“前沿框架”部分,其中列出了支持 React Server 组件的框架;我计划不时查看此页面,看看是否有任何新选项可用。
指定客户端组件
在这个新的“React 服务器组件”范式中,默认情况下假定所有组件都是服务器组件。我们必须“选择加入”客户端组件。
我们通过指定一个全新的指令来做到这一点:
'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 包中,以便它们可以在客户端上重新渲染。
这似乎是指定我们正在创建的组件类型的一种非常奇怪的方法,但是这种事情有一个先例: “use strict” 指令,在JavaScript中选择“严格模式”。
我们不在服务器组件中指定 'use server'
指令;在 React 服务器组件范例中,组件默认被视为服务器组件。实际上,用于服务器操作, 'use server'
这是一个完全不同的功能,超出了本博客文章的范围。
哪些组件应该是客户端组件?
您可能想知道:我应该如何确定给定的组件应该是服务器组件还是客户端组件?
作为一般规则,如果组件可以是服务器组件,则它应该是服务器组件。服务器组件往往更简单,更容易推理。还有一个性能优势:由于服务器组件不在客户端上运行,因此它们的代码不包含在我们的 JavaScript 捆绑包中。React Server Components 范式的好处之一是它有可能改进页面交互 (TTI) 指标。
也就是说,我们也不应该把消除尽可能多的客户端组件作为我们的使命!我们不应该尝试针对最小的 # 客户端组件进行优化。值得记住的是,到目前为止,每个 React 应用程序中的每个 React 组件都是客户端组件。
当你开始使用 React Server 组件时,你可能会发现这非常直观。我们的一些组件需要在客户端上运行,因为它们使用状态变量或效果。您可以在这些组件上打'use client'
上指令。否则,您可以将它们保留为服务器组件。
边界
当我熟悉 React Server 组件时,我遇到的第一个问题是:当道具发生变化时会发生什么?
例如,假设我们有一个这样的服务器组件:
function HitCounter({ hits }) {return (<div>Number of hits: {hits}</div>);
}
假设在初始服务器端渲染中, hits
等于 0
。然后,此组件将生成以下标记:
<div>Number of hits: 0
</div>
但是,如果价值 hits
发生变化会发生什么?假设它是一个状态变量,它从 0
更改为 1
。 HitCounter
需要重新渲染,但它不能重新渲染,因为它是一个服务器组件!
问题是,服务器组件孤立地没有意义。我们必须缩小范围,采取更全面的视图,考虑应用程序的结构。
假设我们有以下组件树:
如果所有这些组件都是服务器组件,那么这一切都是有意义的。任何道具都不会改变,因为没有一个组件会重新渲染。
但是,假设 Article
组件拥有 hits
状态变量。为了使用 state,我们需要将其转换为客户端组件:
你在这里看到问题了吗?重新渲染时 Article
,任何拥有的组件也将重新渲染,包括 HitCounter
和 Discussion
。但是,如果这些是服务器组件,则无法重新呈现。
为了防止这种不可能的情况,React 团队添加了一个规则:客户端组件只能导入其他客户端组件。 该 'use client'
指令意味着这些 Discussion
实例 HitCounter
需要成为客户端组件。
我在使用 React Server Components 时最大的“啊哈”时刻之一是意识到这种新范式都是关于创建客户端边界的。以下是在实践中发生的事情:
当我们将 'use client'
指令添加到组件时 Article
,我们创建了一个“客户端边界”。此边界内的所有组件都将隐式转换为客户端组件。即使像 HitCounter
这样的组件没有指令 'use client'
,在这种特定情况下,它们仍然会在客户端上冻结/渲染。
解决方法
当我第一次得知客户端组件无法呈现服务器组件时,对我来说感觉非常严格。如果我需要在应用程序中使用状态 high up 怎么办?这是否意味着一切都需要成为客户端组件?
事实证明,在许多情况下,我们可以通过重组应用程序来绕过此限制,以便所有者发生变化。
这是一件很难解释的事情,所以让我们举个例子:
'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
)也将隐式成为客户端组件。
为了解决这个问题,让我们将颜色管理的东西提取到它自己的组件中:
// /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
,我们像这样使用这个新组件:
// /components/Homepage.js
import Header from './Header';
import MainContent from './MainContent';
import ColorProvider from './ColorProvider';
function Homepage() {return (<ColorProvider><Header /><MainContent /></ColorProvider>);
}
我们可以从 Homepage
中删除指令 'use client'
,因为它不再使用状态或任何其他客户端 React 功能。这意味着 Header
并且 MainContent
不会再隐式转换为客户端组件!
但是等一下。 ColorProvider
,客户机组件,是 和 MainContent
的 Header
父级。无论哪种方式,它仍然在树上更高,对吧?
但是,当涉及到客户边界时,父/子关系并不重要。 Homepage
是导入和渲染 Header
和 MainContent
.这意味着决定 Homepage
这些组件的道具是什么。
请记住,我们试图解决的问题是服务器组件无法重新渲染,因此无法为其任何道具赋予新值。有了这个新的设置,决定了道具的用途 Header
和 MainContent
,并且由于是服务器组件, Homepage
因此 Homepage
没有问题。
这是令人费解的东西。 即使经过多年的 React 经验,我仍然觉得这很令人困惑😅。为此,需要相当多的练习来培养直觉。
更准确地说,该 'use client'
指令在文件/模块级别工作。在客户端组件文件中导入的任何模块也必须是客户端组件。毕竟,当捆绑器捆绑我们的代码时,它将遵循这些导入!
更改颜色主题?
在我上面的示例中,您可能已经注意到无法更改颜色主题。
setColorTheme
从不调用。我想尽可能减少事情,所以我省略了一些东西。一个完整的示例将使用 React 上下文使 setter 函数可供任何后代使用。只要使用上下文的组件是客户端组件,一切正常!
优势
React 服务器组件是在 React 中运行服务器独占代码的第一种“官方”方式。不过,正如我之前提到的,这在更广泛的 React 生态系统中并不是一件新鲜事;自 2016 年以来,我们已经能够在 Next.js 中运行服务器独占代码!
最大的区别在于,我们以前从未有过在组件中运行服务器独占代码的方法。
最明显的好处是性能。服务器组件不包含在我们的 JS 捆绑包中,这减少了需要下载的 JavaScript 数量,以及需要水合的组件数量:
不过,这对我来说可能是最不令人兴奋的事情。老实说,大多数Next.js应用程序在“页面交互”计时方面已经足够快了。
如果你遵循语义 HTML 原则,你的大多数应用程序应该在 React 水合之前就可以工作。可以点击链接,可以提交表单,可以展开和折叠手风琴(使用 <details>
和 <summary>
)。对于大多数项目来说,如果 React 需要几秒钟来补充水分,那也没关系。
但我觉得这很酷: 我们不再需要在功能与捆绑包大小方面做出相同的妥协!
例如,大多数技术博客都需要某种语法突出显示库。在这个博客上,我使用 Prism。代码片段如下所示:
function exampleJavaScriptFunction(param) {return "Hello world!"
}
一个适当的语法高亮库,支持所有流行的编程语言,将是几兆字节,太大了,无法放在JS包中。因此,我们必须做出妥协,删除非关键任务的语言和功能。
但是,假设我们在服务器组件中进行语法突出显示。在这种情况下,我们的JS包中实际上不会包含任何库代码。因此,我们不必做出任何妥协,我们可以使用所有的花里胡哨的东西。
这是 Bright 背后的伟大思想,这是一个现代语法高亮包,旨在与 React Server 组件配合使用。
这就是让我对 React Server Components 感到兴奋的事情。那些成本过高而无法包含在JS捆绑包中的东西现在可以在服务器上免费运行,为我们的捆绑包增加零千字节,并产生更好的用户体验。
这不仅仅是关于性能和用户体验。在使用 RSC 一段时间后,我开始真正体会到服务器组件是多么轻松。我们永远不必担心依赖数组、过时的闭包、记忆或由事物变化引起的任何其他复杂内容。
归根结底,现在还处于非常早期的阶段。React Server Components 几个月前才从测试版中出现!我真的很高兴看到未来几年事情的发展,因为社区继续创新像 Bright 这样的新解决方案,利用这种新范式。成为 React 开发人员是一个激动人心的时刻!