React 服务端组件 (RSC)
简介
RSC(React Server Components)是 React 官方提出的一种组件模式,它允许一部分组件在服务器上运行,而不是在浏览器里运行。
它的目标是更快的加载、更小的 JS 体积,同时保留 React 的组合式开发体验。
回顾 CSR(客户端渲染)
<!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 包增加更多构建 size,从而延长用户等待的时间。
回顾 SSR (服务端渲染)
服务器端渲染旨在提升用户体验。其核心就是,所有的渲染相关的js(期望上,某些第三方包并未支持需强制使用客户端渲染)都是在服务端先渲染好静态html模板(通过React/Vue 对应的各自 server 端的工具库),然后再统一发送到浏览器客户端,用户收到的是一个完整的 HTML 文档,而不需要等待js加载的白屏过程。
<!DOCTYPE html>
<html>
<body>
<div id="root">
<button>Click me</button>
</div>
<script src="/static/js/bundle.js"></script>
</body>
</html>
但同时由于服务端返回的是纯html字符串,服务器无法处理绑定 onClick
等交互事件,因此此时整个页面是无法交互的。
所以 HTML 文件依然包含 <script>
标签,因为我们仍需要在客户端运行 React 来处理这些交互。但它不再从头生成所有 DOM 节点,而是采用现有的 HTML。这个过程称为**“水合”** (hydration)[/hai'dreiʃən/]。
完整水合过程
- 重建虚拟 DOM → 浏览器执行 React/Vue 代码,生成虚拟 DOM 树。
- 对比现有 HTML → 检查 SSR 的 HTML 跟虚拟 DOM 一致不一致。(不一致则发出警告)
- 绑定事件 → 给按钮、输入框等加上交互能力。
*Hydration 就像用交互性和事件处理程序的“水”来浇灌“干燥的” HTML。
这就是 SSR 的精髓。服务器端生成初始 HTML,这样用户在下载和解析 JS 包时就不必面对空白页面。然后,客户端 React 会接续服务器端 React 的进度,采用 DOM 并添加一些交互功能。
SSG(静态站点生成)
当我们谈论服务器端渲染时,我们通常会想象这样的流程:
- 用户访问 test.com。
- Node.js 服务器接收请求,并立即呈现 React 应用程序,生成 HTML。
- 刚刚生成的 HTML 被发送到客户端。
这是实现服务器端渲染的一种可能方法,但不是唯一的方法。另一种选择是在构建应用程序时生成 HTML。
通常,React 应用程序需要编译,将 JSX 转换为纯 JavaScript,并打包所有模块。如果在同一过程中,我们为所有不同的路由“预渲染”所有 HTML,会怎么样?
这通常被称为 静态站点生成(SSG) 。它是服务器端渲染的另一种方式。
与 SSR 不同的是,SSG 在构建阶段就生成静态 HTML,用户访问时直接拿到现成文件,不需要服务器每次渲染,如果有数据请求,则在构建阶段就提前获取并渲染好。
因此也无法实现动态数据了,如需变更数据,需要重新构建。适合数据变更不太频繁的站点,例如博客,文档,官网,营销页面等。
CSR/SSR 反复横跳
csr
客户端优化方式,数据获取之前,添加加载 Loading,直到请求完成并且 React 重新渲染,用真实内容替换加载 UI。
ssr
在这个新流程中,我们在服务器上执行第一次渲染。用户收到的 HTML 文件并非完全为空。
这算是一种改进——有壳总比空白页好——但最终,它并没有带来什么显著的改变。用户访问我们的应用不是为了看加载页面,而是为了查看内容(商品列表、搜索结果、消息等等)。
通过在服务器上进行初始渲染,我们可以更快地绘制初始“外壳”。这可以让加载体验感觉更快一些,因为它提供了一种进度感,让玩家感觉事情正在发生。
在某些情况下,这将是一个有意义的改进。例如,用户可能只是在等待标题加载,以便他们可以点击导航链接。
但是这个流程是不是感觉有点繁琐? SSR 图表里,仔细发现会注意到请求是从服务器开始的。与其要求第二次往返网络请求,为什么不在初始请求期间就完成数据库查询操作呢?
换句话说,为什么不能这样呢?
不在客户端和服务器之间来回跳转,而是将数据库查询作为初始请求的一部分,将完全填充的 UI 直接发送给用户。
但是,我们究竟该怎么做呢?
生态系统已经针对这个问题提出了很多解决方案。 元框架?像 Next.js 和 Gatsby 已经创建了自己的方式,可以在服务器上专门运行代码。
例如,使用 Next.js(使用旧版 "Page" 路由)时如下所示:
import db from 'mysql';
// 这段代码只运行在服务端
export async function getServerSideProps() {
const link = db.connect('localhost', 'root', 'passw0rd');
const data = await db.query(link, 'SELECT * FROM products');
return {
props: { data },
};
}
// 这段代码在服务端和客户端都会运行
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 有一种方法,ReactRouter 有另一种方法,Remix 又有另一种方法。它们尚未标准化。
- 我们的所有 React 组件都将始终*在客户端上进行水合,就算它们不需要这样做(无需交互页面)。
多年来,React 团队一直在默默地研究这个问题,试图找到一个官方的解决方案。他们的解决方案叫做 React Server Components。
React 服务器组件简介
从高层次上讲,React 服务器组件代表着一种全新的规范。在这个环境下,我们可以创建专门在服务器上运行的组件。这使我们能够在 React 组件内部执行诸如编写数据库查询之类的操作!
以下是“服务器组件”的简单示例:
import db from 'mysql';
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;
这段代码一开始对一个前端开发者来说看起来应该很疯狂
**需要理解的关键点是:**服务器组件永远不会重新渲染。它们在服务器上运行一次以生成 UI。渲染后的值会发送到客户端并锁定在原地。就 React 而言,此输出是不可变的,永远不会改变。
这意味着React 的很大一部分 API 与服务器组件不兼容。例如,我们不能使用 state
,因为 state
可以改变,但服务器组件无法重新渲染。我们也不能使用 effect
,因为 effect
只能在渲染之后在客户端运行,而服务器组件永远不会运行在 React 客户端环境中。
这也意味着我们在规则方面拥有更大的灵活性。例如,在传统的 React 中,我们需要将副作用放在 useEffect
回调或事件函数之类的东西中,这样它们就不会在每次渲染时重复出现。但如果组件只运行一次,我们就不用考虑这些!
服务器组件本身出奇的简单,但 “React 服务器组件” 规范却要复杂得多。这是因为我们也需要使用常规的组件,而它们的组合方式可能相当混乱。
在这个新规范中,我们熟悉的“传统” React 组件被称为 客户端组件。
“客户端组件”这个名称暗示这些组件仅在客户端上渲染,但事实并非如此。客户端组件在客户端和服务器上都会渲染。
使用 react-server-component 需要特殊的编译器,例如 react-server-dom-webpack
,或者直接使用 Nextjs/Remix
这类已经集成好的框架。下面以 NextJS
为例
指定客户端组件
'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 服务器组件规范的优势之一是它有潜力提升页面交互性(TTI) 指标。
当你开始使用 React 服务器组件时,你可能会发现这非常直观。我们的一些组件需要在客户端运行,因为它们使用状态变量或效果。你可以在这些组件上添加 'use client'
指令。否则,你可以将它们作为服务器组件。
客户端组件不能嵌套服务器组件
如图所示,Article
重新渲染时,所有拥有的组件也会重新渲染。但如果这些是服务器组件,则它们无法重新渲染。
为了避免这种不可能的情况,React 团队添加了一条规则:客户端组件只能导入其他客户端组件 。'use client'
下面这些组件都默认设定成为客户端组件。
这意味着我们不用在每个需要在客户端运行的文件中都添加 'use client'
。只需要在创建新的顶部客户端组件时添加。
也因此,你的每个路由页面默认应该是服务器组件,对于需要交互的子组件,就添加 'use client'
将其设定为客户端组件,对于顶部服务端组件,我们可以在里面进行数据获取,或者渲染一些静态列表
TODO LIST //
优势
React 服务器组件是第一个在 React 中运行服务器专用代码的“官方”方法。不过,之前也提到的,这在更广泛的 React 生态系统中并不算是什么新鲜事;自 2016 年以来,就能够在 Next.js 中运行服务器专用代码
最大的区别是,以前从来没有办法在组件内部运行服务器专用代码。
最明显的好处是性能。服务器组件不会包含在我们的 JS 包中,这减少了需要下载的 JavaScript 数量,以及需要加载的组件数量
例如,大多数博客都需要某种语法高亮库来展示代码块。
一个支持所有流行编程语言的语法高亮库,其体积将达到几兆大小,这实在太大,根本无法塞进 JS 包里。因此,我们不得不做出妥协,删减那些非关键的语言和功能。
但是,假设我们在服务器组件中实现语法高亮。在这种情况下,库代码实际上不会包含在浏览器需要下载的 JS 包中。这样一来,我们就不需要任何妥协,可以使用所有的功能。
以前那些因为包体积过高的功能,现在可以直接在服务器上运行,不增加任何 JS 空间,还能带来更佳的用户体验。
Server Action(服务器操作)
另一项值得注意的新特性是 Server Action。这些是用来处理用户交互触发的服务端逻辑,如表单提交或数据变更的异步函数。
使用方式非常简单:你只需要在函数体内(或者文件顶部)添加一个特殊指令 “use server”,React 就会将其识别为服务端操作,并自动处理 RPC(远程过程调用)无需手写 API 接口即可调用该服务器端函数
action.ts
'use server'
async function createTodo(title: string) {
const title = formData.get('title')?.toString() ?? '';
// 验证
if (!title) throw new Error('title 不能为空');
await db.todo.create({ data: { title } });
}
create-todo.tsx
'use client';
import { useState } from "react";
import { addTodo } from "../action";
export default function CreateTodo() {
const [title, setTitle] = useState("");
const handleAddTodo = async () => {
await addTodo(title);
setTitle("");
};
return (
<div className="flex items-center justify-between">
<input
className="border-2 border-gray-300 rounded-md p-2"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button
className="bg-blue-500 text-white rounded-md p-2"
onClick={handleAddTodo}
>
Add Todo
</button>
</div>
);
}
数据更新
revalidatePath()
强制让指定页面路径的缓存失效并立即渲染最新数据
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/prisma';
export async function createTodo(todo: Todo) {
await db.post.create({ data });
// 让 `/` 页面缓存失效
revalidatePath('/');
}
revalidateTag()
指定某个函数缓存失效
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/prisma';
export const getTodos = unstable_cache(
async () => db.todo.findMany(),
['todo'], // 缓存 Key
{ tags: ['todo_list'] } // 缓存标签
);
export async function addTodo(todo: Todo) {
await db.todo.create({ data: todo });
revalidateTag('todo_list'); // 所有用到这个 tag 的缓存失效
}
结尾
React 服务器组件(RSC)为 React 带来了一个新的思路——让部分组件只在服务器运行,从而减少客户端的 JS 体积、提升加载速度、优化交互体验。
与传统的 CSR、SSR、SSG 相比,RSC 最大的亮点是可以在组件内部直接编写服务器端逻辑,并与客户端组件无缝组合,这在过去的 React 生态里是无法做到的。
RSC 并不是替代 SSR 或 CSR 的“万能钥匙”,而是给了开发者更多自由度去按需选择组件运行位置。合理的组件拆分与运行策略,才能最大化发挥它的性能优势与开发体验价值。
未来,随着框架(如 Next.js、Remix)的完善与标准化,RSC 很可能会成为构建高性能 React 应用的默认选项。可以说,它不仅是性能优化的工具,更是 React 架构理念的一次进化。