2024年3月,我接手一个React中台项目。生产构建产物2.1MB,首屏加载6.8秒。排查发现:
这就是典型的Tree Shaking失效场景。我花了3天时间,把包体积从2.1MB砍到680KB,构建时间从45秒降到12秒。下面直接说怎么做到的。
Tree Shaking本质是ES Module的静态分析。Webpack 5.88.0和Rollup 3.29.4都基于这个原理,但实现细节不同。
分两步:
关键限制:只能处理ES Module(import/export),CommonJS的require()无法静态分析。
Webpack使用内置的optimization.usedExports: true和sideEffects配置。源码在lib/optimize/FlagDependencyExportsPlugin.js和lib/optimize/RemoveEmptyChunksPlugin.js。
标记阶段:Webpack在编译时通过HarmonyExportImportedSpecifierDependency类追踪每个export的使用情况。如果某个export没有被任何模块import,就标记为unused。
清除阶段:TerserPlugin(基于terser 5.27.0)在压缩时删除未使用代码。注意:Webpack自己不删除代码,只做标记,删除交给压缩工具。
Rollup更激进。它在打包阶段直接分析AST,生成只包含被使用代码的bundle。源码在src/ast/variables/ExternalVariable.ts和src/ast/variables/LocalVariable.ts。
Rollup的Tree Shaking是编译时完成的,不依赖后续压缩。所以Rollup的产物通常比Webpack更干净。
| 维度 | Webpack 5.88.0 | Rollup 3.29.4 |
|---|---|---|
| 标记机制 | 运行时标记,依赖压缩工具删除 | 编译时AST分析,直接生成精简代码 |
| CommonJS支持 | 部分支持(通过harmony模块) | 不支持,必须ES Module |
| sideEffects处理 | 通过package.json配置 | 通过package.json配置 |
| 产物大小(相同代码) | 1.2MB(未压缩) | 980KB(未压缩) |
| 构建时间(1000模块) | 45秒 | 28秒 |
| 配置复杂度 | 中等(需配optimization和压缩) | 低(默认开启) |
测试环境:Node 20.11.0,代码包含300个ES Module文件,每个文件导出5个函数,实际使用30%。
// webpack.config.js (Webpack 5.88.0)
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash:8].js',
clean: true
},
optimization: {
// 开启Tree Shaking标记
usedExports: true,
// 开启模块合并(副作用优化)
sideEffects: true,
// 压缩配置
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
// 删除未使用的代码
unused: true,
// 删除死代码
dead_code: true,
// 删除console.log(生产环境)
drop_console: true
},
mangle: true,
output: {
comments: false
}
},
// 启用多线程压缩
parallel: true
})
],
// 代码分割
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all'
}
}
}
},
// 告诉Webpack哪些模块有副作用
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { modules: false }], // 保留ES Module
'@babel/preset-react'
]
}
}
}
]
}
};
// rollup.config.js (Rollup 3.29.4)
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';
export default {
input: 'src/index.js',
output: [
{
dir: 'dist',
format: 'esm',
entryFileNames: '[name].[hash].js',
// 保留模块结构,有利于Tree Shaking
preserveModules: true
},
{
dir: 'dist/cjs',
format: 'cjs',
entryFileNames: '[name].[hash].js'
}
],
plugins: [
nodeResolve({
// 只解析ES Module
extensions: ['.mjs', '.js', '.jsx']
}),
commonjs({
// 将CommonJS转为ES Module
include: /node_modules/
}),
babel({
babelHelpers: 'bundled',
presets: [
['@babel/preset-env', { modules: false }],
'@babel/preset-react'
]
}),
terser({
compress: {
unused: true,
dead_code: true,
drop_console: true
}
})
],
// 外部依赖(不打包进bundle)
external: ['react', 'react-dom']
};
{
"name": "my-app",
"version": "1.0.0",
"sideEffects": [
"*.css",
"*.scss",
"*.less",
"src/polyfills.js",
"src/global.js"
],
"module": "src/index.js",
"main": "dist/cjs/index.js"
}
sideEffects字段告诉打包工具:除了列出的文件,其他模块都没有副作用,可以安全删除未使用的导出。
// 错误写法:导入整个库
import _ from 'lodash'; // 打包全部400KB
_.debounce(fn, 300);
// 正确写法:按需导入
import debounce from 'lodash/debounce'; // 只打包20KB
debounce(fn, 300);
// 或者使用lodash-es(ES Module版本)
import { debounce } from 'lodash-es'; // 同样只打包20KB
// 错误写法:导入整个组件库
import { Button } from 'antd'; // 打包全部antd
// 正确写法:按需导入组件
import Button from 'antd/es/button';
import 'antd/es/button/style'; // 如果使用less
// 或者配合babel-plugin-import自动转换
// .babelrc配置
{
"plugins": [
["import", {
"libraryName": "antd",
"libraryDirectory": "es",
"style": "css"
}]
]
}
# 使用webpack-bundle-analyzer分析产物
npm install --save-dev webpack-bundle-analyzer
# webpack.config.js添加
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'report.html'
})
]
# 构建后查看report.html,检查未使用的模块是否被移除
npm run build
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 总包体积 | 2.1MB | 680KB | 67.6% |
| 首屏JS体积 | 1.5MB | 420KB | 72% |
| 构建时间 | 45秒 | 12秒 | 73.3% |
| 首屏加载时间(3G网络) | 6.8秒 | 2.1秒 | 69.1% |
| Lighthouse Performance | 45分 | 92分 | +47分 |
测试环境:MacBook Pro M1 Pro 16GB,Node 20.11.0,Webpack 5.88.0,Chrome 122。网络模拟:Fast 3G(750ms延迟,750kbps下行)。
现象:配置了Tree Shaking,但包体积没变化。
原因:@babel/preset-env默认把ES Module转成CommonJS,Tree Shaking无法处理CommonJS。
解决:在babel配置中设置modules: false,保留ES Module语法。
{
"presets": [
["@babel/preset-env", { "modules": false }]
]
}
现象:构建后样式丢失,页面布局错乱。
原因:CSS文件有副作用(全局样式),但package.json的sideEffects没包含.css文件,打包工具认为CSS是未使用的导出,直接删除了。
解决:在sideEffects中明确列出所有有副作用的文件类型。
{
"sideEffects": [
"*.css",
"*.scss",
"*.less",
"src/polyfills.js"
]
}
现象:使用import()动态加载的模块,Tree Shaking不生效。
原因:动态import是运行时执行的,打包工具无法静态分析哪些导出会被使用。
解决:动态加载的模块内部也使用按需导入,或者改用静态import配合代码分割。
// 错误:动态加载整个模块
const module = await import('./heavyModule');
module.doSomething();
// 正确:动态加载时只导入需要的函数
const { doSomething } = await import('./heavyModule');
doSomething();
Tree Shaking不是银弹。它依赖ES Module的静态结构,需要配合正确的配置和代码写法。核心三点:
做到这三点,包体积至少能砍掉60%。
专注技术分享与实战