陌上人如玉
公子世无双

9.9 Next.js 缓存体系深入

9.9 Next.js 缓存体系深入

一句话:Next.js 的缓存难点不在「有哪些缓存」,而在「四层缓存各自的『生命周期』与『失效方式』不同」——所以「为什么我的数据不更新」与「为什么每次都重新请求」这两个问题,答案都在「你踩到了哪一层」。

一、四层缓存:一张图 + 一张表

⭐⭐⭐ Next.js 的四层缓存(⭐ 记住「范围」与「生命周期」是解题关键):

   浏览器/Router Cache ──▶ 服务端
                            ├─ Full Route Cache(静态路由的 HTML + RSC payload)
                            ├─ Data Cache(fetch 的结果,跨请求持久)
                            └─ Request Memoization(同一次渲染内的 fetch 去重)

   | 层 | 在哪 | 缓存什么 | 生命周期 | 失效方式 |
   | --- | --- | --- | --- | --- |
   | **Request Memoization** | 服务端(React) | ⭐ **同一次服务端渲染中「相同 fetch」的结果** | ⭐ **一次渲染请求**(渲染结束就没了) | 自动(不需要手动) |
   | **Data Cache** | 服务端(持久) | ⭐ `fetch` 的结果(**跨请求**) | 持久(可设 `revalidate`) | ⭐ `revalidateTag` / `revalidatePath` / 时间到期 |
   | **Full Route Cache** | 服务端(构建或运行时) | ⭐ **静态路由的「HTML + RSC payload」** | 持久(直到重新验证/部署) | ⭐ `revalidatePath` / 重新部署 |
   | **Router Cache** | ⭐ **客户端(浏览器内存)** | ⭐ 「访问过的路由」的 RSC payload | ⭐ 一个会话(时间可配) | ⭐ `router.refresh()` / Server Action 里的 revalidate / 时间 |

   ⭐⭐ 「为什么四层?各自解决什么」:
     · **Request Memoization** → 「同一个数据在一次渲染里被多个组件用到」时**不重复请求**
       (⭐ 这是「React 的 `cache()` 语义」,也是「为什么『在多个组件里 fetch 同一个 URL』只发一次请求」)
     · **Data Cache** → 「跨请求复用」(⭐ 类似「服务端的 HTTP 缓存」,但可用 `tag` 精确失效)
     · **Full Route Cache** → 「整个页面静态化」(⭐ 访问静态页时「不需要重新渲染」)
     · **Router Cache** → 「客户端导航时不用重新拉 RSC payload」(⭐ 让「前进后退」很快)
⭐⭐⭐ 两张「必背」的失效 API 表:

   【服务端(Server Action / Route Handler 里调用)】
     · ⭐ `revalidatePath('/blog/[slug]', 'page')` —— 失效「某条路径」(含 Full Route Cache + Data Cache)
     · ⭐ `revalidateTag('posts')` —— 失效「打了这个 tag 的所有数据」(⭐ **最精确、最推荐**)
     · `revalidatePath('/blog', 'layout')` —— 失效「整个 layout 下的所有页面」
     · ⭐ **两者在 Server Action 里调用时,还会「清客户端 Router Cache」**(⭐ 这是「为什么 Server Action 里的 revalidate 能让 UI 立刻更新」)

   【客户端(组件里调用)】
     · ⭐ `router.refresh()` —— 「清 Router Cache + 重新拉当前路由的 RSC payload」(⭐ 但「不影响服务端 Data Cache」)
     · `router.prefetch(href)` —— 预取(⭐ 见 6.13)
     · ⚠️ 「客户端没有 API 去清服务端的 Data Cache」(那必须在服务端做)

二、fetch 的缓存语义:Next 14 与 15 的重大变化

⭐⭐⭐⭐ 这是最容易踩坑的地方 —— **Next 15 改了默认值**:

【Next 14(及之前)】
   · ⭐ `fetch(url)` **默认 `force-cache`(缓存!)**
   · 于是「不显式声明 no-store」的数据「会被永久缓存」
   · ⚠️ 结果:**「为什么我的数据永远不更新」**(经典问题)

【Next 15】
   · ⭐ `fetch(url)` **默认 `no-store`(不缓存)**
   · 于是「不显式声明缓存」的数据「每次请求都重新拉」
   · ⚠️ 结果:**「为什么我的页面每次都重新请求」**(另一个经典问题)

