跳到主要内容

React 服务端组件 (RSC)

· 阅读需 14 分钟

简介

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/]。

完整水合过程

  1. 重建虚拟 DOM → 浏览器执行 React/Vue 代码,生成虚拟 DOM 树。
  2. 对比现有 HTML → 检查 SSR 的 HTML 跟虚拟 DOM 一致不一致。(不一致则发出警告)
  3. 绑定事件 → 给按钮、输入框等加上交互能力。

*Hydration 就像用交互性和事件处理程序的“水”来浇灌“干燥的” HTML。

这就是 SSR 的精髓。服务器端生成初始 HTML,这样用户在下载和解析 JS 包时就不必面对空白页面。然后,客户端 React 会接续服务器端 React 的进度,采用 DOM 并添加一些交互功能。

SSG(静态站点生成)

当我们谈论服务器端渲染时,我们通常会想象这样的流程:

  1. 用户访问 test.com
  2. Node.js 服务器接收请求,并立即呈现 React 应用程序,生成 HTML。
  3. 刚刚生成的 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 包中!

这种方法在当时非常超前。但它也有一些缺点:

  1. 此策略仅适用于路由级别,适用于树最顶端的组件。我们无法在任何组件中执行此操作。
  2. 每个元框架都提出了自己的方法。Next.js 有一种方法,ReactRouter 有另一种方法,Remix 又有另一种方法。它们尚未标准化。
  3. 我们的所有 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 架构理念的一次进化。