nextjs学习9: Next.js 渲染与缓存

157 阅读16分钟

从纯前端视角理解渲染

Part A:心智模型重建(纯前端 → Next.js)

A.1 你过去的纯前端世界

在纯前端(Vite / CRA + React Router)里,世界非常简单、干净:

浏览器加载一个几乎为空的 index.html
   + 下载一整包 JS(React 全家桶)
   → React 在浏览器里"画"出页面
   → 页面要数据?useEffect 里 fetch('/api/xxx')
   → 拿到数据 → setState → 页面局部重渲染

核心心智模型只有三句话:

  • 页面 = 浏览器里用 JS 画出来的,服务器不碰页面长什么样。
  • 数据 = 永远靠前端主动调后端接口拿(fetch / axios)。
  • 更新 = 我手动 setState 或重新 fetch,一切由我控制。

代码示例,纯前端拿列表数据:

// 纯前端(Vite + React)——你熟悉的写法
function DocList() {
  const [docs, setDocs] = useState([]);

  useEffect(() => {
    fetch('/api/docs') // 前端主动发请求
      .then((res) => res.json())
      .then((data) => setDocs(data)); // 拿到数据 → setState → 页面更新
  }, []);

  const createDoc = async () => {
    await fetch('/api/docs', {
      method: 'POST',
      body: JSON.stringify({ title: '新文档' }),
    });
    // 写完后手动重新 fetch 一次,刷新列表
    const res = await fetch('/api/docs');
    const data = await res.json();
    setDocs(data);
  };

  return (
    <ul>
      {docs.map((doc) => (
        <li key={doc.id}>{doc.title}</li>
      ))}
      <button onClick={createDoc}>新建文档</button>
    </ul>
  );
}

这套模型里,服务器只提供"裸数据 API"(REST / GraphQL),从不碰"页面长什么样"。

作为前端开发者,完全控制页面的渲染和数据更新。

A.2 Next.js 的颠覆:服务器也会画页面

Next.js App Router 最大的不同是:React 既可以跑在浏览器,也可以跑在服务器。同一个组件,服务器上先跑一遍生成 HTML,再传到浏览器。

现在"页面长什么样"有两个生产者:

  1. 服务器:首屏 HTML 是它生成的(包含数据)。React Server Components(RSC)就在这层。
  2. 浏览器:后续交互、导航时,React 在浏览器里继续画和更新。Client Components 在这层。

你的代码变成了这样(对比纯前端):

// Next.js App Router —— 服务器组件直接在服务器端拿数据
// app/work/@directory/page.tsx
import { getDocList } from './action';

export default async function Directory() {
  // 注意:没有 useEffect、没有 fetch、没有 useState
  // list 是在服务器上直接查数据库拿到的!
  const list = await getDocList();

  return <DirectoryList list={list} />;
}
// app/work/@directory/action.ts
// 这段代码跑在服务器上,浏览器里完全看不到它
import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';

export async function getDocList() {
  const session = await auth(); // 读 cookie,知道你是谁
  const docs = await prisma.doc.findMany({
    where: { userId: session!.user!.id }, // 直接从数据库查
    orderBy: { updatedAt: 'desc' },
  });
  return docs;
}

关键颠覆:你的组件里没有 fetch、没有 useEffect、没有 setState,数据是在服务器画组件的时候顺手查的。

前端习惯的"前端调接口 → setState 更新"这套流程,在服务器组件里根本不存在。

比喻:

纯前端 ≈ 你去餐厅,服务员(服务器)只给你食材(数据),你自己在桌子上炒菜摆盘(渲染页面)。你想要新菜,得自己去厨房门口喊一声。

Next.js ≈ 厨房(服务器)直接把炒好的菜端到你桌上。你不用管菜是怎么炒的,甚至菜已经装在盘子里了(HTML)。你想换一盘菜?你不能自己炒,你得喊"厨师,重做一份"——这个"喊厨师重做"就是 revalidatePath。

A.3 渲染页面的三种基本方式