⭐ 一张表看清四种写法:
   | 写法 | 含义 | 适合 |
   | --- | --- | --- |
   | `fetch(url, { cache: 'force-cache' })` | ⭐ 「永久缓存」(直到被 revalidate) | 「几乎不变」的数据(配置、类目) |
   | `fetch(url, { cache: 'no-store' })` | ⭐ 「每次实时拉」(Next 15 默认) | 「用户相关」「实时性要求高」 |
   | `fetch(url, { next: { revalidate: 60 } })` | ⭐ **ISR:60 秒内用缓存** | 「能容忍 1 分钟陈旧」(⭐ 最常用) |
   | `fetch(url, { next: { tags: ['posts'] } })` | ⭐ **打 tag(便于精确失效)** | ⭐ **推荐「总是打 tag」**(配合 revalidateTag) |
   | `fetch(url, { cache: 'only-if-cached' })` | 只用缓存(没有就报错) | 少见 |

   ⭐⭐ 实践建议(最省心的组合):
     ① ⭐ **总是显式声明**(不要依赖默认值 —— 因为「默认值在版本间变了」)
     ② ⭐ **总是打 `tags`**(哪怕是「按实体打 tag」:`tag: \`post-${id}\``)
     ③ 「能容忍陈旧 → `revalidate: N`」;「用户相关 → `no-store`」
// ✅ 一个「显式 + 带 tag」的数据获取封装(⭐ 强烈建议:不要在业务代码里裸调 fetch)
/** ⭐⭐ 统一的数据获取层(⭐ 把「缓存策略」变成「函数名」) */
export const api = {
  /** 「几乎不变」(配置、类目)→ 永久缓存 + tag */
  async getConfig() {
    const res = await fetch(`${BASE}/api/config`, {
      // ⭐ 显式声明(不依赖默认值)
      cache: 'force-cache',
      // ⭐ 打 tag(便于「配置变了」时精确失效)
      next: { tags: ['config'] },
    });
    if (!res.ok) throw new Error(`config 请求失败:${res.status}`);
    return res.json() as Promise<Config>;
  },

  /** 「能容忍 1 分钟陈旧」→ ISR */
  async getPostList() {
    const res = await fetch(`${BASE}/api/posts`, {
      next: {
        revalidate: 60,
        tags: ['posts'],                      // ⭐ 「列表」的 tag
      },
    });
    if (!res.ok) throw new Error(`posts 请求失败:${res.status}`);
    return res.json() as Promise<Post[]>;
  },

  /** 「按实体打 tag」(⭐ 这样「改了一条 post」可以只失效它) */
  async getPost(id: string) {
    const res = await fetch(`${BASE}/api/posts/${id}`, {
      next: {
        revalidate: 300,
        // ⭐⭐ 「实体级 tag」—— 比「列表级 tag」更精确
        tags: [`post-${id}`, 'posts'],
      },
    });
    if (!res.ok) throw new Error(`post ${id} 请求失败:${res.status}`);
    return res.json() as Promise<Post>;
  },

  /** 「用户相关 / 实时」→ 不缓存 */
  async getMyOrders() {
    const res = await fetch(`${BASE}/api/me/orders`, {
      // ⭐ 「用户相关」绝不能缓存(⭐⭐ 否则「串用户」)
      cache: 'no-store',
      headers: { /* 认证头(⭐ 由「当前请求的 cookie」派生) */ },
    });
    if (!res.ok) throw new Error(`orders 请求失败:${res.status}`);
    return res.json() as Promise<Order[]>;
  },
};
⭐⭐⭐ 「动态函数」会让整个路由变成「动态渲染」(⭐ 这是「为什么我的页面没被静态化」的答案):

   ⭐ 调用下面任意一个 → **整个路由**变成「每请求渲染」(Full Route Cache 失效):
     · `cookies()`(⭐ 最常见)
     · `headers()`
     · `searchParams`(⭐ page 的 props 里读它)
     · `noStore()`(`unstable_noStore()`)
     · `export const dynamic = 'force-dynamic'`
     · 「用 `POST` 的 Route Handler」/「含 `no-store` 的 fetch」

   ⚠️ 注意「传染性」:**在「某个深层组件」里调了 `cookies()` → 整条路由都变动态**!
     ⭐ 症状:「我明明只想让一小块动态,结果整页都不缓存了」
     ✅ 解法:
       · ⭐ **用 `<Suspense>` 把「动态部分」隔离出来**(⭐ 配合 PPR 时更好,见 4.14)
       · 把「读 cookie 的部分」下沉到「一个被 Suspense 包住的子组件」
       · 或「用 PPR(部分预渲染)」(⭐ 静态外壳 + 动态洞)

三、症状 → 病因 → 处方(排查对照表)

