发布日期

第 23 讲:next build 全流程:从 entry 收集到 nft.json

next build 全流程:entry 收集 → 编译 → 优化 → nft.json 产物生成

阶段四开始我们离开"渲染管线",转向"构建管线"。本讲拆 packages/next/src/build/index.ts 的 main 函数:它把"一个 app 目录"变成"可部署的 .next/ 目录",中间到底做了多少事?

这是一个真正的庞然大物——build 主函数有近 3500 行,涉及 webpack / turbopack / SWC / tracing / Sentry / Telemetry / Worker 池 / manifest 写出。我们按阶段拆开来看。

学习目标

读完本讲,你能:

  1. 复述 next build 的 11 个核心阶段:加载 config → 收集 entries → 编译三套 compiler → 静态分析 → trace nft → 写 manifest → 静态生成 → 产出报告。
  2. 知道每个阶段的耗时占比,哪些可以并行,哪些是瓶颈。
  3. 看懂 webpackBuild(['server']) / webpackBuild(['edge-server']) / webpackBuild(['client']) 三套独立 compiler 的关系。
  4. 解释 nft(Node File Trace)的工作原理,以及为什么 .next/server/app/<page>.js.nft.json 决定了 standalone 部署能否成功。
  5. 在生产中排查 build OOM、build 超时、bundle size 异常等问题。

本讲对应代码:

  • packages/next/src/build/index.ts(主流程)
  • packages/next/src/build/webpack-build/index.ts(webpack compiler 入口)
  • packages/next/src/build/turbopack-build/index.ts(turbopack compiler 入口)
  • packages/next/src/build/collect-build-traces.ts(nft trace)

一、build 入口:next-build CLI → build function

next build
# ↓ packages/next/src/cli/next-build.ts
# ↓ require('./build/index')
# ↓ build(dir, ...args)

packages/next/src/build/index.tsbuild() 是真正的入口:

