next/dynamic 救不了你:为什么 CMS 驱动的 Next.js 页面会把每个 chunk 都发出去
我们那些 CMS 驱动的 Next.js 页面,在每一个路由上都发出了全部约 100 个 section 组件——尽管我们用的是教科书级别的 next/dynamic。我们把 bundler 层面的每一种修法(直接 import()、拆分文件、Turbopack 配置、webpack splitChunks)都试遍了,才找到真正的原因:可达性,而非 chunk 拆分。构建期代码生成加上 rewrites 把 first-load JS 削减了 53%。
我们有一个生产环境的 Next.js 16 站点,大约有 700 个由 CMS 驱动的落地页。每个页面都由若干「section」拼装而成——hero、FAQ、评价、定价等等——总共约 100 个 React 组件,分布在 38 种 section 类型里。一个典型页面会渲染其中的 10 到 15 个。首页却发出了 5.3 MB 的 first-load JavaScript。当我们深挖这些字节都去了哪里时,发现有一个单独的 1.2 MB chunk,里面装着 108 个 section 的代码——而这个页面只渲染了 15 个。
每个 section 早就用 next/dynamic 包起来了。代码分割看上去完全符合教科书。可 bundler 却把所有东西发往了每一个地方。我们花了好几天,把有文档记载的每一种手段都试了个遍——dynamic import、直接 import()、文件重组、bundler 配置,甚至换掉 bundler——而每一种都因为同一个不那么显而易见的原因而失败。本文会带你走过每一条死路,附上真实数字,解释真正的根本原因,并展示那个在不改动任何一个 section 组件的情况下削减了 53% first-load JS 的修复方案。
TL;DR:在 CMS 驱动的动态路由上,代码分割不是一个 chunk 拆分问题——它是一个可达性问题。没有任何 bundler 标志能修好它。真正管用的,是把「这个页面到底用了哪些组件」这个知识从运行期搬到构建期。
架构(你多半也是这套)
这套配置对任何页面搭建器来说都是标准做法:CMS 把一个页面存成一份有序的 section 引用列表,应用里有一个中央注册表,把 section 名称映射到组件。每一项都用 next/dynamic 包起来,完全照着文档推荐的写法:
// One central registry: every section variant wrapped in next/dynamic
export const sectionRegistry = {
'sections.hero': {
concept_1: dynamic(() => import('./sections/Hero/HeroV1')),
concept_2: dynamic(() => import('./sections/Hero/HeroV2')),
// ...10 more hero variants
},
'sections.faq': {
concept_1: dynamic(() => import('./sections/Faq/FaqV1')),
},
// ...38 section types, ~100 variants total
}一个服务端组件在渲染时按名称解析每个 section 并渲染它。页面是带 ISR 的 SSG,所以这一切都发生在服务端——客户端只收到 HTML 加上用于 hydration 所需的那些 chunk:
// Server component: picks ONE section by name from CMS data
export async function SectionRenderer({ name, concept, id }) {
const data = await fetchSectionData(name, id)
const Component = sectionRegistry[name].concepts[concept]
return <Component {...data} />
}这套设计确实很好:营销人员在 CMS 里组合页面,无需部署,一个注册表服务 700 个页面,每个 section 变体都独立地按需懒加载。理论上是这样。可 bundle 讲的是另一个故事。
诚实地测量(DevTools 会骗你)
在做任何修复之前,我们需要一个可以信赖的测量。有三件事会毒害对 DevTools Network 的天真测量:
- 底部栏的总计数字,只要面板还在记录就会一直累加。加载几秒之后,框架的链接预取会开始在后台拉取其他路由的 bundle——在一个空闲的标签页上,总计数字会收敛到「最终把所有东西都拉下来」,从而掩盖掉任何 first-load 上的收益。
- 浏览器扩展会把自己那几 MB 的脚本注入到你的测量里——哪怕在无痕模式下,只要允许它们在那里运行也一样。我们曾亲眼看到单个扩展加了 2 MB 的「页面 JS」。
- 由缓存提供的行((disk cache) / (memory cache))在网络上传输的字节数为零,所以一次热重载什么都测不到。
真正重要的数字——也是 Core Web Vitals 会响应的那个——是初始脚本集合:服务端渲染出的 HTML 里的那些 <script> 标签。那才是阻塞 hydration 的东西。而且它也极易用脚本获取:
// Count the REAL first-load JS: the <script> set of the prerendered HTML.
// (DevTools totals lie — more on that below.)
const html = fs.readFileSync('.next/server/app/en.html', 'utf8')
const scripts = [...new Set(
[...html.matchAll(/<script src="([^"?]+\.js)[^"]*"/g)].map(m => m[1])
)]
let bytes = 0
for (const src of scripts) bytes += fs.statSync('.next' + src).size
console.log(scripts.length, 'chunks,', (bytes / 1024).toFixed(0), 'KB')为了做逐模块的归因,我们临时开启了 productionBrowserSourceMaps,并用一个小小的 VLQ 解析器把每个生成的字节都归因到它的源模块。有一个值得知道的坑:Turbopack 给每个 chunk 的 .map 文件起的 hash,和它的 .js 不一样——所以要从 chunk 末尾读取 sourceMappingURL 注释,而不是靠 js + '.map' 去猜。
五条死路(这样你就不用重蹈覆辙)
下面每一条我们都用一次完整的生产构建和上文那套测量验证过。没有一条能撼动那个数字。这种重复正是重点所在——这个故障模式能扛住你扔向它的每一种工具,因为所有这些工具解决的都是另一个问题。
死路 #1:「直接用 next/dynamic 不就行了」
它本来就在那儿了。约 100 个变体每一个都用 dynamic() 包过。可 first-load JS 照样是 5.3 MB。无论 dynamic() 承诺了什么,在这里它都没兑现——先记住这一点,原因马上就会揭晓。
死路 #2:在服务端组件里直接 await import()
也许问题出在 next/dynamic 的那层包装上?在 App Router 里,服务端组件可以直接 await 一个 dynamic import——这正是官方为库推荐的懒加载模式。于是我们把那个装满 dynamic() 包装器的注册表,换成了一份原始 import() thunk 的映射:
// Dead end #2: replace next/dynamic with a direct server-side import()
const loaders = {
'sections.hero': { concept_1: () => import('./sections/Hero/HeroV1') },
// ...
}
export async function SectionRenderer({ name, concept, id }) {
const data = await fetchSectionData(name, id)
const { default: Component } = await loaders[name][concept]()
return <Component {...data} />
}
// Result: byte-for-byte the same megachunk. Nothing changed.死路 #3:把注册表拆到 38 个文件里
下一个假设:bundler 之所以合并 chunk,是因为约 100 个 import() 调用全都住在同一个模块里。于是我们为每种 section 类型生成一个 loader 文件——38 个文件,每个只装它自己那些变体的 import() thunk,外加一个薄薄的 barrel 用来按名称查找它们。
结果:逐字节完全相同的输出。同样的巨型 chunk,同样近乎一致的大小。这是第一个真正有用的反面结果:bundler 是按模块图来分组 chunk 的,而不是按你那些 import() 调用的文件布局。在文件之间挪动语句对模块图来说是不可见的——同一组模块依然从同一个 resolver 出发保持可达。
死路 #4:bundler 配置
Turbopack 的 chunk 拆分是刻意设计成不可配置的:没有 splitChunks 的等价物,webpack 的魔法注释(webpackChunkName 之类)也会被忽略。唯一相关的、用于嵌套异步 chunk 拆分的实验性标志,在生产构建里本来就已经默认开启了。真的一个能拧的旋钮都不剩了。
死路 #5:切换到 webpack(以及那个解释了一切的实验)
webpack 是可以配置的,于是我们用 next build --webpack 跑了一遍站点。默认配置下:同样的巨型 chunk,约 1 MB,装着所有 section。接着我们用一个逐 section 的 cacheGroup 硬把问题逼出来——每个 section 目录一个 chunk,enforce: true:
config.optimization.splitChunks.cacheGroups.sections = {
test: /[\\/]components[\\/]sections[\\/]/,
chunks: 'all',
minSize: 0,
enforce: true,
priority: 50,
name: (module) => 'section-' + dirNameOf(module), // one chunk per section
}
// Result: the 1 MB megachunk split into 34 neat per-section files...
// ...and the page loaded ALL 34 of them. Same total bytes. Zero win.chunk 拆得漂漂亮亮——34 个整齐的逐 section 文件。然后页面把这 34 个全都加载了。同样的总字节数,同样被阻塞的 hydration,现在还多了几个 HTTP 请求。就在这一刻,真正的问题变得无可否认:我们一直在优化代码是怎么打包的,而问题在于这个路由引用了哪些代码。
真正的根本原因:可达性,而非 chunk 拆分
机制是这样的。这些 section 组件是客户端组件('use client')——它们带有处理函数、滑块、埋点分析。当一棵服务端组件树引用了一个客户端组件时,bundler 就必须把那个组件的 chunk 放进该路由的客户端 bundle 里,好让它能 hydration。那么一个 CMS 驱动的路由引用了哪些客户端组件呢?渲染器是靠一个来自 CMS 的运行期字符串来解析 section 的——所以从静态角度看,注册表里的每一个 section,对每一个用到该渲染器的页面来说都是可达的。bundler 无法知道 /pricing 永远只渲染其中的 12 个。它必须把全部约 100 个都准备好。
next/dynamic 帮不上忙,因为它提供的懒惰性是客户端侧的:它推迟的是一个 chunk 相对于渲染何时被下载,但服务端在客户端跑第一个字节之前,早就决定好了要渲染什么。凡是服务端渲染了的都必须 hydration;凡是可能被渲染的都必须被发出或保持可达。而在一个运行期解析的注册表下,「可能被渲染」等于「所有东西」。
bundler 打包的是可达的东西。如果你的 resolver 能触及 100 个组件,那你的路由就会发出 100 个组件——无论是拆成一个 chunk 还是三十四个,反正都发出去了。唯一真正的杠杆,是去缩小可达性本身。
修复方案:把知识搬到构建期(代码生成 + rewrites)
CMS 驱动的 SSG 页面有一个救命的特点:在构建期,服务端其实已经确切地知道每个页面用了哪些 section——它要从 CMS 拉取这些内容来预渲染 HTML。这个知识是存在的;只是被困在了运行期,bundler 看不到。所以我们把它在 bundler 运行之前物化成代码。
一个代码生成脚本作为构建的第一步运行(串接在这个包的 build 脚本里——这样每一条流水线都能白得这个好处:本地、CI、两条部署路径都是):
// Runs BEFORE next build (chained in the "build" script).
// 1. Find the routes that render CMS pages (scan for the renderer import).
// 2. Ask the CMS which sections each page actually uses —
// union across every locale, and across A/B variants.
// 3. Stamp a physical page file per route whose import map contains
// ONLY those sections. The bundler does the rest.
const pages = await fetchAllCmsPages() // slug -> sections[]
for (const page of inScope(pages)) {
const concepts = unionAcrossLocalesAndVariants(page)
writeFileSync(
`app/[lang]/(generated)/g/${page.key}/page.tsx`,
stampTemplate({ page, concepts }) // inline import() per concept
)
}
writeFileSync('generated-routes.manifest.json', { rewrites })
// Any failure => write nothing => the site behaves exactly as before.对每一个在范围内的页面,它都会烙印出一个物理路由文件,其 import 映射里只包含该页面的那些 section——是所有 locale 的并集(不同 locale 可以有不同的 section 集合),也是所有 A/B 变体的并集。对 bundler 来说,这个生成出来的文件就是一份普通的源代码,带有很窄的静态可达性,所以它无需任何配置就能产出一个小小的逐路由 bundle:
// AUTO-GENERATED on every build — the "manifest" is literal code.
const LOADERS: SectionLoaders = {
'sections.hero': {
concept_5: () => import('@/components/sections/Hero/HeroV5'),
},
'sections.faq': {
concept_1: () => import('@/components/sections/Faq/FaqV1'),
},
// ...exactly the 13 section types this page renders. Nothing else.
}
export default async function GeneratedHome({ params }) {
const { lang } = await params
return <CmsPage loaders={LOADERS} params={{ lang, slug: 'home' }} />
}这些生成出来的路由住在一个丑陋的内部路径下——用户永远看不到。next.config 里的一个 rewrites() 块会读取产出的 manifest,并在 beforeFiles 阶段把真实 URL 映射到这些生成的路由上。关键的性质是:如果 manifest 缺失或损坏,那就干脆没有任何 rewrite,每个页面都由那条未经改动的通用路由来提供服务:
async rewrites() {
// Missing/broken manifest => zero rewrites => yesterday's behavior.
// The codegen can never take the site down.
let generated: Array<{ source: string; destination: string }> = []
try {
const manifest = JSON.parse(
fs.readFileSync(path.join(__dirname, 'generated-routes.manifest.json'), 'utf8')
)
if (Array.isArray(manifest?.rewrites)) generated = manifest.rewrites
} catch { /* fall back to universal routes */ }
return {
beforeFiles: [
...generated,
// e.g. { source: '/:lang(en|de|fr|...)', destination: '/:lang/g/home' }
],
}
}这条渲染链是原始链条的一面镜子——同样的数据拉取、同样的 SEO、同样的埋点分析——只有一处不同,而这处不同正是全部意义所在:组件映射是作为一个 prop 传进来的,而不是从全局注册表里 import 的。有一条不变式必须成立,否则整个收益就蒸发了:生成路由的模块图里,任何东西都不得 import 全局注册表。这也包括间接路径——我们的数据拉取辅助函数曾 import 了注册表,仅仅是为了读取查询模板,而这会让每个 section 又重新变得可达;于是那份 metadata 不得不被拆进它自己那个不依赖注册表的模块里。
// Same rendering logic as before — but the component map arrives
// as a PROP from the generated page. Nothing in this chain may import
// the global registry, or every section becomes reachable again.
export async function SectionRendererGen({ loaders, name, concept, id }) {
const data = await fetchSectionData(name, id)
const loader = loaders[name]?.[concept] ?? loaders[name]?.['concept_1']
if (!data || !loader) return null
const { default: Component } = await loader()
return <Component {...data} />
}扛住 A/B 测试(大家都会问的那部分)
CMS 页面会跑 A/B 实验:middleware 对每个请求评估一个 feature flag,并把落在某个变体桶里的用户 rewrite 到同一 URL 下的另一个页面。这恰恰就是运行期解析当初存在的原因——那么一套构建期系统要怎么应对?靠尊重处理顺序。在 Next.js 里,middleware 总是先于 beforeFiles rewrites 运行,所以这两层是相互组合而不是相互打架:
request /
│
▼
proxy (middleware) — A/B decision, ALWAYS runs first
├─ no experiment → pass through as /en
├─ user in CONTROL → set cookie, pass through as /en
└─ user in VARIANT → rewrite to /en/ab/<variant-slug>
│
▼
beforeFiles rewrites — OUR manifest, runs on whatever path proxy chose
├─ /en → /en/g/home (generated, small)
├─ /en/ab/<variant> → /en/g/ab-<variant> (generated, small)
└─ anything unmatched → untouched → universal route (fallback)代码生成从 middleware 所用的同一个来源读取实验配置(这是一条刻意设立的不变式——不可能产生任何漂移),并为每个变体烙印出一个生成页面,外加一条针对该变体内部 URL 的精确 rewrite。对照组用户拿到快速的生成页面;变体组用户拿到属于他们自己的快速生成页面;用户可见的 URL 从不改变。一个在最后一次构建之后才创建的实验,会直接落到通用路由上,直到下一次部署为止——只是退化到了昨天的性能水平,绝不会坏掉。
fail-open 契约
整套系统被设计成退化到现状,绝不会更差。我们有幸在生产环境里意外地看到了它生效:在第一次部署时,CI 环境暴露 CMS URL 用的变量名跟本地构建的不一样。代码生成没找到 CMS,什么都没产出——而站点就通过通用路由服务了每一个页面,构建通过,对用户零影响。之后一处两行的修复,生成路由就出现了。这份契约是:
- CMS 不可达或某个页面无法解析 → 那个页面(或所有页面)被跳过;构建绝不会因为代码生成而失败。
- 没有 manifest → 没有 rewrites → 通用路由服务所有东西,跟这个项目之前一模一样。
- 构建之后 CMS 里的 section 结构变了 → 页面会渲染旧的那一套,直到下一次部署为止(一个触发重新部署的发布 webhook 能把这个窗口缩短到几分钟)。内容编辑不受影响——数据依然通过 ISR 实时拉取。
- 生成的文件被 gitignore 掉,每次构建时重新生成;审阅者审阅的是这个生成器,而不是 700 个被烙印出来的文件。
结果
| 指标(首页) | 之前 | 之后 |
|---|---|---|
| First-load JS(原始,初始脚本集合) | 5,127 KB (57 chunks) | 2,411 KB (39 chunks) |
| bundle 里的 section 组件数 | 137 | 16 |
| 那个装着所有 section 的巨型 chunk | ~1,200 KB | 0 KB |
−53% 的 first-load JavaScript,通过三种方式独立确认:初始脚本集合测量、逐模块的 source-map 归因(恰好是这个页面自己的 16 个组件——13 个被映射的 section 加上它们的子组件),以及对线上部署的 chunk 做一次标记 grep:
// Verify on the LIVE deployment without source maps: CSS class names
// are embedded in the JS chunks, so grep for section markers.
let all = ''
for (const src of initialScriptsOf('/en')) all += await fetchText(src)
// must be there: the sections the page renders
console.log(all.includes('HeroV5')) // true ✅
// must NOT be there: everything else
console.log(all.includes('fortune_wheel')) // false ✅
console.log(all.includes('holiday_offer')) // false ✅同样的数字在生产预览上精确到千字节地复现了。有一个预期需要提前管理:你 DevTools 里的网络总量看上去几乎没变,因为加载之后,router 会在后台预取其他路由(仍是通用的)的 bundle。这没关系——后台预取不阻塞任何东西。性能关乎的是在可交互之前加载了什么,而那个数字被砍了一半。如果你想要一份方便截图的证据,Lighthouse 和它的 treemap 会毫不含糊地把它展示出来。
坦白讲讲权衡
- 结构的新鲜度是绑定部署的:在 CMS 里增删一个 section 需要一次重新构建才能抵达优化后的路由(由 webhook 触发的部署能把窗口缩短到几分钟;回退机制填补了这个空档)。
- 代码生成会解析你的 CMS 和你的路由约定——它是约 400 行你自己拥有、必须维护的代码,包括它对注册表和路由 props 的解析。
- 规模问题:数百个 CMS 页面 → 数百个独一无二的 section 组合(我们那约 700 个页面有约 380 个独立集合,是一条长尾)。先从流量最高的那些页面入手,用一个显式的范围常量把它们框起来;回退机制服务那条长尾。
- 这并不会缩小站点的总 JS——变体繁多的库和内容丰富的 section 依然存在。它缩小的是每个路由发出的东西。下一个杠杆(把展示型 section 转成服务端组件)是一个独立的、更大的项目。
要点
- next/dynamic 制造的是拆分点,而不是保证。在服务端驱动的页面上,懒惰性是由路由能触及什么来决定的,而不是由 import 怎么写来决定的。
- chunk 拆分配置修不了可达性。我们把它彻底证明了:把巨型 chunk 拆成 34 个逐 section 的 chunk 什么都没改变——这 34 个全都加载了。
- 如果你的页面是从 CMS 来的 SSG/ISR,那你所需要的信息在构建期就已经存在了。代码生成就是那座桥:把运行期查找变成每个路由的静态 import。
- 跟 middleware 组合,而不是跟它对抗:middleware 决定的是哪个页面(A/B),rewrites 决定的是那个页面的哪一份实现。顺序保证了它们能叠加起来。
- 构建 fail-open 的系统:manifest 缺失 = 昨天的站点。我们第一次生产部署就意外地演练了回退机制,而用户根本没察觉。
这个模式可以推广到任何「由 CMS 数据在运行期解析的组件注册表」——页面搭建器、小部件系统、可换主题的店面。如果你的路由什么都能渲染,它就会把所有东西都发出去。让构建知道每个页面实际上是什么,bundler 最终就会去做你一直以为它在做的那件事。