⭐⭐⭐ 这是本篇最实用的一张表(⭐ 「Next.js 缓存问题 90% 都在这里」):

   | 症状 | 可能病因 | 处方 |
   | --- | --- | --- |
   | ⭐ **数据永远不更新**(改了后端但页面不变) | ① Next 14 的 `fetch` 默认缓存;② 忘了 `revalidate`;③ 静态路由的 Full Route Cache;④ 客户端 Router Cache | ⭐ 「用 tag + 在『写操作后』`revalidateTag`」;显式写 `revalidate: N` 或 `no-store` |
   | ⭐ **每次都重新请求**(明明配了缓存) | ① Next 15 默认 `no-store`;② 某处调了 `cookies()`/`headers()`(⭐ 整页变动态);③ `searchParams`;④ 手动 `no-store` | ⭐ 显式 `revalidate`/`force-cache`;把「动态部分」用 Suspense 隔离 |
   | ⭐ **Server Action 后 UI 没更新** | ① 没调 `revalidatePath`/`revalidateTag`;② 调了但「路径不对」(⚠️ 动态路由要用「模板路径」);③ 客户端 Router Cache 没被清 | ⭐ 在 Server Action 里 `revalidateTag`(⭐ 它会「同时清 Router Cache」);或客户端 `router.refresh()` |
   | ⭐ **构建后「有些页面变动态了」**(deployment 里显示 ƒ) | 某处调了动态函数(⭐ 或「读 `searchParams`」) | commit 里查「谁引入了 `cookies()`」;用 Suspense 隔离 |
   | **登录后看到「别人的数据」** | ⭐⭐ 把「用户相关数据」缓存了(**安全级**) | ⭐ 用户数据一律 `no-store`;⭐ 「用 cookie 的页面」不能被 `s-maxage` 缓存(见 9.8) |
   | **本地正常、生产缓存行为不同** | ⚠️ 「开发模式的缓存行为与生产不同」(dev 里很多缓存被禁用) | ⭐ **用 `next build && next start` 验证**(不要只信 `next dev`) |
   | **感觉「不一致」**(同一个数据两处不同) | ① 「两层缓存」状态不同(Data Cache 有新、Router Cache 有旧);② 不同路由用了不同 tag | ⭐ 统一「用 tag 失效」;⭐ 「保证同一实体只有一种 tag 命名」 |
/** ⭐⭐ 一个「写操作之后」的失效封装(⭐ 把「失效哪些 tag」变成「业务语义」) */
'use server';

import { revalidateTag } from 'next/cache';

/** ⭐⭐ 关键:把「tag 的命名规则」集中在一处(否则「各处手写字符串」→ 必然写错) */
export const tags = {
  postList: 'posts',
  post: (id: string) => `post-${id}`,
  userOrders: (userId: string) => `user-${userId}-orders`,
  config: 'config',
} as const;

export async function updatePost(id: string, input: { title: string; content: string }) {
  // ① 校验(⭐ 服务端永远不能信客户端,见 9.10/6.12)
  const parsed = PostInput.safeParse(input);
  if (!parsed.success) return { ok: false as const, error: '参数不合法' };

  // ② 鉴权
  const user = await requireUser();
  const post = await db.post.findUnique({ where: { id } });
  if (!post) return { ok: false as const, error: '不存在' };
  if (post.authorId !== user.id) return { ok: false as const, error: '无权修改' };

  // ③ 写库
  await db.post.update({ where: { id }, data: parsed.data });

  // ④ ⭐⭐ 失效缓存(⭐ 这里就是「为什么 Server Action 之后 UI 会更新」的原因)
  //    ⭐ 注意「两个 tag 都要失效」:
  //      · `post-${id}`(详情页)
  //      · `posts`(列表页 —— ⭐ 因为列表里也包含这条 post 的数据)
  revalidateTag(tags.post(id));
  revalidateTag(tags.postList);

  return { ok: true as const };
}
⭐⭐⭐ 「revalidateTag 与 revalidatePath 怎么选」:

   ✅ **优先用 `revalidateTag`**(⭐ 理由):
     · ⭐ **精确**:「改了一条 post」只失效「那条 post + 列表」,不影响「其它 post 的详情页」
     · ⭐ **不依赖路径**:同一个数据可能出现在「5 个页面」→
       用 `revalidatePath` 要「写 5 次」,用 tag 只写一次
     · ⭐ **重构友好**:页面路径改了,tag 不用改
   ⚠️ **`revalidatePath` 用于**:
     · 「整个页面结构变了」(如「新增了一个页面区块」)
     · 「不好用 tag 表达」的场景(如「CMS 里改了配置,所有页面都要刷新」)
   ⭐ 而且:⚠️ **`revalidatePath` 对「动态路由」要写「模板路径」**
     (`revalidatePath('/blog/[slug]', 'page')`,⭐ 写成 `/blog/my-post` 不会生效)

四、完整实现:一个「缓存策略工具箱」(220 行)

// lib/cache.ts —— 统一的缓存与失效工具(⭐ 把「策略」变成「可读的 API」)
import { revalidateTag, revalidatePath, unstable_cache } from 'next/cache';
import { cache } from 'react';
import { cookies, headers } from 'next/headers';

/** ============ 1. 缓存策略的「三种语义」(⭐ 让业务代码「自解释」) ============ */
export const cachePolicy = {
  /** ⭐ 「几乎不变」(配置/类目)→ 永久 + tag */
  forever: (tags: string[]) => ({ cache: 'force-cache' as const, next: { tags } }),

  /** ⭐ 「能容忍 N 秒陈旧」→ ISR + tag(⭐ 最常用) */
  revalidate: (seconds: number, tags: string[]) => ({ next: { revalidate: seconds, tags } }),

  /** ⭐ 「用户相关/实时」→ 不缓存(⭐⭐ 安全默认值) */
  noStore: () => ({ cache: 'no-store' as const }),
};

