- 发布日期
第 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 写出。我们按阶段拆开来看。
学习目标
读完本讲,你能:
- 复述 next build 的 11 个核心阶段:加载 config → 收集 entries → 编译三套 compiler → 静态分析 → trace nft → 写 manifest → 静态生成 → 产出报告。
- 知道每个阶段的耗时占比,哪些可以并行,哪些是瓶颈。
- 看懂
webpackBuild(['server'])/webpackBuild(['edge-server'])/webpackBuild(['client'])三套独立 compiler 的关系。 - 解释 nft(Node File Trace)的工作原理,以及为什么
.next/server/app/<page>.js.nft.json决定了 standalone 部署能否成功。 - 在生产中排查 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.ts 的 build() 是真正的入口:
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> {
关键参数:
bundler:Turbopack(默认)/Webpack。experimentalBuildMode:'default':完整 build(编译 + 静态生成 + manifest)'compile':只编译,跳过静态生成(Vercel 平台用,再调度generate阶段)'generate':跳过编译直接做静态生成(接续compile)'generate-env':只输出define-env.json
debugPrerender:开启 PPR 调试,bundle 不压缩、保留 source map。
整个 build 函数被一个 OpenTelemetry span 包裹:
const nextBuildSpan = trace('next-build', undefined, {
buildMode: experimentalBuildMode,
version: process.env.__NEXT_VERSION as string,
...enabledFeatures,
})
dev 时只跑路由匹配 + HMR,没有这些阶段。build 是 Next.js 离线最重的代码路径。
二、阶段 1:加载配置 + dotenv
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
)
)
干的事:
loadEnvConfig:按优先级加载.env、.env.local、.env.production,把变量注入到process.env。loadConfig(PHASE_PRODUCTION_BUILD, ...):requirenext.config.js/next.config.mjs,跑用户的 schema 校验,导出NextConfigComplete。reportExperimentalFeatures:收集启用的实验特性(PPR、cacheComponents、reactCompiler 等),后面 telemetry 上报。turborepoTraceAccess:如果在 monorepo 里,记录访问了哪些 turbo cache 输入,用于 turbo cache 验证。
思考题:为什么 dotenv 不在 next.config.js 加载之后?因为
next.config.js里可能process.env.MY_VAR,需要先加载。
三、阶段 2:generateBuildId
const buildId = await nextBuildSpan
.traceChild('generate-build-id')
.traceAsyncFn(() => generateBuildId(config.generateBuildId, nanoid))
buildId 是这次 build 的全局唯一标识,例如 NyDfqf-7uVRfaSqOQK3z-。它有几个用处:
- 写到
.next/BUILD_ID文件,运行时 server 读出来,用作 CDN cache key 的一部分。 - 注入到客户端 chunk URL:
/_next/static/${buildId}/...,部署新版本时 URL 变化,旧 client 不会拿到不兼容的 chunk。 - 写到 prerender-manifest 等所有 manifest 里。
用户可以通过 next.config.js 的 generateBuildId 覆盖(比如绑 git commit hash):
module.exports = {
generateBuildId: async () => {
return require("child_process")
.execSync("git rev-parse HEAD")
.toString()
.trim();
},
};
四、阶段 3:收集 entries(pages / app / routes)
虽然代码分散在 findPagesDir、collectAppPaths 等多个 helper,但概念上:
- 扫描
pages/目录 → 收集 Pages Router 的 entries - 扫描
app/目录 → 递归找所有page.tsx/route.ts/layout.tsx/loading.tsx/error.tsx - 解析
metadata文件(opengraph-image.tsx等) - 解析 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 路径
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 路径
} = 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 进程里?
- OOM 隔离:webpack 大项目动辄占 4-8GB heap,跑在子进程里独立 GC,主进程仍能稳定。
- 资源回收:子进程结束后所有内存自动释放,下一步(trace、prerender)不受影响。
- 并行 server/edge/client:三个 worker 实际跑在不同 CPU 核心上。
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 里:
const staticCheckSpan = nextBuildSpan.traceChild('static-check')
分析每个 page:
- 解析 page module 的 exports:找
dynamic、revalidate、runtime、generateStaticParams、fetchCache等配置。 - bundle 静态分析:扫描 import 树,发现是否用了
cookies()、headers()等会触发 dynamic 的 API。 - 判定 page kind:static / dynamic / ISR / partial prerender。
- 生成
app-paths-manifest.json:路径 → bundle 路径的映射。
七、阶段 6:静态生成(Prerender)
nextBuildSpan.traceChild('static-generation')
对每个判定为静态/部分静态的 page:
- spawn
staticWorker(一组 worker 进程) - 每个 worker require server bundle 里的 page module
- 调用
generateStaticParams→ 拿到 params 列表(第 20 讲讲过) - 对每个 params 跑一次 prerender → 得到 HTML + RSC payload + metadata
- 写入
.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)
最复杂、最容易翻车的环节:
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_ID | buildId 字符串 | server runtime |
routes-manifest.json | 所有路由的规则、重定向、rewrites | router-server resolveRoutes |
prerender-manifest.json | 静态 prerender 的页面列表、fallback、revalidate | base-server.findPrerenderedPath |
app-paths-manifest.json | App pathname → bundle path | base-server.findPageComponents |
app-build-manifest.json | App page → 需要加载的 client chunks | flight-render |
build-manifest.json | 全局 client chunks(_app、webpack runtime) | document.tsx render |
images-manifest.json | next/image 配置 | image-optimizer |
react-loadable-manifest.json | next/dynamic 的 lazy chunk | lazy resolve |
middleware-manifest.json | middleware/edge entries | router-server |
next-server.json / required-server-files.json | standalone 所需文件 | standalone runtime |
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':
在 .next/standalone/ 下复制 node_modules(按 nft)、server.js、package.json、public/、static/,做成一个可独立部署的目录。
十一、阶段 10:print-tree-view
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 上报
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 compile | 50-70% |
| static-check | 5% |
| static-generation | 10-30%(看 page 数) |
| collect-build-traces | 5-15%(看 trace 文件大小) |
| 其它 | < 5% |
十四、生产排障实战清单
| 现象 | 阶段 | 排查 |
|---|---|---|
next build OOM | compile / static-gen | 提高 --max-old-space-size、开 worker、减小 chunk |
| build 卡在 "Compiling..." | webpack/turbopack | TS 检查超时、循环依赖、watcher 锁 |
build 完之后启动报 MODULE_NOT_FOUND | nft 漏 trace | 加 outputFileTracingIncludes |
| dev 正常、prod 白屏 | static-generation | prerender 阶段抛错;看 build log 里的 page error |
| Vercel cold start 很慢 | trace 文件太大 | 排除大依赖、用 dynamic import 拆 chunk |
| build 在 CI 失败、本地成功 | env / Node 版本 | CI 用 pnpm i --frozen-lockfile 保证版本一致 |
| chunk 体积突增 | webpack stats | 用 next 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/
十六、本讲小结
- next build 是 11 个阶段的串行 + 局部并行:load config → entries → 3 套 compile → static check → prerender → nft trace → manifest → standalone → tree view → telemetry。
- 三套 compiler(client / server / edge-server)独立编译,因为目标平台不同;client 必须最后跑(依赖 server 的 flight manifest)。
- nft(Node File Trace) 是 standalone / Vercel 部署的关键,决定每个 serverless function 包含哪些文件。
- manifest 文件是 build 与 runtime 之间的契约,排障时先看 manifest。
- 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)的协作机制。