1. 从一个“单文件加载缓慢”问题说起:为什么需要代码分割
前阵子帮朋友排查一个生产环境的性能问题,现象很典型:一个后台管理系统,首屏白屏时间在弱网环境下能到 8 秒以上。打开浏览器 Network 面板一看,主 JS 文件 6.8MB,一个文件走完下载、解析、执行整条链路,慢得让人怀疑人生。当时项目用的就是 Rollup 打包,配置非常简单——单入口、单输出,所有业务代码连同 node_modules 里的第三方库全部打成了一个 bundle。
这个场景我想很多用 Rollup 的朋友都不陌生。Rollup 的默认行为就是倾向“合并”:把 ES Module 的依赖图完整解析之后,尽可能把模块合并输出成更少的 chunk。在构建库(比如发布一个 npm 包)的时候,这种单文件输出是合理的选择,因为库的消费者更想拿到一个干净、完整、可直接引入的文件。但放到业务项目里,尤其是一个持续迭代、依赖越来越多的应用,单文件输出就会让首屏加载的代价越来越大,用户得等整个应用的代码全部下载完才能看到界面。
代码分割(Code Splitting)解决的核心问题就在于此:把代码拆成多个 chunk,让用户访问某个页面或触发某个功能时,只需要加载对应的那一部分,而不是整个应用。Rollup 从 1.0 版本开始就内置了对代码分割的原生支持,只是很多朋友只在文档里扫过一眼 Dynamic Import 的用法,并没有系统理解它的机制、边界和真正适合自己的拆包姿势。
这篇文章我就以 Rollup 为对象,把代码分割这件事讲透:动态导入是怎么触发分割的、chunk 之间的依赖关系如何处理、多入口和 manualChunks 怎么配合、真实项目里常见的坑有哪些,以及 Rollup 和 Webpack、Vite 在这个能力上的设计差异。文章里涉及的操作和配置我都实际跑过,后面会尽量用可复现的方式写出来。
如果你是刚接触 Rollup 不久,建议先搞懂两个基础概念再往下读:一是 Rollup 以 ES Module 为第一公民,打包过程本质上是构建模块依赖图;二是 output.format 对代码分割的行为有硬约束,es和esm等 ESM 格式支持天然的分割输出,而cjs、iife等格式在分割能力上有明确限制(iife 甚至不支持代码分割)。这两个前提直接影响后面所有的配置。
2. 动态导入:Rollup 触发代码分割的核心机制
代码分割在 Rollup 里的第一触发方式是动态导入(Dynamic Import)。也就是在代码里用import()函数语法异步加载模块。Rollup 在解析阶段发现import()调用后,会把目标模块及其依赖单独划分成一个或多个 chunk,除非你显式关闭这个行为。
2.1 动态导入生成 chunk 的完整过程
我先搭一个最小项目来演示。目录结构如下:
├── package.json ├── rollup.config.mjs └── src ├── index.js └── heavy.jssrc/heavy.js故意写一段体积较大的代码,模拟一个“只有用到某个功能时才需要加载的模块”:
// src/heavy.js export const heavyTask = (input) => { // 这里模拟一个耗时的加密/数据处理过程 const data = []; for (let i = 0; i < 100000; i++) { data.push(input + i); } return data.reduce((sum, item) => sum + item, 0); };src/index.js里用动态导入引用它:
// src/index.js const runHeavyTask = async () => { const { heavyTask } = await import('./heavy.js'); return heavyTask(10); }; runHeavyTask().then((result) => { console.log('result:', result); });Rollup 配置非常简单:
// rollup.config.mjs export default { input: 'src/index.js', output: { dir: 'dist', format: 'esm', entryFileNames: '[name].js', chunkFileNames: 'chunks/[name]-[hash].js' } };注意这里的输出配置用的是dir而不是file。这是一个关键点:只要使用代码分割,Rollup 的输出必须指定dir(输出目录),因为结果是多个文件。如果你沿用单入口时的习惯写output.file,Rollup 会直接报错:
Invalid value "file" for option "output.dir" - you must set "output.dir" instead of "output.file" when code-splitting.
执行npx rollup -c后,dist 目录会生成两个文件:
dist ├── index.js └── chunks/heavy-a1b2c3.jsindex.js里不再是打包进heavy.js内容的完整 bundle,而是保留了动态导入的“骨架”:
// dist/index.js(内容已简化) const runHeavyTask = async () => { const { heavyTask } = await import('./chunks/heavy-a1b2c3.js'); return heavyTask(10); }; runHeavyTask().then((result) => { console.log('result:', result); });这份输出在标准 ESM 环境下可以直接通过静态服务器访问并运行。Rollup 所做的就是把原先的静态依赖关系,转换为运行时按需请求的加载链路。
2.2 共享依赖怎么拆分:chunk 之间会形成依赖图
用动态导入做分割后,一个容易忽视的问题是:如果index.js和heavy.js同时依赖同一个第三方模块,Rollup 会怎么处理?
我改一下示例。新建src/utils.js:
// src/utils.js export const formatMsg = (msg) => `[app] ${msg}`;index.js和heavy.js都引入它:
// src/index.js import { formatMsg } from './utils.js'; const runHeavyTask = async () => { const { heavyTask } = await import('./heavy.js'); return heavyTask(10); }; console.log(formatMsg('app started')); runHeavyTask().then((result) => { console.log('result:', result); });// src/heavy.js import { formatMsg } from './utils.js'; export const heavyTask = (input) => { const data = []; for (let i = 0; i < 100000; i++) { data.push(input + i); } return data.reduce((sum, item) => sum + item, 0) + formatMsg(': heavy done'); };再次打包,dist 结构变成:
dist ├── index.js ├── chunks/utils-abcdef.js └── chunks/heavy-123456.jsutils.js被单独拆成了一个 chunk,index.js和heavy.js在运行时都会加载这个公共 chunk。Rollup 这里采用的是 Webpack 类似的做法——把多个 chunk 的共享模块提取出来形成独立 chunk,避免同一份代码被重复打进多个文件。
这个行为的价值要结合实际场景看。如果两个动态加载的页面模块共享同一个工具模块,把工具模块独立出来,用户访问页面 A 时浏览器缓存了公共 chunk,再访问页面 B 时就不会重复下载这部分代码。如果不提取,同一个工具代码会被分别包含在页面 A 和页面 B 的 chunk 里,每个页面各自白付一份下载成本。
2.3 动态导入的模块“副作用”问题
用动态导入时,模块顶层的副作用代码(side effects)执行时机和静态导入有明显差异,这个很多人踩过坑。
静态import的模块副作用会在父模块执行前同步完成,而动态import()内部模块的副作用,会在异步请求完成后、模块体内的函数和值暴露之前执行。换句话说:
// 静态导入:utils.js 的顶层代码立刻执行 import './utils.js'; // 动态导入:utils.js 的顶层代码在 await 结果返回前才执行 await import('./utils.js');如果你有一个模块,在导入时做了初始化工作(比如设置全局事件监听、填充某个单例的缓存),从静态导入改成动态导入,初始化时机就会延后。大多数情况下这只是影响时序,但如果你依赖的是“模块加载完成即初始化完成”的假设,业务逻辑可能就会出问题。
所以,做动态导入切分前,先问自己一个问题:这个模块的顶层副作用是不是可以按需触发?如果答案是“不能”,那它就不适合动态导入。这也是 Webpack 社区常说的“对具备副作用的基础设施模块避免懒加载”。
3. 多入口配置与 manualChunks:把分割主动权握在手里
动态导入是“代码主动请求分割”,而 Rollup 还提供了一条“配置层面强制分割”的路径——多入口和 manualChunks。这两种方式适合不同的场景,核心区别在于:前者由代码结构驱动,后者由打包配置驱动。
3.1 多入口(Multi-Entry):适合 MPA 和库拆包
多入口的配置非常简单,input可以是对象、数组或者 glob 路径。最常见的用法是传统多页应用(MPA),每个页面一个独立入口:
// rollup.config.mjs export default { input: { home: 'src/pages/home/index.js', detail: 'src/pages/detail/index.js', admin: 'src/pages/admin/index.js' }, output: { dir: 'dist', format: 'esm', entryFileNames: '[name]-[hash].js', chunkFileNames: 'chunks/[name]-[hash].js' } };Rollup 会为每个入口生成一个独立的 entry chunk。入口之间共享的模块,会按照依赖分析的结果提取成公共 chunk。比如两个页面都引入了utils.js和api.js,这两个模块会被打进同一个 shared chunk,被两个 entry 分别加载。
多入口模式有一个细节值得留意:每个入口都是独立的执行起点,Rollup 不会假设入口之间有任何关系。所以如果home和admin都依赖同一个包含副作用的模块(比如初始化埋点 SDK),这个 SDK 的副作用代码就会被提取进公共 chunk,执行时执行一次还是多次取决于浏览器加载公共 chunk 的次数。如果页面是独立 HTML,各自加载各自的公共 chunk,副作用自然是“每页执行一次”,这通常是符合预期的;但如果你在同一个页面里像 SPA 一样动态注入多个 entry,副作用的重复执行就得小心。
多入口另一个常见用途是库拆包。如果你在做一个组件库,想输出button.js、dialog.js、tabs.js等独立文件,让使用方按需引用,多入口是比“一个大文件 + tree-shaking”更可靠的做法。tree-shaking 依赖使用方的打包器正确识别副作用的标记,多入口则直接把“按需”做到了产物层面。
3.2 manualChunks:手动控制公共库分组
动态导入和多入口都解决“代码被拆开”的问题,但没有解决“拆得合不合理”的问题。一个典型的痛点:项目里有 20 个页面动态加载,每个页面都引用了lodash-es或dayjs,Rollup 默认会把公共模块提取成一个 chunk,但如果这些公共模块来自不同性质的库、更新频率不同、浏览器缓存价值不同,统统混在一个 chunk 里并不是最优策略。
output.manualChunks就是用来在配置层面指定:哪些模块应该被手动归入同一个 chunk。它的用法是函数形式:
// rollup.config.mjs export default { input: 'src/index.js', output: { dir: 'dist', format: 'esm', manualChunks(id, { getModuleInfo, getModuleIds }) { if (id.includes('node_modules')) { if (id.includes('lodash-es') || id.includes('dayjs')) { return 'vendor-basic'; } if (id.includes('echarts') || id.includes('d3')) { return 'vendor-charts'; } // 其他 node_modules 里没匹配到的库 // 如果不 return,Rollup 会按默认规则继续处理 } } } };manualChunks回调接收模块的绝对路径id和第二个参数(一个包含getModuleInfo、getModuleIds等方法的对象),返回值就是自定义 chunk 的名称。返回undefined表示“不管”,由 Rollup 的默认逻辑决定;返回字符串则强制该模块进入指定 chunk。
引入manualChunks后,依赖关系会变成:每个页面 chunk 引用vendor-basic或vendor-charts,这些 vendor chunk 相互独立。好处显而易见:lodash-es和dayjs这类底层库基本不变,浏览器缓存命中率极高;echarts这类大而复杂的图表库和业务代码分开,更新业务代码不会让用户重新下载几十兆的图表代码。
3.3 manualChunks 使用中容易踩的坑:循环调用与公共依赖合并
manualChunks在 Rollup 里看起来简单,实际用起来有个很隐蔽的坑:当你在 manualChunks 里引入“共享模块”时,Rollup 可能会把原本已经拆开的入口 chunk 重新合并。
举个例子。你有两个入口a.js和b.js,分别动态导入各自页面,但两个页面都用shared.js。你希望shared.js单独成 chunk。但如果不小心在 manualChunks 里把shared.js的父级(比如a-page.js和b-page.js都依赖的一个容器组件)也指定进了同一个 chunk,Rollup 可能为了满足“a-page 和 b-page 都要引入该 chunk”的条件,生成一个巨大的公共 chunk,或者反过来把整个依赖图拆成不符合预期的形状。
这个问题的根源在于,Rollup 的代码分割和 Webpack 不同,它没有“按运行时入口动态分发”的分包机制,而是完全基于静态模块图。manualChunks 的返回值本质上是在告诉 Rollup:“这些模块必须在一起”,Rollup 为了满足这个约束,会调整 chunk 划分,甚至可能膨胀公共 chunk。
实际操作中我的建议是:manualChunks 只用于“node_modules 里的稳定第三方库”这种边界清晰、依赖关系简单的模块。业务代码模块尽量靠动态导入的默认机制去分割,不要在手写 manualChunks 时把业务模块和第三方模块混在一起分组。
4. 真实项目里的分割策略与体积权衡
理论讲完,落到真实项目上,代码分割策略的选择其实是在三个目标之间找平衡:首屏体积(加载速度)、缓存复用(二次访问体验)、请求数量(HTTP/2 下可以放宽,但 HTTP/1.1 下仍是硬约束)。
4.1 按路由拆:最常见的业务分割模式
SPA 应用里,最天然的分割单位是路由。每个路由对应一个页面组件,只有访问该路由时才加载对应代码。Rollup 下实现方式就是动态导入路由组件,配合路由库的懒加载配置。
如果你用的是 React 和 React Router,代码大致是这样:
// src/router.jsx import { createBrowserRouter } from 'react-router-dom'; import { lazy, Suspense } from 'react'; const HomePage = lazy(() => import('./pages/Home')); const DetailPage = lazy(() => import('./pages/Detail')); const AdminPage = lazy(() => import('./pages/Admin')); const router = createBrowserRouter([ { path: '/', element: <Suspense fallback={<div>loading...</div>}><HomePage /></Suspense> }, { path: '/detail/:id', element: <Suspense fallback={<div>loading...</div>}><DetailPage /></Suspense> }, { path: '/admin', element: <Suspense fallback={<div>loading...</div>}><AdminPage /></Suspense> } ]);这套代码配合 Vite(底层构建用 Rollup)打包后,默认行为就是一个路由一个 chunk。你需要注意的配置点是rollupOptions.output.chunkSizeWarningLimit:
// vite.config.ts export default { build: { rollupOptions: { output: { chunkSizeWarningLimit: 500, // 默认是 500 KB manualChunks: { // 把 React 全家桶单独分出来 'vendor-react': ['react', 'react-dom', 'react-router-dom'] } } } } };chunkSizeWarningLimit本身不改任何打包行为,只是控制警告阈值。如果你不加 manualChunks,Rollup 很可能因为“首屏页面 chunk 超过 500KB”而对你哐哐报警告。噪声太大,警告也就失去警示意义了。
4.2 第三方库怎么拆:按更新频率和体积决定
第三方库的拆包策略,我一般在真实项目里按三个维度排序:体积大小、更新频率、被页面依赖的广泛度。
- 大体积且低频更新(如 echarts、monaco-editor、antd):必须单独拆,最好还能按子模块进一步拆(比如 echarts 按图表类型动态导入)。这类库不进主 chunk,业务更新永远不触发它们的重新下载。
- 小体积且低频更新(如 dayjs、lodash-es、axios):可以合并成一个 vendor chunk。单独为每个小库拆一个 chunk 会导致请求数过多,收益却不明显。把这些小库合在一起,chunk 体积也就几十 KB,缓存策略等同于一个大块,简单有效。
- 体积小且高频更新(内部工具函数、业务公共组件):不应该被打进 vendor chunk,否则会让“低频更新”的 vendor 块失去缓存价值。这类模块让 Rollup 默认提取成业务公共 chunk 就行,或者在 manualChunks 里和第三方库分开。
个人常用的第三方库分组方案:
manualChunks(id) { if (!id.includes('node_modules')) return; if (id.includes('echarts') || id.includes('zrender')) return 'echarts'; if (id.includes('monaco-editor')) return 'monaco'; if (id.includes('antd') || id.includes('@ant-design')) return 'antd'; // 剩余 node_modules 里的库,统一放 vendor return 'vendor'; }注意,antd和echarts这种大型库内部还有自己的动态导入逻辑,你手动把它们归进一个 chunk 后,Rollup 会尽量合并。如果echarts模块里有大量动态分割点,manualChunks 强制合并后最终产物可能会因为循环依赖或模块共享产生额外的公共 chunk——这属于正常现象,不用过度恐慌。
4.3 拆得太碎的教训:chunk 爆炸问题
滚动发布后,总有一种反向优化的冲动:把每个页面、每个组件甚至每个工具函数都拆成独立 chunk。但这在 HTTP/1.1 时代是灾难,在 HTTP/2 时代也要谨慎。
HTTP/2 支持多路复用,请求数量不再是严格瓶颈,但每个请求依然有 header 开销、TLS 握手后的加解密开销,而且浏览器对同一域的并发请求数仍有上限(各家不同,通常 6~8 个)。如果一个页面首屏依赖 30 个 chunk,即使 HTTP/2 能并发下载,浏览器解析、编译、执行这些小型 ESM 模块的开销也非常可观。
Rollup 输出的 ESM chunk 之间是原生 import 关系,浏览器必须等待 import 链上的所有模块下载完成才能执行入口代码。这里有一个很反直觉的性能细节:ESM 的解析是“深度优先”的,浏览器会先 fetch 所有静态 import 的模块,再开始执行。所以 chunk 数量也不是越少越好——太小太多会导致网络往返和解析开销放大;太大则会拖慢首屏。这才是代码分割真正的“权衡的艺术”。
一个务实原则:业务页面按路由拆,第三方库按“大库独立、小库合并”拆,业务公共代码尽最大可能复用单个公共 chunk。不要为了“拆”而拆。
5. 踩坑实记:循环依赖、过碎 chunk 与加载时序问题排查
代码分割在简单项目里跑得很顺,但到了依赖关系复杂的项目里,问题会一个接一个冒出来。这一节记录几个我实际遇到过、也排查过的问题,排查思路比最终答案更有参考价值。
5.1 问题一:动态导入后模块的循环依赖导致运行时 undefined
现象是某个模块从静态导入改成动态导入后,页面正常渲染,但点击某个按钮时报TypeError: Cannot read properties of undefined (reading 'xxx')。追了半个下午,发现是循环依赖。
场景简化如下:
a.js import b.js b.js import c.js c.js import a.js (形成循环)原先静态导入时,Rollup 会尽量把这三个模块合并进同一个 chunk,循环引用在同一个模块作用域里通过“提升”的方式互相引用,运行顺序碰巧没问题。改成动态导入a.js后,a.js被拆成独立 chunk,而a.js依赖的b.js、c.js在另一 chunk 里。动态 chunk 加载完成后,模块初始化顺序发生了变化,c.js里访问a.js导出的变量时,该变量还是undefined。
这类问题常规修复方案有两个:一是调整代码,消除循环依赖(最优解);二是把循环依赖涉及的模块通过 manualChunks 强制放回同一个 chunk,让 Rollup 用合并方式规避初始化顺序问题(治标)。业务代码里尽量还是治本——循环依赖在代码分割场景下会从“不报错”变成“神秘报错”,排查成本很高。
排查这类问题的工具建议:Chrome DevTools 的 Sources 面板里看 Network 请求顺序和 console 报错的 stack trace,报错位置通常在“模块顶层代码里引用了尚未初始化的变量”这一块,而不是在函数调用里。一旦 stack trace 里出现模块初始化阶段(不是函数执行阶段),就可以大胆往循环依赖方向排查了。
5.2 问题二:chunk 数量爆炸导致构建产物目录难以管理
有朋友反馈项目构建后 dist 目录有几百个 chunk 文件,构建时间暴涨,产物部署也慢。看他的配置:每个页面都用动态导入,页面里每个组件又各自动态导入,连一个简单的弹窗组件都用lazy(() => import('./Modal'))包裹。
解决方案不是修改单个配置,而是需要重新审视分割粒度:动态导入的单元应该是“功能模块”或“路由页面”,而不是“组件”。组件级别的懒加载适合那种“体积大、展示少见、仅在用户特定交互后才出现”的重组件,比如富文本编辑器、图表组件。普通弹窗、普通表单组件拆成独立 chunk,只会徒增请求数。
Rollup 对过碎 chunk 的官方回应是experimentalMinChunkSize(Rollup 4 新增)。它允许设置一个最小 chunk 体积,低于阈值的 chunk 会被尝试合并进其他 chunk:
output: { experimentalMinChunkSize: 1000 // 单位:字节 }注意这个 API 名称带了experimental前缀,用法和稳定性随着 Rollup 版本变化可能不同,用之前先查看当前版本对应文档。它做的是“合并过小的 chunk”而不是“撤销代码分割”,所以不会把整个页面 chunk 都吞回去,安全度相对可控。
5.3 问题三:动态加载后页面白屏与“loading 闪烁”
代码分割后,最常见也最容易忽略的是加载时序问题。动态导入是异步加载,在请求完成前页面处在“空白”状态。如果懒加载的 chunk 很大、网速又慢,用户会看到一个长时间无反馈的页面。
处理这个问题的标准姿势是使用 Suspense(React)或路由级 loading 状态。不过更底层的优化是给首屏用的动态 chunk 做 preload 或 prefetch 提示,让它和入口 chunk 并行下载,而不是等入口执行完、解析到import()调用时才去请求。
Rollup 这边不直接提供 preload 标签生成能力,但结合构建产物可以在 HTML 里手动加:
<link rel="preload" href="/assets/home-abc123.js" as="script" />不过手写 hash 文件名在每次构建后都会变,维护成本高。现实项目里,Vite 这种集成方案会自动为入口 chunk 生成<link rel="modulepreload">,对首屏关键动态 chunk 建议你在应用代码层用“自动注入”的方式处理,或者直接接受“入口 chunk 小、动态 chunk 按需加载”的默认行为,把精力花在缩小具体页面 chunk 的体积上。
5.4 问题四:动态导入与公共依赖反复匹配导致的 chunk 循环加载
这是比较深的一个坑。当我用manualChunks把echarts强制分组后,Rollup 偶尔会生成一个同时被主页 chunk 和图表页 chunk 依赖的“额外公共 chunk”,并且这个额外 chunk 反过来依赖echartschunk,表面上看起来像是循环 chunk 引用。
实际排查后发现并没有真正的循环,而是echarts内部有一部分代码是动态导入加载的子模块,这些子模块与业务代码共享了某些小工具模块。当我把echarts全部塞进一个 chunk 后,那些动态导入的子模块也被合并了,导致额外公共 chunk 必须同时被业务 chunk 和echartschunk 引用,引用关系形成“伪循环”。
解法是放弃对echarts的强制分组,允许它的内部拆包按默认规则走,或者只把echarts的核心入口(echarts/core)做 manualChunks,内部更细分的按需模块由 Rollup 自行处理。一句话:manualChunks 的目标越“大而全”,越容易触发 chunk 重组的不可控行为。
6. Rollup、Vite 与 Webpack 在代码分割上的设计差异
讨论 Rollup 代码分割,绕不开和 Webpack 的对比,以及 Vite 在底层使用 Rollup 之后做了哪些增强。
6.1 Rollup 与 Webpack 的分割范式差异
Webpack 的代码分割逻辑建立在“运行时 runtime + chunk graph”基础上。Webpack 在产物里注入一段运行时加载器,负责模块映射、懒加载、公共 chunk 的运行时装配。也就是说,Webpack 的产物不是浏览器原生 ESM,而是一套自己实现的模块运行时系统。这套系统功能强大(支持各种 magic comment、动态 chunk 命名、preload/prefetch 声明),但产物里会有一段不菲的 runtime 代码。
Rollup 的代码分割则完全基于原生 ES Module:产物就是标准 ESM 文件,chunk 之间通过原生import语句互相引用。浏览器本身充当模块加载器和运行时。这意味着 Rollup 的产物更干净、更符合浏览器原生机制,不需要额外 runtime 代码,但代价是:所有依赖原生 ESM 支持的浏览器环境才能运行。在现代浏览器里这就够用了,但在需要兼容老浏览器的项目里,Rollup 的 ESM 产物还得通过额外的构建链改造。
Webpack 还有一个 Rollup 没有的能力是splitChunks的cacheGroups弹性分组策略。它可以在“自动提取公共依赖”和“手动分组”之间做非常精细的规则配置,而 Rollup 的manualChunks更像一把“硬编码”的锤子,灵活度低一档。不过 Rollup 4 的experimentalMinChunkSize和持续演进的 chunk 生成规则在缩小差距。
6.2 Vite 站在 Rollup 肩膀上做了什么
Vite 生产构建用 Rollup,开发环境用 esbuild 做依赖预构建。这套组合的关键在于:开发环境根本不打包代码,浏览器直接请求 ESM 源码;生产环境才让 Rollup 做完整的代码分割和压缩。
Vite 给 Rollup 代码分割加了几个实用增强,最明显的是build.rollupOptions.output.manualChunks的使用体验,它把 Rollup 的函数手动分组和 Vite 的项目结构做了融合。Vite 还默认开启了对node_modules的自动 vendor 拆分,并且提供build.modulePreload.polyfill等选项,帮开发者处理 modulepreload 的兼容问题。
实际开发中,在 Vite 项目里做代码分割,我建议重点掌握两个配置项:
// vite.config.ts export default { build: { rollupOptions: { output: { inlineDynamicImports: false, // 保持动态导入拆包,不要合并 manualChunks(id, { getModuleInfo }) { ... }, } }, modulePreload: { polyfill: true, // 老浏览器自动注入 modulepreload polyfill } } };inlineDynamicImports是个危险选项,默认false。一旦显式设为true,所有动态导入会被强制内联成一个 bundle,代码分割完全失效。有些朋友为了让某些环境(比如 service worker 或 localStorage 缓存场景)拿到单一文件,会打开它,但这基本等于放弃代码分割收益。能不开就别开。
6.3 选型建议:什么时候用 Rollup,什么时候别强求代码分割
最后回到一个现实问题:什么项目适合用 Rollup 做代码分割?
我的经验是:发布 npm 库时优先用 Rollup 单文件输出;业务应用如果主要跑在现代浏览器、希望产物更纯净、追求更快的首屏加载,Rollup(或 Vite 底层 Rollup)是很好的选择;如果需要兼容老浏览器,或者对 chunk 分组的精细控制有强烈诉求,Webpack 的生态和配置灵活度仍然是杀手锏。
代码分割不是构建工具领域的“屠龙之术”,本质上它是体积管理、网络策略、浏览器缓存三者之间的匹配工具。Rollup 的代码分割机制非常简单直接——动态导入触发拆包、配置与手动分组做调优、产物是原生 ESM——理解这条链路,你在真实项目里无论遇到拆包过细、过粗还是循环依赖问题,都能快速定位到正确方向。
我个人在实际项目中最深刻的体会是:先明确自己的分割目标,再动手配置,而不是配置完再去看效果。你要先知道“我希望首屏体积压到多少”“我期望哪些代码可以长期缓存”“用户第二次访问时哪些模块可以直接命中缓存”,然后按这几个目标去设计动态导入边界和 manualChunks 分组。目标不清,配置就只是在碰运气。
最后分享一个小技巧:每次调整分割配置后,不要只看构建日志里的体积数字,真正用浏览器 DevTools 的 Network 面板跑一次首屏加载,观察请求瀑布图,看有没有非预期的串行请求和过大的单文件。代码分割的价值最终体现在“用户的加载体验曲线”上,而不是“构建产物里有多少个 chunk”这个表面指标上。