/** ============ 2. 一个「带策略」的 fetch(⭐ 统一错误处理 + 缓存策略) ============ */
export async function apiFetch<T>(
  url: string,
  init: RequestInit & { policy: ReturnType<typeof cachePolicy[keyof typeof cachePolicy]> }
): Promise<T> {
  const { policy, ...rest } = init;

  const res = await fetch(url, {
    ...rest,
    ...policy,
    headers: {
      ...rest.headers,
      // ⭐ 透传「当前请求的 cookie」(⭐ 只有「服务端渲染时」才有意义)
      //    ⚠️ 但注意:一旦带上 cookie,就应该「不要再缓存」(否则串用户)
      ...(await requestAuthHeaders()),
    },
  });

  if (!res.ok) {
    // ⭐ 把「HTTP 错误」变成「有类型的错误」(⭐ 便于上层分类处理)
    throw Object.assign(new Error(`请求失败 ${res.status}`), { status: res.status, url });
  }
  return res.json() as Promise<T>;
}

/** ⭐⭐ 从「当前请求」派生认证头(⭐ 这是「服务端组件里发请求」的正确姿势) */
async function requestAuthHeaders(): Promise<Record<string, string>> {
  // ⚠️ 调用 `cookies()`/`headers()` 会「让路由变动态」(见上文)
  //    ⭐ 所以「只在真的需要时调」
  const cookieStore = await cookies();
  const token = cookieStore.get('session')?.value;
  return token ? { authorization: `Bearer ${token}` } : {};
}

/** ============ 3. `unstable_cache`:给「非 fetch 的取数」加缓存(⭐ 如「直接查库」) ============ */
/**
 * ⭐ 为什么需要它:`fetch` 有内建 Data Cache,但「直接查数据库」没有 →
 *    用 `unstable_cache` 包一层就「有缓存 + 可 tag 失效」
 */
export function cachedQuery<Args extends unknown[], Result>(
  fn: (...args: Args) => Promise<Result>,
  options: { keyParts: string[]; tags: string[]; revalidate?: number }
) {
  return unstable_cache(
    fn,
    // ⭐ keyParts 必须是「稳定的字符串数组」(⭐ 它会参与「缓存 key」)
    //    ⚠️ 不要放「对象」(它会被「序列化成 `[object Object]`」→ 缓存串味)
    options.keyParts,
    {
      tags: options.tags,
      revalidate: options.revalidate,
    }
  );
}

// 用法:给「直接查库」的查询加缓存
export const getPostById = cachedQuery(
  async (id: string) => db.post.findUnique({ where: { id } }),
  {
    // ⭐⭐ 关键:把「参数」放进 keyParts(⭐ 否则「不同 id」会命中同一个缓存)
    //    ⚠️ 由于 keyParts 是「静态数组」,要「动态参数」时用「函数返回 keyParts」的写法
    keyParts: ['post'],                 // ⚠️ 见下方「坑 ③」的完整处理
    tags: ['posts'],
    revalidate: 300,
  }
);

/** ============ 4. React 的 `cache()`:一次渲染内的去重(⭐ 与 Request Memoization 同源) ============ */
/**
 * ⭐ 用途:「同一次渲染中,多个组件都要「当前用户」」→ 避免「多次查库/多次解析」
 *    ⚠️ 注意:它的生命周期是「一次渲染请求」(⭐ 不是跨请求缓存 —— 别搞混)
 */
export const getCurrentUser = cache(async () => {
  const cookieStore = await cookies();
  const session = cookieStore.get('session')?.value;
  if (!session) return null;
  // ⭐ 这里可能「查库/验签」—— 而 `cache()` 保证「一次渲染里只执行一次」
  return verifyAndLoadUser(session);
});
/** ============ 5. 失效工具(⭐ 集中管理「写操作后要失效什么」) ============ */
export const revalidate = {
  /** ⭐ 一条实体变了 → 失效「它自己 + 列表」(⭐ 这是最常用的组合) */
  entity(kind: 'post' | 'product', id: string) {
    const listTag = kind === 'post' ? 'posts' : 'products';
    return {
      tags: [`${kind}-${id}`, listTag],
      /** ⭐ 执行失效 */
      apply() {
        revalidateTag(`${kind}-${id}`);
        revalidateTag(listTag);
      },
    };
  },

  /** ⭐ 一个用户的私有数据变了 → 只失效「他的」 */
  userScoped(userId: string, scope: 'orders' | 'cart' | 'profile') {
    return {
      tags: [`user-${userId}-${scope}`],
      apply() {
        // ⚠️ 「用户相关」的数据在 Data Cache 里「必须带 userId 的 tag」
        //    (⭐ 否则「一个用户的操作会失效所有人的缓存」或「串数据」)
        revalidateTag(`user-${userId}-${scope}`);
      },
    };
  },

  /** ⭐ 全局配置变了 → 失效一切相关页面 */
  config() {
    revalidateTag('config');
    // ⚠️ 「全局配置」影响很多页面 → 可能需要「整站刷新」
    //    ⚠️ 但那很贵 → ⭐ 优先「让每个页面都依赖 config tag」
  },

  /** ⚠️ 兜底:路径失效(⭐ 用于「不好用 tag 表达」的情况) */
  path(path: string, type: 'page' | 'layout' = 'page') {
    revalidatePath(path, type);
  },
};
/** ============ 6. 客户端侧:Router Cache 的处理(⭐ 与前端的配合) ============ */
'use client';
import { useRouter } from 'next/navigation';
import { useTransition } from 'react';