忘掉"静态/动态"这种术语,先用**"这段代码在哪跑、什么时候跑"**来理解。

方式 1:服务器在"构建时"画好(Static / 预渲染 / SSG)

执行 pnpm build 时,服务器就把页面画好,存成静态 HTML 文件。

// 1. 列出所有已知的 slug(构建时生成这些页面)
export function generateStaticParams() {
  return [
    { slug: 'hello-world' },
    { slug: 'nextjs-guide' },
  ]
}

// 2. fetch 加缓存
async function getPost(slug: string) {
  'use cache'
  cacheLife('hours')
  return fetch(`https://cms.example.com/posts/${slug}`).then(r => r.json())
}

export default async function BlogPost({
  params,
}: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)   // 用缓存版
  return <article>{post.content}</article>
}
  • 什么时候画:pnpm build 的那一刻。
  • 用户访问时:服务器直接把存好的 HTML 文件发给你,这次请求根本不跑你的 React 组件。
  • 特点:极快(不用算),但所有人看到一模一样的内容,且内容停留在"构建那一刻"。
  • 适用:不随用户变化、不频繁更新的内容(官网首页、博客文章、产品介绍页)。
方式 2:服务器在"用户每次请求时"画(Dynamic / 动态 / SSR)

用户访问页面时,服务器现跑你的 React 组件,包含里面的所有 await,画好 HTML 发出去。

// DocAI 中的实际代码 —— 动态渲染
// app/work/@directory/page.tsx
import { getDocList } from './action';

export default async function Directory() {
  // auth() 内部读了 cookies() → 强制动态渲染
  const list = await getDocList(); // 每次请求都在服务器上真查数据库
  return <DirectoryList list={list} />;
}
  • 什么时候画:用户每次访问的那一刻。服务器现算现发。
  • 特点:可以根据当前用户、当前时间生成专属内容,数据永远最新。代价是每次都要跑代码、查数据库,比读静态文件慢一点(但配合缓存策略可大幅优化)。
  • 适用:需要登录态、数据随用户变化、实时性要求高的页面(你的 /work 文档列表)。
方式 3:浏览器里画(Client Component / 跟 SPA 一样)

组件标了 'use client',里面的逻辑(useState、useEffect、事件处理)只在浏览器跑。

// DocAI 中的实际代码 —— 客户端组件
// app/work/[id]/content.tsx
'use client';

import { useEditor, EditorContent } from '@tiptap/react';

export default function EditorContent({ content, docId }: Props) {
  const editor = useEditor({
    content: JSON.parse(content), // 解析服务器传来的初始内容
    onUpdate: ({ editor }) => {
      // 用户在浏览器里打字 → 触发状态更新 → 局部重渲染
      debouncedSave(docId, editor.getJSON());
    },
  });

  return <EditorContent editor={editor} />;
}
  • 注意:即使标了 'use client',它的初始 HTML 也是服务器先画了一版传过来的(为了首屏快、SEO 友好),然后浏览器接手后续交互。这叫"水合(Hydration)"。
  • 适用:需要交互、事件处理、浏览器 API 的部分(编辑器、表单、弹窗、动画)。
三种方式对照表
方式在哪跑什么时候跑类比纯前端
静态(Static)服务器pnpm build 时相当于你把页面+数据打成静态文件丢 CDN,谁来看都一样
动态(Dynamic)服务器用户每次请求时纯前端里没有对应物——纯前端里服务器从不画页面
客户端组件浏览器用户交互时等于纯前端——useState、useEffect、事件都在浏览器

你的 @directory/page.tsx 是服务器组件(没标 'use client')且因为 auth() → cookies() 强制动态渲染,走的是方式 2。

A.4 拿数据的两条路径

在纯前端你只有一条路:前端 fetch 接口 → setState 更新。

Next.js 里有两条完全不同的路:

路 A:服务器组件里直接拿数据(本项目用法)
// 服务器组件(无 'use client' 标记)
// app/work/@directory/page.tsx
export default async function Directory() {
  // 直接 await 数据库查询 —— 这是在服务器上执行的!
  const list = await getDocList(); // 没有 fetch、没有 useEffect、没有 useState
  return <DirectoryList list={list} />;
}

