1. 什么是 Tree Shaking?解决什么问题?
核心思路(一句话)
Tree Shaking 就是在构建阶段分析模块之间的依赖关系和导出使用情况,把确定没有被使用、且可以安全删除的代码从最终产物中移除,从而减小打包体积。
解决方案流程图
text
源代码
│
▼
ES Module 静态分析
│
├── 分析 import / export
│
▼
建立模块依赖图
│
▼
判断哪些导出真正被使用
│
▼
标记未使用代码
│
▼
代码生成 / 压缩
│
▼
删除无用代码
│
▼
更小的最终 Bundle
结构化逻辑
主要矛盾:
最终 Bundle 中包含了运行时根本不会执行的代码,导致 JavaScript 体积变大。
次要矛盾:
Webpack 必须能够静态判断"哪些代码可以安全删除",否则贸然删除可能改变程序行为。
所以 Tree Shaking 的核心不是简单的:
"发现没调用的函数 → 删除。"
而是:
静态分析模块依赖和导出使用关系 → 标记未使用代码 → 在满足安全条件的情况下删除。
2. 举例说明 Tree Shaking 是怎么工作的?
例如:
js
// math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
入口文件:
js
// main.js
import { add } from './math.js';
console.log(add(1, 2));
从依赖关系看:
text
main.js
│
│ import { add }
▼
math.js
├── add ← 被使用
└── subtract ← 没有被使用
理论上的 Tree Shaking 结果:
js
function add(a, b) {
return a + b;
}
console.log(add(1, 2));
subtract 就没有必要进入最终产物。
但是要注意
Tree Shaking 并不是看到"函数没有调用"就一定删除。
例如:
js
export function foo() {
console.log('foo');
}
foo();
或者:
js
export function foo() {
console.log('foo');
}
foo();
函数本身虽然没有被其他模块导入,但模块内部可能执行了它。
更典型的是:
js
// init.js
console.log('初始化程序');
export const value = 100;
即使:
js
import './init.js';
没有使用任何导出,console.log('初始化程序') 仍然可能必须保留。
因此:
Tree Shaking 的核心难点不是"找没用的代码",而是判断删除代码是否会改变程序语义。
3. Webpack 如何开启 Tree Shaking?
核心思路(一句话)
Webpack 通过
optimization.usedExports标记模块中哪些导出没有被使用,再结合压缩器真正删除这些无用代码。
典型配置:
js
// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
mode: 'production',
optimization: {
// 分析每个模块的导出哪些被使用、哪些没有被使用
usedExports: true,
// 对最终代码进行压缩。
// 压缩器会根据 Webpack 的标记结果删除
// 可以安全删除的无用代码。
minimize: true,
minimizer: [
new TerserPlugin(),
],
},
};
usedExports负责分析并标记哪些 export 被使用;它本身主要负责"标记",真正把代码从产物中删除通常还需要后续的代码压缩阶段。
可以理解为:
text
usedExports
↓
告诉 Webpack:
"这个导出没人用"
↓
标记未使用代码
↓
Terser 等压缩器
↓
真正删除无用代码
4. 为什么 Tree Shaking 通常要求使用 ES Module?
核心思路(一句话)
因为 ES Module 的
import/export具有静态结构,Webpack 可以在运行前分析依赖关系;CommonJS 的require可以动态执行,静态分析难度更高。
例如:
js
import { add } from './math.js';
Webpack 在构建阶段就能知道:
text
main.js
↓
需要 math.js 的 add
↓
subtract 没有被使用
而 CommonJS:
js
const name = getModuleName();
const math = require(name);
运行之前甚至无法确定到底加载哪个模块:
text
require(name)
│
▼
name 运行时才能确定
│
▼
静态分析困难
因此:
text
ES Module
静态 import/export
↓
构建阶段可分析
↓
适合 Tree Shaking
而:
text
CommonJS
require() 可以动态执行
↓
依赖关系可能运行时才能确定
↓
Tree Shaking 难度更高
但不要绝对化
不要在面试中说:
"CommonJS 完全不能 Tree Shaking。"
更准确的说法是:
Tree Shaking 最适合具有静态依赖关系的 ES Module。Webpack 对 CommonJS 也存在一定的静态分析能力,但动态
require等场景会限制 Tree Shaking 效果。
5. Tree Shaking 和 sideEffects 是什么关系?
这是面试中非常容易继续追问的问题。
核心思路
usedExports解决"哪些导出没有被使用",sideEffects解决"整个模块在没人使用其导出时能不能直接跳过"。
例如:
js
// polyfill.js
Array.prototype.myMethod = function () {
// ...
};
然后:
js
import './polyfill.js';
虽然没有使用任何 export:
text
polyfill.js
↓
没有 export 被使用
但这个模块执行时会修改全局对象:
text
polyfill.js
↓
修改 Array.prototype
↓
产生副作用
所以不能简单删除。
sideEffects 配置
例如:
json
{
"sideEffects": false
}
表示:
这个包中的模块被认为没有副作用,如果某个模块的导出没有被使用,可以更积极地进行删除。
例如:
json
{
"sideEffects": [
"*.css",
"./src/polyfill.js"
]
}
意思是:
text
默认认为模块没有副作用
│
├── *.css
│ ↓
│ 有副作用
│
└── polyfill.js
↓
有副作用
这样可以避免误删 CSS、Polyfill 等必须执行的模块。
6. usedExports 和 sideEffects 有什么区别?
这是一个很好的面试对比题。
| 配置 | 核心作用 |
|---|---|
usedExports |
分析某个模块的哪些导出被使用 |
sideEffects |
判断模块整体是否可以在没有使用导出的情况下被跳过 |
minimize |
对生成代码进行压缩,实际删除大量无用代码 |
TerserPlugin |
Webpack 常见的 JavaScript 压缩实现 |
可以用一句话记:
text
usedExports
→ 哪个 export 没用?
sideEffects
→ 整个模块能不能不执行?
Terser
→ 这些已经确定没用的代码能不能删掉?
7. Tree Shaking 的底层实现原理是什么?
核心思路(一句话)
Webpack 先基于 ES Module 的静态结构建立依赖图并分析 export 的使用情况,再标记未使用代码,最后通过压缩阶段进行删除。
架构图
text
源代码
│
▼
Module Parser
│
▼
┌──────────────────┐
│ 模块依赖关系图 │
└──────────────────┘
│
▼
分析 import / export 使用关系
│
▼
usedExports
│
┌─────────┴─────────┐
│ │
被使用 未被使用
│ │
▼ ▼
保留 标记删除
│
▼
代码压缩器
│
▼
删除 Dead Code
│
▼
最终 Bundle
为什么叫 Tree Shaking?
因为模块依赖关系可以抽象成一棵树:
text
入口模块
│
├── A
│ ├── A1
│ └── A2
│
└── B
├── B1
└── B2
如果:
text
入口
└── A
└── A1
真正使用到的只有:
text
A
└── A1
那么:
text
A2
B
├── B1
└── B2
如果确定没有副作用,就可以从最终产物中消除。
8. Tree Shaking 的边界场景有哪些?
场景一:动态模块加载
js
const moduleName = getModuleName();
const module = require(moduleName);
依赖关系运行时才确定:
text
构建阶段
↓
无法确定 moduleName
↓
无法完整确定依赖关系
↓
Tree Shaking 能力受限
场景二:模块存在副作用
js
// init.js
window.appInitialized = true;
即使:
js
import './init.js';
没有使用 export,也不能随意删除:
text
删除 init.js
↓
window.appInitialized 不再设置
↓
程序行为发生变化
场景三:CSS
例如:
js
import './style.css';
CSS 本身没有 JavaScript export,但导入行为会产生实际效果。
因此很多项目会:
json
{
"sideEffects": [
"*.css"
]
}
场景四:CommonJS
js
const utils = require('./utils');
尤其是动态:
js
require(`./${name}.js`);
静态分析能力明显受限。
9. Tree Shaking 和代码分割有什么区别?
这个也很容易混淆。
Tree Shaking
解决:
"不要的代码删掉。"
text
一个 Bundle
┌──────────────────────┐
│ 使用代码 │
│ 无用代码 ← 删除 │
└──────────────────────┘
Code Splitting
解决:
"代码不要全部放在一个 Bundle 中,拆成多个 Chunk,按需加载。"
text
App
│
├── main.js
│
├── dashboard.js
│
└── editor.js
例如:
js
const Editor = await import('./Editor.js');
形成:
text
首屏
↓
main.js
用户打开编辑器
↓
Editor.js
↓
动态加载
所以:
text
Tree Shaking
→ 减少"代码总量"
Code Splitting
→ 减少"单次加载量"
两者可以一起使用。
10. Tree Shaking 的实际使用场景
最典型的是工具库。
例如:
js
import { debounce } from 'some-utils';
如果库本身使用 ES Module:
text
some-utils
├── debounce
├── throttle
├── cloneDeep
├── merge
├── ...
而项目只使用:
text
debounce
理想情况下:
text
debounce ← 保留
throttle ← 删除
cloneDeep ← 删除
merge ← 删除
这样可以减少 Bundle 体积。
11. Tree Shaking 最佳实践
① 优先使用 ES Module
js
import { debounce } from './utils.js';
而不是大量使用动态:
js
require(moduleName);
② 正确声明 sideEffects
如果你的包确实没有副作用:
json
{
"sideEffects": false
}
如果存在 CSS、Polyfill 等副作用:
json
{
"sideEffects": [
"*.css",
"./src/polyfill.js"
]
}
不要为了 Tree Shaking 效果随便写 sideEffects: false。
否则可能出现:
text
错误声明
↓
Webpack 认为模块无副作用
↓
模块被删除
↓
初始化代码 / CSS / Polyfill 不执行
↓
程序出现异常
③ 生产环境构建
通常:
js
module.exports = {
mode: 'production'
};
Webpack 会启用生产环境相关优化,包括代码压缩等。
因此实际项目一般不需要手工把所有优化开关逐个配置。
12. 一个完整的 Tree Shaking 示例
项目结构:
text
src/
├── main.js
└── math.js
math.js
js
// math.js
// 这个函数会被 main.js 使用。
// 因此它应该被保留在最终产物中。
export function add(a, b) {
return a + b;
}
// 这个函数没有被 main.js 使用。
// 在满足 Tree Shaking 条件的情况下,最终产物中可以被删除。
export function subtract(a, b) {
return a - b;
}
// 同样没有被使用,也可以被删除。
export function multiply(a, b) {
return a * b;
}
main.js
js
// main.js
// 只导入 add。
// Webpack 可以通过静态分析知道:
// math.js 中只有 add 是当前模块需要使用的导出。
import { add } from './math.js';
// 使用 add。
console.log(add(10, 20));
webpack.config.js
js
const path = require('path');
module.exports = {
// Webpack 的入口文件。
entry: './src/main.js',
// 使用生产模式。
// 生产模式会启用一系列默认优化,包括代码压缩。
mode: 'production',
output: {
// 输出文件名。
filename: 'bundle.js',
// 输出目录。
path: path.resolve(__dirname, 'dist'),
// 每次构建前清理 dist。
clean: true,
},
optimization: {
// 分析 ES Module 的 export 使用情况。
//
// 例如:
// add → 被使用
// subtract → 未使用
// multiply → 未使用
//
// Webpack 会对这些信息进行标记。
usedExports: true,
// 开启最终代码压缩。
//
// 注意:
// usedExports 主要负责"分析和标记",
// 压缩器负责进一步删除已经确定无用的代码。
minimize: true,
},
};
最终可以理解为:
text
math.js
│
├── add ──────→ 使用 → 保留
├── subtract ──────→ 未使用 → 标记 → 删除
└── multiply ──────→ 未使用 → 标记 → 删除
13. Tree Shaking 最容易被面试官追问的几个问题
问题 1:Tree Shaking 是不是 Webpack 独有?
不是。
Tree Shaking 是一种无用代码消除思想,现代前端构建工具普遍支持类似能力,例如:
text
Webpack
Rollup
Rspack
esbuild
其他现代 JavaScript 构建工具
具体实现机制和优化程度可能不同。
问题 2:usedExports: true 就等于 Tree Shaking 吗?
不能简单画等号。
更准确:
text
usedExports
→ 分析 export 使用情况
→ 标记未使用 export
代码压缩器
→ 根据标记删除 Dead Code
所以完整理解应该是:
Tree Shaking 是一套无用代码消除机制,
usedExports是 Webpack 实现其中"使用情况分析"的重要配置。
问题 3:为什么开发环境看到没删除?
因为:
text
开发环境
→ 更关注构建速度、调试体验
→ 不一定进行完整压缩
生产环境
→ 更关注最终 Bundle 体积
→ 通常进行 Tree Shaking + Minification
所以不要仅仅看开发环境输出文件判断 Tree Shaking 是否有效。
问题 4:Tree Shaking 能不能删除所有没有执行的代码?
不能。
核心限制就是:
不能在无法证明"删除后不会改变程序行为"的情况下随意删除代码。
尤其需要关注:
text
副作用
动态依赖
CommonJS
全局变量修改
原型修改
模块初始化
CSS
Polyfill
14. 这道题的真正考点
可以把整道题压缩成四层:
text
第一层:概念
Tree Shaking = 消除无用代码
↓
第二层:为什么能做
ES Module 的 import/export 具有静态结构
↓
第三层:Webpack 怎么做
usedExports
→ 分析 export 使用情况
→ 标记未使用代码
↓
第四层:为什么能安全删除
sideEffects + 静态依赖分析 + 代码压缩
↓
最终
减少 Bundle 体积
主要矛盾
如何准确判断哪些代码既没有被使用,又可以安全删除。
次要矛盾
usedExports、sideEffects、压缩器分别承担什么职责,以及 ES Module / CommonJS 对静态分析能力的影响。
满分答案
面试题:Webpack 如何实现 Tree Shaking?
核心思路:
Tree Shaking 就是在构建阶段利用 ES Module 的静态结构分析模块依赖和 export 的使用情况,标记未使用代码,再结合副作用分析和代码压缩,把可以安全删除的无用代码从最终 Bundle 中移除。
流程图
text
ES Module 源代码
│
▼
分析 import / export
│
▼
建立模块依赖关系
│
▼
分析 export 使用情况
│
▼
usedExports 标记未使用代码
│
├── 有副作用 ──→ 不能随意删除
│
└── 无副作用 ──→ 可以进入删除阶段
│
▼
Terser 等压缩器
│
▼
删除无用代码
│
▼
更小的 Bundle
底层原理
例如:
js
// math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
js
// main.js
import { add } from './math.js';
console.log(add(1, 2));
Webpack 能在构建阶段静态分析:
text
main.js
│
└── 使用 math.js 的 add
math.js
├── add → 使用 → 保留
└── subtract → 未使用 → 标记 → 压缩阶段删除
Webpack 中:
js
optimization: {
usedExports: true,
minimize: true,
}
其中 usedExports 主要负责分析和标记哪些 export 被使用,而代码压缩器负责真正删除能够安全删除的代码。
为什么 ES Module 更适合 Tree Shaking?
因为:
js
import { add } from './math.js';
属于静态模块依赖,构建阶段就可以知道依赖关系。
而:
js
const name = getModuleName();
require(name);
模块在运行时才能确定,静态分析能力会受到限制。
还要注意副作用
例如:
js
// polyfill.js
Array.prototype.foo = function () {};
即使没有使用任何 export,这个模块执行本身也会修改全局对象,因此不能简单删除。
所以还需要正确配置:
json
{
"sideEffects": false
}
或者明确声明有副作用的文件:
json
{
"sideEffects": [
"*.css",
"./src/polyfill.js"
]
}
这里最容易犯的错误是把 usedExports 理解成"直接删除无用代码"。更准确地说,usedExports 负责使用情况分析和标记,最终的代码删除通常由压缩阶段完成。
和代码分割的区别
text
Tree Shaking
→ 删除不需要的代码
→ 减少代码总量
Code Splitting
→ 把代码拆成多个 Chunk
→ 减少单次加载量
两者可以配合使用。
一句话总结
Webpack Tree Shaking 的本质就是:利用 ES Module 的静态依赖关系,分析哪些导出没有被使用,再结合副作用分析和代码压缩,把确定无用的代码从最终 Bundle 中删除,从而减少 JavaScript 体积。