/**
 * ⭐⭐ 「Server Action 之后 UI 为什么没更新」的客户端侧解法
 *    ⭐ 前提:Server Action 里**已经**调了 revalidateTag(⭐ 那会「同时清 Router Cache」)
 */
export function useRefreshAfterAction() {
  const router = useRouter();
  const [isPending, startTransition] = useTransition();

  return {
    isPending,
    refresh: () => {
      // ⭐⭐ 用 `startTransition` 包住 `router.refresh()`
      //    (⭐ 这样「刷新」不会「阻塞输入」,与 4.16 的 transition 语义一致)
      startTransition(() => {
        router.refresh();
      });
    },
  };
}

/** ⭐ 一个「乐观 UI + 刷新」的例子(⭐ 与 2.15 的 useOptimistic 配合) */
export function LikeButton({ postId, initialLikes, action }: {
  postId: string;
  initialLikes: number;
  action: () => Promise<void>;
}) {
  const { isPending, refresh } = useRefreshAfterAction();
  const [optimisticLikes, addOptimistic] = useOptimistic(initialLikes, (n) => n + 1);

  return (
    <button
      disabled={isPending}
      onClick={() => {
        // ⭐ ① 立刻更新 UI(乐观)
        addOptimistic(undefined);
        // ⭐ ② 调 Server Action(它内部会 revalidateTag)
        void action().then(() => {
          // ⭐ ③ 兜底:如果「revalidate 没覆盖到」某些「客户端缓存」,手动 refresh
          //    ⚠️ 实践里「Server Action 里的 revalidateTag 一般就够了」→ 这一步可省
          refresh();
        });
      }}
    >
      ❤️ {optimisticLikes}
    </button>
  );
}

五、本篇特有的坑

// ① ⭐ 「fetch 默认值」在 Next 15 变了(14 缓存、15 不缓存)
fetch(url);                                                    // ⚠️ 行为随版本变
// ✅ 「总是显式声明」:{ cache: 'force-cache' } / { cache: 'no-store' } / { next: { revalidate: N } }

// ② ⭐ 「某处调了 `cookies()`/`headers()`」→ 整条路由变动态(⭐ 传染性)
export default async function Page() {
  const theme = (await cookies()).get('theme');               // ⚠️ 整页不再静态化
}
// ✅ 「读 cookie 的部分」下沉到「被 Suspense 包住的子组件」(⭐ 或上 PPR)

// ③ ⭐ 在「深层组件」里调 `searchParams`/`cookies`(⭐ 影响了整页的静态化)
// ✅ 同上(隔离到「小的动态边界」里)

// ④ 「revalidatePath 对动态路由写错」(用「真实路径」而不是「模板」)
revalidatePath('/blog/my-post');                              // ❌ 不会生效
// ✅ revalidatePath('/blog/[slug]', 'page')(⭐ 或者干脆用 tag)

// ⑤ ⭐ 「tag 命名不一致」(一处写 `posts`、一处写 `post-list`)
// ⚠️ 症状:「失效了但页面没更新」(因为「失效的 tag」没人用)
// ✅ ⭐ **把 tag 集中定义**(`export const tags = {...}`)+ 「只从那里取」

// ⑥ ⭐⭐ 「把用户相关数据缓存了」(串用户,**安全级**)
fetch('/api/me', { cache: 'force-cache' });                   // ❌❌
// ✅ 用户数据 `no-store`;⭐ 「带 Authorization 的请求」绝不进共享缓存

// ⑦ 「用 `force-cache` 但数据会变」(永远不更新)
fetch('/api/products', { cache: 'force-cache' });             // ⚠️
// ✅ 加 `tags` 并在「写操作后」`revalidateTag`

// ⑧ ⭐ 「`revalidateTag` 了但「列表页」没更新」(因为列表用了「另一个 tag」)
revalidateTag(`post-${id}`);                                  // ⚠️ 只失效了详情
// ✅ 「详情 + 列表」都失效(⭐ 因为列表里也有这条的数据)

// ⑨ ⭐ 「`unstable_cache` 的 keyParts 没带参数」(不同参数命中同一缓存)
unstable_cache(fn, ['post'], { ... });                        // ❌ 「所有 id 共用一个缓存」
// ✅ keyParts 里含「参数」(见下方完整处理)