数据获取的完整链路:

用户访问 /work/abc
  → 服务器收到请求
  → 执行 Directory() 组件
  → await getDocList()
      → await auth()  读取 cookies() 识别用户
      → await prisma.doc.findMany() 查数据库
      → 返回文档列表
  → 将数据注入组件,生成 HTML
  → 发送给浏览器

路 A 的更新不归前端管,归"服务器什么时候重新画这个组件"管。要让它更新,就得告诉服务器"你上一次画的结果作废了,重新画"——这就是 revalidatePath 干的事。

路 B:客户端组件里 fetch / 调用 API(跟纯前端一样)
// 客户端组件
'use client';

function DocList() {
  const [docs, setDocs] = useState([]);

  useEffect(() => {
    fetch('/api/docs') // 浏览器里发请求
      .then((res) => res.json())
      .then(setDocs); // 前端 setState 更新
  }, []);

  const createDoc = async () => {
    await fetch('/api/docs', { method: 'POST' });
    // 手动重新 fetch 刷新列表
    const res = await fetch('/api/docs');
    setDocs(await res.json());
  };
}

这条路你完全懂,就是纯前端的老路子,不需要 revalidatePath。

两条路的关键区别
路 A(服务器组件)路 B(客户端组件)
代码在哪跑服务器浏览器
数据怎么拿await getDocList() 直接查库fetch('/api/...') 调接口
怎么更新数据Server Action 写库 + revalidatePathfetch POST + 手动 refetch + setState
你在哪里写 setState不需要需要
是不是你看得见的数据流不——浏览器里看不到查库过程是——Network 面板里能看到请求

最重要的认知:你的 @directory 走的是路 A。所以当你点"新增文档",数据写进数据库了,但左侧列表没更新——不是数据库没存进去,而是服务器没被告知"重新画左侧那个组件"。你以前在路 B 里习惯的"改完数据手动 refetch 列表",在路 A 里的等价动作就是 revalidatePath。

A.5 客户端 Router Cache:动态页面也会被记住

这是最容易被误解、也最关键的机制。

先建立"两段式"认知:页面渲染涉及两个地方:

  1. 服务器:负责"算"出页面内容(查数据库、拼 HTML)。
  2. 浏览器里的 Next.js(Client Router):负责"接管导航",让你点链接时不整页刷新,而是局部换内容。它自己有一个小仓库(Router Cache),会把服务器已经算过的结果存一份。

通俗比喻——政务大厅与超级前台:

想象你去一个政务大厅(服务器)办业务。大厅门口有个超级前台(客户端 Router Cache)。

  • 你第一次问前台:"我要看我的文档列表。"
  • 前台跑去大厅里面(服务器)查了一圈,拿回一份列表给你,并自己偷偷复印了一份贴在墙上。
  • 后来你在楼里(应用内)从一个窗口走到另一个窗口(客户端软导航,比如 /work/A → /work/B),没出大楼、没过安检。
  • 这时候你回头看那面墙上的"文档列表"——前台不会主动去重新查,它给你看的是墙上那张复印版(缓存)。

核心矛盾:

  • 即使政务大厅是"动态"的(每次有人进去查,都现查现给),但墙上那张复印纸是前台自己贴的,跟大厅里面是不是动态无关。
  • 大厅"动态"只保证"有人进去问的时候给最新的"。
  • 但前台那张复印纸是浏览器端的记忆,它默认不会自己撕掉重贴。

这就是"即使路由是动态渲染的,Next.js 也会在客户端把已经渲染过的插槽临时记住"的意思。

代码层面的解释:

// app/work/layout.tsx —— 静态段 layout,持久化侧边栏
export default function WorkLayout({ children, directory }: Props) {
  return (
    <div className="flex h-screen">
      {/* 左侧:@directory 插槽——首次渲染后被 Router Cache 记住 */}
      <aside className="w-64 border-r">{directory}</aside>
      {/* 右侧:children——切换文档时才重取 */}
      <main className="flex-1">{children}</main>
    </div>
  );
}
  • 用户打开 /work/abc → 服务器画左侧 @directory 插槽(查库拿到 [文档1, 文档2])→ 浏览器把结果缓存进 Router Cache。
  • 用户在应用内点"新增文档" → create() 写库成功(数据库现在是 [文档1, 文档2, 文档3])→ 但左侧 @directory 插槽没有被重新请求,浏览器还在用缓存里的 [文档1, 文档2]。

换一个角度理解——F5 整页刷新 vs 应用内操作的区别:

操作发生了什么左侧列表是否更新
F5 整页刷新浏览器向服务器发全新请求 → 服务器重新跑 Directory() → getDocList() 重查库 → 返回新 HTML✅ 更新
应用内点"新增文档"Server Action 写库 ✅,但左侧插槽用 Router Cache 旧值,没重查服务器❌ 不更新
应用内点"新增文档" + revalidatePathServer Action 写库 ✅ + 通知浏览器"左侧缓存作废" → 浏览器重查服务器 → 拿到新数据✅ 更新

A.6 更新页面的四种触发方式

把"页面内容什么时候会变"的所有可能来源列清楚:

#触发谁发起发生什么类比纯前端
1整页刷新(F5 / 地址栏回车)用户浏览器向服务器发全新请求 → 服务器重新画整个页面等于重新加载整个 SPA
2应用内软导航(点 <Link>)用户不刷新整页,Next.js 在浏览器里局部换路由。已渲染过的部分从 Router Cache 取,不会重新请求服务器类似 React Router 切换路由,但多了一层缓存
3客户端交互(路 B)你的代码'use client' 组件里 setState / fetch → 浏览器局部重渲染完全等于纯前端
4Server Action + revalidatePath(路 A 的数据变更)你的代码写库 → revalidatePath 标脏 → 浏览器立刻重取那块并重画纯前端里"调接口后手动 refetch 那个列表"

重点:方式 4 就是你当前需要的方式。

在纯前端中,你改完数据后:

// 纯前端写法
await fetch('/api/docs', { method: 'POST' });
const res = await fetch('/api/docs'); // 手动重新拿列表
setDocs(res);

在 Next.js 路 A 中,等价动作:

// Next.js Server Action 写法
export async function create(data) {
  await prisma.doc.create({ data });
  revalidatePath('/work', 'layout'); // ← 等价于上面那两行
}

A.7 判断口诀

每次写页面或改数据时,问自己三句话:

1. 这个组件是服务器组件还是客户端组件?

  • 没标 'use client' → 服务器组件,数据在服务器拿(路 A)。
  • 标了 'use client' → 客户端组件,可以像纯前端一样 fetch + setState(路 B)。

2. 如果是服务器组件,它读 cookie / 查库 / 用了请求时数据吗?

  • 读了 → 动态渲染(每次请求现画)。
  • 没读且数据不变 → 可能被静态预渲染。

3. 我改了数据库后,想让某块服务器渲染的内容立刻更新,怎么办?

  • 在改数据的 Server Action 里加 revalidatePath(对应路径, 'layout')。
  • 有并行路由插槽 → 必须用 'layout' 类型。
  • 这就等价于纯前端里"改完数据后 refetch 那个列表"。

Part B:revalidatePath 技术详解

B.1 revalidatePath 是什么

revalidatePath 是 Next.js 提供的一个函数,用于标记指定路径的缓存为"陈旧"。

import { revalidatePath } from 'next/cache';

调用后,该路径在下一次访问时会重新渲染——重新执行服务器组件代码、重新查数据库、重新生成 HTML。

如果在 Server Action 中调用,还会立即刷新正在查看的 UI,不需要等用户下次导航。

它同时作用于三层缓存:

  • 全路由缓存(Full Route Cache):当页面被打包成 Static 时,Next.js 在服务器上把"这个页面渲染好的整段 HTML"存进磁盘。下次有人访问,直接从磁盘把 HTML 拿出来发回去,不用再跑一遍渲染逻辑。
  • 数据缓存(Data Cache):fetch 请求的响应缓存。调用后相关路径的缓存数据失效。