938:packages/next/src/build/index.ts
export default async function build(
  dir: string,
  experimentalAnalyze = false,
  reactProductionProfiling = false,
  debugOutput = false,
  debugPrerender = false,
  noMangling = false,
  appDirOnly = false,
  bundler = Bundler.Turbopack,
  experimentalBuildMode: 'default' | 'compile' | 'generate' | 'generate-env',
  traceUploadUrl: string | undefined,
  debugBuildPaths: { app: string[]; pages: string[] } | undefined,
  enabledFeatures: Record<string, unknown> = {}
): Promise<void> {

关键参数:

  • bundlerTurbopack(默认)/ Webpack
  • experimentalBuildMode
    • 'default':完整 build(编译 + 静态生成 + manifest)
    • 'compile':只编译,跳过静态生成(Vercel 平台用,再调度 generate 阶段)
    • 'generate':跳过编译直接做静态生成(接续 compile
    • 'generate-env':只输出 define-env.json
  • debugPrerender:开启 PPR 调试,bundle 不压缩、保留 source map。

整个 build 函数被一个 OpenTelemetry span 包裹:

953:packages/next/src/build/index.ts
const nextBuildSpan = trace('next-build', undefined, {
  buildMode: experimentalBuildMode,
  version: process.env.__NEXT_VERSION as string,
  ...enabledFeatures,
})

dev 时只跑路由匹配 + HMR,没有这些阶段。build 是 Next.js 离线最重的代码路径

二、阶段 1:加载配置 + dotenv

991:packages/next/src/build/index.ts
const { loadedEnvFiles } = nextBuildSpan
  .traceChild('load-dotenv')
  .traceFn(() => loadEnvConfig(dir, false, Log))

const config: NextConfigComplete = await nextBuildSpan
  .traceChild('load-next-config')
  .traceAsyncFn(() =>
    turborepoTraceAccess(
      () =>
        loadConfig(PHASE_PRODUCTION_BUILD, dir, {
          silent: false,
          reactProductionProfiling,
          debugPrerender,
          reportExperimentalFeatures(features) {
            experimentalFeatures = features.toSorted(...)
          },
          bundler,
        }),
      turborepoAccessTraceResult
    )
  )

干的事:

  1. loadEnvConfig:按优先级加载 .env.env.local.env.production,把变量注入到 process.env
  2. loadConfig(PHASE_PRODUCTION_BUILD, ...):require next.config.js / next.config.mjs,跑用户的 schema 校验,导出 NextConfigComplete
  3. reportExperimentalFeatures:收集启用的实验特性(PPR、cacheComponents、reactCompiler 等),后面 telemetry 上报。
  4. turborepoTraceAccess:如果在 monorepo 里,记录访问了哪些 turbo cache 输入,用于 turbo cache 验证。

思考题:为什么 dotenv 不在 next.config.js 加载之后?因为 next.config.js 里可能 process.env.MY_VAR,需要先加载。

三、阶段 2:generateBuildId

921:packages/next/src/build/index.ts
const buildId = await nextBuildSpan
  .traceChild('generate-build-id')
  .traceAsyncFn(() => generateBuildId(config.generateBuildId, nanoid))

buildId 是这次 build 的全局唯一标识,例如 NyDfqf-7uVRfaSqOQK3z-。它有几个用处:

  1. 写到 .next/BUILD_ID 文件,运行时 server 读出来,用作 CDN cache key 的一部分。
  2. 注入到客户端 chunk URL:/_next/static/${buildId}/...,部署新版本时 URL 变化,旧 client 不会拿到不兼容的 chunk。
  3. 写到 prerender-manifest 等所有 manifest 里。

用户可以通过 next.config.jsgenerateBuildId 覆盖(比如绑 git commit hash):

module.exports = {
  generateBuildId: async () => {
    return require("child_process")
      .execSync("git rev-parse HEAD")
      .toString()
      .trim();
  },
};

四、阶段 3:收集 entries(pages / app / routes)

虽然代码分散在 findPagesDircollectAppPaths 等多个 helper,但概念上:

  1. 扫描 pages/ 目录 → 收集 Pages Router 的 entries
  2. 扫描 app/ 目录 → 递归找所有 page.tsx / route.ts / layout.tsx / loading.tsx / error.tsx
  3. 解析 metadata 文件(opengraph-image.tsx 等)
  4. 解析 dynamic params 段 → 与 generateStaticParams 关联

输出:一个entry 描述列表,每个条目记录:

  • pageType:pages / app-page / app-route / metadata
  • pathname:/blog/[slug]
  • absolute path:/proj/app/blog/[slug]/page.tsx
  • runtime:nodejs / edge
  • 编译目标:server / edge-server / client / metadata

这些 entries 会变成 webpack/turbopack 的 entry config。

五、阶段 4:编译三套 compiler

Next.js 不是一次 build 一套 bundle,而是 三套独立 compiler

client compiler          → 浏览器跑的 JS(包含 hydrate + client component bundles)
server compiler          → Node 跑的 server runtime(包含 RSC 渲染 + page modules + node middleware)
edge-server compiler     → Edge Runtime 跑的代码(middleware + edge runtime route/page)

Turbopack 在底层把三套统一,但概念上仍然分离。

Webpack 路径

1753:packages/next/src/build/index.ts
const serverBuildPromise = webpackBuild(useBuildWorker, [
  'server',
]).then((res) => { ... })

const edgeBuildPromise = webpackBuild(useBuildWorker, [
  'edge-server',
]).then((res) => { ... })

await webpackBuild(useBuildWorker, ['client']).then((res) => { ... })

三套可以并行编译runServerAndEdgeInParallel flag),但 client 必须最后跑——因为它需要 server 编译生成的 flight-manifest.json(Client Reference Manifest)。

Turbopack 路径

1645:packages/next/src/build/index.ts
} = await turbopackBuild(
  process.env.NEXT_TURBOPACK_USE_WORKER === undefined ||
    process.env.NEXT_TURBOPACK_USE_WORKER !== '0',
  telemetry
)

