发布日期

第 20 讲:动态路由与 generateStaticParams、fallback 策略

动态路由、generateStaticParams 静态化与 fallback 策略的源码实现

第 19 讲讲了 PPR 怎么"按段"做静态预渲染。但站到 build pipeline 视角,还有一个更底层的问题没解决:当一个动态路由 [slug] 有 100 个商品时,Next.js 怎么知道要 prerender 哪些 slug?没列举到的 slug 又怎么处理?

这就是本讲的主题:generateStaticParamsdynamicParamsFallbackMode,以及它们与 PPR 的交互。

学习目标

读完本讲,你能:

  1. 解释 generateStaticParams 的执行模型:build 时同步收集、嵌套段如何合并、为何允许返回空数组。
  2. 区分 FallbackMode 的 3 种状态(NOT_FOUND / PRERENDER / BLOCKING_STATIC_RENDER)以及对应的 segment 配置(dynamicParams = true/falsedynamic = 'error'/'force-static')。
  3. 理解 PPR 启用时的"root params"概念,以及为什么 root params 缺失会强制使用 blocking fallback。
  4. 在生产中诊断"为什么这个 slug 404 / build 太慢 / 第一个请求超时"等问题。
  5. 解释嵌套动态路由([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 会:

  1. 调用 generateStaticParams(),拿到 params 数组。
  2. 对每个 params 跑一次 prerender(生成 HTML+RSC payload 写到 .next/server/app/products/[slug].html.next/server/app/products/[slug].rsc)。
  3. prerender-manifest.json 里登记每个具体 slug 路径。
  4. 设置 fallback:是 false(NOT_FOUND)/true(PRERENDER)/'blocking'(BLOCKING_STATIC_RENDER)。

三、buildAppStaticPaths:build 时的入口

821:packages/next/src/build/static-paths/app.ts
export async function buildAppStaticPaths({
  dir,
  page,
  route,
  distDir,
  cacheComponents,
  authInterrupts,
  useCacheTimeout,
  staticPageGenerationTimeout,
  segments,
  // ...
  ComponentMod,
  isRoutePPREnabled = false,
  buildId,
  deploymentId,
  rootParamKeys,
}): Promise<StaticPathsResult> {

入参里关键的几项:

  • segments:本路由"链路上的每一段"的元信息(configgenerateStaticParams 函数)。从 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:嵌套段的笛卡尔积

680:packages/next/src/build/static-paths/app.ts
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" }];
};

执行:

  1. 初始 params: [] → 处理 [lang] 这一段,得到 [{lang:'en'},{lang:'zh'}]
  2. 对每个 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'}]
  3. 最终得到 4 条 params 组合。

注意几个细节:

  • 子段可以读 parent paramsgenerateStaticParams({ params }) 收到的是 parent 段的合并结果。
  • 空数组的行为:如果某段返回空数组:
    • PPR 关闭:跳过,直接传 parent params(这一段视为动态)
    • PPR 启用throwEmptyGenerateStaticParamsError() 抛错(PPR 不允许"完全动态"的嵌套段)
710:packages/next/src/build/static-paths/app.ts
        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 的三种状态

24:packages/next/src/lib/fallback.ts
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 时代的 getStaticPathsfallback 字段:

App Router 配置Pages Router 等价FallbackMode含义
dynamicParams = true(默认)fallback: 'blocking'BLOCKING_STATIC_RENDER未列出的 slug 第一次访问时同步渲染 + 缓存
dynamicParams = falsefallback: falseNOT_FOUND未列出的 slug 直接 404
dynamic = 'force-static'fallback: truePRERENDER先返回 fallback 占位 HTML,后台生成实际页面

parseFallbackFieldparseStaticPathsResult 负责把"用户配置"翻译成 FallbackMode 枚举:

93:packages/next/src/lib/fallback.ts
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 的特殊规则

305:packages/next/src/build/static-paths/app.ts
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

为什么 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

  1. ResponseCache MISS
  2. 走完整渲染流程,得到 HTML + RSC payload
  3. 写入 IncrementalCache(dist 写盘 .next/server/app/products/airpods-pro-2.html.rsc
  4. 后续请求直接 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

排障思路:

  1. 生成的 slug 太多:分批用 generateStaticParams 控制数量
  2. 某个 fetch 卡住:加 AbortSignal 超时
  3. 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 一直 404dynamicParams=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 永远是 dynamicpnpm build && pnpm start 验证

十五、配套 fixture:4 种 fallback 行为对比

fixtures/lecture-20/ 提供 4 个对照路由:

  1. /static-only/[slug]dynamicParams = false,未列出 → 404
  2. /isr/[slug]dynamicParams = true + revalidate = 60,未列出 → blocking + cache
  3. /nested/[lang]/[slug]:嵌套 generateStaticParams,验证 cartesian product
  4. /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 效果。

十六、本讲小结

  1. generateStaticParams 在 build 时同步收集,嵌套段做 cartesian product。
  2. FallbackMode 三种枚举:NOT_FOUND(404)、BLOCKING_STATIC_RENDER(ISR)、PRERENDER(占位 + 后台生成)。
  3. dynamicParams 是段级配置;与 generateStaticParams 共同决定 fallback 行为。
  4. PPR 启用时:root params 必须由用户列举,否则强制 blocking;中间段允许 fallback。
  5. 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。