async function getPosts() {
  'use cache'; // ← 结果进 Data Cache
  cacheLife('hours');
  return db.post.findMany();
}
  • 客户端 Router Cache:浏览器访问过一个路由后,会把服务器返回的 RSC 结果(Server Component 渲染产物)暂存在内存里。下次再进这个路由(比如用户点"返回"),不用重新向服务器要,直接拿来用,所以切换飞快。

它和前两个的本质区别:前两个在服务器上,这个是浏览器里;前两个存的是"数据/HTML",这个存的是"渲染结果快照"。

注意:即使你的页面是动态渲染(每次请求现算)、数据是直接查 Prisma(不走 Data Cache),revalidatePath 仍然有用——它主要起作用的是第三层(客户端 Router Cache),让浏览器不再复用已缓存的内容,而是去服务器重新取。

B.2 type: 'page' vs 'layout'

这是最容易踩的坑,也是最需要理解清楚的参数。

// 让 /work 这个 layout 及其下所有嵌套 page 都失效
revalidatePath('/work', 'layout');

// 只让 /work/[id] 这个 page 层失效
revalidatePath('/work/abc', 'page');

// 不传 type 时默认为 'page'
revalidatePath('/work/abc'); // 等同于 revalidatePath('/work/abc', 'page')
参数详解
调用type影响范围示意图
revalidatePath('/blog/[slug]', 'page')'page'只让 page 层重执行,layout 层不动窄
revalidatePath('/blog/[slug]', 'layout')'layout'layout + 所有嵌套 layout + page 全部重执行宽
revalidatePath('/blog/hello')默认 'page'让具体路径的 page 重执行窄
revalidatePath('/', 'layout')'layout'全局刷新,清空整个客户端路由缓存最宽
为什么 'layout' 范围更大?
// Next.js 路由嵌套示意
app/
├── layout.tsx              ← 根 layout
├── work/
│   ├── layout.tsx          ← /work 的 layout(@directory 挂载点)
│   ├── @directory/
│   │   └── page.tsx        ← 左侧目录插槽(layout 层插槽)
│   └── [id]/
│       └── page.tsx        ← 右侧编辑页(page 层)
  • revalidatePath('/work/abc', 'page'):只让 /work/abc 这个 page 层失效。@directory 是 /work/layout.tsx 下的并行插槽(属于 layout 层),不会被标脏。
  • revalidatePath('/work', 'layout'):让 /work 的 layout 层及下面所有东西失效,包括 @directory 插槽 + [id]/page.tsx。

B.3 调用位置决定行为

revalidatePath 的效果差异取决于你在哪里调用它:

在 Server Action 中调用
// app/work/@directory/action.ts
'use server';

export async function create(formData: FormData) {
  // 1. 写数据库
  await prisma.doc.create({ data: { ... } });

  // 2. 标脏路径
  revalidatePath('/work', 'layout');

  // 3. Server Action 执行完后,Next.js 自动:
  //    a. 触发 router.refresh() 重新请求当前路由
  //    b. 因为 /work 的 layout 层被标脏,浏览器丢弃缓存
  //    c. 浏览器向服务器重新取 @directory 插槽
  //    d. 服务器重新执行 getDocList() → 拿到最新数据
  //    e. 左侧列表即时更新!
}

Server Action 执行完后 Next.js 默认会触发 router.refresh()(重新请求当前路由),加上你手动调了 revalidatePath,两个叠加的效果是:当前页面立即用新数据重新渲染,用户无需刷新。

⚠️ 重要:router.refresh() 本身会请求整条路由(含并行插槽),但并行路由里"URL 段没变"的插槽(如 @directory)默认会被 Router Cache 复用,不真去服务器重查。因此仅靠 Server Action 自带的 refresh 不足以可靠刷新并行插槽,必须配合 revalidatePath。

