React 服务端组件数据获取去重 cache 机制

各位好!我是你们的老朋友,一个在 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>;
}

神奇之处来了:

  1. 服务端渲染:UserCard 开始渲染时,它调用了 getUser()。React 说:“好,我去请求这个数据。”
  2. 数据传递: UserCard 渲染完毕,它把 user 这个对象(或者正在解析的 Promise)序列化,扔给了客户端。
  3. 客户端水合: 客户端组件 UserDetails 挂载了。它也调用了 use(getUser())
  4. 去重判定: 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 不重复,但它不能保证跨组件树的去重。

如果 UserCardUserProfile 不在同一个父组件下呢?

// 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>
  );
}

在这种情况下,UserCardUserProfile 是兄弟组件。它们各自调用 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 最迷人的地方在于,服务端渲染的数据,可以无缝传递给客户端组件,并且依然享受去重的好处。

想象一下这个场景:

  1. 服务端: Page 组件渲染了 UserCardUserDetails。服务端发起了请求,拿到了数据。
  2. 序列化: 服务端把数据序列化成 JSON,打包进 HTML 发给浏览器。
  3. 客户端: 浏览器收到 HTML。UserCardUserDetails 现在是客户端组件了。它们调用 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 的注意事项:

  1. 副作用: cache 函数是纯函数式的。如果你在里面写 console.log 或者修改全局变量,可能会有问题,因为 React 可能会复用缓存的函数执行结果(虽然通常只复用返回值,但行为需要小心)。
  2. 内存管理: 缓存是存储在内存中的。如果应用运行时间长,缓存会占用内存。对于静态数据,这没问题;对于实时性要求极高的数据,慎用。

第五部分: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. revalidatePathrevalidateTag

当你需要手动触发缓存失效时,不要刷新页面,也不要重新请求。

'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 是不缓存的。每次调用都会执行服务端代码。

但是,我们可以通过 createServerActioncreateServerActionWithCache 来改变这一点。

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>;
}

优化分析:

  1. PostList 只请求了 1 次。
  2. PostItem 请求了 1 次文章详情。
  3. Comments 请求了 1 次评论。
  4. 总共 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 本身不缓存网络请求(除非配合 fetchcache 选项)。它只是管理数据流的引用。

2. cache (用于函数结果缓存)

  • 用途: 缓存纯函数的执行结果。
  • 去重机制: 基于输入参数。如果输入参数相同,返回相同的对象引用。
  • 适用场景: 你有一个复杂的计算逻辑或数据库查询逻辑,你想避免重复计算。
  • 关键点: cache 是手动控制,你需要显式调用 cache(func)

类比:

  • use 就像是服务员。你点菜(调用 use),服务员去厨房(发起请求)。如果你再点一道一样的菜,服务员会看一眼,发现厨房已经做好了,直接端给你,不用再去厨房了。
  • cache 就像是记忆宫殿。你有一个数学公式。如果你算一遍 1+1,然后算第二遍,你不需要重新推导公式,直接回忆结果就行。

结语

好了,各位。React Server Components 的数据获取机制,归根结底,就是两个字:引用

无论是 use 还是 cache,或者是 fetch 的缓存策略,其核心思想都是:如果数据已经存在,就不要重复获取;如果结果已经存在,就不要重复计算。

当你理解了这一点,你写出来的代码就不会是那种“到处都是 useEffectfetch”的屎山,而是一个优雅、高效、像瑞士钟表一样精准的数据流系统。

记住,不要为了缓存而缓存,要为了性能而缓存。如果你的数据每秒都在变,那就别缓存。如果你发现你的服务器因为重复请求而喘不过气,那就赶紧用上 usefetch 的缓存策略。

去优化你的代码吧,让服务器少流点汗,让你的用户跑得更快!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注