- 发布日期
第 20 讲:动态路由与 generateStaticParams、fallback 策略
动态路由、generateStaticParams 静态化与 fallback 策略的源码实现
第 19 讲讲了 PPR 怎么"按段"做静态预渲染。但站到 build pipeline 视角,还有一个更底层的问题没解决:当一个动态路由
[slug]有 100 个商品时,Next.js 怎么知道要 prerender 哪些 slug?没列举到的 slug 又怎么处理?这就是本讲的主题:
generateStaticParams、dynamicParams、FallbackMode,以及它们与 PPR 的交互。
学习目标
读完本讲,你能:
- 解释
generateStaticParams的执行模型:build 时同步收集、嵌套段如何合并、为何允许返回空数组。 - 区分
FallbackMode的 3 种状态(NOT_FOUND / PRERENDER / BLOCKING_STATIC_RENDER)以及对应的 segment 配置(dynamicParams = true/false、dynamic = 'error'/'force-static')。 - 理解 PPR 启用时的"root params"概念,以及为什么 root params 缺失会强制使用 blocking fallback。
- 在生产中诊断"为什么这个 slug 404 / build 太慢 / 第一个请求超时"等问题。
- 解释嵌套动态路由(
[lang]/[slug])的 cartesian product 行为。
本讲对应代码:
packages/next/src/build/static-paths/app.ts(buildAppStaticPaths、generateRouteStaticParams、calculateFallbackMode)packages/next/src/lib/fallback.ts(FallbackMode 枚举)packages/next/src/server/base-server.ts:getStaticPaths(运行时读取 fallback 字段)
一、动态路由回顾
App Router 的动态路由 segment:
| 写法 | 含义 |
|---|---|
[slug] | 必选单段动态参数 |
[...slug] | 必选多段动态参数(catch-all) |
[[...slug]] | 可选多段动态参数(optional catch-all) |
思考题:
/blog/[slug]/page.tsx访问/blog/hello,slug 是字符串;访问/blog/(无段)会怎样? 答案:会进/blog/page.tsx(如果存在),否则 404。而
/blog/[[...slug]]/page.tsx访问/blog/时slug是[](空数组),访问/blog/2025/01/post时是['2025','01','post']。
二、generateStaticParams 是什么
// app/products/[slug]/page.tsx
export async function generateStaticParams() {
const products = await fetch("https://api.example.com/products").then((r) =>
r.json(),
);
return products.map((p) => ({ slug: p.slug })); // [{slug:'iphone-15'},...]
}
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// ...
}
build 时 Next.js 会:
- 调用
generateStaticParams(),拿到 params 数组。 - 对每个 params 跑一次 prerender(生成 HTML+RSC payload 写到
.next/server/app/products/[slug].html、.next/server/app/products/[slug].rsc)。 - 在
prerender-manifest.json里登记每个具体 slug 路径。 - 设置 fallback:是
false(NOT_FOUND)/true(PRERENDER)/'blocking'(BLOCKING_STATIC_RENDER)。
三、buildAppStaticPaths:build 时的入口
export async function buildAppStaticPaths({
dir,
page,
route,
distDir,
cacheComponents,
authInterrupts,
useCacheTimeout,
staticPageGenerationTimeout,
segments,
// ...
ComponentMod,
isRoutePPREnabled = false,
buildId,
deploymentId,
rootParamKeys,
}): Promise<StaticPathsResult> {
入参里关键的几项:
segments:本路由"链路上的每一段"的元信息(config、generateStaticParams函数)。从 root layout 一直到 page。isRoutePPREnabled:本路由是否启用 PPR(来自 next.config.js + page 的experimental_ppr)。rootParamKeys:所谓 root params——必须由用户在generateStaticParams里提供的"顶层动态参数"。cacheComponents:是否启用'use cache'/PPR 配套。
工作流程:
ComponentMod.patchFetch() // 让 fetch 接入 IncrementalCache,build 时收集 cache
const incrementalCache = await createIncrementalCache({ ... })
const pathnameRouteParamSegments = extractPathnameRouteParamSegments(...)
const store = createWorkStore({ page, renderOpts, ... })
const routeParams = await workAsyncStorage.run(
store,
generateRouteStaticParams,
segments,
store,
isRoutePPREnabled,
rootParamKeys
)
// 之后基于 routeParams 决定 fallback、生成静态文件
四、generateRouteStaticParams:嵌套段的笛卡尔积
export async function generateRouteStaticParams(
segments: ReadonlyArray<
Readonly<Pick<AppSegment, 'config' | 'generateStaticParams'>>
>,
store: Pick<WorkStore, 'fetchCache' | 'page'>,
isRoutePPREnabled: boolean,
rootParamKeys: readonly string[]
): Promise<Params[]> {
if (segments.length === 0) return []
const implicitTags = await getImplicitTags(store.page, store.page, null)
interface WorkItem {
segmentIndex: number
params: Params[]
}
const queue: WorkItem[] = [{ segmentIndex: 0, params: [] }]
// ...
它用一个工作队列遍历 segments,把每个 segment 的 generateStaticParams 的结果"逐级合并"到 parent params 里。
例子:/[lang]/[slug],两个 segment 都各自有 generateStaticParams。
// app/[lang]/layout.tsx
export const generateStaticParams = () => [{ lang: "en" }, { lang: "zh" }];
// app/[lang]/[slug]/page.tsx
export const generateStaticParams = ({ params }) => {
if (params.lang === "en") return [{ slug: "home" }, { slug: "about" }];
if (params.lang === "zh") return [{ slug: "shouye" }, { slug: "guanyu" }];
};
执行:
- 初始
params: []→ 处理[lang]这一段,得到[{lang:'en'},{lang:'zh'}] - 对每个 parent,调子段的 generateStaticParams:
- parent
{lang:'en'}→[{slug:'home'},{slug:'about'}]→ 合并为[{lang:'en',slug:'home'},{lang:'en',slug:'about'}] - parent
{lang:'zh'}→ 合并为[{lang:'zh',slug:'shouye'},{lang:'zh',slug:'guanyu'}]
- parent
- 最终得到 4 条 params 组合。
注意几个细节:
- 子段可以读 parent params:
generateStaticParams({ params })收到的是 parent 段的合并结果。 - 空数组的行为:如果某段返回空数组:
- PPR 关闭:跳过,直接传 parent params(这一段视为动态)
- PPR 启用:
throwEmptyGenerateStaticParamsError()抛错(PPR 不允许"完全动态"的嵌套段)
if (result.length > 0) {
for (const item of result) {
nextParams.push({ ...parentParams, ...item })
}
} else if (isRoutePPREnabled) {
throwEmptyGenerateStaticParamsError()
} else {
业务示例:电商有 50 个商品分类、每个分类 100~1000 商品,
/[category]/[productId]启用 generateStaticParams:
- 不限制时 build 会产出 ~50,000 个 HTML/RSC 文件,build 时间动辄半小时
- 通常做法:只 prerender "热门 + 最近"的 product,剩下交给
dynamicParams: true在 runtime 按需生成
五、FallbackMode 的三种状态
export const enum FallbackMode {
/**
* A BLOCKING_STATIC_RENDER fallback will block the request until the page is
* generated. No fallback page will be rendered, and users will have to wait
* to render the page.
*/
BLOCKING_STATIC_RENDER = 'BLOCKING_STATIC_RENDER',
/**
* When set to PRERENDER, a fallback page will be sent to users in place of
* forcing them to wait for the page to be generated. This allows the user to
* see a rendered page earlier.
*/
PRERENDER = 'PRERENDER',
/**
* When set to NOT_FOUND, pages that are not already prerendered will result
* in a not found response.
*/
NOT_FOUND = 'NOT_FOUND',
}
三种模式对应 Pages Router 时代的 getStaticPaths 的 fallback 字段:
| App Router 配置 | Pages Router 等价 | FallbackMode | 含义 |
|---|---|---|---|
dynamicParams = true(默认) | fallback: 'blocking' | BLOCKING_STATIC_RENDER | 未列出的 slug 第一次访问时同步渲染 + 缓存 |
dynamicParams = false | fallback: false | NOT_FOUND | 未列出的 slug 直接 404 |
dynamic = 'force-static' | fallback: true | PRERENDER | 先返回 fallback 占位 HTML,后台生成实际页面 |
parseFallbackField 和 parseStaticPathsResult 负责把"用户配置"翻译成 FallbackMode 枚举:
export function parseStaticPathsResult(
result: GetStaticPathsFallback
): FallbackMode {
if (result === true) {
return FallbackMode.PRERENDER
} else if (result === 'blocking') {
return FallbackMode.BLOCKING_STATIC_RENDER
} else {
return FallbackMode.NOT_FOUND
}
}
六、calculateFallbackMode:PPR 与 root params 的特殊规则
export function calculateFallbackMode(
dynamicParams: boolean,
fallbackRootParams: readonly string[],
baseFallbackMode: FallbackMode | undefined
): FallbackMode {
return dynamicParams
? // If the fallback params includes any root params, then we need to
// perform a blocking static render.
fallbackRootParams.length > 0
? FallbackMode.BLOCKING_STATIC_RENDER
: (baseFallbackMode ?? FallbackMode.NOT_FOUND)
: FallbackMode.NOT_FOUND
}
读懂这个函数:
dynamicParams = false→ 一切未列出的都 404,简单粗暴。dynamicParams = true:- 如果有 root params 缺失(即 PPR 下某些顶层动态参数 build 时没枚举到)→ 强制
BLOCKING_STATIC_RENDER - 否则使用用户的
baseFallbackMode(默认NOT_FOUND)
- 如果有 root params 缺失(即 PPR 下某些顶层动态参数 build 时没枚举到)→ 强制
为什么 PPR 下 root params 缺失就要 blocking?因为:
- PPR 的 prelude HTML 是 按 route shape 生成的,root params 在 URL 中决定 shape(比如
[lang]/products/[slug],lang 是 root,决定整个 layout) - 如果 root params build 时没枚举,runtime 进来一个新
lang,需要立刻为这条新 root 生成 prelude,不能用别的 lang 的 prelude 冒充 - 这就是 blocking——必须等服务端跑完一次完整 prerender 再响应
七、与 dynamicParams 的关系
每个段(不只是 page)都可以有 export const dynamicParams = true/false。Next.js 会沿 LoaderTree 向上找最近一个显式设置:
// 全局允许
// 默认 dynamicParams = true
// app/admin/layout.tsx
export const dynamicParams = false; // /admin/* 下未列出的 slug 全部 404
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
// 即使这里只列了 10 个 slug,第 11 个 slug 访问会被父级 dynamicParams=false 拦截
}
业务用途:
- 公共页面用默认
dynamicParams = true(按需生成 + 缓存) - 后台/老旧路径用
dynamicParams = false(避免攻击者构造无意义 slug 引发 build/render storm)
八、运行时:fallback 的实际效果
在 base-server.ts 的 renderToResponseWithComponentsImpl 里:
let fallbackMode = parseFallbackField(prerender?.fallback);
读取 prerender-manifest.json 里这条 route 的 fallback 字段,决定如何处理"未预渲染"的请求。
NOT_FOUND
if (fallbackMode === FallbackMode.NOT_FOUND) {
throw new NoFallbackError();
// 上层 invokeRender catch 后,进入 404 渲染流程
}
任何未列出的 slug 立刻 404。
BLOCKING_STATIC_RENDER
// 直接进 renderHTML,生成新页面 → 写入 IncrementalCache
const result = await responseCache.get(cacheKey, async () => {
return await this.renderHTML(...)
})
第一次请求 /products/airpods-pro-2:
- ResponseCache MISS
- 走完整渲染流程,得到 HTML + RSC payload
- 写入 IncrementalCache(dist 写盘
.next/server/app/products/airpods-pro-2.html、.rsc) - 后续请求直接 HIT
这是 ISR 的工作模式。
PRERENDER
App Router 里 PRERENDER 模式相对少见(Pages Router 时代的 fallback: true):
- 服务端先返回 fallback 占位 HTML(
generateStaticParams返回的 fallback 模板) - 后台 spawn 一个生成任务,写入 IncrementalCache
- 客户端 hydrate 后通过 RSC 请求拿到完整数据
业务示例:评论系统每条评论都是独立路由
/comments/[id],但有 50 万条。dynamicParams = true+ ISR 模式下:
- build 只 prerender 最近 1000 条
- 第一个访问历史评论 ID 的人会等 ~500ms(blocking render)
- 后续访问者立刻 HIT 缓存
- revalidate 配合
revalidateTag('comment-' + id)实时刷新
九、staticPageGenerationTimeout:build 超时
staticPageGenerationTimeout: number; // 默认 60s
每个 page 的 prerender 不能超过这个时长,否则 build 抛错:
Static page generation for /products/[slug] is still timing out after 3 attempts.
See more info here https://nextjs.org/docs/messages/static-page-generation-timeout
排障思路:
- 生成的 slug 太多:分批用 generateStaticParams 控制数量
- 某个 fetch 卡住:加 AbortSignal 超时
- CPU 密集组件:把它放在 Suspense 里(开 PPR 后可作为 dynamic hole)
十、generateStaticParams 内部的 IncrementalCache
注意 buildAppStaticPaths 里:
ComponentMod.patchFetch()
const incrementalCache = await createIncrementalCache({ ... })
build 时也创建了一个 IncrementalCache 实例。意思是:
generateStaticParams里调用fetch('https://...')会被patchFetch拦截- 命中 cache 直接返回
- 这套 cache 后续会序列化到
resumeDataCache(第 19 讲讲过),让 runtime resume 时复用
业务示例:generateStaticParams 取产品列表 + page 内 generateMetadata 再取详情,两次 fetch 同一个 URL,第二次直接命中 build 时的 IncrementalCache,不会发起新请求。
十一、Interception / Parallel 路由的参数
第 8 讲讲过 LoaderTree 与 parallel/interception 路由。
对于带 slot 的并行路由(@modal),generateStaticParams 只用 page 自身的段;slot 里的页面如果也有 generateStaticParams,会单独枚举。
对于 interception 路由((.)photo/[id]),build 时会 prerender 两种 shape:
- 普通路径:
/photo/123 - intercepted 路径:
/feed/(.)photo/123
这是 extractPathnameRouteParamSegments 的工作——它会遍历 LoaderTree 找到所有 interception slot,把它们的 params 合并到主路由的 params 里。
排障小贴士:你看到
prerender-manifest.json里同一个动态路由生成了 2N 个条目,多半是 interception 路由的 cartesian product 起作用。
十二、未生成的 slug:404 vs blocking 实测
最直观的对比实验(fixture):
// /static-only/[slug]/page.tsx
export const dynamicParams = false;
export async function generateStaticParams() {
return [{ slug: "iphone" }, { slug: "macbook" }];
}
// /isr/[slug]/page.tsx
export const dynamicParams = true; // (默认)
export const revalidate = 60;
export async function generateStaticParams() {
return [{ slug: "iphone" }, { slug: "macbook" }];
}
build:
○ /static-only/iphone (静态)
○ /static-only/macbook (静态)
ƒ /static-only/[slug] (NOT_FOUND fallback)
○ /isr/iphone (静态)
○ /isr/macbook (静态)
ƒ /isr/[slug] (BLOCKING_STATIC_RENDER, revalidate=60s)
ƒ 标识动态/fallback 路由。访问 /static-only/ipad → 404;访问 /isr/ipad → 首次约 200ms,之后 <10ms 命中 cache。
十三、PPR + generateStaticParams:组合使用
PPR 开启时,generateStaticParams 仍可用,但行为略有不同:
// app/products/[slug]/page.tsx
export const experimental_ppr = true;
export async function generateStaticParams() {
return [{ slug: "iphone" }, { slug: "macbook" }]; // 这些 slug 有 prelude
}
- 列出的 slug → build 时生成 PPR prelude(static 部分)+ postponedState(dynamic hole 模板)
- 未列出的 slug → fallback 行为,但 PPR root params 检查(见第 6 节)会强制 blocking
实际经验:PPR 下 generateStaticParams 主要用来 限定 root params 的枚举值(如语言列表),中间段的动态参数留给 fallback。
十四、生产排障实战清单
| 现象 | 位置 | 排查方向 |
|---|---|---|
| build 卡在 "Generating static pages..." | 某个 path prerender 超时 | 减少 generateStaticParams 数量、加 AbortSignal、看 staticPageGenerationTimeout 是否要调大 |
| 上线后某些 slug 一直 404 | dynamicParams=false 又没列出 | 改为 true(ISR)或补全 generateStaticParams |
| 第一次访问某 slug 慢 800ms+ | BLOCKING_STATIC_RENDER 首渲 | 把热门 slug 预先列入 generateStaticParams |
| build OOM | 路径太多,并发 prerender 占内存 | 调整 staticWorkerRequestDeduping、降低并发、分批构建 |
PPR 启用后 build 报 throwEmptyGenerateStaticParamsError | 嵌套段 generateStaticParams 返回空 | PPR 下不允许空,要么填值要么关闭该段的 PPR |
| Root params 错误 | Required root params (lang) were not provided | 在最顶层段的 generateStaticParams 至少给一组值 |
| dev 模式表现与生产不一致 | dev 永远是 dynamic | 用 pnpm build && pnpm start 验证 |
十五、配套 fixture:4 种 fallback 行为对比
fixtures/lecture-20/ 提供 4 个对照路由:
/static-only/[slug]:dynamicParams = false,未列出 → 404/isr/[slug]:dynamicParams = true+revalidate = 60,未列出 → blocking + cache/nested/[lang]/[slug]:嵌套 generateStaticParams,验证 cartesian product/dynamic/[slug]:dynamic = 'force-dynamic',每次请求都重新渲染
启动:
cd learning/nextjs-40-lectures/fixtures/lecture-20
pnpm install
pnpm build # 看 build log 中的 ○/ƒ/λ 标记
pnpm start
# http://localhost:3020
build log 里能直接看到每个 path 的 fallback mode。运行后用 curl -w '%{time_total}\n' 测响应时间,第一次和第二次的差距就是 ISR cache 效果。
十六、本讲小结
generateStaticParams在 build 时同步收集,嵌套段做 cartesian product。FallbackMode三种枚举:NOT_FOUND(404)、BLOCKING_STATIC_RENDER(ISR)、PRERENDER(占位 + 后台生成)。dynamicParams是段级配置;与generateStaticParams共同决定 fallback 行为。- PPR 启用时:root params 必须由用户列举,否则强制 blocking;中间段允许 fallback。
buildAppStaticPaths内置 IncrementalCache:build 时的 fetch 与 runtime resume cache 通过 patchFetch 串起来。
下讲预告
第 21 讲《Edge Runtime:V8 isolate 与 Node 兼容层》。我们会拆 NextWebServer 的实现:它没有 findPageComponents、不能 require()、所有 page module 都打包到一个 chunk 里;它的 RSC + SSR 双流是怎么跑的;Edge Middleware 又怎么和 Edge Runtime page 共享 sandbox。