// ⑩ ⭐ 「`unstable_cache` 里用了「每次渲染都变的东西」(如 `Date.now()`/`Math.random()`)
// ⚠️ 会让「缓存永不命中」或「缓存了错误的计算结果」
// ✅ 让「被缓存的函数」是「纯函数」(⭐ 与 render 必须纯是同一个道理)

// ⑪ ⭐ 「`cache()`(React)与 `unstable_cache`(Next)搞混」
//    · `cache()` → **一次渲染内去重**(不跨请求)
//    · `unstable_cache` → **跨请求持久缓存**(可 tag 失效)
// ✅ 分清「我要『这次渲染不重复』」还是「我要『跨请求缓存』」

// ⑫ 「在客户端组件里读服务端缓存」(读不到)
'use client'; const data = await fetch(...);                  // ⚠️ 客户端组件不能 await
// ✅ 「服务端组件负责取数」→ 「客户端组件通过 props 接收」

// ⑬ ⭐ 「开发模式(`next dev`)下缓存行为与生产不同」
// ⚠️ 「本地好好的,生产不是这样」
// ✅ ⭐ **用 `next build && next start` 验证缓存行为**

// ⑭ 「部署后缓存没清」(跨部署残留)
// ⭐ Next 的缓存按「构建」区分,通常部署即失效;
//    ⚠️ 但如果用了「外部缓存(Redis/CDN)」→ 要「手动清或版本化 key」

// ⑮ 「用 `force-dynamic` 一刀切关了缓存」(性能直接变差)
export const dynamic = 'force-dynamic';                       // ⚠️ 全页 SSR
// ✅ ⭐ 「精确到『数据』级别」(用 tag + revalidate),而不是「整页动态」

// ⑯ ⭐ 「CDN 缓存了「带 cookie 的响应」」(串用户)⭐ 安全级
// ✅ 「带 cookie → 不加 `s-maxage`」(见 9.8);⭐ 或用「Vary: Cookie」

// ⑰ 「`revalidateTag` 在「Route Handler」里有效,但在「客户端」调不到」
// ⭐ 客户端只能用 `router.refresh()`(⭐ 它「不清服务端缓存」)

// ⑱ 「`fetch` 在「Server Component」与「Route Handler」里的默认行为不同」
// ⚠️ Route Handler 里「默认不缓存」(除非显式声明)
// ✅ 「两处都显式声明」

// ⑲ 「用 `revalidate: 0`」(等价于「每次都重新验证」)
fetch(url, { next: { revalidate: 0 } });                      // ⚠️ 语义容易误解
// ✅ 要「不缓存」就明确写 `cache: 'no-store'`

// ⑳ 「忽略了 `stale-while-revalidate` 的『边缘层』」⭐
// ⭐ Next 的 Data Cache 与「CDN/边缘缓存」是「两层」(见 9.8)
// ✅ 「两层都要考虑」(⭐ 而且「TTFB 往往由边缘层决定」)
// ⑨ 的完整处理(`unstable_cache` 的 keyParts 必须含参数)
import { unstable_cache } from 'next/cache';

/**
 * ⭐⭐ 正确的封装:让「keyParts 由参数派生」
 *    ⚠️ `unstable_cache` 的第二个参数是「静态的 keyParts 数组」,
 *       所以「带参数的缓存」要「在调用时构造」——
 *       用「闭包 + 每次新建缓存函数」或「用 `cache()` 包一层」
 */
export function createCachedQuery<Args extends unknown[], R>(
  fn: (...args: Args) => Promise<R>,
  config: {
    /** ⭐ 从参数派生「稳定的 key 片段」 */
    keyOf: (args: Args) => string[];
    tags: (args: Args) => string[];
    revalidate?: number;
  }
) {
  // ⭐ 用 Map 缓存「已创建的 unstable_cache 函数」(⭐ 避免「每次调用都新建」)
  const cacheByKey = new Map<string, (...args: Args) => Promise<R>>();

  return async (...args: Args): Promise<R> => {
    const keyParts = config.keyOf(args);
    const key = keyParts.join('|');

    let cached = cacheByKey.get(key);
    if (!cached) {
      cached = unstable_cache(fn, keyParts, {
        // ⭐ tag 也按参数派生(⭐ 这样「实体级失效」才精确)
        tags: config.tags(args),
        revalidate: config.revalidate,
      });
      // ⚠️ 要有「上限」(否则「参数无限多」时 Map 会无限增长)
      if (cacheByKey.size > 500) cacheByKey.clear();
      cacheByKey.set(key, cached);
    }
    return cached(...args);
  };
}

