从纯前端视角理解渲染
Part A:心智模型重建(纯前端 → Next.js)
A.1 你过去的纯前端世界
在纯前端(Vite / CRA + React Router)里,世界非常简单、干净:
sql
浏览器加载一个几乎为空的 index.html
+ 下载一整包 JS(React 全家桶)
→ React 在浏览器里"画"出页面
→ 页面要数据?useEffect 里 fetch('/api/xxx')
→ 拿到数据 → setState → 页面局部重渲染
核心心智模型只有三句话:
- 页面 = 浏览器里用 JS 画出来的,服务器不碰页面长什么样。
- 数据 = 永远靠前端主动调后端接口拿(
fetch/axios)。 - 更新 = 我手动
setState或重新fetch,一切由我控制。
代码示例,纯前端拿列表数据:
tsx
// 纯前端(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,再传到浏览器。
现在"页面长什么样"有两个生产者:
- 服务器:首屏 HTML 是它生成的(包含数据)。React Server Components(RSC)就在这层。
- 浏览器:后续交互、导航时,React 在浏览器里继续画和更新。Client Components 在这层。
你的代码变成了这样(对比纯前端):
tsx
// 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} />;
}
ts
// 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 文件。
tsx
// 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 发出去。
tsx
// 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、事件处理)只在浏览器跑。
tsx
// 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:服务器组件里直接拿数据(本项目用法)
tsx
// 服务器组件(无 'use client' 标记)
// app/work/@directory/page.tsx
export default async function Directory() {
// 直接 await 数据库查询 ------ 这是在服务器上执行的!
const list = await getDocList(); // 没有 fetch、没有 useEffect、没有 useState
return <DirectoryList list={list} />;
}
数据获取的完整链路:
scss
用户访问 /work/abc
→ 服务器收到请求
→ 执行 Directory() 组件
→ await getDocList()
→ await auth() 读取 cookies() 识别用户
→ await prisma.doc.findMany() 查数据库
→ 返回文档列表
→ 将数据注入组件,生成 HTML
→ 发送给浏览器
路 A 的更新不归前端管 ,归"服务器什么时候重新画这个组件"管。要让它更新,就得告诉服务器"你上一次画的结果作废了,重新画"------这就是 revalidatePath 干的事。
路 B:客户端组件里 fetch / 调用 API(跟纯前端一样)
tsx
// 客户端组件
'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 写库 + revalidatePath |
fetch POST + 手动 refetch + setState |
你在哪里写 setState |
不需要 | 需要 |
| 是不是你看得见的数据流 | 不------浏览器里看不到查库过程 | 是------Network 面板里能看到请求 |
最重要的认知 :你的
@directory走的是路 A 。所以当你点"新增文档",数据写进数据库了,但左侧列表没更新------不是数据库没存进去,而是服务器没被告知"重新画左侧那个组件" 。你以前在路 B 里习惯的"改完数据手动 refetch 列表",在路 A 里的等价动作就是revalidatePath。
A.5 客户端 Router Cache:动态页面也会被记住
这是最容易被误解、也最关键的机制。
先建立"两段式"认知:页面渲染涉及两个地方:
- 服务器:负责"算"出页面内容(查数据库、拼 HTML)。
- 浏览器里的 Next.js(Client Router) :负责"接管导航",让你点链接时不整页刷新,而是局部换内容。它自己有一个小仓库(Router Cache),会把服务器已经算过的结果存一份。
通俗比喻------政务大厅与超级前台:
想象你去一个政务大厅(服务器)办业务。大厅门口有个超级前台(客户端 Router Cache)。
- 你第一次问前台:"我要看我的文档列表。"
- 前台跑去大厅里面(服务器)查了一圈,拿回一份列表给你,并自己偷偷复印了一份贴在墙上。
- 后来你在楼里(应用内)从一个窗口走到另一个窗口(客户端软导航,比如
/work/A→/work/B),没出大楼、没过安检。 - 这时候你回头看那面墙上的"文档列表"------前台不会主动去重新查,它给你看的是墙上那张复印版(缓存)。
核心矛盾:
- 即使政务大厅是"动态"的(每次有人进去查,都现查现给),但墙上那张复印纸是前台自己贴的,跟大厅里面是不是动态无关。
- 大厅"动态"只保证"有人进去问的时候给最新的"。
- 但前台那张复印纸是浏览器端的记忆,它默认不会自己撕掉重贴。
这就是"即使路由是动态渲染的,Next.js 也会在客户端把已经渲染过的插槽临时记住"的意思。
代码层面的解释:
tsx
// 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 旧值,没重查服务器 | ❌ 不更新 |
应用内点"新增文档" + revalidatePath |
Server Action 写库 ✅ + 通知浏览器"左侧缓存作废" → 浏览器重查服务器 → 拿到新数据 | ✅ 更新 |
A.6 更新页面的四种触发方式
把"页面内容什么时候会变"的所有可能来源列清楚:
| # | 触发 | 谁发起 | 发生什么 | 类比纯前端 |
|---|---|---|---|---|
| 1 | 整页刷新(F5 / 地址栏回车) | 用户 | 浏览器向服务器发全新请求 → 服务器重新画整个页面 | 等于重新加载整个 SPA |
| 2 | 应用内软导航(点 <Link>) |
用户 | 不刷新整页,Next.js 在浏览器里局部换路由。已渲染过的部分从 Router Cache 取,不会重新请求服务器 | 类似 React Router 切换路由,但多了一层缓存 |
| 3 | 客户端交互(路 B) | 你的代码 | 'use client' 组件里 setState / fetch → 浏览器局部重渲染 |
完全等于纯前端 |
| 4 | Server Action + revalidatePath(路 A 的数据变更) |
你的代码 | 写库 → revalidatePath 标脏 → 浏览器立刻重取那块并重画 |
纯前端里"调接口后手动 refetch 那个列表" |
重点:方式 4 就是你当前需要的方式。
在纯前端中,你改完数据后:
tsx
// 纯前端写法
await fetch('/api/docs', { method: 'POST' });
const res = await fetch('/api/docs'); // 手动重新拿列表
setDocs(res);
在 Next.js 路 A 中,等价动作:
tsx
// 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 提供的一个函数,用于标记指定路径的缓存为"陈旧"。
ts
import { revalidatePath } from 'next/cache';
调用后,该路径在下一次访问时会重新渲染------重新执行服务器组件代码、重新查数据库、重新生成 HTML。
如果在 Server Action 中调用,还会立即刷新正在查看的 UI,不需要等用户下次导航。
它同时作用于三层缓存:
- 全路由缓存(Full Route Cache):当页面被打包成 Static 时,Next.js 在服务器上把"这个页面渲染好的整段 HTML"存进磁盘。下次有人访问,直接从磁盘把 HTML 拿出来发回去,不用再跑一遍渲染逻辑。
- 数据缓存(Data Cache) :
fetch请求的响应缓存。调用后相关路径的缓存数据失效。
ts
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'
这是最容易踩的坑,也是最需要理解清楚的参数。
ts
// 让 /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' 范围更大?
tsx
// 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 中调用
ts
// 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 中调用
ts
// 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 中"新建文档 → 左侧列表即时更新"的完整链路串起来:
关键步骤标注:
- 🔴
revalidatePath('/work', 'layout')------ 如果不做这一步,下面第 4 步中 Router Cache 不会主动丢弃 @directory 插槽的旧值。 - 🔵
router.refresh()------ Server Action 自带的自动刷新。但仅靠它不够(见误区 3)。 - 🟢
SELECT * FROM doc------ 服务器重新查库,拿到的数据永远是最新的。 - 🟡 浏览器丢弃缓存旧值,用服务器返回的新 RSC Payload 更新 UI。
总结:一页纸速记
sql
┌─────────────────────────────────────────────────────────────────┐
│ Next.js 数据更新决策树 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 我要更新页面数据了 │
│ │ │
│ ├─ 数据是"客户端组件里 fetch"拿的? │
│ │ └─ 走纯前端老路:useEffect + fetch + setState │
│ │ 不需要 revalidatePath │
│ │ │
│ ├─ 数据是"服务器组件里直接 await 拿的"? │
│ │ └─ 需要 revalidatePath │
│ │ │ │
│ │ ├─ 有并行路由插槽(@directory 等)? │
│ │ │ └─ 必须用 revalidatePath('/path', 'layout') │
│ │ │ │
│ │ └─ 没有并行路由? │
│ │ └─ revalidatePath('/path') 通常够用 │
│ │ │
│ └─ F5 整页刷新能拿到新数据吗? │
│ ├─ 能 → 说明服务器端每次查库没问题,问题是客户端缓存 │
│ ├─ 不能 → 先排查服务器端数据获取逻辑 │
│ └─ 都是需要 revalidatePath 的信号 │
│ │
└─────────────────────────────────────────────────────────────────┘