Turbopack 内部并行化更好(Rust + Tokio);它把三套 entries 一起喂给 turbopack-core,输出一致的 manifest 文件,让上层逻辑不需要区分 bundler。

useBuildWorker:fork 出独立进程

useBuildWorker: boolean;

为什么 webpack build 要在独立 worker 进程里?

  1. OOM 隔离:webpack 大项目动辄占 4-8GB heap,跑在子进程里独立 GC,主进程仍能稳定。
  2. 资源回收:子进程结束后所有内存自动释放,下一步(trace、prerender)不受影响。
  3. 并行 server/edge/client:三个 worker 实际跑在不同 CPU 核心上。
1599:packages/next/src/build/index.ts
const useBuildWorker = Boolean(
  config.experimental.webpackBuildWorker ||
    (config.experimental.webpackBuildWorker === undefined && ...)
)

业务建议:CI 内存有限时关闭 build worker(用 experimental.webpackBuildWorker = false),让 webpack 跑在主进程里复用内存;但容易 OOM,主流是开 worker 给 4-8GB。

六、阶段 5:Static Analysis(静态分析)

编译完了,还要分析每个 page 的"静态/动态"属性。在 static-check span 里:

2057:packages/next/src/build/index.ts
const staticCheckSpan = nextBuildSpan.traceChild('static-check')

分析每个 page:

  1. 解析 page module 的 exports:找 dynamicrevalidateruntimegenerateStaticParamsfetchCache 等配置。
  2. bundle 静态分析:扫描 import 树,发现是否用了 cookies()headers() 等会触发 dynamic 的 API。
  3. 判定 page kind:static / dynamic / ISR / partial prerender。
  4. 生成 app-paths-manifest.json:路径 → bundle 路径的映射。

七、阶段 6:静态生成(Prerender)

2852:packages/next/src/build/index.ts
nextBuildSpan.traceChild('static-generation')

对每个判定为静态/部分静态的 page:

  1. spawn staticWorker(一组 worker 进程)
  2. 每个 worker require server bundle 里的 page module
  3. 调用 generateStaticParams → 拿到 params 列表(第 20 讲讲过)
  4. 对每个 params 跑一次 prerender → 得到 HTML + RSC payload + metadata
  5. 写入 .next/server/app/<path>.html.rsc.meta

并发度通过 getNumberOfWorkers(config, maxTasks) 计算:默认 os.cpus().length,可通过 experimental.staticWorkerRequestDeduping 调整。

业务陷阱:一个大站 5000 个商品页面,build 时 prerender 卡 1 小时。原因往往是:

  • 每个 page 里 fetch 拉的接口共用大量数据,但没 share cache(patchFetch 的 IncrementalCache 没启用)
  • 数据库连接没池化、每 page 一个连接 → 数据库连接耗尽
  • 解决:把数据下沉到 build cache,或者只 prerender top 100,剩下用 ISR

八、阶段 7:collectBuildTraces(nft)

最复杂、最容易翻车的环节:

1714:packages/next/src/build/index.ts
const buildTraceWorker = new Worker(
  require.resolve('./collect-build-traces'),
  { ... }
) as Worker & typeof import('./collect-build-traces')

buildTracesPromise = nextBuildSpan
  .traceChild('collect-build-traces')
  .traceAsyncFn(() => {
    return buildTraceWorker
      .collectBuildTraces({
        dir,
        config,
        distDir,
        edgeRuntimeRoutes: collectRoutesUsingEdgeRuntime(new Map()),
        staticPages: [],
        buildTraceContext,
        outputFileTracingRoot,
      })
  })

什么是 nft(Node File Trace)

nft 是 Vercel 维护的工具(@vercel/nft),它通过静态分析一个 JS 入口文件的所有 require / import,递归找出运行时真正用到的所有文件——包括:

  • 直接 import 的 .js
  • 间接依赖的 node_modules 文件
  • fs.readFileSync('./template.html') 这种"运行时读文件"的字符串路径(通过 hacky 的 AST 分析)

每个 page 产出一份 .next/server/app/<path>.nft.json

