☰
深度拆解Tree-shaking:原理、配置与打包体积优化实战
2026/9/29 15:41:16 网站建设 项目流程

1. 先说结论:Tree-shaking 到底在解决什么问题

如果你写过前端项目,大概率遇到过这种场景:明明只import { debounce } from 'lodash',结果打包出来发现体积多了好几十 KB;明明import * as echarts from 'echarts',结果一个折线图页面打包出了 1MB 的 JS。你第一反应是“这库真大”,第二反应是“能不能只打包我用到的那一部分”。Tree-shaking 就是干这件事的。

Tree-shaking 这个词直译是“摇树”,指的是构建工具在打包阶段把模块中没被引用到的代码“摇掉”。它的名字很形象:你的项目代码像一棵树,每个模块是树枝,每个导出是树叶和果实,构建工具抓住模块依赖关系的根部使劲摇一下,没被用到的“枯枝败叶”就掉下来了,剩下的是真正会运行的代码。

这个机制解决的核心痛点就是打包体积。体积直接影响页面加载速度,尤其在移动端、弱网环境,多出来的几十 KB 可能就意味着白屏时间多几百毫秒。对于工具库开发者来说,tree-shaking 更是决定库能不能被现代构建链优雅使用的基础能力。

这篇文章我会从原理、配置、实操验证到踩坑排查,把 Tree-shaking 完整拆一遍。适合刚接触前端工程化、对“为啥我打包体积这么大”有困惑的同学,也适合想优化自己工具库体积的包作者。

2. 原理解析:为什么能“摇”得动代码

2.1 ES Module 的静态结构是前提

Tree-shaking 能成立,前提是代码必须使用 ES Module(import/export)语法。为什么?因为 ES Module 的导入导出是静态的——import和export必须写在模块顶层,不能嵌套在if或者循环里;导入路径也必须是确定的字符串,不能是变量。

这意味着构建工具在做编译时分析的时候,不需要真正运行你的代码,就能把整个模块依赖关系画成一张静态的图。在这张图里,每个模块导出了哪些东西、每个文件从哪里导入了什么,都是一目了然的。既然能够在编译阶段拿到导出的全集和导入的使用情况,自然就能做匹配:哪些导出被引用到了,哪些导出从头到尾没人碰,然后打上“没用”的标记。

这里的判断是基于“顶层语句的引用分析”,并不要求去执行你的业务逻辑。编译器做的是静态判断,不是动态分析。

2.2 CommonJS 为什么做不到

那为什么require不行?因为 CommonJS 的require是运行时加载的。路径可以是require(path)这种变量拼出来的,模块本身也可以在代码运行过程中被反复加载、修改不会有一个固定的导出集合。构建工具如果碰到const mod = require('./utils'),根本不知道mod上最终会有哪些属性,也没法安全地判定“哪个属性没被用到、可以删掉”。

所以一个实用判断标准是:你的项目只要还在用 CommonJS 规范写业务代码,tree-shaking 基本就废了。现代框架用 Vite 默认给的是 ESM 开发环境,Webpack 也默认对import语法做处理,但如果你自己装的老依赖是 CJS 格式,或者 Babel 配置把import全转成了require,那 tree-shaking 就空转了。

2.3 摇树不是“删掉不用代码”这么简单

实际工程中,tree-shaking 通常是两部分协作完成的:

  • 模块标记:构建时分析出哪些 export 没有被引用,在产物里保留一个“已使用/未使用”的标记信息。
  • 压缩器删除:后续 Terser 这类压缩插件读取标记,把未使用的导出语句从最终代码里物理移除。

这也就是为什么有时候你觉得“我明明开了 production 模式,tree-shaking 也生效了”,但打包产物里还是能搜到一些未使用代码的碎片——标记做了,但压缩器没删干净,或者反过来,标记没做到位,压缩器不敢删。理解这个协作关系之后,排查体积问题的思路会清晰很多。

3. 工具侧的 Tree-shaking 配置:Webpack、Rollup 与 Vite

3.1 Webpack:production 模式眼里的 tree-shaking

Webpack 在mode: 'production'下,默认会开启一堆优化选项,其中和 tree-shaking 相关的主要有两个:

  • usedExports:分析每个模块的导出使用情况,给未用导出打标记。
  • minimize:用 TerserPlugin 做代码压缩,压缩时根据标记物理删除未用代码。

也就是说,用 Webpack 构建生产包的时候,只要你的代码是 ESM、依赖是 ESM,tree-shaking 是默认生效的。但注意:Webpack 对“副作用”的处理非常谨慎,后面我会专门讲sideEffects字段,那是 Webpack 能否深度摇树的关键开关,如果你不告诉它“这个模块是纯函数模块,删掉也没关系”,它宁可保守一点,把整个模块保留。

如果你用的是 Webpack 5,还有个更激进的选项叫optimization.innerGraph,默认开启。这个选项让 Webpack 能做更细粒度的推断,比如函数内部的未用参数、未用分支,能力上更强,但也更依赖正确的sideEffects声明。

