- 发布日期
第 24 讲:webpack 配置与核心插件——next-bundle 是如何"产生 bundle"的
Next.js webpack 配置层、核心插件(FlightClientEntryPlugin 等)工作原理
第 23 讲讲了 build pipeline 的"调度层"——三套 compiler 怎么调度、nft 怎么 trace、manifest 怎么写。本讲深入"编译层":Next.js 写了一份 ~3000 行的 webpack config,配套 26+ 个自研 plugin,把"app/ 目录里的 .tsx"变成"client + server + edge 三套 bundle"。
我们重点拆
getBaseWebpackConfig、FlightClientEntryPlugin、ClientReferenceManifestPlugin、NextTraceEntrypointsPlugin、MiddlewarePlugin这五块。
学习目标
读完本讲,你能:
- 看懂
getBaseWebpackConfig的核心分支:isClient/isNodeServer/isEdgeServer各自的 entry、resolve、loader、plugin 配置。 - 解释 RSC 双层 bundle 的实现:server 端通过
react-servercondition 拿一套 React,client 端拿另一套,靠 webpackresolve.conditionNames隔离。 - 看懂
FlightClientEntryPlugin如何从 server graph 里找到'use client'模块,生成 client entry。 - 看懂
ClientReferenceManifestPlugin如何输出_client-reference-manifest.js,让 RSC payload 能引用客户端组件。 - 排查 webpack-specific 的生产问题:framework 切片错误、
Module not found配置 alias、edge bundle 超限。
本讲对应代码:
packages/next/src/build/webpack-config.ts(getBaseWebpackConfig,~3000 行)packages/next/src/build/webpack/plugins/flight-client-entry-plugin.tspackages/next/src/build/webpack/plugins/flight-manifest-plugin.tspackages/next/src/build/webpack/plugins/next-trace-entrypoints-plugin.tspackages/next/src/build/webpack/plugins/middleware-plugin.ts
一、为什么 Next.js 不能直接用社区 webpack config
社区脚手架(CRA、Vite SSR template)的 webpack 大体能用,但放到 Next.js 里完全跑不动,原因:
- 三套 compiler:同一段 React 组件,client 编译成浏览器 chunk、server 编译成 Node 可 require 模块、edge 编译成 worker bundle。三个 target 完全不同。
- RSC 双层 React:server bundle 引
react@experimental的 react-server 通道、client bundle 引同版本的浏览器通道。同一个 import 路径要解析到不同文件。 - Client Reference:
'use client'文件在 server bundle 里要变成一个占位符引用({$$typeof: Symbol.for('react.client.reference')}),不能把真实组件代码引进来。 - 流式 chunk 加载:client 端不是一次性加载所有 JS,而是渲染过程中按需 lazy load chunk;要在 RSC payload 里嵌入 chunk URL。
- Edge 限制:edge bundle 不能含 Node API,必须用 webworker target + DCE 删 Node 分支。
这些约束让 Next.js 不得不维护自己一份巨型 webpack config。
二、入口:getBaseWebpackConfig
export default async function getBaseWebpackConfig(
dir: string,
{
buildId,
encryptionKey,
config,
compilerType,
dev = false,
entrypoints,
deferredEntrypoints,
isDevFallback = false,
pagesDir,
rewrites,
// ...
}
): Promise<webpack.Configuration> {
const bundler = getWebpackBundler()
const isClient = compilerType === COMPILER_NAMES.client
const isEdgeServer = compilerType === COMPILER_NAMES.edgeServer
const isNodeServer = compilerType === COMPILER_NAMES.server
入参里关键的:
compilerType:决定整个 config 的分支。三个值'client' | 'server' | 'edge-server',每次 build 调用 3 次。entrypoints:webpack entry,由createEntrypoints提前生成(page → loader path 映射)。buildId:用于 chunk hash 命名。appDir:是否启用 App Router;影响 alias、condition、plugin 启用。previewProps:preview mode 相关 secret,注入 bundle。
整个函数返回一个标准 webpack.Configuration,会被 webpack(或 rspack)消费。
三、Target 的差异
target: isClient ? ["web", "es5"] : isEdgeServer ? "webworker" : "node18";
- client:
['web', 'es5']:浏览器,输出 ES5(兼容老 Safari);webpack 默认带documentwindowglobals。 - edge-server:
'webworker':和 Web Worker target 一致——没有 Node 全局,只有 fetch/URL/Crypto/TextEncoder 等。 - server:
'node18':Node.js 环境,可 require、有 Buffer/process/fs。
这一行决定了:
- webpack 的全局环境(
globalObject) - 哪些 module 走
externals - chunk loading 方式(
__webpack_require__实现)
四、Entry:怎么把 pages 变成 entry
App Router 时代每个 page 对应多个 entry(不像 pages router 一对一):
app/blog/[slug]/page.tsx 编译产物:
├─ app-page-bundle-loader!app/blog/[slug]/page.tsx → 给 server compiler(RSC + SSR 合一)
├─ next-app-loader → 给 client compiler(router boundary 入口)
├─ flight-client-entry-loader → 客户端 component bundle(按需创建)
└─ metadata-image-loader(如果有 og image)
具体由 createEntrypoints 生成;它扫遍 app/ 目录,识别每个 special file(page/layout/loading/error/route/...),用对应的 loader 包装。
业务示例:你新建
app/foo/page.tsx,dev mode 第一次访问/foo时会触发 on-demand entry,临时把这个 entry 加进 webpack compilation,触发 incremental compile,dev server 把新 chunk 发给浏览器。
五、Resolve:条件加载与 react-server channel
conditionNames: [
...(isEdgeServer ? [edgeConditionName] : []),
// 'react-server' for server bundle, default for client
],
extensions: ['.js', '.mjs', '.tsx', '.ts', '.jsx', '.json', '.wasm'],
modules: ['node_modules', ...nodePathList],
alias: createWebpackAliases({ ... }),
mainFields: getMainField(compilerType, false),
最关键的是 conditionNames(webpack 5 的 conditional exports)。React 19 的 package.json 里:
{
"exports": {
".": {
"react-server": "./react.react-server.js",
"default": "./index.js"
}
}
}
- server bundle resolve 时带上
react-servercondition → 拿到 RSC 专用的 React(react-serverchannel) - client bundle 不带 → 拿到普通 React(含 hooks)
这是 RSC "服务端组件不能用 useState" 的底层实现:server bundle 里的 React 根本没 useState 这个 export。
第 7 讲讲过 react-server condition;这里是它在 webpack 配置里的落点。
六、Alias:定向到 vendored React
alias: {
'react': 'next/dist/compiled/react',
'react-dom': 'next/dist/compiled/react-dom',
// ...
}
Next.js 不让你直接用 npm 上的 React,而是 强制 alias 到 next/dist/compiled/react——一份 vendored(内置打包)的 React。
为什么?
- 版本绑定:Next.js 14/15 各自需要特定的 React 版本(有时是 Canary),自己 vendor 进来避免用户装错。
- react-server 通道 patch:Next.js 给 React 加了一些内部 patch(hint markers、stream 接入),通过 vendor 方式注入。
- 多版本隔离:node_modules 里可能因为 monorepo 引入多个 React,vendor 后只剩一份。
排障:你 import 的某个 component 库 internal 用了 import { useId } from 'react',但 webpack alias 后这个 react 是 vendored 版本,二者实例不同,会触发 Invalid hook call。修复:库的 React 声明为 peer dependency,alias 透传。
七、Module Rules:loader 链
简化后的核心 loader 链:
| Test | Loader |
|---|---|
app/.../page.tsx(server compiler) | next-app-loader → next-flight-loader → babel/swc |
app/.../page.tsx(client compiler) | next-flight-client-entry-loader → babel/swc |
'use client' 文件 | server compiler 用 next-flight-loader 改写成 client reference 占位 |
.css | next-style-loader + css-loader + postcss-loader(dev)/ mini-css-extract + ... + css-loader + postcss-loader(prod) |
.svg | next/image SVG 或 raw(看 import) |
.mdx | @next/mdx-loader(如果启用) |
用户的 .ts/.tsx | swc-loader(默认)或 babel-loader |
next 自研 loader 的核心:
next-flight-loader:扫文件首行'use client'/'use server',把 client component 变成 client reference 占位符。next-app-loader:app router 的 page entry 包装层,负责生成 LoaderTree 数据结构。next-metadata-image-loader:把opengraph-image.tsx编译成动态路由 handler。barrel-optimization-loader:tree-shakeindex.tsre-export(barrel file),减少 client bundle。
八、核心 plugin(1):FlightClientEntryPlugin
这是 RSC 编译最核心的 plugin。它的工作:
- 扫描 server compiler 的 module graph,找到所有
'use client'文件(被next-flight-loader打过标记)。 - 为每个 client component 创建 client entry:把"
'use client'模块及其全部子依赖"作为一个独立的 client bundle entry。 - 生成 SSR manifest:server 渲染时怎么找到对应 client chunk 的 URL。
- 追加到 client compiler 的 entries:client compilation 接到这些新增 entry 后开始独立编译。
为什么需要这一切?因为:
- App Router 渲染时,server 完成 RSC 后会生成 payload,里面有
{$$id: 'src/Button.tsx#Button'}这种 reference - 浏览器拿到 payload 后要根据 reference 加载对应的 client chunk
- 这个 chunk 必须是 client compiler 单独打的,server 不知道怎么打 client
FlightClientEntryPlugin 就是 server/client compiler 之间的"桥梁",从 server graph 找出"边界",告诉 client 该打什么。
九、核心 plugin(2):ClientReferenceManifestPlugin
export class ClientReferenceManifestPlugin {
'server/app' + pageBundlePath + '_' + CLIENT_REFERENCE_MANIFEST + '.js',
为每个 App Router page 输出一个 <page>_client-reference-manifest.js 文件,内容是一个 JSON 注入到全局:
globalThis.__RSC_MANIFEST = globalThis.__RSC_MANIFEST || {}
globalThis.__RSC_MANIFEST['/blog/[slug]'] = {
clientModules: {
'src/Button.tsx#default': {
id: 1234,
name: 'default',
chunks: ['static/chunks/123.js', 'static/chunks/456.js'],
async: false
},
// ...
},
ssrModuleMapping: { ... },
edgeSSRModuleMapping: { ... },
cssFiles: ['static/css/abc.css']
}
server 渲染 RSC 时通过 getClientReferenceManifest(page) 拿到这份数据,编码到 payload header。浏览器收到 payload,按 chunks 数组的 URL fetch JS,再 instantiate component。
业务排障:生产
Cannot find client reference 'xxx',通常是 client 编译时漏了某个 entry,或者 manifest 没正确写到.next/server/app/。检查 build log 里有没有Generated client reference manifest for ...提示。
十、核心 plugin(3):NextTraceEntrypointsPlugin
new NextTraceEntrypointsPlugin({...})
负责调用 @vercel/nft 在编译完成后对每个 server entry 跑 trace,输出 .nft.json 文件(第 23 讲讲过)。
它 hook 进 webpack 的 compilation.hooks.processAssets(一个非常晚的阶段),从 compilation 拿到每个 entry 的 emit file,然后 trace。
性能优化点:只在 production server build 跑(client/edge 不需要 trace,因为 client 进 CDN、edge 是 self-contained bundle)。
十一、核心 plugin(4):MiddlewarePlugin
new MiddlewarePlugin({ dev, sriEnabled });
middleware/edge-server bundle 必须满足:
- self-contained:不能在 runtime 再 require 外部文件
- 大小受限:Vercel 默认 1MB
- 不能用 Node API:fs / Buffer / crypto.Hash 等禁用
MiddlewarePlugin 工作:
- 扫描 module graph,检测是否引入了 Node 专属 API → 报错或警告
- 把所有 chunk 合并到一个文件(middleware bundle 不分 chunk,因为 edge runtime 不支持 lazy load)
- 输出
middleware-manifest.json:每个 edge entry 的入口路径、size、matchers、所属 page - 计算 bundle size,超过
experimental.maxBundleSizes时报错
注意它和 sandbox 的协作:dev 模式下 middleware code 在 EdgeRuntime 沙箱里跑,所以 MiddlewarePlugin 要保证生成的代码符合 sandbox 的约束(没有 require 外部、不依赖 Node global)。
十二、其它常用 plugin 速览
| Plugin | 作用 |
|---|---|
BuildManifestPlugin | 输出 build-manifest.json:全局 chunks 与 _app 依赖 |
PagesManifestPlugin | 输出 pages-manifest.json:Pages Router 的路径映射 |
MemoryWithGcCachePlugin | webpack 5 持久化 cache 的 GC,防止 OOM |
JsConfigPathsPlugin | 处理 tsconfig.json 的 paths 别名 |
MiniCssExtractPlugin | css 抽离成独立文件(prod) |
CssChunkingPlugin | App Router 的 CSS 分片策略 |
ProfilingPlugin | dev 时给 trace 记每个 module 的 build time |
SlowModuleDetectionPlugin | 检测编译慢的 module,dev 模式输出 hint |
NextFontManifestPlugin | next/font 输出 manifest,运行时 inline @font-face |
SubresourceIntegrityPlugin | 生成 SRI hash(CSP nonce / hash 配合) |
DeferredEntriesPlugin | dev 模式延迟编译某些 entry(on-demand-entries) |
十三、framework 切片:把 React 分到独立 chunk
for (const packageName of [
"react",
"react-dom",
...(hasAppDir
? [
`next/dist/compiled/react${bundledReactChannel}`,
`next/dist/compiled/react-dom${bundledReactChannel}`,
]
: []),
]) {
addPackagePath(packageName, dir, topLevelFrameworkPaths);
}
topLevelFrameworkPaths 后面被用作 splitChunks.cacheGroups.framework.test,意思是"凡是从这些路径来的 module 单独打成一个 framework chunk"。
效果:浏览器加载页面时,framework.js(React + React-DOM + Scheduler)作为一个独立 chunk,多页面共享,CDN cache 友好,新 page 不重新下载。
业务陷阱:你装了一个组件库,它 bundle 时复制了一份 react 到自己包里。webpack alias 没正确生效,framework 切片把"library 内的 react"切片到一个 chunk,"项目的 react"切到另一个 chunk,runtime 两个 React 实例报
Invalid hook call。修复:alias 把所有 react 重定向到一份。
十四、webpack vs rspack vs turbopack
const isRspack = Boolean(process.env.NEXT_RSPACK)
Next.js 维护三套 bundler 路径:
| Bundler | 实现 | 状态 |
|---|---|---|
| webpack | JS 实现,社区 ecosystem | 仍是 stable,但默认已是 turbopack |
| rspack | Rust 实现的 webpack 兼容层 | 实验性,NEXT_RSPACK=1 开启 |
| turbopack | Rust 实现,重新设计的 API | 默认,性能最快 |
getBaseWebpackConfig 同时为 webpack 和 rspack 输出 config(rspack 99% API 兼容 webpack)。turbopack 走完全不同的入口(turbopackBuild),但目标和插件语义相同。
排障建议:跨 bundler 行为不一致时,强制走 webpack 验证:
next build --webpack # 不走 turbopack
NEXT_RSPACK=1 next build # 走 rspack
十五、生产排障实战清单
| 现象 | 排查方向 |
|---|---|
Module not found: Can't resolve 'react/jsx-runtime' | alias 配置错;检查是否 npm 装了多版本 react |
Invalid hook call 生产报错 | 两个 React 实例;用 npm ls react 看依赖、用 alias 强制一份 |
Cannot find client reference 'xxx' | FlightClientEntryPlugin 漏 entry;pnpm build --no-cache 重试 |
| 生产 client chunk 突然 OOM | barrel-optimization-loader 没正常生效;检查 optimizePackageImports 配置 |
| Edge bundle 超 1MB | 用 dynamic import 切;移到 nodejs runtime |
| CSS 在 prod 顺序错乱 | CssChunkingPlugin 排序问题;升级到最新 Next.js |
| dev OK、prod 缺 CSS | mini-css-extract 抽离失败;看 build log 里有没有警告 |
| 自定义 webpack config 不生效 | 用户在 next.config.js 里 config.module.rules.push(...),被 Next.js 覆盖;要用 config.module.rules.unshift 或 prepend |
[webpack.cache.PackFileCacheStrategy] Caching failed | persistent cache 磁盘满;删 .next/cache/ |
十六、配套 fixture:观察 plugin 产物
fixtures/lecture-24/ 设置一个最小可观察的 demo:
- 1 个 server component 引用 1 个 client component
- 启用一个自定义 webpack config 钩子(log 当前 compiler type)
- 提供 jq 脚本读取 client reference manifest
启动:
cd learning/nextjs-40-lectures/fixtures/lecture-24
pnpm install
# 1. 看自定义 webpack hook 的 log
pnpm build 2>&1 | grep '\[lec24\]'
# 2. 看 client reference manifest(每个 app page 一个)
ls .next/server/app/*_client-reference-manifest.js
cat .next/server/app/page_client-reference-manifest.js | head -20
# 3. 看 framework 切片
ls .next/static/chunks/framework-*.js
# 4. 看 webpack stats(开 stats 时)
cat .next/build-manifest.json | jq
十七、本讲小结
- 三套 compiler:
isClient/isNodeServer/isEdgeServer是 webpack-config.ts 的核心分支变量。 - react-server condition:webpack 5 的 conditional exports 让同一个
reactimport 在 server/client 解析到不同文件。 - FlightClientEntryPlugin + ClientReferenceManifestPlugin 是 RSC 编译的核心:从 server graph 找 client boundary、为每个 page 输出 reference manifest。
- NextTraceEntrypointsPlugin 在 server build 末尾跑 nft,生成
.nft.json给 standalone 用。 - MiddlewarePlugin 保证 edge bundle 是 self-contained 且大小受限。
- framework 切片 把 React/ReactDOM 切到独立 chunk,多页面共享 + CDN cache 友好。
下讲预告
第 25 讲《SWC、Turbopack 与 Rust 工具链》。我们会从 crates/next-core 看 Turbopack 的 task graph 模型、SWC plugin 怎么实现 'use client' / 'use server' transform、为什么 turbopack 比 webpack 快 10x,以及 dev 模式下的增量编译。