// 用法
export const getPostById = createCachedQuery(
  async (id: string) => db.post.findUnique({ where: { id } }),
  {
    // ⭐⭐ keyOf 把「参数」变成「key 片段」
    keyOf: ([id]) => ['post', id],
    // ⭐⭐ tags 按「实体」派生 → 「改了 post-123」只失效它
    tags: ([id]) => [`post-${id}`, 'posts'],
    revalidate: 300,
  }
);
// ⑥ 的完整处理(「带认证的请求绝不能进共享缓存」⭐ 安全级)
export async function safeFetch<T>(url: string, init: RequestInit & { tags?: string[]; revalidate?: number } = {}) {
  const auth = await requestAuthHeaders();
  const hasAuth = Object.keys(auth).length > 0;

  // ⭐⭐ 「有认证」→ 强制 no-store(⭐ 不管调用方怎么写缓存参数)
  const cacheOptions: RequestInit & { next?: { tags?: string[]; revalidate?: number } } = hasAuth
    ? { cache: 'no-store' }
    : {
        ...(init.revalidate !== undefined
          ? { next: { revalidate: init.revalidate, tags: init.tags } }
          : { cache: 'force-cache' as const, next: { tags: init.tags } }),
      };

  const res = await fetch(url, { ...init, ...cacheOptions, headers: { ...init.headers, ...auth } });
  if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { status: res.status });
  return res.json() as Promise<T>;
}

六、面试延伸

  1. 「Next.js 有哪几层缓存?」
    ⭐ 四层(⭐ 关键是「范围」与「生命周期」):① Request Memoization——同一次服务端渲染中「相同 fetch」的结果(⭐ 生命周期就是「一次渲染请求」),解决「多个组件 fetch 同一个 URL 时重复请求」(它是「React 的 cache() 语义」);② Data Cache——fetch 的结果跨请求持久化(⭐ 可设 revalidate,可用 tag 精确失效);③ Full Route Cache——静态路由的「HTML + RSC payload」(⭐ 访问静态页时不需要重新渲染);④ ⭐ Router Cache——客户端(浏览器内存)里「访问过的路由的 RSC payload」(⭐ 让「前进后退」很快,⚠️ 它「在客户端」所以「服务端的 revalidate 要能传到客户端」——⭐ 在 Server Action 里 revalidateTag 会同时清它)。

  2. 「Next 14 和 15 的缓存默认值有什么变化?」
    ⭐ 两个重大变化:① fetch 的默认——Next 14 是 force-cache(默认缓存),Next 15 改成 no-store(默认不缓存)。⚠️ 这解释了「两个时代相反的两类问题」:14 上「数据永远不更新」(因为被缓存了),15 上「每次都重新请求」(因为不缓存)。② Router Cache 默认时间——Next 14 是「静态 5 分钟 / 动态 30 秒」,Next 15 默认改成 0(不缓存)(要手动配 staleTimes)。⭐ 实践纪律:「总是显式声明缓存策略」(cache: 'force-cache' / cache: 'no-store' / next: { revalidate, tags })——⭐ 不要依赖默认值,因为它在版本间会变。

  3. 「revalidateTag 和 revalidatePath 怎么选?」
    ⭐ 优先 revalidateTag:① 精确——「改了一条 post」只失效「那条 + 列表」,不影响其它 post 的详情页(⭐ 而 revalidatePath 要「把『出现这个数据的所有路径』都写一遍」);② ⭐ 不依赖路径——同一数据出现在 5 个页面时,tag 只写一次;③ ⭐ 重构友好——页面路径改了,tag 不用改。revalidatePath 的适用场景:「整个页面结构变了」或「不好用 tag 表达」(如「CMS 改了全局配置,所有页面都要刷新」)。⚠️ 两个注意:① revalidatePath 对动态路由要写「模板路径」(/blog/[slug],⭐ 写真实路径不会生效);② ⭐ 在 Server Action 里调用时,它还会「清客户端 Router Cache」(⭐ 这是「为什么 Server Action 之后 UI 会立刻更新」)。

  4. 「为什么『调了 cookies() 之后整页都不缓存了』?」
    因为 ⭐ 动态函数有「传染性」——cookies()/headers()/searchParams/noStore() 会让整个路由变成「每请求渲染」(Full Route Cache 失效)。⚠️ 症状就是「我明明只想让一小块动态,结果整页都不缓存了」。解法:① ⭐ 用 <Suspense> 把「动态部分」隔离到一个「小的子树」(把「读 cookie 的组件」下沉);② ⭐ 上 PPR(部分预渲染)——静态外壳 + 动态洞(见 4.14);③ 把「读 cookie」的逻辑放到「客户端组件」(⚠️ 但要小心「闪烁」与「需要服务端数据」的情况)。⭐ 一句话:「动态性应该被限制在『最小的边界』里,而不是『从调用点向上污染整页』」。

  5. 「unstable_cache 和 React 的 cache() 有什么区别?」
    ⭐ 生命周期完全不同:① React 的 cache()——「一次渲染内去重」(⭐ 生命周期 = 一次服务端渲染请求),用途是「同一次渲染里多个组件都要『当前用户』」→ 避免「重复查库/重复解析」;⚠️ 它「不跨请求」(下一个请求会重新执行);② ⭐ unstable_cache(Next)——「跨请求持久缓存」(⭐ 可以设 revalidate 与 tags,⭐ 所以能「tag 失效」),用途是「给『直接查数据库』这类『没有经过 fetch 的取数』加缓存」(因为「fetch 有内建 Data Cache,但查库没有」)。⚠️ 两个坑:① unstable_cache 的 keyParts 必须包含「参数」(否则「不同 id 命中同一缓存」);② 被缓存的函数必须是「纯函数」(⚠️ 别在里面用 Date.now()/Math.random()——那会让「缓存永不命中」或「缓存错误结果」,⭐ 与「render 必须纯」是同一个道理)。

  6. 「Server Action 之后 UI 没更新,怎么排查?」
    按顺序查四件事:① ⭐ Server Action 里有没有 revalidateTag/revalidatePath(❌ 没有 → 服务端缓存还是旧的);② ⭐ tag 是否「命名一致」(⚠️ 一处写 posts、一处写 post-list → 「失效的 tag 没人用」→ 数据不更新,⭐ 这是最常见的隐蔽 bug,✅ 解法是「把 tag 集中定义」);③ ⭐ 是否漏了「相关的 tag」(如「改了 post 只失效了 post-${id},但列表页用的是 posts」→ ⚠️ 列表不变);④ 客户端 Router Cache——⭐ 在 Server Action 里调 revalidateTag 会「同时清它」,但如果「不在 Server Action 里」(如「在 Route Handler 里」)→ 客户端要用 router.refresh()(⭐ 且建议用 startTransition 包住,避免「刷新阻塞输入」)。

  7. 「Next.js 的缓存有什么安全风险?」
    ⭐ 最大的是「串用户」(安全级事故):① ⭐⭐ 把「用户相关数据」缓存了(如 fetch('/api/me', { cache: 'force-cache' }))→ ⚠️ 「A 用户的数据被 B 用户看到」;② ⭐ CDN/边缘缓存了「带 cookie 的响应」(见 9.8)→ 同样串用户;③ ⭐ 「带 Authorization 的请求」进了共享缓存。防御纪律:① ⭐ 「用户数据一律 no-store」;② ⭐ 「有认证头 → 强制 no-store」(✅ 封装在「统一的 fetch 层」里,⭐ 不依赖「每个调用方都记得写」——因为「迟早有人忘」);③ 「带 cookie 的页面」不加 s-maxage(或用 Vary: Cookie);④ 「用户相关缓存」要带 user-${id} 的 tag(⭐ 否则「一个用户的操作会失效所有人的缓存」)。

  8. 「缓存问题怎么验证?」
    ⭐ 三条纪律:① ⭐ 「用 next build && next start 验证」——⚠️ next dev 的缓存行为与生产不同(dev 里很多缓存被禁用),「本地好好的,生产不是这样」是经典事故;② ⭐ 看构建输出——next build 会标注每个路由是「○ Static(静态)」「ƒ Dynamic(动态)」「● SSG」等——⭐ 如果「你以为是静态的页面」显示成 ƒ,说明「某处引入了动态函数」(顺着 commit 查「谁加了 cookies()」);③ ⭐ 有观测——给「缓存命中/未命中」打点(⭐ 用「响应头 + RUM」,见 7.13),否则你只能靠「用户反馈」发现问题。