3.2 Rollup 与 Vite:天生为摇树而生

Rollup 是把 tree-shaking 作为核心设计目标的打包器,所以它对 ESM 的支持和摇树能力比早期 Webpack 激进得多。Rollup 会做真正的“依赖图级死代码消除”:它只把被引用到的导出打进产物,并且分析得更透。Vite 的生产构建底层就是 Rollup,所以你用 Vite 构建项目时,只要在vite.config.ts里没有刻意关掉相关优化,tree-shaking 天然是生效的。

用量化一点的感受来说:同样一个import { debounce } from 'lodash-es'的 demo,Webpack 打出来可能保留了一些模块壳子和辅助函数,Rollup 产物里你只会看到一个干净的 debounce 实现——这就是两者摇树力度的差异。

3.3 最容易废掉摇树的“元凶”:Babel 的模块转换

这是我在实际项目里踩过最多坑的地方,值得重点标记。很多人项目里装了@babel/preset-env,配置里写着:

{ "presets": [ ["@babel/preset-env", { "modules": "commonjs" }] ] }

modules: "commonjs"会把代码里的import全部转成require。如果这个配置作用于你的业务代码,那么到了 Webpack 手里,看到的已经是一堆 CommonJS 了——静态分析的目标消失,tree-shaking 直接失效。

现代前端构建链里,Webpack 自己就能处理import,根本不需要 Babel 先转成 CommonJS。所以 Babel 预设里务必要设置modules: false,让 Babel 只做语法降级(比如把可选链转成 ES5 语法),不要动模块语法。这个配置我建议直接记死:

Babel 处理模块语法的正确姿势是modules: false,把模块解析的活留给 Webpack / Rollup。

3.4 关键配置速查表

工具开启方式关键配置备注
Webpack 4+mode: 'production'optimization.usedExports、optimization.sideEffectsWebpack 4 之前需要手动配,4 之后默认开启
Rollup默认开启treeshake选项可自定义推荐保持默认
Vite默认开启底层 Rollup 传递生产构建vite build生效
Babel不限presets 设置modules: false防止 ESM 被转换成 CommonJS

4. 实操验证:从零到一确认 Tree-shaking 真的生效

4.1 构造一个测试用例

动手验证远比看文档来得实在。我建议你建一个极小的 demo 工程,自己感受一下“摇之前”和“摇之后”的体积差异。

假设你有一个utils.js:

