各位好!我是你们的老朋友,一个在 React 服务器组件(RSC)的坑里摸爬滚打了三年的资深工程师。
今天我们不聊那些花里胡哨的 Hooks,也不谈如何用 TypeScript 把自己逼疯。今天我们要聊的是 RSC 的“内功”——数据获取去重与缓存机制。
你知道那种感觉吗?你的应用里有一个“用户卡片”组件,还有一个“用户详情”组件。为了保险起见,你让这两个组件各自去调接口拿用户数据。结果呢?用户访问页面,浏览器发出了两次一模一样的请求,服务器也傻乎乎地查询了两次数据库,然后你还得在客户端把这两份数据合并起来。这就好比你点了一杯奶茶,服务员说“好的”,然后转身去做了两杯,端上来告诉你:“这是你的,这是你的,合起来就是你要的。”
这简直是灾难。今天,我们就来聊聊如何用 React 的智慧,避免这种“奶茶双倍”的尴尬。
第一部分:RSC 的“引用”哲学
在传统的 React 客户端渲染(CSR)中,数据获取通常是混乱的。useEffect 调用 fetch,然后更新状态。每个组件可能都有自己的 useEffect,导致重复请求。
但在 React Server Components(RSC)的世界里,事情变得优雅多了。这得益于一个叫做 use 的 Hook。
核心概念:Promise 的唯一性
在服务端,当你调用 use(getUser()) 时,React 实际上做了一件事:它把 getUser() 返回的那个 Promise 对象,存到了一个内部缓存里。
这意味着什么?这意味着同一个组件树中,多次调用 use(getUser()) 得到的实际上是同一个 Promise 对象的引用。
让我们看个例子:
// components/UserCard.tsx
import { use } from 'react';
async function getUser() {
console.log('Fetching user...'); // 只有第一次会打印
const res = await fetch('https://api.example.com/user/1');
return res.json();
}
export default function UserCard() {
// 第一次调用 use
const user = use(getUser());
return <div>Card: {user.name}</div>;
}
// components/UserDetails.tsx
import { use } from 'react';
async function getUser() {
console.log('Fetching user...'); // 不会打印!
const res = await fetch('https://api.example.com/user/1');
return res.json();
}
export default function UserDetails() {
// 第二次调用 use,拿到的依然是同一个 Promise
const user = use(getUser());
return <div>Details: {user.email}</div>;
}
神奇之处来了:
- 服务端渲染: 当
UserCard开始渲染时,它调用了getUser()。React 说:“好,我去请求这个数据。” - 数据传递:
UserCard渲染完毕,它把user这个对象(或者正在解析的 Promise)序列化,扔给了客户端。 - 客户端水合: 客户端组件
UserDetails挂载了。它也调用了use(getUser())。 - 去重判定: React 发现,客户端已经有了一个
getUser()的 Promise(来自父组件传递下来的数据)。于是,它拒绝再次发起网络请求。它直接复用了父组件传下来的那个 Promise。
这就是去重。它不是通过复杂的请求拦截器实现的,而是通过对象引用实现的。只要 Promise 对象没变,React 就认为“我已经有了,别再折腾了”。
第二部分:fetch 的自动魔法
刚才的例子中,我们假设 getUser() 内部有网络请求。在 RSC 中,我们通常会直接使用 fetch。
React 和 Next.js(或者其他的 RSC 运行时)联手做了一件很聪明的事。如果你在服务端组件中直接使用 fetch,React 会自动拦截这个请求,并对其进行缓存。
默认行为:
async function getUser(id: string) {
// React 会自动给这个请求打上缓存标记
const res = await fetch(`https://api.example.com/user/${id}`, {
// 注意:默认是 { cache: 'no-store' },但如果是同一个 URL,React 会做特殊处理
// 在 Next.js 中,如果你不设置 cache 选项,它会使用默认的缓存策略
});
return res.json();
}
但是! 这里有个坑,很多新手会踩。React 的 use Hook 虽然能保证同一个组件树内 Promise 不重复,但它不能保证跨组件树的去重。
如果 UserCard 和 UserProfile 不在同一个父组件下呢?
// layout.tsx
export default async function Layout({ children }) {
return (
<html>
<body>
<nav>...</nav>
{children}
</body>
</html>
);
}
// page.tsx
import UserCard from './components/UserCard';
import UserProfile from './components/UserProfile';
export default async function Page() {
return (
<div>
<h1>Dashboard</h1>
<UserCard />
<UserProfile />
</div>
);
}
在这种情况下,UserCard 和 UserProfile 是兄弟组件。它们各自调用 use(getUser())。由于它们不在同一个上下文中,React 无法识别它们引用的是同一个 Promise。
结果: 服务器可能会发出两个请求。
解决方案:显式缓存
为了解决这个问题,我们需要告诉 React:“嘿,这两个组件实际上想要的是同一个数据,请帮我去重。” 我们通过 fetch 的缓存选项来实现。
async function getUser(id: string) {
const res = await fetch(`https://api.example.com/user/${id}`, {
// 关键点:使用 'force-cache' 或者 Next.js 的默认缓存策略
// 这样 React 就知道这个 URL 的数据是可以被共享的
cache: 'force-cache',
});
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
}
但是等等! 如果所有请求都缓存了,用户修改了数据怎么办?数据不就永远不过时了吗?
这就是 React 的另一个机制:缓存失效。通常我们会结合 revalidate 选项。但在 RSC 中,我们通常不手动设置 revalidate,而是依赖服务器端的 HTTP 缓存头(如 ETag, Last-Modified),或者通过 Server Actions 来触发数据更新。
第三部分:客户端 use 与服务端 use 的爱恨情仇
RSC 最迷人的地方在于,服务端渲染的数据,可以无缝传递给客户端组件,并且依然享受去重的好处。
想象一下这个场景:
- 服务端:
Page组件渲染了UserCard和UserDetails。服务端发起了请求,拿到了数据。 - 序列化: 服务端把数据序列化成 JSON,打包进 HTML 发给浏览器。
- 客户端: 浏览器收到 HTML。
UserCard和UserDetails现在是客户端组件了。它们调用use(getUser())。
关键点:
服务端已经解析好的 Promise(或者数据),会被 React 序列化到 Payload 中。
客户端组件拿到 Payload 后,会“水合”这个组件树。
如果服务端已经解析了数据(即 Promise 变成了普通对象),客户端组件调用 use(getUser()) 时,React 会直接把这个解析好的对象传给它。
这意味着什么?
这意味着客户端组件之间也是可以自动去重的!只要它们在同一个组件树层级上,React 就会复用同一个对象引用。
// components/ClientSideCard.tsx (客户端组件)
'use client';
import { use } from 'react';
async function getUser() {
// 在服务端解析后,这里直接拿到结果,不会发起请求
const res = await fetch('https://api.example.com/user/1', {
cache: 'force-cache',
});
return res.json();
}
export default function ClientSideCard() {
const user = use(getUser());
// 假设这是从服务端传下来的数据
return <div>Client: {user.name}</div>;
}
// components/ClientSideDetails.tsx (客户端组件)
'use client';
import { use } from 'react';
async function getUser() {
// 同样的函数,同样的 URL
// React 会检测到这是同一个 Promise 引用(如果是在同一个上下文中)
// 或者直接复用服务端解析好的数据
const res = await fetch('https://api.example.com/user/1', {
cache: 'force-cache',
});
return res.json();
}
export default function ClientSideDetails() {
const user = use(getUser());
return <div>Details: {user.email}</div>;
}
结论: 无论是在服务端还是客户端,共享同一个 Promise 引用就是去重的王道。
第四部分:React.cache – 手动控制的缓存
虽然 fetch 很好用,但它有时候太“傻”了。比如,你有一个非常复杂的逻辑函数,它需要查询数据库、调用第三方 API、进行复杂的计算,最后返回数据。
你不想每次都重新执行这个逻辑,但又不想把数据存在内存里(因为可能被清理)。这时候,React.cache 就是你的救星。
React.cache 允许你手动创建一个函数的缓存版本。它会记住这个函数的输入参数和返回值,下次调用时,如果参数一样,直接返回缓存的结果。
代码示例:
import { cache } from 'react';
// 假设这是一个复杂的业务逻辑
async function getComplexUserData(id: string) {
// 模拟耗时操作
await new Promise(resolve => setTimeout(resolve, 1000));
console.log(`[Database] Querying user ${id}...`);
// 模拟数据库查询
const user = { id, name: 'Alex', role: 'Admin' };
return user;
}
// 创建缓存版本的函数
const getCachedUser = cache(getComplexUserData);
export default async function UserProfile() {
// 第一次调用
const user1 = await getCachedUser('123');
// 第二次调用(参数相同)
const user2 = await getCachedUser('123');
// user1 和 user2 是同一个对象引用吗?
// 在 RSC 环境下,是的!因为 React.cache 拦截了调用。
console.log(user1 === user2); // true
return <div>User: {user1.name}</div>;
}
React.cache 的注意事项:
- 副作用:
cache函数是纯函数式的。如果你在里面写console.log或者修改全局变量,可能会有问题,因为 React 可能会复用缓存的函数执行结果(虽然通常只复用返回值,但行为需要小心)。 - 内存管理: 缓存是存储在内存中的。如果应用运行时间长,缓存会占用内存。对于静态数据,这没问题;对于实时性要求极高的数据,慎用。
第五部分:Next.js App Router 的特殊缓存策略
既然我们要聊 React RSC,就绕不开 Next.js App Router。Next.js 在 fetch 缓存上做了一些非常人性化的封装。
1. 默认缓存策略:
在 Next.js App Router 中,fetch 默认是有缓存的。
async function getData() {
const res = await fetch('https://api.example.com/data');
// Next.js 默认会缓存这个请求
return res.json();
}
这意味着,如果你在多个页面调用同一个 getData,Next.js 只会发起一次请求。
2. 禁用缓存:
如果你需要每次都获取最新数据(比如实时股票价格),你需要显式禁用缓存:
async function getLiveStock() {
const res = await fetch('https://api.example.com/stock', {
cache: 'no-store', // 禁用缓存
});
return res.json();
}
3. 重新验证:
这是 Next.js 最强大的功能之一。你可以设置一个时间,让数据在多少秒后自动失效。
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }, // 60秒后重新获取数据
});
return res.json();
}
4. revalidatePath 和 revalidateTag:
当你需要手动触发缓存失效时,不要刷新页面,也不要重新请求。
'use client';
import { revalidatePath } from 'next/cache';
export function RefreshButton() {
async function handleRefresh() {
// 告诉 Next.js:去刷新 /dashboard 路径下的缓存
revalidatePath('/dashboard');
}
return <button onClick={handleRefresh}>Refresh Data</button>;
}
第六部分:Server Actions 与数据获取的统一
在 RSC 生态中,Server Actions 是数据获取的另一种形式。它们本质上也是服务端函数。
Server Actions 的缓存机制:
默认情况下,Server Actions 是不缓存的。每次调用都会执行服务端代码。
但是,我们可以通过 createServerAction 和 createServerActionWithCache 来改变这一点。
import { createServerAction } from 'next/server';
// 这是一个简单的 Server Action
async function incrementLike(id: string) {
// 数据库操作
await db.like.update({ where: { id }, data: { count: { increment: 1 } } });
return { success: true };
}
// 使用 createServerActionWithCache 包装
// 这会缓存这个 action 的返回值
const cachedIncrementLike = createServerActionWithCache(incrementLike);
export function LikeButton({ id }: { id: string }) {
// 这里调用的是缓存版本
const [result, formAction] = cachedIncrementLike({ id });
return (
<form action={formAction}>
<button type="submit">Like ({result?.data?.count})</button>
</form>
);
}
Server Actions 的去重:
如果你在同一个组件树中多次调用同一个 Server Action,React 会自动去重。这和 use Hook 的逻辑是一样的。
export default async function Page() {
// 多次调用同一个 action
const res1 = await incrementLike('1');
const res2 = await incrementLike('1');
// res1 和 res2 是同一个返回值吗?
// 是的!因为 React.cache 拦截了函数调用。
console.log(res1 === res2); // true
}
第七部分:实战演练 – 构建一个“去重”的仪表盘
让我们把这些理论组合起来,构建一个真实的场景。假设我们有一个博客系统,我们需要显示文章列表、文章详情、评论列表,以及当前用户的个人信息。
糟糕的代码(重复请求):
// components/PostList.tsx
export async function PostList() {
// 请求 1: 获取文章列表
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return <div>{posts.map(p => <PostItem key={p.id} post={p} />)}</div>;
}
// components/PostItem.tsx
export async function PostItem({ post }: { post: Post }) {
// 请求 2: 获取文章详情
const details = await fetch(`https://api.example.com/posts/${post.id}`).then(r => r.json());
return <div>{details.title}</div>;
}
// components/Comments.tsx
export async function Comments({ postId }: { postId: string }) {
// 请求 3: 获取评论(假设这里需要 postId,或者从列表里传下来)
// 但如果 PostItem 没传,这里就得重新请求文章详情或者用其他方式获取 ID
// 假设我们通过 URL 参数获取 ID
const id = useParams().id;
const comments = await fetch(`https://api.example.com/posts/${id}/comments`).then(r => r.json());
return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}
问题: PostList 获取了文章列表,PostItem 又重新获取了文章详情。如果列表有 10 篇文章,我们就发了 11 个请求(1 个列表 + 10 个详情)。这简直是浪费带宽。
优化后的代码(数据共享):
// lib/data.ts
// 1. 定义获取文章详情的函数,并开启缓存
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
cache: 'force-cache', // 关键:开启缓存
});
return res.json();
}
// 2. 定义获取评论的函数,也开启缓存
async function getComments(postId: string) {
const res = await fetch(`https://api.example.com/posts/${postId}/comments`, {
cache: 'force-cache',
});
return res.json();
}
// components/PostList.tsx
export async function PostList() {
// 只请求一次列表
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return <div>{posts.map(p => <PostItem key={p.id} post={p} />)}</div>;
}
// components/PostItem.tsx
export async function PostItem({ post }: { post: Post }) {
// 请求 1: 获取文章详情
const postDetails = await getPost(post.id);
return (
<div>
<h3>{postDetails.title}</h3>
<Comments postId={post.id} />
</div>
);
}
// components/Comments.tsx
export async function Comments({ postId }: { postId: string }) {
// 请求 2: 获取评论
// 注意:这里我们复用了 postDetails 里的 postId,但不需要再请求文章详情了
const comments = await getComments(postId);
return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}
优化分析:
PostList只请求了 1 次。PostItem请求了 1 次文章详情。Comments请求了 1 次评论。- 总共 3 次请求。完美。
第八部分:缓存失效的“艺术”
缓存是为了性能,但缓存也是为了数据一致性。如果缓存了旧数据,用户体验会很差。
在 RSC 中,主要有两种失效策略:
1. 时间失效
这是最简单的。设置 next: { revalidate: 3600 }。React 会自动在 1 小时后标记数据为过期,下次访问时重新请求。
2. 手动失效
这是最精确的。当你更新了数据,你需要告诉 React:“嘿,那个 URL 的缓存没用了,给我重新拉取。”
在 Next.js 中,这通常通过 Server Actions 实现。
'use client';
import { revalidateTag } from 'next/cache';
// 定义一个 Server Action 来更新数据
async function updatePost(id: string, newContent: string) {
await db.post.update({ where: { id }, data: { content: newContent } });
// 告诉 Next.js:刷新带有 'post-list' 标签的缓存
revalidateTag('post-list');
// 告诉 Next.js:刷新特定路径的缓存
revalidatePath(`/post/${id}`);
}
export function EditButton({ id }: { id: string }) {
async function handleSubmit(formData: FormData) {
await updatePost(id, formData.get('content') as string);
}
return (
<form action={handleSubmit}>
<textarea name="content"></textarea>
<button type="submit">Save</button>
</form>
);
}
原理:
当你调用 revalidatePath 时,Next.js 会在后台重新运行那个路径对应的组件函数。由于 fetch 请求的数据是新的(或者因为 revalidate 触发了重新请求),页面会自动更新。
第九部分:深入 fetch 头部与网络层
你可能很好奇,React/RSC 到底是怎么知道我要缓存这个请求的?
这全靠 HTTP 头部。
当你使用 cache: 'force-cache' 时,React(或 Next.js)会在 fetch 请求中添加一些特殊的头部。最常见的可能是 x-nextjs-cache 或者类似的自定义头部。
服务器端(比如 Vercel Edge Network 或者你的 Node.js 服务器)会读取这些头部。
- 命中缓存: 如果服务器收到请求,发现这是缓存请求,并且有缓存数据,它会直接返回缓存的数据,而不会执行你的业务逻辑函数,也不会去数据库查询。这极大地减轻了服务器负载。
- 未命中缓存: 服务器执行你的函数,获取数据,保存到缓存中,然后返回。
手动控制头部:
你甚至可以手动操作这些头部:
async function getUser() {
const res = await fetch('https://api.example.com/user', {
headers: {
'x-react-cache': 'force-cache', // 直接告诉服务器
},
});
return res.json();
}
这给了你极大的控制权。你可以混合使用 next: { revalidate: 60 } 和自定义头部。
第十部分:React.use vs React.cache 的终极对决
最后,我们来总结一下这两个核心 API 的区别,这通常是面试的高频题,也是实际开发中的困惑点。
1. use Hook (用于数据传递)
- 用途: 在组件树中传递数据(从服务端到客户端,或从父组件到子组件)。
- 去重机制: 基于对象引用。如果同一个组件树中多次调用
use(func),React 会保证返回同一个 Promise 对象。 - 适用场景: 你想在多个组件中获取同一个数据,但不想重复请求。
- 关键点:
use本身不缓存网络请求(除非配合fetch的cache选项)。它只是管理数据流的引用。
2. cache (用于函数结果缓存)
- 用途: 缓存纯函数的执行结果。
- 去重机制: 基于输入参数。如果输入参数相同,返回相同的对象引用。
- 适用场景: 你有一个复杂的计算逻辑或数据库查询逻辑,你想避免重复计算。
- 关键点:
cache是手动控制,你需要显式调用cache(func)。
类比:
use就像是服务员。你点菜(调用use),服务员去厨房(发起请求)。如果你再点一道一样的菜,服务员会看一眼,发现厨房已经做好了,直接端给你,不用再去厨房了。cache就像是记忆宫殿。你有一个数学公式。如果你算一遍1+1,然后算第二遍,你不需要重新推导公式,直接回忆结果就行。
结语
好了,各位。React Server Components 的数据获取机制,归根结底,就是两个字:引用。
无论是 use 还是 cache,或者是 fetch 的缓存策略,其核心思想都是:如果数据已经存在,就不要重复获取;如果结果已经存在,就不要重复计算。
当你理解了这一点,你写出来的代码就不会是那种“到处都是 useEffect 和 fetch”的屎山,而是一个优雅、高效、像瑞士钟表一样精准的数据流系统。
记住,不要为了缓存而缓存,要为了性能而缓存。如果你的数据每秒都在变,那就别缓存。如果你发现你的服务器因为重复请求而喘不过气,那就赶紧用上 use 和 fetch 的缓存策略。
去优化你的代码吧,让服务器少流点汗,让你的用户跑得更快!