跳到主要内容

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))

理想情况下,最终产物里应只保留 addmultiply 对应代码会被摇掉。

所以更准确的理解是:

  • 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 主流程

先记主流程,再补“静态分析”和“副作用”这两个追问点。

这条链路里最容易被忽略的是两点:

  1. 先标记“谁被用了”
  2. 再判断“没被用的东西能不能安全删”

不是所有“没被引用”的代码都能直接删,因为它可能仍然有副作用。

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 export
  • b 是 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

即使 ab 都没被用,模块本身也未必能整个删。

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__*/、压缩器协同