做Webpack工程优化这段时间,我最大的感受是:调参只能解决局部痛点,结构才能解决全局问题。很多人一听到模块打包优化,回手就是splitChunks、thread-loader、持久化缓存,这些确实管用,但当一组稳定核心逻辑被业务入口反复引用,又因为依赖关系被Webpack重复编译几百次时,问题往往不在配置参数,而在打包职权没有分开。这时候在Webpack旁边加一个Rollup子方案,专门处理那种高复用、纯JS、低副作用的模块池,反而是性价比非常高的做法。
这篇文章不会劝你把整个工程从Webpack换到Rollup,而是分享一种混合构建的思路:保留Webpack作为主构建器,同时把符合条件的核心模块抽成独立子工程,借助Rollup完成预打包和Tree Shaking,产物直接交给Webpack消费。这套模式能缓解构建时间膨胀、产物体积虚高、运行时模块包装冗余三个问题,也适配“Webpack项目里局部调用wasm模块”这类特殊场景。适合已经熟悉Webpack配置、正在做前端工程性能提升的团队,或者需要在多个入口、多个子应用之间复用公共核心库的工程。
1. 先搞清思路:Webpack负责调度,Rollup负责精简核心模块
1.1 Webpack不是不行,是大而全的代价越来越明显
Webpack本质上是一个通用模块打包器,它的设计目标是处理前端工程里的所有资源:JS、CSS、图片、字体、wasm,还要兼顾HMR、代码分割、多页应用、loader机制和插件生态。这种全能型设计带来的问题是,为了兼容各种模块形态,Webpack在构建产物里引入了自己的模块运行时,每个模块都会被一层函数包裹,模块之间靠__webpack_require__来串联。
对于业务代码来说,这层运行时完全可以接受,反正不管怎样你都得靠模块系统加载依赖。但对于一块长期稳定、被多个入口复用的核心逻辑模块,这层包装就有不少冗余了。你可以把它理解成一个旅行团,Webpack就像一个什么都帮你背的管家,什么资源都能塞进箱子,确实省心,但箱子永远比实际需要的大一号。Rollup恰好相反,它只对ES Module负责,基于import/export的静态结构做分析,打出来的包非常干净,接近手写代码,Tree Shaking也做得更彻底。
Webpack 5的Tree Shaking和experiments.outputModule确实进步明显,但它的底层实现要照顾太多模块类型和插件介入点,面对一个纯函数库的“精确裁剪”需求时,仍不如Rollup极致。所以我的结论不是“Webpack不行”,而是不该让Webpack承担所有打包工作,尤其当某块代码本身不需要Webpack那套复杂生态的时候。
1.2 满足这几个条件的模块,才值得交给Rollup预打包
Rollup子方案不是把任何代码都塞过去就完事。根据我踩过的坑,你至少要让准备拆分的模块满足以下条件,否则后期维护会非常痛苦:
- 代码形态是纯JS或纯ESM,不依赖CSS、图片、字体等静态资源。
- 逻辑本身没有DOM操作,不跟浏览器环境强绑定,至少是“框架无关”的。
- 模块的依赖图可控,外部依赖不超过三五个,且基本都是纯函数库。
- 不在热更新链路内,不需要对这段代码做频繁的HMR开发。
- 被多个业务入口或子应用复用,值得做一次独立缓存。
反过来,如果你要拆的模块里混入了import './style.css'、读取process.env、隐式依赖__dirname,或者内部引用的npm包本身带有比较重的作用域环境,那拆到Rollup里就会变成事故现场。前期可以用一个很简单的脚本扫描一下源码目录,确认是否混入非JS资源引用,再决定要不要拆:
bash复制rg "from ['\"].*\.(css|scss|svg|png|wasm)" packages/core-utils/src
这条命令输出为空时才值得继续往下走。这不算什么高科技,但能帮你把最危险的交叉依赖挡在方案之外。
1.3 适合这套方案的工程画像与收益预期
不是所有Webpack项目都需要再加一套Rollup构建,如果你的应用只有两三百个模块,连Webpack本身的编译压力都不大,引入Rollup纯属给自己找活干。从我的实战经验来看,适合上这套方案的工程一般长这样:
- Webpack生产构建时解析的模块数量超过2000,且其中有相当一部分属于长期不变的内部工具代码。
- 核心逻辑包被两个以上入口使用,每次业务构建都要重复编译它。
- 产物体积里有一块明显属于“纯JS逻辑”但很难被Tree Shaking剪干净的区域。
- 团队内部有多个前端应用,共用同一个计算、协议、校验或数据转换核心。
这套方案能带来的收益一般体现在三个层面:Webpack需要分析的模块数量减少;最终产物剥离了重复的模块包装和死代码;团队因为核心逻辑独立成包,被迫把接口边界定义得更清楚,长期维护性会变好。至于具体数据能优化到什么程度,我在第4节会贴出一个参考工程的实测记录。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 落地架构设计:核心模块拆成独立子工程
2.1 三种集成模式对比,自己选,别跟风
想把Rollup和Webpack放进同一套工程里,可选的方式并没有那么多,我实测下来比较靠谱的有三种,整理成表格方便你对照:
| 集成模式 | 做法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| A. 独立双构建 | npm script先跑Rollup,再跑Webpack主构建 | 稳定、CI友好、失败定位直观 | 多一次构建调度,流程稍长 | 大多数中后台项目 |
| B. Webpack loader内嵌 | 写一个loader,在Webpack编译某段代码时调用Rollup API做实时预构建 | 可以吃到Webpack watch链路 | 缓存难对齐,loader链复杂度高 | 对HMR要求很高的特殊场景 |
| C. 独立包/Workspace子包 | 把核心模块发布成npm包,Webpack通过alias或依赖引入 | 多项目复用、版本清晰 | 本地开发要维护link或workspace | 多应用共享核心库的团队 |
如果你所在团队对Rollup的掌握还没那么深,我建议从A开始,先把两个构建流程跑通,再去想怎么玩B。C方案适合那种已经决定把核心库跨项目复用的团队,本质上它跟本文标题里的“单一Webpack工程优化”已经不是同一个层级了,但架构思路一致。
2.2 目录结构与打包脚本串联方案
我推荐一个相对简单的目录结构,不需要引入复杂的Monorepo工具,在项目内平铺即可:
code复制project/
├── src/ # Webpack业务侧源码
│ ├── pages/
│ ├── app.js
│ └── ...
├── core-utils/ # Rollup子模块工程
│ ├── src/
│ │ ├── index.js
│ │ ├── calc.js
│ │ └── protocol/
│ ├── package.json
│ ├── rollup.config.mjs
│ └── dist/
│ ├── core-utils.esm.js
│ └── core-utils.cjs.js
├── webpack.config.js
├── package.json
└── ...
脚本串联可以拆细一点。我在package.json里通常这样配:
json复制{
"scripts": {
"build:core": "cross-env NODE_ENV=production rollup -c core-utils/rollup.config.mjs",
"build:app": "cross-env NODE_ENV=production webpack --config webpack.prod.js",
"build": "npm run build:core && npm run build:app",
"dev:core": "rollup -c core-utils/rollup.config.mjs -w",
"dev:app": "webpack serve --config webpack.dev.js"
}
}
名字上我刻意把Rollup的构建叫build:core,把Webpack主构建叫build:app,这样团队心智里能明确区分“子模块构建”和“应用构建”。开发环境里先跑npm run dev:core等它输出完第一版产物,再跑dev:app,顺序不能反,否则Webpack第一次编译时如果没找到Rollup产物会直接报错。
2.3 划清边界:哪些依赖绝不能出现在Rollup产物里
核心模块边界一旦没划清楚,后续会连续爆炸。我的经验是无论如何都要守住下面几条“红线”:
- 禁止在core-utils里引用CSS或任何静态资源,Rollup本身能通过插件处理这些资源,但处理完的结果跟Webpack的asset体系不兼容,你会被夹在两个构建器的资源管逻辑中间。
- 外部依赖要收敛,并且明确哪些走
external字段,哪些打进去。如果core模块依赖了dayjs、lodash-es这类纯函数库,我会倾向于让Rollup直接把它打进产物,避免Webpack侧二次解析时出现重复打包。如果你不打算打进去,就必须保证Webpack最终能解析到同一个版本,否则最容易出现“同一个库两个实例”这种诡异问题。 - 不要在core模块里引用Webpack业务侧的全局配置或环境变量,更不能引用业务代码里的任何模块。Rollup构建产物必须在任何入口下都自洽。
判断方法也很原始:在core-utils/src里跑一遍grep -r "from './../src"或者直接搜import的路径,如果出现指向src/之外的相对路径,那基本就是边界被破坏的信号。
3. Rollup配置与Webpack衔接实操
3.1 rollup.config.mjs 核心参数逐个拆
先给一个能直接用起来的配置:
javascript复制import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import babel from '@rollup/plugin-babel';
import terser from '@rollup/plugin-terser';
import pkg from './package.json' with { type: 'json' };
const isProd = process.env.NODE_ENV === 'production';
export default {
input: 'src/index.js',
preserveModules: false,
plugins: [
nodeResolve({
extensions: ['.js', '.mjs']
}),
commonjs(),
babel({
babelHelpers: 'bundled',
exclude: 'node_modules/**'
}),
isProd && terser({
compress: {
pure_getters: true,
passes: 2
}
})
],
output: [
{
file: pkg.module,
format: 'es',
sourcemap: true
},
{
file: pkg.main,
format: 'cjs',
exports: 'named',
sourcemap: true
}
],
external: [
...Object.keys(pkg.peerDependencies || {})
]
};
几个关键点分别说下。
nodeResolve和commonjs的顺序尽量不要乱,前者负责把import的包名解析成真实文件路径,后者负责处理产物里可能残留的CommonJS依赖。注意,nodeResolve里我加了.mjs扩展名,否则有些纯ESM的npm包会被漏掉。
preserveModules这个字段值得单独解释。如果你把整块core-utils当做一个整体、入口只有一个index.js,那设为false即可,Rollup会把所有内部模块折叠成一个文件,Tree Shaking效果最好。如果你的场景是希望保留模块目录结构给外部按需引用,可以设为true,但产物内部仍然保留大量模块函数,瘦身效果会打折扣。我的建议是普通业务项目直接false,做成一个文件最省心。
babelHelpers: 'bundled'是另一个容易踩的坑。如果改成'runtime',Rollup会要求你引用@babel/runtime,并且把helpers作为外部依赖拆分,这在纯打包场景里引入不必要的复杂度。设成bundled等于把用到的helpers直接塞进产物,省事。
关于压缩,我在Rollup侧用terser做了第一道压缩。这样做的原因是Webpack生产构建时虽然也会压缩,但如果Rollup产物已经走过一遍深度清理,Webpack侧的二次压缩至少能省掉大量对死代码和无用作用域的分析时间。
3.2 wasm模块处理:rollup-plugin-wasm的两种接入姿势
Webpack本身对wasm有官方支持,那为什么还要在Rollup侧用rollup-plugin-wasm处理wasm?答案是边界问题。如果你有一段Rust或C编译出的wasm,它正好服务于core-utils里的计算逻辑,那我希望这个wasm的加载、实例化、模块封装都能在core子工程内完成,而不是让Webpack的业务规则去处理一个半路塞进来的二进制文件。
rollup-plugin-wasm的典型配置长这样:
javascript复制import wasm from 'rollup-plugin-wasm';
export default {
input: 'src/index.js',
plugins: [
wasm({
maxFileSize: 300 * 1024,
publicPath: '/assets/wasm/'
})
],
output: {
file: 'dist/core-utils.esm.js',
format: 'es'
}
};
这个插件有两种处理路径:wasm文件体积小于maxFileSize时,它会读取wasm二进制,转成base64字符串内联进产物JS;体积超过阈值时,插件会把wasm作为独立文件保留,并通过publicPath生成加载URL。实际项目里我建议优先走内联路径,前提是wasm控制在几百KB以内。原因很直接:一旦走外置文件路径,Webpack消费时又要处理一次资源路径,万一CDN目录结构跟本地public不一样,产物里的相对路径直接崩掉。内联成base64虽然会让JS文件大一点,但在大多数业务场景下,多几十KB换来的部署简单性非常划算。
使用外置路径时,我建议别把部署路径写死,而是在Rollup产物里预留一个可替换的占位,比如`
WASM_PUBLIC_PATH`,再由Webpack在读取产物前注入实际部署路径。
`
如果你看到报错里出现了WebAssembly.instantiate或CompileError,先别急着怀疑插件配置,大概率是产物被Webpack的asset模块再次处理导致二进制内容被转成了标准JS字符串。解决办法很简单:在Webpack的module.rules里把core-utils的dist目录排除出wasm相关rule的命中范围。
3.3 Webpack侧配置:把Rollup产物作为一等模块引入
Rollup产物生成后,Webpack侧需要做三件事:用alias指向产物、避免产物被二次loader转换、给产物规划一个合理的webpack缓存分组。
最简单的消费方式就是alias:
javascript复制const path = require('path');
module.exports = {
resolve: {
alias: {
'@core-utils': path.resolve(__dirname, './core-utils/dist/core-utils.esm.js')
}
},
module: {
rules: [
{
test: /\.(js|mjs)$/,
exclude: [
path.resolve(__dirname, './core-utils/dist')
],
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
}
};
这段配置的核心目的,是让业务代码里可以写import { calcSomething } from '@core-utils',但Webpack实际解析的是core-utils已经压缩过的ESM产物,不再对那一整个源码目录做loader处理。注意那个exclude,它的作用不是省那几毫秒,而是防止Babel把Rollup已经编译成目标语法的产物又转一遍。你可能会问,Babel再转一遍又会怎样?在一部分情况下会引入多余的helper,更糟的是如果Babel配置里目标浏览器范围跟Rollup侧不一致,会让最终产物语法反而变得乱七八糟。
如果想让产物单独成一个chunk,利用长缓存,还可以在Webpack的splitChunks里加一个高优先级的cacheGroup:
javascript复制optimization: {
splitChunks: {
cacheGroups: {
core: {
test: /core-utils[\\/]dist/,
name: 'core-utils',
chunks: 'initial',
priority: 30
}
}
}
}
这里priority给到30,就是要让Webpack优先把core产物从主bundle里拆出来。这样core-utils只在业务代码的入口处被首次加载,后续版本更新时浏览器也能单独缓存这一块,不用跟着整个业务bundle一起失效。
还有一个小细节,网上很多文章会建议用module.noParse来跳过对Rollup产物的解析。我的实测结论是,noParse对ESM格式的产物并不安全,因为被noParse的模块如果包含import/export语句,Webpack不会再去理解它的模块依赖关系,运行时经常报Cannot use import statement outside a module。所以不要图省事用noParse,老老实实走exclude就好。
3.4 产物版本与Webpack缓存的一致性设计
第一版接入时我踩过一个比较恶心的坑:Rollup配置了cache、webpack也配置了cache.filesystem,然后Rollup重新构建产物后,Webpack有时还会用旧的缓存内容。原因并不神秘,Webpack对产物文件的依赖记录是基于文件路径、mtime和文件内容的,如果输出文件名不变而内容更新,理论上它应该能检测到,但在Rollup和Webpack并行watch的场景下,经常出现Webpack先启动、读到旧文件后把快照缓存住,Rollup随后覆盖产物但Webpack没有及时重新触发编制。
我的解法是在Rollup输出文件名里带上构建hash,同时提供一个不变的入口文件:
javascript复制import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
export default {
output: [
{
file: 'dist/core-utils.esm.[hash].js',
format: 'es'
}
]
};
然后手动写一个很小的dist/index.js,内容只有一行export * from './core-utils.esm.[hash].js'。这样Webpack的alias指向dist/index.js,永远不需要改文件名,但内容变了就会让Webpack感知到依赖变更。原理很朴素,就是把“路径稳定”和“内容变化”这件事拆开,让Webpack的缓存机制能正常工作。
4. 构建与运行时性能实测:到底优化了什么
4.1 参考工程状态与改造目标
为了不空谈,我拿一个典型的中后台加数据可视化工程来说明。这个工程在Webpack 5.89下生产构建,模块总数超过3400个,其中有一块大约500个文件的核心工具集合,包含数据协议解析、性能指标计算、图表option生成、脱敏校验等逻辑。这个工具集合本身横跨两个业务入口,几乎每个页面都会间接引用到,而且代码极其稳定,业务开发根本不会去动它。
改造目标非常简单:让Webpack不要再重复分析这500个文件,让这500个文件在进入Webpack之前就已经被Rollup折叠成一个高压缩率的ESM文件。整体Webpack配置基本不动,只是把原项目里对核心模块的引用路径,统一改到新接入的@core-utils别名上。这类改造的风险不高,因为它不改业务运行逻辑,只是把代码的打包物理路径换了。
4.2 构建期数据:模块数、耗时与产物体积
改造前后几组数据对整个方案的效果有非常直观的说明,我在参考工程里实测下来大致是这样一个水平,注意不同机器和工程结构数字会有浮动:
| 指标 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| Webpack生产编译模块数 | 3415 | 1890 | 约减少44.6% |
| Webpack侧生产构建耗时 | 约49s | 约31.5s | 约减少35.7% |
| Rollup子构建耗时 | 无 | 约3.2s | 新增但总体仍下降 |
| 主bundle JS体积(未gzip) | 1.36MB | 1.08MB | 约减少20.6% |
| 主bundle gzip后体积 | 338KB | 286KB | 约减少15.4% |
| 核心模块产物独立后体积 | - | 179KB(gzip后约52KB) | 可独立长缓存 |
模块数下降的主要原因,是Webpack不再需要对core-utils里的几百个内部文件和它们的npm依赖做依赖图展开。耗时下降虽然没有严格等比例,但效果很明显,因为Webpack对每个模块的解析、loader执行、语法转换都是要花时间的,你让它在编译开始前拿到的就是一个已经折叠好的文件,后续它能省去大量工作量。
Rollup子构建本身也需要时间,表格里写的是大约3.2秒,跟Webpack动辄几十秒的构建时间相比非常便宜。即便把3.2秒加回去,整体生产构建耗时仍然比原来少很多。
4.3 运行时收益的核心:模块包装变薄了
产物体积下降很好理解,但运行时收益很多人容易忽略。Webpack的产物里,每个模块都被包在一个函数作用域内,模块间通过__webpack_require__互相调用。当你的业务代码执行到某个工具方法时,浏览器首先要执行一大串Webpack runtime的加载、缓存和调用逻辑。而Rollup的ESM产物里,模块内部就是一个正常函数,导入导出的方式接近原生ES Module,浏览器解析和执行的代价更小。
把核心逻辑拆成单个ESM文件后,浏览器收到的不是几十个小模块嵌套的依赖网络,而是一个结构清晰的文件。如果你用的是支持ES Module的现代浏览器,这部分代码在执行时可以更方便地被V8做优化。如果产物被压缩过,变量名和作用域都比Webpack的包装结构干净得多,首屏parse和execute的耗时会有一定下降。
我在Chrome DevTools的Performance面板里对比过同样的核心计算任务,改造后脚本执行时间出现了大约10%到15%的下降。这个数字不算夸张,但它说明滚子方案不仅是在开发者构建期省时间,用户在浏览器端的性能感知也有正向收益。对于核心计算任务非常重、每次页面加载都要执行的工程,这个收益会进一步放大。
5. 高频踩坑与排查方案清单
5.1 Tree Shaking把副作用代码删没了,行为全变了
这是Rollup子方案最典型的翻车现场。你拆过去一个模块,构建没报错,但上线后发现页面初始化顺序变了,或者某个全局对象莫名其妙不存在了。问题多半出在副作用代码被Rollup的Tree Shaking判定为“可删除”。
具体情况是,如果core-utils/package.json里写了"sideEffects": false,Rollup会认为所有模块都没有副作用,任何一个只有导入而没有导出赋值的模块都可能被删掉。这时候如果你代码里有import './patch.js'这种用来挂载原型方法的模块,就会被静默丢弃。正确做法是在sideEffects字段里把需要保留副作用的文件列白名单:
json复制{
"sideEffects": [
"**/*.css",
"src/patch/*.js"
]
}
如果不确定到底是哪个模块被误删,最快的办法是临时在Rollup配置里把treeshake关掉,再跑一次产物对比行为区别。但我建议别依赖这个手段调优,因为关闭Tree Shaking等于放弃了Rollup的核心价值,最终还是要靠sideEffects声明和模块结构来解决问题。
5.2 产物内部出现webpack运行时残留或编译错误
有时候Rollup构建的输入文件里,会混入一些从Webpack业务侧复制出来的UMD包或已打包产物,这些文件内部可能包含__webpack_require__、window.webpackJsonp之类的残留代码。Rollup按普通模块分析时不会识别这些标记,最后打包出来的ESM产物一旦被Webpack加载,浏览器就会直接报错__webpack_require__ is not defined。
我的建议是严格规定Rollup的输入源只能是core-utils/src下的原生源码,绝不能把Webpack的dist目录或某个已经打包过的文件当成Rollup的输入。如果你只是需要在业务项目里复用一个老SDK,先确认这个SDK的npm包有没有提供ESM入口,没有的话就把它留在Webpack侧用commonjs插件处理,不要硬拽进Rollup子工程。
还会遇到一种情况是浏览器报ReferenceError: exports is not defined,这多半是Rollup产物里还残留CommonJS的exports变量。这时把output.format确认成es,并且给commonjs插件传入strictRequires: true,同时检查nodeResolve是否错误地优先解析到了某个包的主入口而不是module入口。
5.3 wasm资源路径错乱导致线上加载不到
第一版用外置wasm时,我在本地一切正常,推到测试环境的CDN后就发现产物报404。排查下来原因是Rollup生成产物时用的是`publicPath: '/assets
