Tree Shaking 的原理是什么?
很多人会把 Tree Shaking 背成“删除没用代码”。这句话方向没错,但面试里不够。更准确的说法是:Tree Shaking 本质上是基于 ESM 静态结构做“可达性分析 + 副作用保守判断 + 死代码消除”的一套构建优化机制。
0. 面试速答(30 秒版 TL;DR)
- Tree Shaking 不是运行时优化,而是构建时优化。
- 它的核心前提是:模块依赖和导出关系必须能在编译阶段静态分析出来,所以 ESM 天然更适合,CommonJS 天然更差。
- 主流 bundler 的大致流程是:
- 从入口建立模块图
- 标记哪些导出真正被使用
- 判断模块和语句是否可能有副作用
- 保留必要代码
- 再交给压缩器删除不可达分支和未引用代码
- 它不是“想删就删”。只要 bundler 不能证明删除后语义一定不变,就会保守保留。
1. 先建立心智模型:它摇掉的不是“文件”,而是“无用导出对应的代码分支”
很多人容易误解成:
- 一个模块没被用,就整个删掉
这只是一部分情况。更常见的是:
- 模块被导入了,但模块里的某些导出没被用到
- bundler 只会尽量删掉“未被引用且可证明无副作用”的那部分代码
例如:
// math.js
export function add(a, b) {
return a + b
}
export function multiply(a, b) {
return a * b
}
// main.js
import { add } from './math.js'
console.log(add(1, 2))
理想情况下,最终产物里应只保留 add,multiply 对应代码会被摇掉。
所以更准确的理解是:
- Tree Shaking 针对的是“模块图里不可达的导出、语句和依赖分支”
- 不是简单按文件维度删除
2. 为什么 ESM 更适合 Tree Shaking?
核心原因是:ESM 是静态结构(static structure)。
像下面这样的导入导出关系,在编译阶段就能看清:
import { foo } from './utils.js'
export { bar } from './other.js'
bundler 能提前知道:
- 依赖谁
- 导入了哪些名字
- 导出了哪些名字
- 这些绑定之间如何关联
但 CommonJS 往往是动态的:
const mod = require(getPath())
module.exports[someKey] = value
这类代码的问题是:
- 依赖路径可能运行时才知道
- 导出对象可能被动态改写
- bundler 很难在构建阶段精确判断“到底用了谁”
所以面试里可以直接说:
- Tree Shaking 的基础不是“语法新旧”,而是“模块关系能否静态分析”。
- ESM 天然友好,CommonJS 天然受限。
3. 一张图看懂 Tree Shaking 主流程
先记主流程,再补“静态分析”和“副作用”这两个追问点。
这条链路里最容易被忽略的是两点:
- 先标记“谁被用了”
- 再判断“没被用的东西能不能安全删”
不是所有“没被引用”的代码都能直接删,因为它可能仍然有副作用。
4. 标记阶段:bundler 怎么知道哪些导出被用了?
从入口出发,bundler 会沿着模块依赖图往下走,并记录:
- 哪个模块被谁导入
- 导入了哪些具名导出
export *最终转发到了哪里- 动态导入会形成哪些异步边界
例如:
// utils.js
export const a = 1
export const b = 2
// main.js
import { a } from './utils.js'
console.log(a)
在标记阶段,bundler 通常能得出:
a是 used exportb是 unused export
但到这里还不够,接下来要问:
- 删除
b的声明会不会改变模块执行结果?
如果不会,才有机会被真正删除。
5. 副作用判断:为什么“没用到”也不一定能删?
这是 Tree Shaking 最核心的边界。
5.1 什么叫副作用(side effects)?
这里的副作用可以简单理解为:执行这段代码会对外部可观察结果产生影响,而不仅仅是返回一个值。
常见副作用包括:
- 修改全局变量
- 修改模块外部状态
- 注册事件
- 发请求
- 打日志
- 执行立即调用代码
- 引入会自动执行的 polyfill / 样式文件
例如:
export const version = '1.0.0'
console.log('module loaded')
即使 version 没人用,上面的模块也不能简单整个删掉,因为模块顶层的 console.log 也是执行结果的一部分。
5.2 为什么 bundler 会“宁可多留,也不误删”?
因为误删副作用代码会造成行为错误,而多留一点代码通常只是包体变大。
所以 Tree Shaking 的策略天然偏保守:
- 能证明安全才删
- 证明不了就保留
这也是为什么有些同学会觉得“我明明没用这个函数,为什么它还在包里”。
答案往往不是 Tree Shaking 失效,而是:
- bundler 无法证明这段代码无副作用
6. 模块级优化:sideEffects 配置为什么重要?
在主流工程里,Tree Shaking 不只看“导出有没有被用”,还会看“整个模块是否可以被安全跳过”。
典型场景:
import './setup.js'
import { sum } from './math.js'
这里:
./setup.js没有导出被消费- 但它可能在顶层做初始化
如果 bundler 知道某些文件没有副作用,就可以在它们未被真正消费时直接丢弃。反过来,如果你错误声明“这个文件无副作用”,就可能把本该执行的初始化删掉。
以 Webpack 常见工程实践来说:
package.json里的sideEffects用来帮助 bundler 判断文件是否可能有模块级副作用- 典型写法可能是:
{
"sideEffects": false
}
或者更保守地排除样式文件:
{
"sideEffects": ["*.css", "*.scss"]
}
面试里要注意一句话:
sideEffects: false不是“这个项目完全没有副作用”,而是“未被引用的模块可以按无副作用模块处理”。
6.1 为什么样式文件经常要被排除在外?
因为:
import './index.css'的价值本来就是“执行引入后产生样式效果”- 如果把它也当成无副作用模块,构建时可能会被错误删掉
7. 语句级优化:为什么压缩器也很关键?
严格说,Tree Shaking 往往不是 bundler 单独完成的。
更常见的实际分工是:
- bundler 负责建立模块图、标记 used exports、做模块拼接与保留策略
- 压缩器负责进一步删除未引用语句、不可达分支、恒定条件分支等
例如:
function square(x) {
return x * x
}
function cube(x) {
return x * x * x
}
console.log(square(2))
当 bundler 已经标记 cube 未被引用后,后续压缩阶段才更容易把对应代码彻底擦除。
所以面试里更稳的说法是:
- Tree Shaking 是“静态标记 + 保守裁剪 + 压缩清扫”的组合结果。
8. /*#__PURE__*/ 是干什么的?
它是给压缩器或构建工具的一个提示:这个调用表达式如果结果没被使用,可以当作纯调用删除。
例如:
const result = /*#__PURE__*/ createHeavyObject()
如果 result 最终没被用到,而工具又信任这个标记,就可能把这次调用一起删掉。
它常见于:
- 编译器自动注入
- 库作者给工厂函数、包装器、组件创建逻辑做标记
但要注意:
- PURE 标记只该用于“确实无副作用”的调用
- 标错了,本质上就是在骗优化器,可能直接删出 bug
9. 哪些写法会让 Tree Shaking 变差?
9.1 CommonJS 或动态 require
const lib = require('./lib')
这会让导出使用情况更难静态推断。
9.2 动态访问导出对象
import * as utils from './utils.js'
console.log(utils[someKey])
这里 someKey 运行时才知道,bundler 很难确定到底会访问哪个导出。
9.3 模块顶层存在副作用
doInit()
export const a = 1
export const b = 2
即使 a、b 都没被用,模块本身也未必能整个删。
9.4 错误的 barrel 文件设计
index.js 这类聚合导出文件本身不是问题,问题在于它是否夹带副作用代码。
坏例子:
export * from './button.js'
export * from './input.js'
startMonitor()
这里聚合文件一旦被导入,就可能因为顶层副作用而拖累裁剪效果。
10. 主流工具里,Tree Shaking 的理解口径有什么差异?
以当前主流工程实践看,可以用下面这套口径回答:
- Rollup:一直以静态分析和高质量产物见长,库构建场景里 Tree Shaking 口碑通常最好。
- Webpack 5:具备完整 Tree Shaking 能力,但常依赖生产模式、
usedExports、压缩器以及正确的sideEffects声明协同发挥效果。 - Vite:生产构建通常基于 Rollup,所以生产态 Tree Shaking 本质上更多继承 Rollup 这套能力。
- esbuild:也支持 Tree Shaking,速度很快,但很多场景下大家对“极致产物精细度”的预期仍常把 Rollup 单独拎出来讨论。
注意这里的答法重点不是背工具细节,而是说明:
- 不同工具的 Tree Shaking 能力差异,往往不在“有没有”,而在“静态分析精细度、保守策略、产物质量和配置协同”。
11. 面试高频题与标准答法
11.1 Tree Shaking 为什么依赖 ESM?
标准答法:
因为 ESM 的导入导出是静态结构,bundler 能在编译阶段知道模块依赖关系和导出绑定关系,从而做使用标记和裁剪。CommonJS 更动态,静态分析难度更高,所以 Tree Shaking 效果通常较差。
11.2 Tree Shaking 是不是把没 import 的文件直接删掉?
标准答法:
不准确。它更核心的是从入口出发分析模块图,判断哪些导出真正被使用,再结合副作用分析决定哪些模块、语句和依赖分支可以安全删除。所以它是“按可达性和副作用”裁剪,不只是按文件删。
11.3 为什么我明明没用某段代码,打包后它还在?
标准答法:
常见原因有三个:一是使用了 CommonJS 或动态写法,导致静态分析不充分;二是模块或语句存在副作用,工具只能保守保留;三是还需要压缩阶段进一步做死代码消除,当前构建链没有把这一步打通。
11.4 sideEffects 和 Tree Shaking 是什么关系?
标准答法:
sideEffects 主要是帮助 bundler 做模块级裁剪判断。它不是用来标记“导出是否被用到”,而是告诉工具“一个模块在未消费导出时,能不能整个跳过执行”。它和 used exports 分析是互补关系。
12. 常见误区
- 误区 1:Tree Shaking = 删除没用变量。 太浅。它本质上依赖模块图、静态分析、副作用判断和压缩协同。
- 误区 2:只要用了 ESM,就一定能完全摇干净。 不对。顶层副作用、动态访问、聚合导出夹带逻辑、工具配置不当都会影响效果。
- 误区 3:
sideEffects: false越早开越好。 不严谨。声明错了会把真实初始化、样式、副作用模块一起删掉。 - 误区 4:Tree Shaking 和代码分割是一回事。 不是。代码分割解决“怎么拆包”,Tree Shaking 解决“包里哪些代码没必要留”。
13. 速记要点(可背诵)
- Tree Shaking = 基于 ESM 静态分析的可达性裁剪
- 主链路 = 模块图 -> used exports 标记 -> 副作用判断 -> 压缩删除
- 核心前提 = 静态 import / export
- 最大边界 = 副作用
- 工程关键字 = ESM、
sideEffects、/*#__PURE__*/、压缩器协同