每年秋天,果园主小明都要做一件大事:摇树。他有一棵苹果树,树上除了红彤彤的果子,还挂着大片枯黄的叶子。他只想要果子不想要叶子,于是抱住树干使劲摇——枯叶哗啦啦掉了一地,果子却稳稳挂在枝头。这个画面,就是前端世界里大名鼎鼎的 Tree Shaking(摇树优化)。
把大树想象成最终的 JS 文件,枯叶就是那些从没被任何代码 import 用到的导出函数、变量和模块,果子则是被真正引用的代码。构建工具——不管是 Webpack 还是 Vite——都像果园主小明一样,先要看清楚每一根枝条的来龙去脉(静态分析),再决定摇哪一根。但要注意:不是所有树都能摇。如果枝条乱成一团,比如用 CommonJS 的 require 在代码里动态拼路径,小明的账本就看不清了;如果有些叶子背后还挂着蜜蜂的巢(模块副作用),更不能随便剪。这篇文章,就带你跟着小明把整套摇树工序学明白。
掌握摇树优化,能帮你的项目打包体积减半、首屏秒开,是前端工程化实战和面试中的高频考点,也是从“会写页面”迈向“懂构建”的关键一步。
可视化演示
可视化ESM静态分析依赖树、标记副作用模块、摇树后打包体积对比,滑块调节摇树强度观察死代码消除效果。
本节配有交互动效(进入视口后自动加载,约 3KB)。
依赖树:ESM静态分析标记死代码 根模块M:入口被引用 使用模块A:有导入引用 叶子模块C/E:被引用保留 死代码B/D/F:未被引用,摇树消除 Dead = Module ∉ Reachable(Entry) 摇树强度越大,死代码消除越彻底
何为摇树
小明的果园里除了苹果树,还有几棵老树一直不结果。他总算明白:树要养,但也不能让废枝废叶白白消耗养分。放在前端世界里,这个“养分”就是用户的带宽和设备的内存。一个 JS 文件里,可能有 30% 甚至更多的代码从来没有被执行过——它们可能是上个版本遗留的工具函数、被注释掉的业务逻辑、或者整包引入的库中从未用到的方法。这些代码就是“枯叶”,专业上叫做死代码(dead code)。
摇树优化要做的事情,正是这些:在打包阶段识别出哪些导出从来没有被引用,然后把这些代码从最终产物里剔除。死代码减少后,打包体积变小,浏览器下载和解析的时间都缩短,首屏加载自然更快。为什么体积如此重要?因为移动端网络的每一 KB 都可能意味着多几百毫秒的白屏,而用户的耐心往往只有三秒。
小明第一次摇树时,心里也没底:到底能省下多少?这有一个直观的衡量公式:
第一次摇完树,小明看着地上厚厚的落叶,忍不住感慨:原来这棵树上藏着这么多没用的枝条。而在前端工程里,Tree Shaking 每省下 1KB,都是给用户体验的账户里存了一笔真金白银。
- 节省率 V_saved指摇树后代码体积减少的百分比,是衡量优化效果的核心指标,类似小明清点后算出“少了多少筐空枝”。
- 摇后体积 V_shaken经过 Tree Shaking 和压缩之后的最终包体积,相当于地上只留下的好果子重量。
- 原始体积 V_original什么都没做时的打包体积,包括所有枯枝烂叶,是优化的起点。
- 比例与补数
V_shaken / V_original表示剩余部分占原来的几分之几,用 1 减去它,得到的就是被摇掉的比例。比值越接近 0,节省率越接近 100%。
静态分析
小明摇树前,会先翻一本果园账本,上面清清楚楚记录着:这根枝条属于哪棵果树、结了几个果子、哪个果子已经被预订(被 import 引用)。Tree Shaking 之所以能精准地只在“该摇的树”上动手,靠的也是类似的账本——模块依赖图。
账本能成立的前提,是 ES Module 的静态性。import 和 export 必须写在文件的顶层,不能包在 if 或函数里,也不能用变量拼接导入路径。这意味着构建工具在运行代码之前,光靠“读”就能确定谁依赖谁。于是它从入口文件出发,像巡园一样遍历每一根枝条,每遇到一条 import { add } from './utils',就给 utils 模块的 add 导出记一笔“被引用”。遍历结束后,哪些导出一次都没被记过,就进入了死代码清单。
这个过程可以抽象成一个集合公式:
那为什么 CommonJS 的 require 就不能摇?因为 const path = './a' + name; require(path) 这样的代码,路径是运行时才能算出来的。小明的账本全是空白,他根本不知道这捆乱藤通向哪里,自然不敢乱剪。所以业界才反复强调:想摇树,请优先使用 ES Module。
- 死代码集合 DD 是构建工具整理出的待删除清单,每一项都对应一段永远不会被运行的代码。
- 导出成员 ff 是模块中通过
export暴露出的函数、变量或类,相当于果园账本上挂着一颗果实的枝条。 - 全部导出集合 EE 是模块提供的所有可用成员的集合,覆盖了摇树机考察的全部范围。
- 引用计数 Ref(f)
Ref(f)统计整个项目中有多少条 import 真正用到了 f;等于 0 就代表这朵果子从来没被预订过。
副作用守卫
又一年秋天,小明正要挥剪子剪掉一片看似干枯的叶子,养蜂的老王拦住他:“别剪!那片叶子底下挂着我的蜂箱!”叶子虽然不结果,但它支撑着蜂箱,影响到明年的授粉。这就是“没有明面产出,却有潜在影响”的代码——专业术语叫副作用。
在 JS 里,副作用是指模块加载时对全局环境产生的修改:比如给 window 挂一个全局变量、给浏览器打 polyfill 补丁、或者引入一段 CSS。这类模块可能全程没有导出任何东西,但一旦删掉,整个应用都会崩溃。为了安全,构建工具需要开发者提供一份“体检表”——package.json 里的 sideEffects 字段。当它被声明为 false 时,等于告诉工具:“这个包里所有模块都是纯的、无副作用的,没被用到就大胆删!”也可以写成数组,只把有副作用的文件列进白名单。
更精细的场合,我们会用 /*#__PURE__*/ 注释标记纯函数,告诉压缩器这个函数调用可以安全移除。综合来看,一个模块能否被整个删除,取决于它是否被使用以及是否有副作用:
从那天起,小明的果园多了一条铁律:动手前先看体检表。对前端项目来说,正确配置 sideEffects 是摇树安全的第一道护栏——它决定了你能多大刀阔斧,又会不会误伤队友。
- 死模块集合 M_dead这是比死导出更狠的一层筛选——把整个模块文件都丢进回收站。
- 候选模块 m 与全集 Mm 是构建时考察的每一个模块文件,M 是所有模块组成的全集。
- 未使用谓词 unused(m)
unused(m)为真表示没有任何其他模块 import 过 m,相当于这捆枝条从来没被人预订过。 - 副作用为空
sideEffects(m) = \varnothing表示模块在执行时不会改动任何外部状态,剪掉它不会牵连任何“蜂箱”。
Webpack实践
小明攒够了钱,请来一台“自动摇树机”——这就是 Webpack。在 Webpack 的世界里,摇树不是默认随时开启的,只有在 mode: 'production' 生产模式下,它才会进入工作状态。因为开发模式下需要快速热更新、保留完整错误堆栈,摇树反而碍事。
配置中的 usedExports: true 是摇树的“标记环节”:Webpack 在静态分析阶段给每个被引用到的导出打上“有用”标签,没有标签的导出先做标注,随后交给压缩器动刀。minimize: true 则启用 TerserPlugin——它才是真正执行删除的刽子手,会把标记为未使用的代码连同注释、空格一起清掉。除此之外,Webpack 还会做一件很有意思的事:模块合并(scope hoisting),把多个模块内联到同一个作用域里。就像小明把几十个小筐合成一个大筐,减少了运输过程中的空耗。
在实际接入时有一个典型陷阱:如果你引入的是 CommonJS 格式的第三方包,比如老版本的 lodash,Webpack 面对动态 require 也无能为力。解决之道是换用 lodash-es 等 ESM 版本,或者依赖库自带的 module 字段。只要依赖本身是 ESM,自动摇树机就能顺畅运转,把 import { debounce } from 'lodash-es' 里没用的 cloneDeep、throttle 通通摇落。
// webpack.config.jsmodule.exports = { mode: 'production', optimization: { usedExports: true, minimize: true, },};
Vite实践
如果说 Webpack 是一台笨重但可靠的摇树机,那 Vite 就像开着电动马达的新式装备:又快又准。Vite 在生产构建时,底层借用了 Rollup 的能力,而 Rollup 从诞生第一天起就是“为摇树而生”的 ESM 打包器。所以在 Vite 项目中,vite build 默认开启 Tree Shaking,几乎不需要额外配置。
Vite 的第二个独门绝技是 esbuild 预构建。很多第三方库还是 CommonJS 老古董,Vite 会先用 esbuild 把它们打包并转换成 ESM 格式,再交给 Rollup 摇树。这个过程相当于小明把那些乱藤先理成整齐的枝条,再装上摇树机。对于大型图表库、日期库这类重资产,你还可以通过 manualChunks 手动分包,把体积最大的依赖单独拆出来利用浏览器缓存:
配合组件库的按需引入,比如在 Vue 项目中使用 unplugin-vue-components,Vite 会把你 import 的每个组件单独打包,其余组件直接留在库里不上车。想象一下,小明以前运果子要装一整卡车,现在按需只装客人点的那几筐——满载率反而更高了。
// vite.config.jsexport default { build: { rollupOptions: { output: { manualChunks: { echarts: ['echarts'] }, }, }, },};
死码消除
摇树是“整枝剪除”,但果园的死木头远不止整根枝条。有些树干内部早就空心腐烂,表面看不出问题,它们对应的是死代码消除(DCE)要处理的另一类情况。Tree Shaking 和 DCE 的关系就像大扫除和擦玻璃:一个管宏观清理,一个管微观细节。
死代码消除是编译器领域的经典优化技术,它处理的形态包括:不可达分支(if (false) { ... } 永远不执行)、常量折叠(const x = 2 * 3 直接算成 6)、以及冗余变量消除。这些优化能把代码中“逻辑上不会被跑到”的部分提前删除,往往和 Tree Shaking 协同作战。Terser 等压缩器在 Webpack 的摇树标记之后,还会顺手完成这一层清理:摇树负责删除「模块导出的死代码」,DCE 负责删除「函数内部的死代码」。
经过两轮修剪,最终产物体积可以写成:
小明的感受是:摇树让果园看起来清爽,DCE 让他连果子上的一层浮灰都擦掉了。两者配合,运出去的每一筐都是真正的精品果。
- 最终体积 V_final所有优化完成后浏览器实际下载的代码大小。
- 求和符号把每一份存活代码的大小累加在一起。
- 存活导出集合 E_liveR 里所有被引用的导出成员集合,相当于经历摇树和修剪后还稳稳挂在枝头的果实。
- 单个导出体积 size(f)每个导出成员自身代码的长度,累加起来就是产物总大小。
排查调优
果园经营到第三年,小明开始用账本复盘:每棵树的产出、每筐果子的重量、每车运输的成本,全部记录在案。前端体积优化同样需要“用数据说话”,而不是凭感觉乱摇。最常用的工具是 webpack-bundle-analyzer(Vite 项目可用 rollup-plugin-visualizer),它能把打包产物渲染成一棵可视化的矩形树,哪个包占了半壁江山一目了然。
下一步是检查未用导出。在代码里,我们经常把整库导入进来只用一两个方法,或者留着一堆 export 不清理。构建工具会给出警告,提示哪些导出被打上了 unused 标记。这时候要做减法:删除不再使用的函数,把 import * as utils 改成具名导入 import { formatDate } from './utils'。
第三类问题出在第三方库上。老牌的 moment.js 体积巨大且难以摇树,可以换成 dayjs;lodash 选用 lodash-es;图表库选用支持 ESM 的版本或配置按需引入。如果某个库实在无法摇,再考虑把它单独分包,利用 CDN 和浏览器缓存。
总结一份调优清单:项目入口使用 ESM 语法、package.json 正确声明 sideEffects、优先 ESM 第三方库、组件按需引入、用体积分析工具周期性体检。小明每年收获后都会对账:今年比去年少运了几车、多赚了几筐。前端工程师也一样——每一次摇树,都是在为用户的时间账户里多存进一毫秒。
AI 应用场景
大型中后台项目体积优化
阶段:构建优化与前端工程化
解决的问题:传统中后台系统为了省事,常常整包引入 UI 组件库和工具函数库,导致首屏 JS 动辄 5MB 以上。人工逐个查找无用依赖费时费力,而 Tree Shaking 配合按需引入能自动把未引用的组件与函数剔除。加上正确配置 sideEffects,构建工具能在不破坏全局副作用的前提下放心摇树,从源头压缩体积。
具体案例:某后台管理系统使用 Webpack 5,原先引入完整 antd 与 lodash,打包体积 5.2MB。团队改用 babel-plugin-import 按需引入组件,并将依赖换成 lodash-es、配置 sideEffects: false 后,体积降到 1.8MB,首屏加载从 3 秒缩短到 1.2 秒。
移动端 H5 首屏性能优化
阶段:性能调优与包体治理
解决的问题:移动端网络环境复杂,用户耐心有限。Vite 默认开启 Rollup 摇树,配合路由级动态 import() 自动分包,让首屏只加载当前页面需要的代码;再结合 manualChunks 将大体积图表库单独拆分,利用浏览器长缓存避免重复下载。
具体案例:某电商 H5 活动页用 Vite 构建,通过摇树和手动分包,首屏 JS 从 1.6MB 降至 420KB。页面在 4G 网络下的可交互时间从 4.5 秒降到 1.8 秒,活动转化率提升了约 15%。
开源组件库发布配置
阶段:开源维护与生态建设
解决的问题:开源作者发布 npm 包时,如果不声明 sideEffects 和 ESM 入口,使用方就难以摇树,导致用户被迫下载全部代码。在 package.json 中同时配置 "module" 字段指向 ESM 构建产物、"sideEffects": false(或列出样式文件白名单),可以让下游项目自动获得最佳包体。
具体案例:某 Vue 组件库在 v2 版本发布时,补充了 "sideEffects": ["*.css"] 配置并输出 ESM 格式。升级后,使用方的构建体积平均减少 35%,GitHub 上 star 数和社区口碑明显提升。
Python 演示
# -*- coding: utf-8 -*-"""模拟 Tree Shaking: 从入口遍历模块图, 删除未被引用的模块与导出.""" MODULES = { "entry": { "exports": [], "imports": [("utils", "add"), ("utils", "sub")], "size": 10, }, "utils": { "exports": ["add", "sub", "mul", "div"], "imports": [], "size": 120, "side_effects": False, }, "debug": { "exports": ["log", "trace", "assert"], "imports": [], "size": 80, "side_effects": False, }, "polyfill": { "exports": [], "imports": [], "size": 60, "side_effects": True, },} def collect_live(mods, entry): live_mods = {entry} live_exports = {} stack = [entry] while stack: name = stack.pop() mod = mods[name] used = set() for target, symbol in mod.get("imports", []): live_mods.add(target) used.add(symbol) stack.append(target) live_exports[name] = used return live_mods, live_exports def shake(mods, entry): live_mods, live_exports = collect_live(mods, entry) dead_mods = [ name for name, mod in mods.items() if name not in live_mods and not mod.get("side_effects", False) ] dead_exports = {} saved_size = 0 for name in dead_mods: saved_size += mods[name]["size"] for name, used in live_exports.items(): mod = mods[name] unused = set(mod.get("exports", [])) - used if unused: dead_exports[name] = sorted(unused) saved_size += 10 * len(unused) return dead_mods, dead_exports, saved_size if __name__ == "__main__": dead_mods, dead_exports, saved = shake(MODULES, "entry") print("死模块:", dead_mods) print("每个模块的死导出:", dead_exports) print("可节省代码量:", saved)
collect_live(mods, entry)从入口开始 BFS 遍历,把所有可达模块标记为“存活”。for target, symbol in mod.get("imports", [])逐个读取模块的 import 列表,相当于构建工具在读依赖关系。used.add(symbol)记录某模块中被真正用到的导出符号,这是判断死导出的关键。if name not in live_mods and not mod.get("side_effects", False)同时满足“不可达”和“无副作用”的模块才能整个删除。set(mod.get("exports", [])) - used用全部导出减去已使用导出,差集就是死导出。saved_size += mods[name]["size"]累加被删除模块的代码量,相当于果园里称一称枯枝的重量。if __name__ == "__main__":脚本入口,运行后打印死模块、死导出和可节省的代码量。
常见误区
误区我以为只要 `mode: 'production'` 一开,所有没用的代码都会自动消失。可项目打包完体积还是很大,是不是 Webpack 坏了?
正解Webpack 的摇树只对 ES Module 生效。如果你的依赖混入了 CommonJS 或 UMD 格式,比如老版本 lodash、moment.js,require 是运行时动态解析的,构建工具无法静态判断,摇树机就会“空转”。必须换成 ESM 版本(如 lodash-es)或先用 esbuild 预构建转成 ESM,摇树才能真正生效。
为何常见:宣传材料总说“生产模式自动摇树”,大家就误以为是包治百病;实际上“自动”只是省去手动配置,前提条件依然严格。
误区我在 package.json 里写上 `"sideEffects": false` 后,打包体积确实小了,但页面样式却乱了,有些全局功能也消失了。
正解sideEffects: false 是向构建工具承诺“这个包里的模块都没有副作用,不用就可以删”。如果包里有 CSS 样式、polyfill 补丁、或修改原型的代码,它们全是副作用来源。正确做法是用数组声明例外,比如 "sideEffects": ["*.css"],或者确保模块真的纯净。小明的体检表不能乱填,填错了明年就颗粒无收。
为何常见:看到配置项名字简单,就以为默认都该写 false;忽视副作用的概念,是前端新人最常见的翻车点。
误区压缩器不是把代码体积减小了吗?那 Tree Shaking 和 minify 应该是同一件事吧?
正解两者分工完全不同。Tree Shaking 在模块与导出层面做静态分析,删除“整根没被引用的枝条”;压缩(minify)是在代码内部做局部优化,比如缩短变量名、删除空格和不可达分支。实际流程中,Webpack 先用 usedExports 标记,再交给 Terser 压缩删除,两者是流水线上的上下游关系。
为何常见:从效果上看都是“代码变少”,不看构建过程很难分清,于是容易混为一谈。
误区听说 Tree Shaking 依赖静态分析,那 `import('./page')` 这种动态导入是不是就完全没法摇树了?
正解恰恰相反。动态 import() 会把代码拆成单独的 chunk,每个 chunk 内部依然会做静态分析和摇树;而且路由级拆分让首屏只加载当前页面需要的代码,是重要的优化手段。真正不能摇的是 CommonJS 的 require(变量),因为路径运行时才可知。
为何常见:把“静态分析”理解成“所有 import 都必须在顶部”,忽略了动态 import 天然的分包优势。
误区我把 `import * as utils from './utils'` 当成方便之门,反正工具库内部会被摇树吧?
正解使用命名空间导入后,构建工具往往无法确定你到底用了 utils 的哪个成员,为了安全只能把整个模块保留,摇树效果大打折扣。正确的做法是具名导入:import { add } from './utils',让引用关系明明白白,就像小明一样清楚地知道哪根枝条被预订了。
为何常见:import * as 写起来省事,让人误以为省事和高效可以兼得;实际它模糊了引用边界,牺牲了优化空间。
面试题
Q1. Tree Shaking 能生效,最核心的前提是什么?
- A.支持 CommonJS 的 require 动态加载
- B.依赖 ES Module 的静态 import/export← 正确答案
- C.使用 AMD 的 define 异步加载
- D.借助 Node 的 module.exports 导出
答案:B。Tree Shaking 依赖 ES Module 的静态性——import/export 必须在顶层、路径不能是变量,构建工具才能在不执行代码的情况下画出依赖图。CommonJS 的 require 是运行时动态的,无法静态分析,所以 A、D 都不行;AMD 偏重异步加载,同样不提供摇树所需的结构。
Q2. package.json 中哪个字段告诉构建工具“这个模块没有副作用,没被用到可以直接删”?
- A."main" 字段指定入口文件
- B."module" 字段指定 ESM 入口
- C."sideEffects" 字段声明模块副作用← 正确答案
- D."exports" 字段限制模块访问
答案:C。sideEffects: false 是给打包器的安全承诺,声明模块无副作用。main 和 module 只是指定入口格式,exports 控制模块的可访问路径,都与副作用无关。这就像果园体检表上“此枝无蜂箱”的标记,有了它才能放心剪枝。
Q3. 以下哪种代码无法被 Tree Shaking 静态分析识别和删除?
- A.import { add } from "./utils.js"
- B.const p = "./a.js"; require(p)← 正确答案
- C.export function add() {} 且无人引用
- D.import("./page.js") 动态按需加载
答案:B。require(p) 的路径是变量 p,值在运行时才确定,构建工具无法静态判断它指向哪个模块,所以不能摇树。A 是标准 ESM 具名导入可分析;C 是典型的死导出可删除;D 的动态 import 可以分包,chunk 内部依然能摇树。
Q4. 项目配置了 "sideEffects": false,以下哪种问题可能发生?
- A.打包速度会明显变慢
- B.全局样式与 polyfill 被误删← 正确答案
- C.生成代码无法被压缩
- D.模块合并逻辑完全失效
答案:B。如果模块实际有副作用(引入 CSS、打 polyfill、修改全局对象),却声明为无副作用,构建工具会当作“没有蜂箱的枯枝”直接剪掉,导致样式丢失、功能异常。正确做法是用数组列出有副作用的文件,如 "sideEffects": ["*.css"]。
Q5. Webpack 中 optimization.usedExports 的作用是什么?
- A.把未被引用的导出标记出来,供压缩器删除← 正确答案
- B.自动删除所有 console 调试语句
- C.将所有模块合并成一个巨大文件
- D.把 CommonJS 自动转换为 ES Module
答案:A。usedExports: true 是摇树的“标记环节”,它只负责给被引用的导出打上“有用”标签,真正执行删除的是 Terser 压缩器。B 是 console 专项清理工具的职责,C 是 scope hoisting 的效果,D 需要借助 esbuild 等额外工具。
Q6. 在代码中写 /#PURE/ 注释,其作用是什么?
- A.标记函数无副作用,可放心摇树删除← 正确答案
- B.强制该函数参与模块合并
- C.禁止构建工具对该函数进行压缩
- D.把函数作为入口模块单独打包
答案:A。纯函数注释是给压缩器(Terser)看的承诺:这个函数调用不会产生副作用。如果该调用的返回值没被使用,就可以直接移除。它解决的是“函数体内不可达代码”级别的清理,比模块级 sideEffects 更精细。
Q7. 以下哪些做法能有效减小 Vite 项目的打包体积?
- A.使用 ESM 版本的第三方库← 正确答案
- B.正确配置 sideEffects 字段← 正确答案
- C.用 require 引入所有依赖
- D.对组件库进行按需引入← 正确答案
答案:ABD。Vite 基于 Rollup 摇树,ESM 依赖能被分析,sideEffects 配置保证删除安全,按需引入避免整包带入,三者都促进体积优化。C 的 require 是 CommonJS 动态加载,会阻断静态分析,与优化目标背道而驰。
Q8. Webpack 的 scope hoisting(模块合并)带来的主要好处是什么?
- A.将多个模块合并为更少的闭合函数← 正确答案
- B.把所有代码强行压缩成一行
- C.删除所有未被引用的 CSS 类名
- D.自动分析 JSON 数据依赖
答案:A。scope hoisting 会把小模块直接内联进引用它的模块,减少闭包和函数声明的数量,既降低代码体积又提升运行效率。它和压缩不同,不是简单地变成一行;同时它不负责 CSS 和 JSON,属于纯 JS 模块层的优化。
Q9. 关于 CommonJS 与 Tree Shaking,以下说法正确的是?
- A.require 是运行时动态的,无法静态分析← 正确答案
- B.构建工具对 CJS 包照样能完美摇树
- C.换成 ESM 版本或用 esbuild 预构建后可摇← 正确答案
- D.CJS 模块内完全没有死代码存在
答案:AC。CJS 的模块路径可以拼接变量,构建工具无法在编译期确定依赖关系,因此不能摇树。解决方式是改用 ESM 版本,或用 esbuild 预构建把 CJS 转成 ESM 后再分析。CJS 模块内部当然也存在死代码,只是工具删不掉罢了。
Q10. 以下哪些情况会导致 Tree Shaking 失效或受阻?
- A.模块存在未标记且不可判定的副作用← 正确答案
- B.使用具名 import 导入具体成员
- C.使用 import * as 命名空间批量导入← 正确答案
- D.配置 sideEffects 为 false 且确无副作用
答案:AC。未标记的副作用让工具不敢删除,命名空间导入让工具看不清具体用了哪些成员,都会阻碍摇树。具名导入是摇树的最优姿势,而确无副作用的 sideEffects: false 是强力加速器。A 和 C 是实际项目中摇树失效的两大主因。