发布日期

第 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"。

我们重点拆 getBaseWebpackConfigFlightClientEntryPluginClientReferenceManifestPluginNextTraceEntrypointsPluginMiddlewarePlugin 这五块。

学习目标

读完本讲,你能:

  1. 看懂 getBaseWebpackConfig 的核心分支:isClient / isNodeServer / isEdgeServer 各自的 entry、resolve、loader、plugin 配置。
  2. 解释 RSC 双层 bundle 的实现:server 端通过 react-server condition 拿一套 React,client 端拿另一套,靠 webpack resolve.conditionNames 隔离。
  3. 看懂 FlightClientEntryPlugin 如何从 server graph 里找到 'use client' 模块,生成 client entry。
  4. 看懂 ClientReferenceManifestPlugin 如何输出 _client-reference-manifest.js,让 RSC payload 能引用客户端组件。
  5. 排查 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.ts
  • packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts
  • packages/next/src/build/webpack/plugins/next-trace-entrypoints-plugin.ts
  • packages/next/src/build/webpack/plugins/middleware-plugin.ts

一、为什么 Next.js 不能直接用社区 webpack config

社区脚手架(CRA、Vite SSR template)的 webpack 大体能用,但放到 Next.js 里完全跑不动,原因:

  1. 三套 compiler:同一段 React 组件,client 编译成浏览器 chunk、server 编译成 Node 可 require 模块、edge 编译成 worker bundle。三个 target 完全不同。
  2. RSC 双层 React:server bundle 引 react@experimental 的 react-server 通道、client bundle 引同版本的浏览器通道。同一个 import 路径要解析到不同文件。
  3. Client Reference'use client' 文件在 server bundle 里要变成一个占位符引用{$$typeof: Symbol.for('react.client.reference')}),不能把真实组件代码引进来。
  4. 流式 chunk 加载:client 端不是一次性加载所有 JS,而是渲染过程中按需 lazy load chunk;要在 RSC payload 里嵌入 chunk URL。
  5. Edge 限制:edge bundle 不能含 Node API,必须用 webworker target + DCE 删 Node 分支。

这些约束让 Next.js 不得不维护自己一份巨型 webpack config。

二、入口:getBaseWebpackConfig

380:packages/next/src/build/webpack-config.ts
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 默认带 document window globals。
  • 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-server condition → 拿到 RSC 专用的 React(react-server channel)
  • 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。

为什么?

  1. 版本绑定:Next.js 14/15 各自需要特定的 React 版本(有时是 Canary),自己 vendor 进来避免用户装错。
  2. react-server 通道 patch:Next.js 给 React 加了一些内部 patch(hint markers、stream 接入),通过 vendor 方式注入。
  3. 多版本隔离: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 链:

TestLoader
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 占位
.cssnext-style-loader + css-loader + postcss-loader(dev)/ mini-css-extract + ... + css-loader + postcss-loader(prod)
.svgnext/image SVG 或 raw(看 import)
.mdx@next/mdx-loader(如果启用)
用户的 .ts/.tsxswc-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-shake index.ts re-export(barrel file),减少 client bundle。

八、核心 plugin(1):FlightClientEntryPlugin

这是 RSC 编译最核心的 plugin。它的工作:

  1. 扫描 server compiler 的 module graph,找到所有 'use client' 文件(被 next-flight-loader 打过标记)。
  2. 为每个 client component 创建 client entry:把"'use client' 模块及其全部子依赖"作为一个独立的 client bundle entry。
  3. 生成 SSR manifest:server 渲染时怎么找到对应 client chunk 的 URL。
  4. 追加到 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

packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts
export class ClientReferenceManifestPlugin {
packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts
'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 必须满足:

  1. self-contained:不能在 runtime 再 require 外部文件
  2. 大小受限:Vercel 默认 1MB
  3. 不能用 Node API:fs / Buffer / crypto.Hash 等禁用

MiddlewarePlugin 工作:

  1. 扫描 module graph,检测是否引入了 Node 专属 API → 报错或警告
  2. 把所有 chunk 合并到一个文件(middleware bundle 不分 chunk,因为 edge runtime 不支持 lazy load)
  3. 输出 middleware-manifest.json:每个 edge entry 的入口路径、size、matchers、所属 page
  4. 计算 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 的路径映射
MemoryWithGcCachePluginwebpack 5 持久化 cache 的 GC,防止 OOM
JsConfigPathsPlugin处理 tsconfig.jsonpaths 别名
MiniCssExtractPlugincss 抽离成独立文件(prod)
CssChunkingPluginApp Router 的 CSS 分片策略
ProfilingPlugindev 时给 trace 记每个 module 的 build time
SlowModuleDetectionPlugin检测编译慢的 module,dev 模式输出 hint
NextFontManifestPluginnext/font 输出 manifest,运行时 inline @font-face
SubresourceIntegrityPlugin生成 SRI hash(CSP nonce / hash 配合)
DeferredEntriesPlugindev 模式延迟编译某些 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

packages/next/src/build/webpack-config.ts
const isRspack = Boolean(process.env.NEXT_RSPACK)

Next.js 维护三套 bundler 路径:

Bundler实现状态
webpackJS 实现,社区 ecosystem仍是 stable,但默认已是 turbopack
rspackRust 实现的 webpack 兼容层实验性,NEXT_RSPACK=1 开启
turbopackRust 实现,重新设计的 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 突然 OOMbarrel-optimization-loader 没正常生效;检查 optimizePackageImports 配置
Edge bundle 超 1MB用 dynamic import 切;移到 nodejs runtime
CSS 在 prod 顺序错乱CssChunkingPlugin 排序问题;升级到最新 Next.js
dev OK、prod 缺 CSSmini-css-extract 抽离失败;看 build log 里有没有警告
自定义 webpack config 不生效用户在 next.config.jsconfig.module.rules.push(...),被 Next.js 覆盖;要用 config.module.rules.unshift 或 prepend
[webpack.cache.PackFileCacheStrategy] Caching failedpersistent 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

十七、本讲小结

  1. 三套 compilerisClient / isNodeServer / isEdgeServer 是 webpack-config.ts 的核心分支变量。
  2. react-server condition:webpack 5 的 conditional exports 让同一个 react import 在 server/client 解析到不同文件。
  3. FlightClientEntryPlugin + ClientReferenceManifestPlugin 是 RSC 编译的核心:从 server graph 找 client boundary、为每个 page 输出 reference manifest。
  4. NextTraceEntrypointsPlugin 在 server build 末尾跑 nft,生成 .nft.json 给 standalone 用。
  5. MiddlewarePlugin 保证 edge bundle 是 self-contained 且大小受限。
  6. 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 模式下的增量编译。