Next.js 的架构、运行与数据流
这份文档不教你"怎么写一个页面",而是带你看清 Next.js 这台机器内部是怎么转的——它的架构怎么分层、一次请求在它体内如何流动、有哪些关键数据结构在背后支撑、数据又是如何从服务器一路串到屏幕上的。读完你应该能在面试里把"原理题"答出底气。
00定位与心智模型
面试官问"Next.js 是什么",最差的答案是"一个 React 框架"。这句话对,但什么都没说。准确的定位是:
Next.js 是一个 建立在 React 之上的全栈应用框架,它把"服务端渲染、路由、数据获取、构建打包、部署运行时"这些原本要你自己拼装的能力,整合成一套以文件系统为约定、以 React Server Components 为渲染内核的一体化方案。它要解决的核心矛盾是:既要服务端渲染带来的首屏速度与 SEO,又要单页应用的交互体验,还要让开发者写起来像写普通 React。
三个层次理解它
要真正"懂"Next.js,得在脑子里分清它同时扮演的三个角色。这三层贯穿全文,建议先记住:
"Next.js 相比纯 React(CRA/Vite)多了什么?" 答题骨架:① 服务端渲染能力(SSR/SSG/ISR/PPR),首屏更快、利于 SEO;② 约定式路由与嵌套布局,省去手动配置 React Router;③ React Server Components,让一部分组件在服务端运行、零 JS 下发;④ 内建数据获取与缓存模型;⑤ 内建优化(图片、字体、脚本、代码分割);⑥ 全栈能力(Route Handlers / Server Actions 让前后端同仓);⑦ 开箱即用的构建与部署优化。一句话收尾:它把"渲染策略 + 路由 + 数据 + 构建 + 运行时"打包成了一个有明确约定的整体。
版本演进:为什么现在长这样
理解架构必须知道它"从哪来"。这条演进线本身就是高频面试题:
| 阶段 | 关键变化 | 背后的架构动机 |
|---|---|---|
| Pages Router (经典时代) | pages/ 目录、getServerSideProps / getStaticProps / getInitialProps 三件套 | 页面级的渲染策略,数据获取与组件分离,整页要么静态要么动态,粒度粗。 |
| App Router (13 引入 / 现默认) | app/ 目录、React Server Components、嵌套布局、fetch 直接在组件里写、Streaming | 把渲染粒度下沉到组件级:同一页面里可以混合服务端与客户端组件、静态与动态片段。这是范式转变。 |
| 14 / 15 | Server Actions 稳定、Turbopack 逐步成熟、缓存默认行为调整、请求 API 异步化 | 补齐数据"写"路径(Actions),并开始反思"默认全缓存"带来的心智负担。 |
| 16 (当前) | Turbopack 成为默认;middleware.ts → proxy.ts(Node 运行时);Cache Components 新缓存模型;同步请求 API 移除 | 缓存从"隐式默认缓存"翻转为"默认动态、显式 use cache 才缓存",让缓存可预测;PPR 成为常态化能力。 |
很多人把 App Router 当成"换了个目录名的 Pages Router"。不是。 App Router 的内核是 RSC,渲染默认发生在服务端、组件默认零 JS;Pages Router 里所有组件都是客户端组件(在服务端只是被渲染成 HTML 字符串再水合)。两者的数据获取、缓存、组件能力都不一样。本文以 App Router 为主线,必要处对照 Pages Router。
01整体架构:一次请求看穿全貌
先建立"上帝视角"。下面这张图是 Next.js 处理一个页面请求时,各个子系统的协作关系。后面每一章,本质上都是在放大其中某一块。
把这张图拆开,本文的章节其实就是沿着这条流水线展开的:路由匹配(第 5 章)→ RSC 渲染(第 2 章)→ SSR 流水线 + Streaming(第 7、8 章)→ 数据与缓存(第 10、11 章)→ 回到客户端的 水合与导航(第 9 章)。而第 13、14 章专门讲流水线里流动的"数据结构"是什么样子。
关键架构原则:服务端优先,按需下沉到客户端
App Router 的所有设计都围绕一条原则:默认在服务端做尽可能多的事,只把必须交互的部分下放到客户端。 这条原则带来三个直接结果——默认更少的客户端 JS、组件可以直接访问后端资源、数据获取就在组件内联完成。理解了这条原则,后面 RSC、边界、缓存的种种约定就都顺理成章了。
02React Server Components:渲染内核
RSC 是 App Router 的地基。如果只能记住一个概念,就是它。
它到底是什么
React Server Components 是一类只在服务端运行、永远不会被打包进客户端 JS 的组件。 在 App Router 里,组件默认就是 Server Component,除非你显式声明 "use client"。
把页面想象成一栋房子。Server Component 是预制好的墙体和家具——在工厂(服务端)就组装好,运到现场已经是成品,不需要现场施工(不下发 JS、不在浏览器执行)。Client Component 是需要通电的电器——必须把它的"电路图"(JS bundle)也运到现场,插上电(水合)才能用(响应点击、维持状态)。一栋房子里大部分是墙和家具,只有少数地方需要通电。
Server Component 能做与不能做
| Server Component(默认) | Client Component("use client") | |
|---|---|---|
| 运行位置 | 仅服务端(构建时或请求时) | 服务端预渲染一次 + 浏览器水合后运行 |
| 下发 JS | ❌ 零客户端 JS | ✅ 组件代码打包进客户端 |
| 直接访问后端 | ✅ 数据库、文件系统、密钥 | ❌ 只能通过 API / Action |
async/await | ✅ 组件本身可以是 async | ❌ 不能(用 use / hooks) |
| state / effect | ❌ 不能用 useState/useEffect | ✅ 完整 hooks |
| 事件处理 | ❌ 不能 onClick 等 | ✅ 可交互 |
| 浏览器 API | ❌ 无 window/localStorage | ✅ 有 |
// 没有 "use client",这就是一个服务端组件
// 注意它是 async 的,可以直接 await 数据 —— 这是 RSC 才有的能力
export default async function PostsPage() {
// 直接读数据库 / 调内部服务,代码不会下发到浏览器
const posts = await db.post.findMany({ orderBy: { createdAt: 'desc' } })
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
)
}
"Server Component 为什么能直接 await 数据,而普通 React 组件不行?" 因为 RSC 在服务端被渲染成一段序列化的描述(RSC Payload),而不是立即生成 DOM;服务端渲染本身就是一个可以等待的异步过程,React 在服务端会等 Promise resolve 后再把结果写进 payload。客户端组件要参与浏览器的同步水合,无法在渲染期挂起等待网络(要异步数据得用 use() 配合 Suspense 或 effect)。
RSC 的输出不是 HTML,而是"RSC Payload"
这是最容易答错的点。Server Component 渲染的产物不是直接的 HTML 字符串,而是一种 React 专用的序列化格式,常被称为 RSC Payload 或 Flight 数据。它本质上是一棵被序列化的 React 元素树,里面包含:
- Server Component 渲染出来的结果(已经"算好"的元素树);
- 遇到 Client Component 的地方,不内联代码,而是放一个模块引用(reference)——告诉客户端"这里要挂载哪个 chunk 里的哪个组件";
- 从服务端传给 Client Component 的 props(必须可序列化);
- Suspense 边界的占位与后续补发的分块(用于 Streaming)。
这个 payload 有两个用途:① 服务端用它生成初始 HTML(首屏);② 同时把 payload 本身也发给客户端,客户端据此水合,并在后续导航时复用。第 13 章会详细拆 payload 的结构。
"RSC = 服务端渲染 SSR" 是错的。RSC 是组件模型,SSR 是渲染过程,二者正交。 RSC 决定"哪些组件在服务端运行、产出 payload";SSR 决定"把这棵树转成 HTML 字符串发给浏览器"。App Router 同时用了两者:先 RSC 产出 payload,再 SSR 把它转成 HTML。一个 RSC 应用也可以完全静态化(构建时就跑完 RSC)。
03Server / Client 边界
整棵组件树被 "use client" 切成两个世界。理解"边界画在哪、props 怎么穿过去"是 App Router 工程实践的核心,也是面试常考的"原理 + 实践"结合题。
"use client" 是一道传染性的边界
"use client" 写在文件顶部,含义是:从这个模块开始,它以及它 import 进来的所有模块,都进入客户端世界。 它标记的是"进入客户端的入口点",而不是"只有这个组件是客户端"。
关键规则:Server 可以包 Client,Client 不能直接 import Server
数据流方向决定了组合方式。下面这三条是必背规则:
- Server Component 可以渲染 Client Component(在树里把客户端组件当子节点)。
- Client Component 不能直接
import一个 Server Component——因为 Client 的代码要打包进浏览器,而 Server Component 本不该出现在浏览器。 - 但可以通过 children / props 把 Server Component "插槽"进 Client Component。这是绕过上一条限制的标准姿势。
// ✅ 正确:服务端组件作为 children 注入客户端组件
// app/page.tsx (Server)
import ClientWrapper from './client-wrapper' // client
import ServerData from './server-data' // server
export default function Page() {
return (
<ClientWrapper>
{/* ServerData 在服务端渲染好,作为 RSC payload 的一部分 */}
{/* 被"投影"进客户端组件的插槽里,客户端拿到的是渲染结果而非源码 */}
<ServerData />
</ClientWrapper>
)
}
"为什么 Client Component 不能 import Server Component,但能用 children 传进来?" 因为 import 是构建期的静态依赖,会把被引模块打包进客户端 bundle——这会让服务端组件的代码(含密钥、DB 访问)泄漏到浏览器。而 children/props 是运行期的数据流:服务端先把 Server Component 渲染成 payload,再把"渲染结果"作为一个已序列化的节点传给客户端组件,客户端拿到的只是结果,不是源码,所以安全。
跨越边界传给客户端组件的 props 要能被序列化(写进 RSC payload)。函数、类实例、Symbol、Date 之外的复杂对象不能直接传(Server Actions 是被特殊处理的例外)。所以你不能把一个数据库连接、一个回调函数随手传过边界。这也是为什么"事件处理函数不能从 Server Component 传给 DOM"。
实践:把 "use client" 推到叶子节点
性能上的最佳实践是让边界尽量靠近叶子。一个常见反模式是在根布局上写 "use client",结果整棵树都变成客户端,RSC 的优势荡然无存。正确做法:只把真正需要交互的那一小块(按钮、表单输入、带动画的卡片)标成客户端,其余保持服务端。
04编译与构建架构
这一章回答"你的代码是怎么变成能跑的东西的"。面试里属于加分项——能讲清工具链说明你懂底层。
工具链分工
| 工具 | 语言 | 职责 |
|---|---|---|
| Turbopack | Rust | 当前默认打包器(bundler),负责模块解析、依赖图、代码分割、HMR。取代了 webpack。Next 16 起 dev 与 build 默认走 Turbopack。 |
| SWC | Rust | 编译器(compiler),负责 TS/JSX 转译、语法降级、各种 Next 专属转换(如标记 Server/Client 边界、Server Actions 转换)。取代了 Babel。 |
| React Compiler | — | 1.0 已稳定(可选开启)。自动做 memo 化,减少手写 useMemo/useCallback。 |
核心机制:一份代码,两份产物
这是构建阶段最关键的认知。因为有 Server/Client 之分,构建器会从你的代码生成两套 bundle:
构建器以 "use client" 标记的模块为分界:边界以上(含 Server Components)进入服务端图,不打包给浏览器;边界以下进入客户端图,做代码分割后输出到 static/chunks。RSC payload 里对 Client Component 的"模块引用",指向的就是客户端图里对应的 chunk。
next build 都做了什么
- 编译:SWC 把所有 TS/JSX 转译成可执行 JS。
- 打包与分割:Turbopack 构建依赖图,按路由做代码分割(每个路由只加载自己需要的 chunk)。
- 预渲染(prerender):对可静态化的路由,在构建时就跑完 RSC + SSR,产出静态
.html和对应的.rsc(RSC payload 快照)。 - 生成清单(manifests):把"哪个路由对应哪些文件、哪些是静态/动态、入口 chunk 是什么"等元信息写成一堆 JSON(第 14 章详解)。
- 优化:tree-shaking、minify、图片/字体处理等。
开发模式(next dev)和生产(next build && next start)行为不同:开发模式很多东西按需编译、缓存行为也更宽松。调试缓存/渲染问题,永远以生产构建为准。 这是实战里反复踩的坑,也值得在面试里点一句。
05路由系统:文件即路由
Next.js 用文件系统约定来表达路由——目录结构就是路由结构,特殊文件名承担特殊角色。这是"开发者界面"层最直观的部分,也是数据结构层"路由树"的来源。
约定式特殊文件
在 app/ 下,每个目录代表一个路由段(route segment),目录里的特殊文件决定这一段的行为:
| 文件 | 作用 | 关键特性 |
|---|---|---|
page.tsx | 该路由的页面 UI,使路由可被访问 | 有它路由才"存在"。默认 Server Component。 |
layout.tsx | 共享布局,包裹子路由 | 导航时不重新挂载、保留状态;可嵌套。必须渲染 children。 |
template.tsx | 类似 layout,但每次导航重新挂载 | 需要"进入即重置"(如埋点、动画)时用。 |
loading.tsx | 该段的加载态 | 本质是自动包了一层 <Suspense>,配合 Streaming。 |
error.tsx | 该段的错误边界 | 必须是 Client Component;捕获子树渲染错误。 |
not-found.tsx | 404 UI | 由 notFound() 触发。 |
route.ts | API 端点(Route Handler) | 导出 GET/POST/...,处理 Request→Response。与 page 互斥。 |
default.tsx | 平行路由的兜底 UI | 配合 @slot 使用。 |
global-error.tsx | 根级错误边界 | 兜住整个应用的渲染错误。 |
这些文件如何"组装"成一个页面
关键认知:同一路由的特殊文件会按固定顺序嵌套包裹起来。 Next.js 渲染一个路由时,自动生成这样一棵包裹树:
<Layout>
<Template>
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<ErrorBoundary fallback={<NotFound />}>
<Page />
</ErrorBoundary>
</Suspense>
</ErrorBoundary>
</Template>
</Layout>
"layout 和 template 的区别?" 两者都包裹子内容,但 layout 在导航中持久化——切换兄弟路由时 layout 实例及其内部状态(如展开的侧边栏、滚动位置)保留;template 每次导航都重新创建实例,状态清空、effect 重跑。需要"跨页面保持"用 layout,需要"每次进入都重来"用 template。
嵌套布局:路由树天然分层
目录嵌套天然形成布局嵌套。app/layout.tsx 包裹一切,app/dashboard/layout.tsx 只包裹 dashboard 子树。导航到 /dashboard/settings 时,只有变化的最深层段会重新渲染,外层布局原地不动——这是性能与体验的关键。
动态路由与高级路由约定
| 约定 | 含义 | 例子 |
|---|---|---|
[id] | 动态段,匹配单段 | app/post/[id] → /post/42 |
[...slug] | 捕获所有后续段 | /docs/a/b/c |
[[...slug]] | 可选捕获(含空) | 匹配 /docs 也匹配 /docs/a |
(group) | 路由组:组织目录但不进入 URL | (marketing)/about → /about |
@slot | 平行路由:同一布局里渲染多个独立子树 | 仪表盘里 @team 与 @analytics 并存 |
(.)folder | 拦截路由:用当前布局拦截另一路由(如模态框) | 列表里点图片弹模态而非整页跳转 |
_folder | 私有目录:不参与路由 | 放工具/组件 |
// Next 16:params / searchParams 都是 Promise,必须 await
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
return <article>{post.title}</article>
}
// 静态预渲染:告诉构建器要为哪些 slug 生成静态页
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((p) => ({ slug: p.slug }))
}
params、searchParams 以及 cookies()、headers()、draftMode() 在 16 里全部异步化,必须 await。同步用法在 16 已被移除。面试若被问"Next 15→16 有哪些破坏性变更",这是必答点之一(另两个是 Turbopack 默认、middleware.ts→proxy.ts)。
路由系统在内存里:路由树(Route Tree)
所有这些文件约定,最终被 Next.js 解析成一棵路由树。每个节点是一个 route segment,携带该段的 layout / page / loading / error 引用、是否动态、参数名等。这棵树既用于服务端匹配请求,也用于客户端导航时定位"哪一段变了"。它的数据结构形态见第 13 章。
06渲染策略全景:SSG / SSR / ISR / CSR / PPR
这是 Next.js 面试的"必考大题"。先把五种策略一次性摆清楚,再讲它们在 App Router 里如何统一到一个模型下。
| 策略 | HTML 何时生成 | 适用场景 | 权衡 |
|---|---|---|---|
| SSG 静态生成 | 构建时一次性生成 | 博客、文档、营销页等内容不常变 | 最快、可走 CDN;但内容更新要重新构建 |
| ISR 增量静态再生 | 构建时生成 + 按需/定时在后台再生 | 电商商品页、列表,量大且更新有节奏 | 兼顾静态速度与新鲜度;首个过期请求可能拿到旧值(stale-while-revalidate) |
| SSR 服务端渲染 | 每次请求时生成 | 个性化、强实时(用户面板、搜索结果) | 总是最新;但每请求都要算,TTFB 受服务端速度影响 |
| CSR 客户端渲染 | 浏览器里用 JS 生成 | 纯交互、登录后内部工具、无需 SEO 的部分 | 首屏需等 JS;交互后体验好 |
| PPR 部分预渲染 | 静态壳构建时 + 动态洞请求时 | 同一页面里既有静态框架又有个性化片段 | 兼得:壳秒开走 CDN,动态部分流式补上(Next 16 默认能力) |
把渲染想成"出餐":SSG 是预制菜,开门前全做好,谁来都直接端(最快但样式固定);SSR 是现点现做,每位客人都新鲜炒(最对口味但要等);ISR 是热柜里的预制菜,卖光或过一段时间就补一锅新的;CSR 是给你一套食材和说明书,回家自己炒;PPR 是预制好餐盘和摆盘(静态壳),只把那道需要现做的主菜(动态洞)现炒后补上桌。
App Router 如何把这些统一起来
在经典 Pages Router 里,渲染策略是页面级的(由你导出 getStaticProps 还是 getServerSideProps 决定)。App Router 把粒度下沉到组件 / 数据级,并在 Next 16 的 Cache Components 模型下统一成一句话原则:
默认动态(dynamic by default),显式缓存(use cache)才静态。 一个组件/函数有没有标 use cache、读不读请求特定数据(cookies/headers/动态 params),共同决定了它是被预渲染进静态壳,还是在请求时动态计算。整页不再是"非静态即动态",而是一页之内静态与动态并存——这正是 PPR。
具体而言,决定某段是静态还是动态的因素:
- 标了
use cache(配合cacheLife)→ 可被预渲染、缓存复用 → 走静态/ISR 语义。 - 读取了请求特定数据(
cookies()/headers()/ 动态searchParams)→ 只能请求时算 → 动态。 - 未缓存的数据访问被
<Suspense>包裹 → 成为"动态洞",壳先发、它流式补上 → PPR。
Next 16 之前是"默认缓存":fetch 默认被缓存、整页倾向静态,靠 export const dynamic = 'force-dynamic' / revalidate 等路由段配置来"退出缓存"。Next 16 启用 cacheComponents: true 后翻转为"默认动态",靠 use cache 主动"进入缓存"。心智从"我要怎么关掉缓存"变成"我要给什么加缓存",更可预测。注意:不开 cacheComponents 时,旧的 export const revalidate 等仍可用,但官方建议迁移到 use cache。
07SSR 渲染流水线:从请求到屏幕
把第 6 章的"策略"落到"过程"。一次动态请求,HTML 是怎么一步步出现在用户屏幕上的?这是把 RSC、SSR、Streaming、Hydration 串起来的关键一章。
逐步拆解
① 路由匹配。 服务端拿到 URL,在路由树里定位命中的 segments,组装出第 5 章那棵"layout→…→page"的包裹树。
② 运行 RSC。 服务端执行所有 Server Components。async 组件在这里 await 数据。产物是 RSC Payload——元素树 + Client 组件的模块引用 + 序列化 props。
③ SSR 转 HTML。 React 把 payload 转成 HTML 字符串。这一步让首屏不依赖 JS 就能显示,对 SEO 和首屏速度至关重要。
④ Streaming。 关键优化:HTML 不是等全部算完才发,而是边算边发。能立即确定的"静态壳"先发出去;被 <Suspense>(或 loading.tsx)包裹、还在等数据的部分先发 fallback 占位,数据 ready 后再把真实内容作为后续 chunk 补发,浏览器原地替换占位。
⑤–⑧ 客户端接管。 浏览器边收边渲染首屏(用户很快看到内容);同时按 payload 里的模块引用下载交互组件的 JS chunk;然后进行水合——把已有的静态 HTML"激活",给客户端组件接上事件监听与状态,不重新创建 DOM。水合完成,页面完全可交互。
"什么是 Hydration?为什么会有 hydration mismatch?" 水合是 React 在客户端"复用服务端已渲染的 HTML"、把事件与状态附加上去的过程,避免重绘。mismatch 发生在服务端渲染出的 HTML 与客户端首次渲染结果不一致时——常见原因:用了 Date.now()/Math.random()/window 等服务端客户端不一致的值,或把只在浏览器存在的状态用在了首渲染里。解决:把这类逻辑放进 useEffect、用 suppressHydrationWarning、或确保两端输入一致。
Streaming 像上菜不等齐:先上能上的(静态壳),慢的菜(动态数据)做好了再端上来,客人不用干等整桌。Hydration 像给一栋已盖好的样板房通电:墙、家具(HTML)已经在了,电工只是把电线接上、开关装好(附加事件与状态),而不是把房子推倒重盖。
08PPR:部分预渲染(静态壳 + 动态洞)
PPR 是把第 6、7 章的能力推到极致的产物,也是 Next 现代架构的"招牌"。Next 16 通过 cacheComponents 让它成为常态行为。
核心思想:一页 = 一个静态壳 + 若干动态洞
传统模型里一个页面要么整页静态、要么整页动态。PPR 打破这点:把页面里能静态确定的部分(导航栏、布局、产品标题)在构建时预渲染成静态壳,可直接走 CDN 秒开;把个性化/实时的部分(购物车数量、推荐、库存)留作"动态洞",用 <Suspense> 包起来,在请求时流式填充。
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true, // 启用:默认动态 + use cache 才静态 + PPR 默认
}
export default nextConfig
import { Suspense } from 'react'
// 静态片段:标了 use cache,可预渲染进壳
async function ProductInfo({ id }: { id: string }) {
'use cache'
const product = await getProduct(id) // 缓存复用
return <h1>{product.name}</h1>
}
// 动态片段:读实时库存,无缓存 → 成为动态洞
async function LiveStock({ id }: { id: string }) {
const stock = await getStock(id) // 每次请求真实查询
return <span>剩余 {stock} 件</span>
}
export default async function Page({
params,
}: { params: Promise<{ id: string }> }) {
const { id } = await params
return (
<div>
{/* 静态壳的一部分,秒出 */}
<ProductInfo id={id} />
{/* 动态洞:壳先发,骨架占位,数据好了再流式替换 */}
<Suspense fallback={<StockSkeleton />}>
<LiveStock id={id} />
</Suspense>
</div>
)
}
"PPR 和 ISR/SSR 的关系?" 可以这样收束:ISR/SSR/SSG 是页面级的渲染时机选择,而 PPR 是把它们融合进同一页面——壳走"构建时静态(≈SSG)"并可走 CDN,洞走"请求时动态(≈SSR)",靠 Streaming 把两者拼起来。它的前提是 RSC + Suspense + 流式渲染。一句话:PPR 让"静态的快"与"动态的新"在一个页面里共存。
10数据获取与缓存模型
这是 Next.js 区别于纯前端框架的灵魂,也是面试最能拉开差距的部分。我们分"读"(本章)与"写"(下一章 Server Actions)两条线。
读路径:在组件里直接取数据
App Router 的数据获取哲学是"数据获取下沉到需要它的组件,且优先在服务端完成"。Server Component 是 async 的,直接 await 即可——不需要 getServerSideProps 那种页面级钩子。
async function UserProfile({ id }: { id: string }) {
// 直接调数据库 / 内部服务 / fetch,代码不下发浏览器
const user = await db.user.findUnique({ where: { id } })
return <h2>{user.name}</h2>
}
四类缓存:先认清各自管什么
"缓存"在 Next.js 里不是一个东西,而是分布在不同层、生命周期不同的几套机制。这是面试的"深水区",务必分清:
| 缓存 | 位置 / 生命周期 | 缓存什么 | 怎么控制 |
|---|---|---|---|
| Request Memoization 请求记忆化 | 服务端 · 单次渲染内 | 同一次渲染中相同的 fetch/cache() 调用去重,只发一次 | React 自动;React.cache() 包裹自定义函数 |
| Data Cache (经典模型) | 服务端 · 跨请求持久 | 数据获取结果,可定时/按需再验证 | 旧模型 fetch 选项 / revalidate;新模型由 use cache 取代 |
| Full Route Cache (经典模型) | 服务端 · 构建/再验证时 | 静态路由的 HTML + RSC payload | 路由是否静态决定;新模型由 Cache Components 统管 |
| Router Cache 客户端路由缓存 | 客户端内存 · 会话内 | 已访问/预取路由的 RSC payload | prefetch、router.refresh()、revalidate* |
Next 16 的新缓存模型:Cache Components
Next 16 用 Cache Components 重塑了上面的"服务端持久缓存"那部分。核心是三个新原语,配合 cacheComponents: true 使用:
| 原语 | 作用 |
|---|---|
'use cache' | 放在文件顶部或函数/组件内首行,把这个文件的导出 / 这个函数 / 这个 async 组件标记为可缓存。它的参数与 props 自动成为缓存键。 |
cacheLife(profile) | 设定缓存的生命周期。内置 profile:seconds/minutes/hours/days/weeks/max,也可自定义 { stale, revalidate, expire }。语义是 stale-while-revalidate。 |
cacheTag(tag) | 给缓存条目打标签,便于按标签精准失效。 |
import { cacheLife, cacheTag } from 'next/cache'
async function ProductCard({ id }: { id: string }) {
'use cache' // 标记可缓存
cacheLife('hours') // 缓存按"小时"档过期/再验证
cacheTag(`product-${id}`) // 打标签,便于精准失效
const product = await db.product.findById(id)
return <ProductDetail product={product} />
// id 作为入参,自动成为缓存键的一部分:
// 不同 id 命中不同缓存条目
}
失效:让缓存"过时"
缓存的另一半是"如何让它失效"。数据变更(通常在 Server Action 里)后调用:
revalidateTag('product-42')—— 失效所有打了该标签的缓存条目(精准、推荐)。revalidatePath('/products')—— 失效某路径相关缓存。updateTag('...')—— 较新 API,在当前这次变更后立即让对应内容在本次渲染中更新(更强的即时一致性)。
"Next 16 缓存模型相比以前最大的变化是什么?" 由隐式默认缓存翻转为显式按需缓存:以前 fetch 默认被缓存、整页倾向静态,开发者要不断"退出缓存"且容易踩坑;现在默认动态,靠 use cache/cacheLife/cacheTag 主动声明哪些可缓存、缓存多久、怎么失效。配合 PPR,缓存粒度精确到组件/函数级,行为更可预测。补一句:客户端的 SWR/React Query 解决的是另一层问题(客户端缓存、聚焦重取、乐观更新),与 use cache(服务端)不冲突。
use cache 的作用域里不能读请求特定数据(cookies()/headers()),因为那是 per-request 的、无法被多请求共享的缓存复用。带鉴权的页面别整页 use cache——应让页面保持动态,只缓存其中与用户无关的数据函数;或使用私有缓存语义。
11Server Actions:数据变更(写路径)
读路径之外,App Router 用 Server Actions 统一了"写"。它让你在客户端直接调用一个运行在服务端的函数,无需手写 API 路由。
本质:编译器生成的安全 RPC
带 "use server" 标记的函数,会被编译器处理成一个可从客户端安全调用的服务端端点。客户端调用它时,实际是发起一个 POST 请求,参数被序列化送到服务端执行,结果(或副作用)再返回。你写起来像调一个本地异步函数,底层是 RPC。
'use server' // 整个文件的导出都是 Server Action
import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
// 直接在服务端写库(安全,代码不下发)
await db.post.create({ data: { title } })
// 写完让相关缓存失效,UI 拿到新数据
revalidateTag('posts')
redirect('/posts') // 也可重定向
}
import { createPost } from './actions'
export default function NewPost() {
return (
// 把 action 直接绑到 form,提交即调用服务端函数
<form action={createPost}>
<input name="title" />
<button type="submit">发布</button>
</form>
)
}
Server Action 像店里的服务铃:你(客户端)按一下铃并说出需求(序列化参数),后厨(服务端)真正去做事(写库、改状态),做完顺手把菜单上的"今日已售"更新掉(revalidateTag),你不用知道后厨怎么走线、也碰不到食材库(密钥、DB)。而传统做法是你得自己跑去后厨门口(手写 /api 路由 + fetch)递条子。
Server Action 的完整数据流
"Server Action 和 Route Handler(route.ts)怎么选?" Server Action 适合应用内的变更(表单提交、点赞、增删改),与 RSC/缓存失效深度集成、写法简洁、支持渐进增强;Route Handler 适合需要标准 HTTP 接口的场景(给第三方/移动端用的 REST、webhook、需要自定义响应头/流式响应)。一句话:对内变更用 Action,对外接口用 Route Handler。
Server Action 是公开的端点——任何人都可能构造请求调用它。必须在 Action 内部自行做鉴权与输入校验(别假设只有你的表单会调它)。这是常被忽视的安全考点。
12数据流全链路:把一切串起来
这一章是全文的"会合点"。我们把前面的零件拼成一张完整的数据流图,覆盖读(首屏→交互)与写(变更→回流)两个方向。能把这张图讲顺,原理题基本稳了。
读方向(首屏)一句话串联
数据源 →(经缓存层去重/复用)→ Server Component await 取数 → 产出 RSC Payload → SSR 转 HTML 并 Streaming → 浏览器首屏直出 → 下载 chunk → 水合 → 可交互。
读方向(软导航)一句话串联
用户点 <Link> → 客户端路由器算出变化段 → 只请求该段的新 payload(命中 Router Cache 则直接用)→ 与保留布局拼接 → 局部更新。
写方向一句话串联
用户提交 → Server Action 序列化参数 POST 到服务端 → 服务端执行写操作 → revalidateTag/Path 失效相关缓存(服务端 Data Cache + 客户端 Router Cache)→ 服务端重渲生成新 payload 回传 → 客户端局部更新,UI 反映最新数据。
"从用户在地址栏敲回车,到他点击页面上的按钮看到数据更新,整个过程发生了什么?" 这就是把图 1、图 5、图 7、图 8 串起来:① proxy 预处理 → ② 路由匹配 → ③ RSC 渲染(带缓存)产出 payload → ④ SSR+Streaming 发 HTML → ⑤ 浏览器首屏 → ⑥ 水合可交互 → ⑦ 用户点击触发 Server Action → ⑧ 服务端写库 + 失效缓存 → ⑨ 回传新 payload → ⑩ 局部更新。能把这十步讲顺,就拿下了大半。
13核心数据结构
前面流水线里"流动"的东西,落到具体形态就是这几个数据结构。把它们的形状、职责、生命周期记清楚,是回答"有哪些重要数据结构"的标准答案。
① 路由树 / 路由段(Route Tree / Segment)
文件约定被解析成一棵树,每个节点是一个 segment。它是服务端匹配请求和客户端定位变化的共同依据。概念上每个节点大致携带:
type RouteSegment = {
name: string // 段名,如 'dashboard'、'[id]'
type: 'static' | 'dynamic' | 'catch-all' | 'group'
layout?: ComponentRef // 该段的 layout.tsx
page?: ComponentRef // page.tsx
loading?: ComponentRef // loading.tsx(→ Suspense fallback)
error?: ComponentRef // error.tsx(→ ErrorBoundary)
children: RouteSegment[] // 子段
params?: string[] // 动态参数名,如 ['id']
}
② RSC Payload(Flight 数据)—— 最核心
这是整个体系的"血液":Server Component 渲染的序列化产物,既用于生成 HTML,也发给客户端用于水合与导航复用。它是一个流式的、分行的序列化结构,概念上每一"行"是一个带 id 与类型标记的条目:
| 条目类型 | 承载内容 |
|---|---|
| 元素树 | Server Component 已渲染好的 React 元素树(标签、文本、属性) |
| 模块引用 | 遇到 Client Component 时,不内联代码,而是一个指向客户端 chunk 的引用("挂载 chunk X 里的组件 Y") |
| 序列化 props | 从服务端传给客户端组件的可序列化属性 |
| Suspense 分块 | 挂起边界的占位,以及数据 ready 后后续补发的内容块(Streaming 的载体) |
RSC Payload 像一份宜家家具的图纸 + 已组装好的部件清单:大部分是已经拼好的成品(Server 渲染结果),少数地方写着"此处安装 3 号电动模块"(Client 组件的模块引用),并附上该模块的安装参数(props)。客户端拿到这份清单,去仓库(chunks)取出 3 号模块装上、通电(水合),而不需要重新生产那些已拼好的部件。
RSC Payload 不是 JSON、也不是 HTML,是 React 的专有序列化格式(社区常称 "Flight")。它能描述 React 元素、Promise、Suspense、模块引用等 JSON 表达不了的东西,并且支持流式分块。面试别说成"服务端返回 JSON 数据"。
③ Router Cache(客户端路由缓存)
浏览器内存里的一份缓存,按路由段为键存储已获取/预取的 RSC payload。生命周期是当前会话(刷新即清)。它支撑前进后退秒回与 <Link> 预取。概念形态:
// 以路由段路径为键,缓存对应的 RSC payload 与状态
type RouterCache = Map<
string, // segment key,如 '/dashboard/billing'
{
payload: RSCPayload // 该段的渲染数据
prefetched: boolean // 是否由 Link 预取
timestamp: number // 用于判定新鲜度
}
>
④ Build / Prerender 产物
构建期对静态路由生成的快照,存在 .next 里:每个可静态化路由一份 HTML + 一份 .rsc(RSC payload 快照)+ 元数据。运行时静态请求直接吐这些文件,ISR 再生时在后台更新它们。第 14 章细看。
| 数据结构 | 在哪 | 生命周期 | 一句话职责 |
|---|---|---|---|
| 路由树 / Segment | 服务端(构建/运行) | 构建期确定 | 把文件约定变成可匹配的路由结构 |
| RSC Payload | 服务端产出 → 传客户端 | 每次渲染 | 承载组件树+引用+props,是渲染与导航的血液 |
| Router Cache | 客户端内存 | 会话内 | 缓存 payload,支撑秒回与预取 |
| Prerender 快照 | .next 磁盘 / 缓存 | 构建→再生 | 静态路由的 HTML+rsc,秒开走 CDN |
| 缓存条目(tag/life) | 服务端缓存 | 按 cacheLife | 可复用的数据缓存,按 tag 失效 |
14构建产物与清单文件
这一节"很实在"——讲 next build 后磁盘上到底有什么。知道这些,部署、调试、自托管时才不慌,也是面试加分细节。
.next 目录结构(概念)
.next/
├─ server/ # 服务端运行的产物
│ ├─ app/ # 各路由的服务端模块、预渲染 .html / .rsc
│ ├─ chunks/ # 服务端代码分块
│ └─ *-manifest.json # 服务端用的各种清单
├─ static/ # 发给浏览器的静态资源
│ ├─ chunks/ # 客户端 JS 分块(含 use client 组件)
│ └─ css/ # 样式
├─ cache/ # 构建/数据/图片等缓存
└─ *-manifest.json # 顶层清单(见下表)
关键清单文件(Manifests)
清单是一堆 JSON,记录"路由↔文件↔chunk"的映射与元信息,让运行时能快速找到该加载什么。常见的:
| 清单 | 记录什么 |
|---|---|
routes-manifest.json | 所有路由的定义:静态/动态、rewrites、redirects、headers、动态路由的正则匹配规则 |
app-paths-manifest.json | App Router 各路由对应的服务端入口模块 |
build-manifest.json / app-build-manifest.json | 每个页面/路由需要加载的客户端 JS/CSS 文件列表(决定首屏要拉哪些 chunk) |
prerender-manifest.json | 哪些路由被预渲染、其 ISR 再验证配置、回退策略 |
react-loadable-manifest.json | 动态导入(next/dynamic)的模块映射,支撑按需加载 |
middleware-manifest.json | 中间件 / proxy 的匹配规则与入口 |
next-font-manifest.json | next/font 字体优化的产物映射 |
被问"Next.js 怎么做代码分割的"时,可以落到清单层面:构建器按路由切 chunk,build-manifest 记录每个路由需要的 chunk 列表,运行时只下发当前路由所需的 JS;next/dynamic 的懒加载映射记在 react-loadable-manifest。这样回答比泛泛说"自动代码分割"更有深度。
产物如何运行取决于部署目标:Vercel 会自动把静态壳放 CDN、动态部分跑在 Serverless/Edge;自托管常用 next start(Node 服务器)或 output: 'standalone'(产出自带依赖的精简包,便于打 Docker);纯静态站点可用 output: 'export'(但放弃 SSR/ISR 等动态能力)。Next 16 还引入了稳定的 Adapter API,让不同托管平台能以统一方式适配。
15proxy.ts / 中间件
在请求进入路由渲染之前,有一道可编程的"关卡"。Next 16 把它从 middleware.ts 改名并改造为 proxy.ts。
它是什么、能干什么
这道关卡在请求抵达页面渲染之前运行,对所有匹配的请求生效。典型用途:鉴权拦截、重定向、URL 重写、A/B 分流、注入/改写请求与响应头、地理或语言路由。它不渲染 UI,只做"请求路由层"的决策。
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const token = request.cookies.get('session')?.value
// 未登录访问后台 → 重定向到登录页
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next() // 放行
}
// 只对匹配的路径运行,避免拦截静态资源
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*'],
}
① middleware.ts 已废弃,替代者是 proxy.ts;② 旧 middleware 运行在 Edge 运行时,新 proxy 运行在 Node.js 运行时(能力更全、更少限制);③ 旧 middleware.ts 仍可用但不建议。面试问"Next 16 破坏性变更",这是三大点之一。
不要把它当作唯一的鉴权防线。它适合做"边缘快速拦截 / 重定向",但敏感的授权校验仍应在数据访问层(Server Component / Server Action / Route Handler)再做一次。历史上出现过绕过 middleware 的安全公告,纵深防御是正解。
16完整请求生命周期(总装)
把所有零件按时间顺序装成一条线。这是面试"讲讲一个请求的完整过程"的标准答题脚本。
- 发起:地址栏输入或应用内导航产生请求。
- proxy.ts:边缘/Node 关卡先处理——可重定向(如未登录)、重写、改 header,或直接放行。
- 路由匹配:在路由树定位命中的 segments,组装 layout→…→page 的包裹树。
- 缓存检查:若该路由/片段有可用静态快照或缓存命中,直接复用,跳过重渲。
- RSC 渲染:运行 Server Components,
await数据(受缓存层去重/复用),产出 RSC Payload;Client 组件以模块引用形式占位。 - SSR + Streaming:payload 转 HTML,静态壳先发,Suspense 包裹的动态洞发 fallback、数据 ready 后补发。
- 浏览器首屏:边收边画,用户快速看到内容(不依赖 JS)。
- 下载与水合:按 payload 引用拉取客户端 chunk,水合激活交互。
- 进入 SPA:之后导航走客户端软导航,只取变化段 payload,命中 Router Cache 则秒回。
- 变更回流:用户操作触发 Server Action → 服务端写 + 失效缓存 → 回传新 payload → 局部更新。
17项目工程化要点
实际项目里除了原理,还有一组"做对就省心"的工程实践,面试也常顺带问。
推荐项目结构
src/
├─ app/ # 路由与页面(约定式)
│ ├─ layout.tsx # 根布局
│ ├─ page.tsx
│ ├─ (marketing)/ # 路由组:组织而不入 URL
│ ├─ dashboard/
│ │ ├─ layout.tsx
│ │ └─ page.tsx
│ └─ api/ # Route Handlers(对外接口)
├─ components/ # 共享组件(区分 server/client)
├─ lib/ # 数据访问、工具、db 客户端
├─ actions/ # Server Actions("use server")
└─ proxy.ts # 请求关卡
环境变量与密钥
- 仅
NEXT_PUBLIC_前缀的变量会被打进客户端 bundle;密钥绝不要加该前缀。 - Server Component / Action 里可以直接读私有环境变量与 DB——因为它们不下发浏览器。这正是 RSC 的安全优势之一。
性能优化清单(高频问)
| 手段 | 解决什么 |
|---|---|
把 "use client" 推到叶子 | 减少客户端 JS,最大化 RSC 优势 |
next/image | 自动尺寸/格式优化、懒加载、防布局抖动(CLS) |
next/font | 字体自托管、消除字体闪烁与外部请求 |
<Link> 预取 + 软导航 | 导航近乎瞬时 |
<Suspense> + Streaming | 慢数据不阻塞首屏;配合 PPR |
use cache + cacheTag | 精准缓存与失效,降低重复计算/查询 |
next/dynamic | 大组件按需懒加载,拆小首屏 bundle |
| React Compiler | 自动 memo 化,减少重渲 |
Next.js 的工程心法可以浓缩成:"能在服务端做的就别推给客户端,能缓存复用的就别每次重算,能流式先给的就别让用户干等。" 这三句分别对应 RSC、Cache Components、Streaming/PPR——也正是本文的三条主线。
18面试速记 Q&A
把全文压缩成可以快速过的问答。点击问题看要点(也可直接通读)。这些覆盖了 Next.js 面试的绝大多数原理题。
getServerSideProps/getStaticProps 取数。use cache 才静态。Date.now()/Math.random()/window 等。解决:移进 useEffect、suppressHydrationWarning、保证输入一致。<Suspense>/loading.tsx 包裹的慢内容先发 fallback,数据 ready 后把真实内容作为后续 chunk 补发,浏览器原地替换占位。降低 TTFB、首屏更快。use cache/cacheLife/cacheTag) 重塑了服务端持久缓存部分。use cache 才缓存"。心智从"如何退出缓存"变为"给什么加缓存",更可预测;缓存粒度到组件/函数级,配合 PPR。revalidateTag/revalidatePath(失效服务端 Data Cache 与客户端 Router Cache),或客户端 router.refresh();需要本次渲染即时一致可用 updateTag。"use server" 的函数,编译为可从客户端安全调用的服务端 RPC,适合应用内变更、与缓存失效集成、支持渐进增强。对外的标准 HTTP 接口(REST/webhook/自定义响应)用 Route Handler。Action 是公开端点,必须自做鉴权与校验。<Link> 会自动预取视口内目标。middleware.ts → proxy.ts(Edge→Node 运行时);③ 请求 API 异步化(cookies/headers/params/searchParams 须 await,同步用法移除);④ Cache Components 新缓存模型(cacheComponents 取代 dynamicIO/ppr/unstable_cache)。build-manifest 记录每路由所需 chunk,运行时只下发当前路由 JS;next/dynamic 懒加载映射记在 react-loadable-manifest。NEXT_PUBLIC_ 前缀变量进客户端 bundle;密钥不加该前缀,仅在 Server Component/Action/Route Handler 中使用(这些代码不下发浏览器)。