{
  "version": 1,
  "files": [
    "../../../node_modules/react/index.js",
    "../../../node_modules/.../some-template.html",
    "../../../public/data.json"
    // ...
  ]
}

为什么需要这个?

  • standalone 部署output: 'standalone' 时 Next.js 把这些 trace 文件复制到 .next/standalone/,做成"自包含部署目录",没有 node_modules 也能跑。
  • Vercel / Cloudflare 部署:平台读 nft 决定每个 serverless function 的 bundle 包含哪些文件,控制 cold start 大小。

trace 是在独立 worker 里跑的(Worker pool),因为 nft 需要扫整个 dependency tree,可能占很大内存。

nft 常见问题

现象原因
standalone 启动报 Cannot find module 'foo'nft 漏 trace;通常是动态 require(require(variable)),nft 看不到
Vercel 部署后某文件 404是 public/ 里的静态文件,没被任何 import 引用;要么 import,要么放到 unstable_includeFiles
nft 包含巨量无关文件tree-shake 不掉 monorepo 兄弟包;要 experimental.outputFileTracingExcludes 排除

修复方式:

// next.config.js
module.exports = {
  outputFileTracingIncludes: {
    "/api/*": ["./templates/**/*.html"], // 强制纳入
  },
  outputFileTracingExcludes: {
    "/*": ["node_modules/@swc/core-*/**"], // 强制排除
  },
};

九、阶段 8:写 manifest 文件

build 结尾产生多个 manifest 文件,部署到生产时被 server 读出来:

文件内容谁读
BUILD_IDbuildId 字符串server runtime
routes-manifest.json所有路由的规则、重定向、rewritesrouter-server resolveRoutes
prerender-manifest.json静态 prerender 的页面列表、fallback、revalidatebase-server.findPrerenderedPath
app-paths-manifest.jsonApp pathname → bundle pathbase-server.findPageComponents
app-build-manifest.jsonApp page → 需要加载的 client chunksflight-render
build-manifest.json全局 client chunks(_app、webpack runtime)document.tsx render
images-manifest.jsonnext/image 配置image-optimizer
react-loadable-manifest.jsonnext/dynamic 的 lazy chunklazy resolve
middleware-manifest.jsonmiddleware/edge entriesrouter-server
next-server.json / required-server-files.jsonstandalone 所需文件standalone runtime
551:packages/next/src/build/index.ts
async function writePrerenderManifest(...)
async function writeClientSsgManifest(...)

这些 manifest 是 server-runtime 与 build 之间的契约。生产排障时先看 manifest 是不是有这个 path、对得上 bundle 路径

十、阶段 9:runAfterProductionCompile + standalone

await runAfterProductionCompile({ config, ... })

跑用户在 next.config.js 里写的 afterProductionCompile 钩子(少见,做后处理用)。

然后如果 output: 'standalone'

writeStandaloneDirectory(...)

.next/standalone/ 下复制 node_modules(按 nft)、server.jspackage.jsonpublic/static/,做成一个可独立部署的目录。

十一、阶段 10:print-tree-view