一句话速记

Next.js 有 四层缓存:Request Memoization(⭐ 一次渲染内去重,生命周期=一次渲染)/ Data Cache(⭐ 跨请求持久,可用 **tag 精确失效)/ Full Route Cache(⭐ 静态路由的 HTML+RSC)/ Router Cache(⭐ 客户端内存,Server Action 里的 revalidate 会顺便清它);⭐⭐ **Next 15 改了默认值(fetch 从 force-cache → no-store;Router Cache 默认 0)——所以 永远显式声明缓存策略(force-cache / no-store / next: { revalidate, tags });⭐ 优先 revalidateTag 而不是 revalidatePath(精确、不依赖路径、重构友好;⚠️ revalidatePath 对动态路由要写模板路径 /blog/[slug]);⭐ 动态函数(cookies/headers/searchParams/noStore)有「传染性」——会让整条路由变动态(→ 用 <Suspense> 隔离或 PPR 把动态限制在最小边界);⭐ 两个经典症状:「数据永远不更新」(Next 14 默认缓存 / 忘了 revalidate / 静态路由 / Router Cache)与「每次都重新请求」(Next 15 默认 no-store / 某处调了 cookies());⚠️ 缓存最常见的隐蔽 bug 是「tag 命名不一致」(✅ 把 tag 集中定义)与「改了实体只失效了详情、漏了列表」(✅ 实体 tag + 列表 tag 一起失效);⚠️ 安全级风险是「串用户」(⭐ 用户数据一律 no-store;封装成「有认证头就强制 no-store」;用户缓存要带 user-${id} tag);分清 cache()(一次渲染去重)与 unstable_cache(跨请求 + tag 失效,⚠️ keyParts 必须含参数、被缓存函数必须纯);⭐ 验证要用 next build && next start(dev 行为不同)+ 看构建输出的 ○/ƒ 标记 + 给缓存命中率打点。

赞(0) 打赏
未经允许不得转载:陌上寒 » 9.9 Next.js 缓存体系深入

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