前言
你可能已经听说过 Tree Shaking ------ 这个听起来很酷的术语,意思是"把没用到的代码像枯叶一样摇下来"。Webpack、Rollup、esbuild 都声称支持 Tree Shaking,你也确实在配置里开启了 production 模式,但打完包一看:bundle 体积依然庞大,很多明明没用到的代码还是被打了进去。
问题出在哪?
本文将从原理层面深入剖析 Tree Shaking 的工作机制,列举它失效的常见场景,并给出可落地的排查和修复方案。无论你是业务开发者还是库作者,都能从中找到优化思路。
什么是 Tree Shaking
Tree Shaking 是一种基于 ES Module 静态分析的死代码消除(Dead Code Elimination)技术。
它的核心思想很简单:如果一个模块导出了 10 个函数,但使用方只 import 了其中 2 个,那么剩下的 8 个函数就不应该出现在最终产物中。
bash
源码依赖树(摇树前) 打包产物(摇树后)
+------------------+ +------------------+
| utils/index.ts | | utils/index.ts |
| | | |
| export add | -------> | export add |
| export subtract | X | |
| export multiply | X | |
| export divide | -------> | export divide |
+------------------+ +------------------+
X = 未被任何模块引用,被 Tree Shaking 移除
需要注意的是,Tree Shaking 并不是一个独立的优化手段,它通常和**压缩(Minification)**配合工作。打包工具先通过静态分析标记未使用的导出,然后由压缩器(如 Terser)在最终阶段将这些标记为"未使用"的代码真正删除。
工作原理:为什么必须是 ES Module
Tree Shaking 能够工作的前提是:模块系统必须是静态可分析的。这正是 ES Module 和 CommonJS 的根本区别。
ES Module 的静态结构
typescript
// ES Module - 静态导入,编译时即可确定依赖关系
import { add } from './math'; // 明确知道只用了 add
import { format } from './string'; // 明确知道只用了 format
ES Module 的关键特性:
import/export必须出现在模块顶层,不能嵌套在 if/for 等语句中- 导入的模块标识符是字符串字面量,不能是变量
- 导入的绑定是只读的,不能被重新赋值
这些约束使得打包工具可以在**编译时(而非运行时)**确定模块之间的依赖关系,从而安全地移除未使用的导出。
CommonJS 的动态特性
javascript
// CommonJS - 动态加载,运行时才能确定依赖
const lib = require('./math'); // 导入整个模块对象
const fn = require(condition ? 'a' : 'b'); // 模块路径可以是表达式
if (process.env.NODE_ENV === 'dev') {
require('./dev-tools'); // 条件加载
}
module.exports[name] = value; // 动态导出
CommonJS 的 require 是一个普通函数调用,可以出现在任何位置、接受任何表达式作为参数。这意味着打包工具无法在编译时确定哪些导出会被使用,只能保守地将整个模块打包进去。
对比总结
lua
+--------------------+------------------------+------------------------+
| 特性 | ES Module | CommonJS |
+--------------------+------------------------+------------------------+
| 语法 | import / export | require / module.exports|
| 加载时机 | 编译时静态解析 | 运行时动态加载 |
| 导入位置 | 只能在顶层 | 可在任意位置 |
| 模块标识符 | 必须是字符串字面量 | 可以是表达式 |
| Tree Shaking | 支持 | 不支持 |
| 浏览器原生支持 | 是 | 否 |
+--------------------+------------------------+------------------------+
为什么你的代码没被摇掉:常见陷阱
理论上 Tree Shaking 应该能自动移除所有未使用的代码,但实际项目中它经常"失灵"。以下是最常见的五个原因。
陷阱一:模块存在副作用(Side Effects)
这是 Tree Shaking 失效的最常见原因。
所谓"副作用",是指模块在被导入时,除了导出值之外还会执行一些影响外部状态的操作。
typescript
// side-effect-example.ts
// 这个模块有副作用:导入时会修改全局对象
window.globalConfig = { theme: 'dark' };
// 这个模块有副作用:导入时会执行 polyfill
if (!Array.prototype.flat) {
Array.prototype.flat = function() { /* ... */ };
}
// 这个模块有副作用:IIFE 立即执行
(function() {
console.log('module loaded');
})();
export function pureFunction() {
return 42;
}
即使你只导入了 pureFunction,打包工具也不敢移除这个模块的其他代码,因为那些副作用可能是项目正常运行所必需的。
package.json 的 sideEffects 字段
为了解决这个问题,Webpack 引入了 sideEffects 配置。它允许库作者声明自己的包是否存在副作用:
json
{
"name": "my-utils",
"sideEffects": false
}
sideEffects: false 告诉打包工具:"这个包的所有模块都是纯的,没有副作用。如果某个模块的导出没被使用,可以安全地整个跳过。"
你也可以指定哪些文件有副作用:
json
{
"name": "my-ui-lib",
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.ts",
"./src/register-global.ts"
]
}
这样打包工具就知道:除了列出的文件,其他模块都可以安全地 Tree Shake。
陷阱二:桶文件重导出(Barrel Exports)
这是中大型项目中非常常见的模式:
typescript
// utils/index.ts - 桶文件(barrel file)
export { add, subtract, multiply, divide } from './math';
export { formatDate, parseDate, diffDate } from './date';
export { capitalize, truncate, slugify } from './string';
export { debounce, throttle } from './timing';
export { deepClone, merge, pick, omit } from './object';
使用方通常这样导入:
typescript
// 只需要 add 和 formatDate
import { add, formatDate } from '@/utils';
看起来很整洁,但问题在于:打包工具需要先加载整个 index.ts,解析所有重导出的模块,然后才能确定哪些导出被使用了。
在理想情况下(所有模块都标记了 sideEffects: false),Tree Shaking 可以正常工作。但在实际项目中,往往会因为以下原因导致失效:
sql
使用桶文件导入:
import { add } from '@/utils'
|
v
+---------------------------+
| utils/index.ts | <-- 必须加载解析整个文件
| |
| re-export math -------+---> 加载 math.ts (需要 add)
| re-export date -------+---> 加载 date.ts (不需要,但可能有副作用)
| re-export string -------+---> 加载 string.ts (不需要,但可能有副作用)
| re-export timing -------+---> 加载 timing.ts (不需要,但可能有副作用)
| re-export object -------+---> 加载 object.ts (不需要,但可能有副作用)
+---------------------------+
直接导入:
import { add } from '@/utils/math'
|
v
+---------------------------+
| utils/math.ts | <-- 只加载这一个文件
+---------------------------+
桶文件的问题不仅是 Tree Shaking 失效,还会拖慢构建速度(需要解析更多模块)和开发时的 HMR 速度。
陷阱三:CommonJS 模块无法被 Tree Shake
如果你的依赖是用 CommonJS 格式发布的,那么 Tree Shaking 对它基本无效:
javascript
// node_modules/some-lib/index.js (CommonJS)
module.exports = {
funcA: function() { /* ... */ },
funcB: function() { /* ... */ },
funcC: function() { /* ... */ },
};
// 你的代码
import { funcA } from 'some-lib';
// funcB 和 funcC 依然会被打包进来
虽然 Webpack 5 对 CommonJS 做了一定程度的静态分析优化,但效果远不如原生 ES Module。这也是为什么社区一直在推动"ESM-first"的原因。
陷阱四:动态属性访问
当你使用动态的方式访问模块导出时,打包工具无法确定哪些属性会被访问到:
typescript
// 打包工具无法分析动态属性访问
import * as validators from './validators';
function validate(type: string, value: unknown) {
// type 是运行时才知道的值,打包工具无法确定需要哪些 validator
const validatorFn = validators[type];
if (validatorFn) {
return validatorFn(value);
}
return false;
}
改写为静态导入可以让 Tree Shaking 正常工作:
typescript
// 静态导入 + 显式映射
import { isEmail, isPhone, isUrl } from './validators';
const validatorMap = {
email: isEmail,
phone: isPhone,
url: isUrl,
} as const;
function validate(type: keyof typeof validatorMap, value: unknown) {
return validatorMap[type](value);
}
陷阱五:类方法无法被单独 Tree Shake
Tree Shaking 的粒度是模块导出,而不是类的方法:
typescript
// DateHelper.ts
export class DateHelper {
static format(date: Date): string { /* 100 行实现 */ }
static parse(str: string): Date { /* 80 行实现 */ }
static diff(a: Date, b: Date): number { /* 60 行实现 */ }
static add(date: Date, days: number): Date { /* 40 行实现 */ }
}
// 使用方
import { DateHelper } from './DateHelper';
DateHelper.format(new Date());
// parse, diff, add 的代码依然会被打包
因为类是一个整体,打包工具无法确定你是否会在运行时通过反射等方式访问其他方法。
解决方案:使用独立的函数导出代替类方法
typescript
// date-utils.ts
export function formatDate(date: Date): string { /* ... */ }
export function parseDate(str: string): Date { /* ... */ }
export function diffDate(a: Date, b: Date): number { /* ... */ }
export function addDays(date: Date, days: number): Date { /* ... */ }
// 使用方 - 只有 formatDate 会被打包
import { formatDate } from './date-utils';
formatDate(new Date());
实战排查:确认 Tree Shaking 是否生效
方法一:webpack-bundle-analyzer
这是最直观的方式。它会生成一个交互式的可视化图表,展示 bundle 中每个模块的体积占比。
bash
# 安装
npm install --save-dev webpack-bundle-analyzer
# webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static', // 生成静态 HTML 文件
reportFilename: 'bundle-report.html',
openAnalyzer: false,
}),
],
};
构建后打开生成的 HTML 文件,你可以清楚地看到:
- 哪些模块占用了大量体积
- 是否有重复打包的模块
- 某个库是否被完整打包进来(而不是只包含你用到的部分)
lua
+-------------------------------------------------------+
| bundle-report |
| |
| +------------------+ +---------------------------+ |
| | | | | |
| | lodash | | moment | |
| | 71.5 KB | | 232 KB | |
| | | | | |
| | (你只用了 | | (包含全部 locale 文件 | |
| | 3 个函数) | | 你只需要中文) | |
| +------------------+ +---------------------------+ |
| |
| +----------+ +--------+ +--------+ +-----------+ |
| | src/ | | react | | axios | | other | |
| | 45 KB | | 6.3 KB | | 13 KB | | 28 KB | |
| +----------+ +--------+ +--------+ +-----------+ |
+-------------------------------------------------------+
方法二:source-map-explorer
如果你不想安装 Webpack 插件,可以使用 source-map-explorer 直接分析已有的 source map:
bash
# 安装
npm install --save-dev source-map-explorer
# 分析
npx source-map-explorer dist/main.js
# 生成 HTML 报告
npx source-map-explorer dist/main.js --html result.html
方法三:手动检查打包产物
对于快速验证,你可以直接在打包产物中搜索特定函数名:
bash
# 在未压缩的产物中搜索
# 如果某个你没用到的函数名出现在产物中,说明 Tree Shaking 没有生效
grep -n "unusedFunction" dist/main.js
方法四:Webpack Stats 分析
javascript
// webpack.config.js
module.exports = {
// 开启 usedExports,在产物中标记未使用的导出
optimization: {
usedExports: true, // 在开发模式下方便调试
},
};
在开发模式下构建,Webpack 会在产物中添加注释标记:
javascript
/* unused harmony export subtract */
/* unused harmony export multiply */
看到这些注释说明 Webpack 正确地识别出了未使用的导出,在 production 构建时 Terser 会将它们移除。
修复 Tree Shaking 问题
1. 正确配置 sideEffects
在你的项目和库的 package.json 中明确声明副作用:
json
{
"name": "my-project",
"sideEffects": [
"*.css",
"*.less",
"*.scss",
"./src/polyfills.ts",
"./src/init.ts"
]
}
如果你的项目是纯函数式的,没有任何副作用文件:
json
{
"sideEffects": false
}
2. 消除桶文件的影响
方案 A:直接从源文件导入
typescript
// Before - 从桶文件导入
import { debounce } from '@/utils';
// After - 直接导入
import { debounce } from '@/utils/timing';
方案 B:使用路径映射简化导入
json
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/utils/math": ["./src/utils/math"],
"@/utils/date": ["./src/utils/date"],
"@/utils/string": ["./src/utils/string"]
}
}
}
方案 C:如果必须保留桶文件,确保所有子模块都标记为无副作用
json
// utils/package.json
{
"sideEffects": false
}
3. 选择 ESM 格式的依赖包
typescript
// Before - CommonJS 版本,无法 Tree Shake
import { get } from 'lodash'; // 打包整个 lodash (71.5 KB)
// After - ESM 版本,支持 Tree Shake
import { get } from 'lodash-es'; // 只打包 get 相关代码 (~2 KB)
如果某个包没有提供 ESM 版本,可以考虑:
- 寻找同功能的 ESM 替代包
- 使用按路径导入(如果包支持的话)
- 向包作者提 Issue 请求发布 ESM 版本
4. 避免 namespace import 后动态访问
typescript
// Before - 阻碍 Tree Shaking
import * as utils from './utils';
const fn = utils[name];
// After - 支持 Tree Shaking
import { specificUtil } from './utils';
5. Webpack 关键配置检查清单
javascript
// webpack.config.js (production)
module.exports = {
mode: 'production', // 必须
optimization: {
usedExports: true, // 标记未使用的导出 (production 默认开启)
minimize: true, // 启用压缩以真正删除代码 (production 默认开启)
concatenateModules: true, // 模块合并,也叫 Scope Hoisting (production 默认开启)
sideEffects: true, // 读取 package.json 的 sideEffects 字段 (production 默认开启)
},
};
库作者指南:如何发布可 Tree Shake 的包
如果你是一个 npm 包的作者,以下是让你的包支持 Tree Shaking 的最佳实践。
1. 同时提供 ESM 和 CJS 格式
json
{
"name": "my-awesome-lib",
"version": "1.0.0",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.mjs",
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.js",
"types": "./dist/types/index.d.ts"
},
"./math": {
"import": "./dist/esm/math.mjs",
"require": "./dist/cjs/math.js",
"types": "./dist/types/math.d.ts"
}
},
"sideEffects": false,
"files": ["dist"]
}
各字段的作用:
lua
+------------------+--------------------------------------------------+
| 字段 | 作用 |
+------------------+--------------------------------------------------+
| main | CommonJS 入口,Node.js require() 使用 |
| module | ESM 入口,打包工具(Webpack/Rollup)优先使用 |
| exports | Node.js 12.11+ 的条件导出,最高优先级 |
| sideEffects | 告诉打包工具哪些文件有副作用 |
| files | 控制 npm publish 包含哪些文件 |
+------------------+--------------------------------------------------+
2. 使用独立函数而非类
typescript
// 推荐 - 每个函数可独立 Tree Shake
export function createStore<T>(initial: T) { /* ... */ }
export function combineReducers(reducers: Record<string, Function>) { /* ... */ }
export function applyMiddleware(...middlewares: Function[]) { /* ... */ }
// 不推荐 - 类无法按方法粒度 Tree Shake
export class Store {
create<T>(initial: T) { /* ... */ }
combineReducers(reducers: Record<string, Function>) { /* ... */ }
applyMiddleware(...middlewares: Function[]) { /* ... */ }
}
3. 提供子路径导入
json
// package.json
{
"exports": {
".": "./dist/esm/index.mjs",
"./math": "./dist/esm/math.mjs",
"./date": "./dist/esm/date.mjs",
"./string": "./dist/esm/string.mjs"
}
}
这样使用者可以只导入需要的子模块:
typescript
import { add } from 'my-awesome-lib/math';
4. 标注纯函数
对于打包工具难以判断的调用,使用 /*#__PURE__*/ 注释标记纯函数调用:
typescript
// 告诉打包工具:如果返回值没被使用,这个调用可以安全移除
export const defaultConfig = /*#__PURE__*/ createDefaultConfig();
export const logger = /*#__PURE__*/ createLogger({ level: 'info' });
5. 构建工具配置示例(Rollup)
javascript
// rollup.config.js
export default {
input: 'src/index.ts',
output: [
{
dir: 'dist/esm',
format: 'esm',
preserveModules: true, // 保留模块结构,有利于 Tree Shaking
entryFileNames: '[name].mjs',
},
{
dir: 'dist/cjs',
format: 'cjs',
preserveModules: true,
entryFileNames: '[name].js',
},
],
external: ['react', 'react-dom'],
plugins: [
typescript({ tsconfig: './tsconfig.build.json' }),
],
};
真实案例分析
案例一:lodash vs lodash-es
lodash(CommonJS 版本)
typescript
// 导入方式 1:从主入口导入(最差)
import { get, set, cloneDeep } from 'lodash';
// 结果:打包整个 lodash,约 71.5 KB (gzipped ~25 KB)
// 导入方式 2:按路径导入(较好,但依然是 CJS)
import get from 'lodash/get';
import set from 'lodash/set';
// 结果:只打包用到的函数,约 8 KB
lodash-es(ESM 版本)
typescript
// 从 ESM 版本导入,Tree Shaking 自动生效
import { get, set, cloneDeep } from 'lodash-es';
// 结果:只打包用到的函数,约 6 KB
对比数据:
diff
+---------------------+-----------------+------------------+
| 导入方式 | 打包体积 | Tree Shaking |
+---------------------+-----------------+------------------+
| lodash 整体导入 | 71.5 KB | 不生效 |
| lodash 按路径导入 | ~8 KB | 手动拆分 |
| lodash-es 导入 | ~6 KB | 自动生效 |
+---------------------+-----------------+------------------+
案例二:moment vs dayjs
moment.js 是一个典型的"无法 Tree Shake"的库:
- 使用 CommonJS 格式
- 内置了所有 locale 文件
- 采用可变对象(mutable)的 API 设计
typescript
// moment - 即使只用了 format,也会打包整个库
import moment from 'moment';
moment().format('YYYY-MM-DD');
// 打包体积:232 KB (gzipped ~67 KB),其中 locale 文件占 170+ KB
dayjs 是 moment 的现代替代品,专为 Tree Shaking 设计:
- 使用 ESM 格式
- locale 和插件按需加载
- 不可变(immutable)的 API 设计
- API 与 moment 高度兼容
typescript
// dayjs - 按需导入,体积极小
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
dayjs().format('YYYY-MM-DD');
// 打包体积:核心 2.9 KB + relativeTime 插件 ~1 KB = ~4 KB (gzipped)
对比数据:
lua
+------------------+-------------------+------------------+-----------------+
| 库 | 完整打包体积 | 按需加载体积 | Tree Shaking |
+------------------+-------------------+------------------+-----------------+
| moment | 232 KB | 无法按需 | 不支持 |
| dayjs | 2.9 KB (核心) | ~4 KB (含插件) | 支持 |
| date-fns | 78 KB (完整) | ~6 KB (3个函数) | 支持 |
+------------------+-------------------+------------------+-----------------+
案例三:实际项目优化前后对比
以一个典型的中后台管理系统为例,通过 Tree Shaking 优化前后的 bundle 体积对比:
sql
优化前(未正确配置 Tree Shaking)
+------------------------------------------+
| main.js (1.82 MB) |
| |
| lodash 71.5 KB ████████ |
| moment 232 KB ██████████████ |
| antd (全量) 580 KB ██████████████████████████████ |
| @utils (桶文件) 45 KB █████ |
| 业务代码 180 KB ██████████ |
| 其他依赖 712 KB ████████████████████████████ |
+------------------------------------------+
优化后(正确配置 Tree Shaking + 替换依赖)
+------------------------------------------+
| main.js (486 KB) |
| |
| lodash-es 6 KB █ |
| dayjs 4 KB █ |
| antd (按需) 95 KB ██████ |
| @utils (直接导入) 12 KB █ |
| 业务代码 180 KB ██████████ |
| 其他依赖 189 KB ██████████ |
+------------------------------------------+
体积减少: 1.82 MB -> 486 KB (减少 73%)
优化措施总结:
lua
+-----+---------------------------+---------------------------+-----------+
| 序号 | 优化项 | 具体操作 | 体积减少 |
+-----+---------------------------+---------------------------+-----------+
| 1 | lodash -> lodash-es | 替换导入源 | -65.5 KB |
| 2 | moment -> dayjs | 替换库 + 按需加载插件 | -228 KB |
| 3 | antd 按需导入 | babel-plugin-import 配置 | -485 KB |
| 4 | 消除桶文件 | 改为直接导入源文件 | -33 KB |
| 5 | 添加 sideEffects 配置 | package.json 声明 | -52 KB |
| 6 | 其他依赖替换 ESM 版本 | 逐个排查替换 | -523 KB |
+-----+---------------------------+---------------------------+-----------+
| | 合计 | | -1386 KB |
+-----+---------------------------+---------------------------+-----------+
总结
Tree Shaking 是前端性能优化中投入产出比极高的一环,但它并不是"开了 production 模式就万事大吉"。要真正发挥它的效果,需要理解它的工作原理和局限性:
- 前提条件:使用 ES Module 格式,这是 Tree Shaking 工作的基础
- 副作用声明 :在
package.json中正确配置sideEffects字段 - 避免桶文件 :尽量直接从源模块导入,而非通过
index.ts中转 - 选择 ESM 依赖:优先使用提供 ESM 格式的依赖包(lodash-es、dayjs 等)
- 静态分析友好:避免动态属性访问、namespace import 等模式
- 定期检查:使用 webpack-bundle-analyzer 等工具定期审查 bundle 体积
- 库作者责任:如果你发布 npm 包,请同时提供 ESM 格式并声明 sideEffects
Tree Shaking 不是银弹,但当你理解了它的原理并正确配置后,轻松减少 50% 以上的 bundle 体积是完全可以实现的。
如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。