Tree Shaking:只带走该带的东西

一、它到底是什么

假设你有一个巨大的工具箱,里面塞了一千件工具。但你今天要干的活,只需要一把锤子和两颗钉子。出门的时候,你是把整个箱子扛走,还是把用不上的工具留在家?

代码世界里每天都在发生这种事。Tree Shaking 干的就是"只带该带的东西"——把你写的代码和引用的第三方库里,那些没人用到的部分抖掉。

它为什么能做到?因为现代 ES 模块有个关键性质:你在文件顶部写的 importexport,是静态的、一眼能看清楚的。就像每个箱子外面都贴着标签——"里面装了什么、谁拿走了什么"。

打包工具(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 走,发现 subtractmultiplydivide 从来没人调用——它们是死代码。最终产物里这三个函数整个消失,只剩 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,但 setNamesavetoJSON 通常全留在包里。原因和例 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?这一条查清楚,往往比换打包工具省下的体积还多。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容