
一、它到底是什么
假设你有一个巨大的工具箱,里面塞了一千件工具。但你今天要干的活,只需要一把锤子和两颗钉子。出门的时候,你是把整个箱子扛走,还是把用不上的工具留在家?
代码世界里每天都在发生这种事。Tree Shaking 干的就是"只带该带的东西"——把你写的代码和引用的第三方库里,那些没人用到的部分抖掉。
它为什么能做到?因为现代 ES 模块有个关键性质:你在文件顶部写的 import 和 export,是静态的、一眼能看清楚的。就像每个箱子外面都贴着标签——"里面装了什么、谁拿走了什么"。
打包工具(webpack、Rollup、Vite 背后那一层)从你的入口文件出发,顺着这些标签一条条走下去,画出一张"谁真正用到了什么"的地图。凡是这张地图里没被碰过的代码——你 import 了却从没调用的函数、第三方库里你压根没用到的功能——它就是死代码。打包时,工具把这些死代码像抖树上的枯叶一样抖掉,不塞进最终发给浏览器的文件里。
名字就是这么来的:摇一摇树,没用的叶子掉下来。
核心一句话:Tree Shaking 本质是靠静态结构做推理。你给的边界越清晰,它抖得越干净。
二、为什么非要有它不可
浏览器得下载、解析、编译你发过去的每一行 JS。代码越多,用户等得越久、流量烧得越多、设备发热越厉害。大自然不背多余的重量,代码也不该背。
更现实的原因:现代开发几乎离不开第三方库。一个完整引入的库可能有几十 KB,但你只用了其中一个函数。没有 Tree Shaking,你就得把整个库都发给每一个用户;有了它,只发你用的那一小块——省下的往往是实打实的加载时间。
三、六个例子,看清它能摇什么、摇不动什么
例 1:最干净的情况——没用到的导出,直接掉
// mathUtils.js
export function add(a, b) { return a + b }
export function subtract(a, b) { return a - b }
export function multiply(a, b) { return a * b }
export function divide(a, b) { return a / b }
// main.js
import { add } from './mathUtils.js'
console.log(add(1, 2))
打包工具顺着 main.js 的 import 走,发现 subtract、multiply、divide 从来没人调用——它们是死代码。最终产物里这三个函数整个消失,只剩 add。
关键在哪?import { add } 是静态的,工具一眼就能确认你只拿了 add。边界清清楚楚。
例 2:副作用——为什么"用都没用"的代码却留着
// logger.js
console.log('logger 已加载') // 加载即执行
window.__appLogger = function() {} // 往全局挂东西
export function log(msg) { console.log(msg) }
// main.js
import { log } from './logger.js'
log('hi')
你以为那两行"加载即执行"的代码会被抖掉?不会。只要这个模块被 import,它就会顺手干这两件事——哪怕你只想要 log 函数。打包工具不敢扔,扔了 window.__appLogger 就没了,依赖它的别处可能直接崩。这种"加载即产生效果"的代码,就叫副作用。
怎么让它能被抖?在库的 package.json 里签字声明:
{ "sideEffects": false }
意思是"我这个包加载时不干任何多余的事,纯函数,随便抖"。工具拿到保证,才敢把没用到的导出连同副作用一起扔。
如果包里确实有文件有副作用(比如引入全局 CSS),精确列出来:
{ "sideEffects": ["*.css", "./src/polyfill.js"] }
这样 CSS 和 polyfill 永远保留,其余照抖不误。
例 3:CommonJS 摇不动——动态写法让工具瞎眼
同一个工具,用老写法:
// mathUtils.cjs
exports.add = (a, b) => a + b
exports.subtract = (a, b) => a - b
// main.js
const { add } = require('./mathUtils.cjs')
console.log(add(1, 2))
工具拿到 require,没法在不运行代码的情况下确定你从 exports 上拿了哪个。exports 是对象,运行时才能拼属性名、能条件判断、能循环赋值——静态分析无从下手。结果:subtract 大概率整包保留,抖不掉。
一句话:能不能摇,先看你写的是 import/export 还是 require。ES 模块是摇树的土壤,CommonJS 不是。
例 4:动态路径 / eval——主动把绳子剪断
就算用了 ES 模块,下面这种写法也会让工具举手投降:
// 动态拼接的路径,工具不知道你要加载哪个文件
const name = 'feature' + someFlag
import(`./modules/${name}.js`)
// 或者
eval('console.log(add(1,2))')
import() 的参数是运行时才拼出来的字符串,工具在打包阶段看不到真相;eval 把代码藏进字符串里。这两种地方,工具只能保守地全部保留。摇树的前提永远是:结构在打包时就已经确定。
例 5:类的方法——用了两个,其余还留着
// User.js
export class User {
constructor(name) { this.name = name }
getName() { return this.name }
setName(n) { this.name = n }
save() { /* 往数据库写 */ }
toJSON() { return { name: this.name } }
}
import { User } from './User.js'
const u = new User('a')
u.getName()
你只用了 getName,但 setName、save、toJSON 通常全留在包里。原因和例 4 同源:工具怕你用字符串动态访问属性名(u['sa' + 've']()),保守起见不敢删类成员。这是 Tree Shaking 的盲区——它甩得动"整个没用到的导出",甩不动"对象内部没用到的方法"。
例 6:第三方库——能不能摇,取决于它配不配合
你自己代码写得再标准也没用,引用的库如果是这样发布的:
- 只发了 CommonJS 版本(例 3 的问题)
- 没标
sideEffects,但内部其实有副作用(例 2 的问题) - 入口文件在顶层执行了副作用代码
那你这边的摇树就白搭。验证方法很简单:打包完之后,搜一下产物里有没有那个库你确定没用过的函数名或字符串。还在?说明它没被摇掉。
一个实用的排查动作:用支持 ESM 的库版本,并确认它的 sideEffects 配置正确。比如用 lodash 时,别 import _ from 'lodash'(整包引入),改成 import debounce from 'lodash/debounce',或者直接上 lodash-es——后者是纯 ESM 发布,摇起来事半功倍。
四、总结
把六个例子连起来,规律只有一条:Tree Shaking 靠"静态、无副作用、边界清晰"做推理。你给的边界越清晰、越静态、越没副作用,它抖得越干净;你藏得越动态、越有副作用、越混用写法,它越只能全盘保留。
你想想看——你项目里引用的那几个库,是真的按 ES 模块写的,还是混着 CommonJS?这一条查清楚,往往比换打包工具省下的体积还多。