export function add(a, b) { return a + b; } export function multiply(a, b) { return a * b; } export function mysteriousFunction() { // 这段代码很复杂,但没被使用 return 'I am never called'; } // 模块顶层的副作用语句 console.log('module loaded');

入口index.js:

import { add } from './utils'; console.log(add(1, 2));

这里有个关键:console.log('module loaded')是模块顶层语句,它跟add导入没有引用关系。Rollup 在摇树时碰见这种顶层副作用语句,会默认认为“这个模块不能被整体删除”,因为它执行的时候确实有外部可观察的副作用(打印日志)。这正是mysteriousFunction能被删掉、但console.log却保留的原因。

4.2 用 Rollup 看真实产物

跑一次 Rollup 的零配置构建,产物的format用es,你看编译出的文件你会发现,multiply和mysteriousFunction完全消失了,但console.log('module loaded')保留了。这段打印语句就是 Rollup 判断的“模块副作用”——它不确定你删掉这个模块会不会影响别的逻辑,所以不敢动。

这告诉我们一个重要信息:想让 tree-shaking 摇得干净,不是只靠配置,更要在代码层面“让模块可被安全摇动”。如果你的工具函数模块顶部都放着console.log、全局事件绑定、window 属性修改,那不管打包器多激进,它都得把这个模块的副作用保留下来。

4.3 用 Webpack 和 Bundle Analyzer 验证

Webpack 项目建议装一个webpack-bundle-analyzer,可以可视化看出每个模块打进了多大体积。配合 source-map-explorer 可以定位到具体代码位置。验证步骤:

  1. 生产构建开启mode: 'production'。
  2. 在 package.json 中给各个依赖模块配置正确的sideEffects。
  3. 构建完成后,用webpack-bundle-analyzer dist/*.js或者直接搜索产物代码,找那个没被引用的函数名。

如果你搜到了一个你没引用的工具函数名字,说明要么这个模块的副作用声明有问题,要么这个函数被真正引用了(可能是间接引用)。这个排查过程比任何文档都有说服力。

4.4 “按需引入”的经典实战:lodash 换成 lodash-es

老生常谈但必须谈:lodash的 CJS 版本是没法摇树的,你要用 ESM 版本的lodash-es。实际对比:

  • import { debounce } from 'lodash',安装后构建,产物很大,因为整个 lodash 被打入。
  • import { debounce } from 'lodash-es',安装后构建,产物很小,因为lodash-es每个模块是一个独立 ESM 文件,tree-shaking 能精确定位到debounce这一个函数。

我实测过一个小 demo:只用debounce一个方法,lodash打出来约 70KB,lodash-es打出来不到 1KB。这个差距就是 tree-shaking 的价值。

5. 常见问题与排查技巧实录

5.1 问题一:为什么 Tree-shaking 没生效?

最常见的原因按出现频率排序:

  1. Babel 把 ESM 转成了 CJS:查@babel/preset-env配置,确认modules: false。
  2. 依赖包本身是 CJS/UMD 格式:比如老版本 React 生态的部分库。这种情况只能换 ESM 替代品,或者用 CDN 外链减少打包体量。
  3. package.json里没配置sideEffects:Webpack 默认对node_modules里的模块比较保守,不确认“安全”就不删。需要库的作者在package.json中显式声明。
  4. 代码里有顶层副作用:如模块加载时执行console.log、修改全局对象、绑定事件。打包器看到副作用,整个模块都不删。
  5. 动态导入/动态引用:import(variable)这种动态路径,或者obj[methodName]()这种动态属性访问,都会让静态分析失效。对于属性访问,可以用/*#__PURE__*/注释辅助标记。

5.2 问题二:sideEffects到底怎么配?配错了会怎么样?

sideEffects字段写在库的package.json里,作用是告诉打包工具:“我这个包里面的模块有没有副作用,能不能安全摇掉。”

  • "sideEffects": false:整个包中的所有模块都无副作用,打包器可以放心删除未引用模块。
  • "sideEffects": ["*.css", "*.scss"]:除了样式文件,其他模块都无副作用。样式文件是必须保留的,因为它们通常会被直接import但内部没有导出任何变量,属于典型“副作用模块”。
  • 不配置sideEffects:Webpack 默认认为所有模块都有副作用,摇树能力大打折扣。

这个字段配错的代价是真实的:如果你维护的库在package.json里标了"sideEffects": false,但某个模块顶层其实修改了全局对象,那使用方打包时会把这个模块整体删掉,导致线上运行报错。这是库作者最容易埋的雷。

我个人在维护组件库时的做法是:

  • 组件主体 JS 模块统一声明无副作用;
  • CSS/LESS 文件单独列进副作用数组;
  • 每个入口文件手动检查一遍,有没有在顶层写全局逻辑。

5.3 问题三:样式文件丢了怎么办?

典型场景:组件库使用了import './index.css'这种字面导入,但库作者写了sideEffects: false。打包时 Webpack 发现这个导入没有使用任何导出,判定“可以删除”,于是你的 CSS 就没了。

解决方式就是上文说的——在sideEffects数组里把*.css明确标记为副作用文件:

{ "sideEffects": ["*.css", "*.scss", "*.less"] }

所有工具库作者看到这段,建议直接抄进你的package.json。

5.4 问题四:如何在代码层面“帮”打包器做得更好?

几个亲测有效的技巧:

技巧一:用 PURE 注释标记纯函数。如果是为了保持链式调用风格而刻意写的表达式语句,你可以给它们加/*#__PURE__*/注释,明确告诉压缩器“这里没有副作用,可以删”:

/*#__PURE__*/ configureStore({ reducer: rootReducer, });

这个注释在 Webpack、Rollup、Terser 中都有一致的识别规则,是官方推荐的优化辅助手段。

技巧二:避免直接导出整个对象。比如不要这样写:

export default { add, multiply, mysteriousFunction, };

默认导出对象会让分析器认为“对象的所有属性都有可能被外部访问”,很难精确摇掉。除非你能接受只按需导入命名的导出,否则建议写成:

export { add, multiply };

技巧三:尽量避免顶层副作用。模块加载时做初始化、绑定全局变量、修改原型链,这些都是阻断摇树的因子。把这些逻辑放进显式函数里,让使用方按需调用,反而促进了 tree-shaking。

5.5 快速排查清单

排查体积问题时,我一般按下面的步骤走,效率很高:

  • 确认代码链路都是 ESM:import/export,没被 Babel 转掉。
  • 确认目标包是 ESM 格式:看node_modules/xxx/package.json的module字段,如果没有module字段基本就是 CJS。
  • 确认sideEffects配置没有误伤:尤其样式文件被删了,先查这里。
  • 用webpack-bundle-analyzer可视化,直接看哪块体积异常。
  • 在产物里搜未引用函数的独有字符串,确认它到底还在不在。

这套排查法我反复用了两年多,碰到“打包体积突然变大”“样式丢失”“线上报某方法未定义”这三大类问题,基本都是上面这几个原因。原理不复杂,配置也就几个字段,但联动了 Babel、Webpack、库作者的 metadata,任何一个环节脱节,结果就是白打包几十 KB。

我自己在被sideEffects: false坑掉组件库样式那次之后,所有发布的包都把副作用声明写成显式数组,再也不图省事直接写false了。这个习惯建议每个包作者都养成——你在 package.json 里多写几行字,可能就帮下游开发者省下一下午的排错时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询