在 Route Handler 中调用
// app/api/revalidate/route.ts
export async function POST(request: Request) {
  revalidatePath('/work', 'layout');
  return Response.json({ revalidated: true });
}

在 Route Handler 中调用时,仅标记缓存失效,不会立即触发 UI 更新。因为 Route Handler 不在浏览器交互上下文中,Next.js 无法知道"当前显示的是哪条路由"。缓存在下次用户访问该路径时才会被重新计算。

两种调用位置的对比
调用位置是否立即刷新 UI适用场景
Server Action✅ 是(官方:Updates the UI immediately)用户操作后需要即时看到变化(新建、编辑、删除)
Route Handler❌ 否,仅标记(下次访问才生效)外部系统回调、Webhook、定时任务触发的内容更新

B.8 完整链路图

下面用一张 Mermaid 图,把整个 DocAI 中"新建文档 → 左侧列表即时更新"的完整链路串起来:

sequenceDiagram
    actor U as 用户
    participant B as 浏览器<br/>(Client Router Cache)
    participant SA as Server Action<br/>(create)
    participant DB as PostgreSQL
    participant RSC as RSC 服务器<br/>(@directory/page.tsx)

    Note over B: 当前显示 /work/abc<br/>左侧列表: [文档A, 文档B]

    U->>B: 点击&#34;新建文档&#34;按钮
    B->>SA: 调用 create() Server Action

    activate SA
    SA->>DB: INSERT INTO doc ...
    DB-->>SA: 新文档 id = xyz
    Note over DB: 数据库有 3 条了: [A, B, C] ✅

    SA->>SA: revalidatePath('/work', 'layout')
    Note over SA: 标脏 /work layout 层<br/>(含 @directory 插槽)

    SA-->>B: 返回结果
    deactivate SA

    Note over B: Server Action 执行完<br/>Next.js 自动 router.refresh()

    B->>B: 检查 Router Cache<br/>@directory 插槽 → 已标脏 → 丢弃旧值!

    B->>RSC: 重新请求 @directory 插槽
    activate RSC
    RSC->>DB: SELECT * FROM doc WHERE userId = ...
    DB-->>RSC: [文档A, 文档B, 文档C]
    RSC-->>B: 新的 RSC Payload (含 3 条文档)
    deactivate RSC

    B->>B: 左侧列表更新为<br/>[文档A, 文档B, 文档C] ✅

关键步骤标注:

  1. 🔴 revalidatePath('/work', 'layout') —— 如果不做这一步,下面第 4 步中 Router Cache 不会主动丢弃 @directory 插槽的旧值。
  2. 🔵 router.refresh() —— Server Action 自带的自动刷新。但仅靠它不够(见误区 3)。
  3. 🟢 SELECT * FROM doc —— 服务器重新查库,拿到的数据永远是最新的。
  4. 🟡 浏览器丢弃缓存旧值,用服务器返回的新 RSC Payload 更新 UI。

总结:一页纸速记

┌─────────────────────────────────────────────────────────────────┐
│                    Next.js 数据更新决策树                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  我要更新页面数据了                                               │
│      │                                                          │
│      ├─ 数据是"客户端组件里 fetch"拿的?                           │
│      │   └─ 走纯前端老路:useEffect + fetch + setState            │
│      │      不需要 revalidatePath                                │
│      │                                                          │
│      ├─ 数据是"服务器组件里直接 await 拿的"?                      │
│      │   └─ 需要 revalidatePath                                 │
│      │       │                                                  │
│      │       ├─ 有并行路由插槽(@directory 等)?                  │
│      │       │   └─ 必须用 revalidatePath('/path', 'layout')     │
│      │       │                                                  │
│      │       └─ 没有并行路由?                                    │
│      │           └─ revalidatePath('/path') 通常够用              │
│      │                                                          │
│      └─ F5 整页刷新能拿到新数据吗?                                │
│          ├─ 能 → 说明服务器端每次查库没问题,问题是客户端缓存        │
│          ├─ 不能 → 先排查服务器端数据获取逻辑                       │
│          └─ 都是需要 revalidatePath 的信号                        │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