2023年Q3,我接手一个中台项目。代码里只用了Ant Design的Button组件,但webpack打包后vendor.js体积2.3MB。打开source map一看,整个antd/es目录下的所有组件都被打包进去了,包括Table、Form、DatePicker这些根本没import过的模块。
排查发现:项目里import { Button } from 'antd',但antd的package.json里"main": "lib/index.js"指向的是CommonJS版本,Tree Shaking直接失效。换成"module": "es/index.js"后,体积降到412KB。这个案例让我决定彻底搞懂Tree Shaking的底层逻辑。
Tree Shaking不是魔法,它依赖ES Module的静态结构:import和export必须在顶层声明,不能动态计算。这给了打包工具在编译期分析依赖关系的能力。
对比CommonJS:require可以在if语句里,甚至字符串拼接路径,打包工具只能保守地保留整个模块。
Webpack 5使用内置的terser-webpack-plugin进行死代码消除,但前提是模块必须标记为"sideEffects": false。核心流程分三步:
关键配置在webpack.config.js中:
// webpack.config.js (Webpack 5.88.0)
module.exports = {
mode: 'production', // 自动启用TerserPlugin
optimization: {
usedExports: true, // 标记未使用的export
sideEffects: true, // 根据package.json的sideEffects字段处理
minimize: true, // 启用压缩
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
unused: true, // 删除未使用的变量
dead_code: true, // 删除死代码
},
mangle: true,
},
}),
],
},
};
Rollup从设计上就为Tree Shaking而生。它不依赖terser,而是自己实现了一套基于AST的静态分析。核心差异:Rollup在打包阶段就直接删除未使用的export,而不是等压缩阶段。
Rollup的流程:
Rollup配置示例:
// rollup.config.js (Rollup 3.29.4)
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'esm',
},
plugins: [
nodeResolve(), // 解析node_modules
commonjs(), // 将CommonJS转为ESM
terser(), // 可选压缩
],
};
我用一个包含100个export的工具库(实际只用了5个)做对比测试:
| 指标 | Webpack 5 | Rollup 3 |
|---|---|---|
| 打包后体积 | 45KB | 12KB |
| 构建时间 | 2.3s | 0.8s |
| Tree Shaking深度 | 依赖terser,可能遗漏 | 静态分析,更彻底 |
| CommonJS支持 | 原生支持 | 需要插件转换 |
| 代码分割 | 完善 | 有限 |
结论:库作者用Rollup,应用开发者用Webpack。但两者都需要遵循ES Module规范。
假设我们要写一个工具库,包含add、subtract、formatDate三个函数,但用户可能只用一个。
// src/math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
// src/date.js
export function formatDate(date) {
return date.toISOString().split('T')[0];
}
// src/index.js
export { add, subtract } from './math.js';
export { formatDate } from './date.js';
{
"name": "my-utils",
"version": "1.0.0",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"sideEffects": false,
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js"
}
}
}
// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
export default [
{
input: 'src/index.js',
output: [
{ file: 'dist/index.esm.js', format: 'esm' },
{ file: 'dist/index.cjs.js', format: 'cjs' },
],
plugins: [resolve(), commonjs(), terser()],
external: ['lodash'], // 外部依赖不打包
},
];
// user-app.js
import { add } from 'my-utils';
console.log(add(1, 2)); // 只打包add函数
# 使用webpack-bundle-analyzer分析
npx webpack --config webpack.config.js --profile --json > stats.json
npx webpack-bundle-analyzer stats.json dist/
测试环境:Node 18.17.0,Webpack 5.88.0,Rollup 3.29.4,macOS 14.0,M1 Pro 32GB
测试项目:一个包含antd、lodash、moment的React中台应用,共200+组件
| 优化措施 | 体积 | 首次加载时间(3G网络) | 构建时间 |
|---|---|---|---|
| 未优化(CommonJS) | 2.3MB | 8.2s | 12.5s |
| 开启usedExports | 1.8MB | 6.5s | 13.1s |
| sideEffects: false | 412KB | 2.1s | 11.8s |
| Rollup打包库 | 380KB | 1.9s | 9.2s |
关键发现:sideEffects: false是最大功臣,单独开启usedExports只减少20%体积。
现象:import { Button } from 'antd/lib/button' 打包后还是整个antd。
原因:CommonJS的require是动态的,打包工具无法静态分析。
解决:使用ES Module版本,antd从4.x开始提供es目录。配置webpack的resolve.alias:
resolve: {
alias: {
antd: 'antd/es',
},
},
现象:设置"sideEffects": false后,antd的样式文件被删除了。
原因:CSS文件被当作副作用被删除。
解决:显式声明样式文件有副作用:
{
"sideEffects": [
"**/*.css",
"**/*.less"
]
}
现象:使用import()动态加载模块,Tree Shaking失效。
原因:动态import是运行时行为,打包工具无法静态分析。
解决:尽量使用静态import,动态加载只用于代码分割。
现象:配置了@babel/preset-env后,Tree Shaking失效。
原因:Babel默认将ES Module转为CommonJS。
解决:配置modules: false:
presets: [
['@babel/preset-env', { modules: false }],
],
现象:lodash的Tree Shaking效果很差。
原因:lodash的package.json没有module字段,只有main指向CommonJS。
解决:使用lodash-es替代lodash,或者配置webpack的resolve.mainFields:
resolve: {
mainFields: ['module', 'main'],
},
Webpack 5还有一个隐藏优化:ModuleConcatenationPlugin(作用域提升)。它将所有模块合并到一个闭包中,减少函数调用开销,同时让Tree Shaking更彻底。
开启方式:
// webpack.config.js
optimization: {
concatenateModules: true, // 生产模式默认开启
},
效果:打包后代码从多个IIFE变成一个大的自执行函数,体积再减少10%-15%。
但注意:如果模块有副作用,这个优化会被禁用。所以sideEffects: false是前提。
Tree Shaking不是银弹,它需要整个生态配合:库作者输出ES Module、正确配置sideEffects、应用层使用静态import。Webpack 5和Rollup 3各有优劣,选型取决于项目类型。记住:先检查package.json的module字段,再配置sideEffects,最后用分析工具验证。
专注技术分享与实战