4277:packages/next/src/build/index.ts
await nextBuildSpan.traceChild('print-tree-view').traceAsyncFn(() =>

build log 里最后那张表:

Route (app)                       Size     First Load JS
┌ ○ /                             5.2 kB         95 kB
├ ○ /about                        1 kB           92 kB
├ ƒ /api/health                   0 B            0 B
└ λ /blog/[slug]                  3 kB           93 kB
+ First Load JS shared by all     90 kB

数据来源:

  • Size:从 webpack/turbopack 的 stats.json 读各 page 的 bundle 大小
  • First Load JS:所有 shared chunks + page-specific chunks 总和
  • 符号: 静态、 SSG with data、ƒ dynamic、λ dynamic(functions)、 PPR

十二、阶段 11:Telemetry 上报

1660:packages/next/src/build/index.ts
telemetry.record(
  eventBuildCompleted(pagesPaths, {
    bundler: 'turbopack',
    durationInSeconds: Math.round(compilerDuration),
    totalAppPagesCount,
  })
)

上报给 telemetry.nextjs.org(用户可 next telemetry disable 关闭)。事件包含:

  • bundler、duration
  • 各 page count
  • 启用的 experimental features
  • Node 版本 / OS / arch(匿名)

不上报:业务数据、URL、用户代码。

十三、build 性能 profiling

# 1. 看每个 span 的时间
NEXT_TELEMETRY_DEBUG=1 next build

# 2. 详细 trace(OpenTelemetry)
TRACE_TARGET=stdout next build 2>build.log
grep next-build build.log

# 3. CPU profile
NEXT_CPU_PROF=1 NEXT_CPU_PROF_DIR=./profiles next build
# 用 Chrome DevTools 打开 .cpuprofile 文件分析

# 4. trace upload 给 Vercel
next build --upload-trace

典型耗时分布(大型项目):

阶段占比
load-config< 1%
webpack/turbopack compile50-70%
static-check5%
static-generation10-30%(看 page 数)
collect-build-traces5-15%(看 trace 文件大小)
其它< 5%

十四、生产排障实战清单

现象阶段排查
next build OOMcompile / static-gen提高 --max-old-space-size、开 worker、减小 chunk
build 卡在 "Compiling..."webpack/turbopackTS 检查超时、循环依赖、watcher 锁
build 完之后启动报 MODULE_NOT_FOUNDnft 漏 traceoutputFileTracingIncludes
dev 正常、prod 白屏static-generationprerender 阶段抛错;看 build log 里的 page error
Vercel cold start 很慢trace 文件太大排除大依赖、用 dynamic import 拆 chunk
build 在 CI 失败、本地成功env / Node 版本CI 用 pnpm i --frozen-lockfile 保证版本一致
chunk 体积突增webpack statsnext build --analyze 看 webpack-bundle-analyzer
build 时间从 5 分钟跳到 30 分钟compile / trace是否新增了大依赖;turbopack 是否启用
experimental.workerThreads 报错webpack worker关闭 worker fallback 跑

十五、配套 fixture:观察 build pipeline

fixtures/lecture-23/ 提供一个小项目,里面包含:

  • 1 个静态 page
  • 1 个 ISR page
  • 1 个 dynamic page
  • 1 个 edge route handler

启动:

cd learning/nextjs-40-lectures/fixtures/lecture-23
pnpm install

# 1. 看 build span 拆解
NEXT_TELEMETRY_DEBUG=1 pnpm build 2>&1 | tee build.log

# 2. 读 manifest
cat .next/BUILD_ID
cat .next/prerender-manifest.json | jq '.routes | keys'
cat .next/app-paths-manifest.json | jq

# 3. 看 nft trace
cat .next/server/app/page.js.nft.json | jq '.files | length'
cat .next/server/app/blog/[slug]/page.js.nft.json | jq '.files | length'

# 4. 看每个 page 的 bundle size
ls -lh .next/server/app/

十六、本讲小结

  1. next build 是 11 个阶段的串行 + 局部并行:load config → entries → 3 套 compile → static check → prerender → nft trace → manifest → standalone → tree view → telemetry。
  2. 三套 compiler(client / server / edge-server)独立编译,因为目标平台不同;client 必须最后跑(依赖 server 的 flight manifest)。
  3. nft(Node File Trace) 是 standalone / Vercel 部署的关键,决定每个 serverless function 包含哪些文件。
  4. manifest 文件是 build 与 runtime 之间的契约,排障时先看 manifest。
  5. build worker 在子进程跑 webpack,避免 OOM 影响主进程。

下讲预告

第 24 讲《webpack 配置:next-bundle 是怎么"产生 bundle"的》。我们会拆 getBaseWebpackConfig,看 entry、resolve、loader、plugin 是怎么针对 client / server / edge 三套 compiler 分别配置的;以及 Next.js 关键自研 webpack plugin(Flight、ClientReferenceManifest、Trace、CSS)的协作机制。