Tree Shaking
Tree Shaking 是构建时根据代码的使用关系,移除可以安全省略的声明或模块的优化。它要同时回答两个问题:这段代码的结果是否还会被用到,以及省略它的执行会不会改变程序行为。
这决定了为什么一个未使用的导出可以消失,而同一文件里的一次全局注册仍需要保留。理解 ESM 的导入、导出与求值关系是前提,可以先阅读 ES Module 与 CommonJS。本文用 webpack 5 演示构建结果,具体优化形态不作为所有工具和版本的承诺。
1. 面试核心
先记住这几条,再读正文- Tree Shaking 利用入口和使用关系识别不需要的代码。没有被使用只是候选条件,删除还必须保持所承诺的可观察行为。卡 · 使用关系与安全删除
- ESM 的导入导出结构便于静态分析。关键是构建器实际收到的模块形态;源码写了 import,提前转换后仍可能失去部分分析条件。卡 · 模块形态与分析边界
- 副作用是执行过程中对外部可观察状态产生的影响。导出没有被使用,不代表模块初始化没有作用;注册监听器、安装 polyfill 和导入样式都要单独判断。卡 · 副作用与模块求值
- webpack 的
usedExports分析导出是否使用,sideEffects帮助判断模块是否可跳过。标记未使用、跳过模块和压缩删除是相关但不同的工作。卡 · webpack 的分析与删除 sideEffects: false是关于模块求值的约定。它不是关闭所有副作用的运行开关,也不代表导出的每个函数都没有副作用;错误声明可能让生产产物缺少功能。卡 · 副作用与模块求值/*#__PURE__*/标记紧随其后的调用或构造表达式可在结果无用时省略。它不会自动把参数表达式也变成无副作用,不能拿来强行删除有用的注册行为。卡 · 纯调用标注- 静态分析存在边界。动态属性选择、对象整体逃逸或运行时构造接口,可能迫使工具保留更多代码;导入命名空间不必然导致全部保留。卡 · 命名空间与动态访问
- 验证需要同时看产物和功能。包变小不能单独证明优化正确,分包也不等于删除代码。卡 · 综合题
2. 使用关系与安全删除
构建从入口开始,沿导入关系找到依赖,再分析哪些导出、声明和表达式参与最终行为。这种分析需要在运行应用之前完成,称为静态分析。
下面把代码分别保存为 math.mjs 和 main.mjs:
// math.mjs
export function double(value) {
return value * 2;
}
export function unusedLabel() {
return "UNUSED_LABEL";
}
// main.mjs
import { double } from "./math.mjs";
console.log(double(21)); // 42
double 的返回值参与日志输出,必须保留等价行为。unusedLabel 从未被使用,它的函数体也没有在模块求值时执行,所以整个函数有机会被删除。压缩器还可能把 double(21) 计算成常量,最终不保留名为 double 的函数;这也是合法优化,因为需要保留的是输出 42 的行为,而不是源码中的每个函数名字。
从这个例子可以看出两个层次:跨模块分析帮助确定哪些导出被使用;语句级优化进一步判断哪些表达式、分支和调用可以去掉或简化。工程中经常把它们一起称为 Tree Shaking,但排查时区分职责更容易找到原因。
压缩同样可能缩短变量名、合并表达式或删除不可达分支。它不只是删空格;Tree Shaking 则特别强调基于使用关系去掉不需要的代码,两者可以协作。
3. 副作用与模块求值
副作用是执行某段代码时,除了产生供调用方使用的结果,还改变了外部可观察状态。例如写全局对象、输出日志、发起请求、修改 DOM、注册事件或定时器,都可能产生副作用。
副作用是否发生,要看执行时机。声明一个会修改页面的函数,并不等于此刻已经修改了页面;顶层直接调用它,则是在模块求值时发生变化。
// register.mjs
globalThis.featureReady = true;
// main.mjs
import "./register.mjs";
console.log(globalThis.featureReady === true); // true
register.mjs 没有导出,仍然不能直接删掉,因为 main.mjs 依赖它的初始化。这里的副作用导入明确表达了“执行该模块”的需要。
换成真实项目,类似行为可能是初始化错误监控、补齐某个运行环境 API、注册插件或应用 CSS。若把这些依赖删掉,应用可能成功构建,却在运行时缺功能。
3.1 模块副作用与函数副作用
一个模块可以在求值时只定义函数,因此没有需要保留的顶层副作用;它导出的函数被调用后,仍然可以发请求或修改 DOM。
// logger.mjs
export function logMessage(message) {
console.log(message);
}
// main.mjs
import { logMessage } from "./logger.mjs";
logMessage("ready"); // ready
声明 logger.mjs 的求值可以在无人使用时跳过,不代表调用 logMessage("ready") 可以删除。调用结果虽然是 undefined,日志仍是可观察行为。区分模块初始化和函数调用,才能正确理解下一节的配置。
3.2 无法证明无副作用时
工具需要谨慎处理不确定的操作。一个属性读取可能触发 getter,一次函数调用可能修改外部状态,构造类也可能执行构造器或静态初始化。仅凭名称像 createConfig 或返回值没人用,不能证明执行无影响。
静态分析也可以识别部分实现,从而证明某次操作可删除。不能反过来断言“出现函数调用就一定保留”。最后保留多少,取决于可见信息、分析能力和配置所承诺的语义。
4. webpack 的分析与删除
webpack 5 中,几项配置的职责不同:
| 配置或阶段 | 主要职责 | 不能单独保证的事情 |
|---|---|---|
optimization.usedExports | 分析导出是否被使用,为后续优化提供信息 | 不保证所有未使用声明已经从最终文本删除 |
optimization.sideEffects | 使用模块副作用信息,决定能否跳过相关模块与依赖 | 不证明作者声明一定正确 |
压缩器与 optimization.minimize | 结合使用信息分析、简化并删除语句 | 不会在所有动态行为下都得到最小结果 |
mode: "production" | 启用适合生产的默认优化组合 | 不保证错误的副作用标注仍能保持功能 |
为了观察第二节的示例,可以在已经安装 webpack 5 的独立实验目录中保存以下 build.cjs,然后运行 node build.cjs:
// build.cjs
const path = require("node:path");
const webpack = require("webpack");
const compiler = webpack({
mode: "production",
context: __dirname,
entry: "./main.mjs",
output: { path: path.join(__dirname, "dist"), filename: "bundle.js" },
});
compiler.run((error, stats) => {
compiler.close((closeError) => {
if (error || closeError || stats?.hasErrors()) {
console.error(error || closeError || stats.toString({ all: false, errors: true }));
process.exitCode = 1;
return;
}
console.log("build complete");
});
});
构建成功后,dist/bundle.js 执行时仍输出 42。检查生成的 JavaScript,可以观察未使用的 UNUSED_LABEL 是否还存在。具体变量名、函数是否内联,以及文件大小可能不同,不应把某一段压缩文本当成标准答案。
若为了观察中间状态关闭压缩,产物里可能仍能看见未使用函数。此时需要同时看导出使用信息和压缩阶段,不能只因函数文本还在就认定依赖分析没有工作。
5. sideEffects 声明
webpack 可以读取包的 package.json 中的 sideEffects 字段。false 表示作者声明这个包中的模块在相关导出不需要时,可以跳过其求值。工具可利用这条约定省去整个模块,减少逐条分析的工作。
它不会在运行时禁止副作用,也不会自动检查并纠正虚假的声明。对第三节的注册示例错误设置 sideEffects: false,构建器就可能跳过 register.mjs,导致日志从 true 变成 false。
包含初始化文件和样式的包,应按实际发布路径声明例外。例如下面只是一个包的字段片段,合并到该包真实的 package.json:
{
"sideEffects": ["./src/register.mjs", "**/*.css"]
}
匹配项表示这些文件的求值可能有副作用,需要按依赖使用情况保留;未匹配的文件则按可跳过求值处理。路径要对应实际消费到的包内容。若发布的是 dist/,却只写了不存在于发布包中的 src/ 路径,这份声明就不能保护实际入口。
数组也不是“把这些文件无条件打进每个产物”的白名单。一个组件整体未被引用、其模块也可以跳过,那么只有这个组件依赖的 CSS 不一定需要被纳入;保留副作用文件仍然建立在相关依赖被访问的前提上。
对有样式的组件库,至少要验证两个方向:只导入一个组件时,它需要的样式仍在;没有使用的其他组件及其私有依赖可以被省去。不能只验证整个组件库全部导入时页面正常。
6. 纯调用标注
/*#__PURE__*/ 是构建工具和压缩器识别的一种标注,用来说明紧随其后的调用或构造表达式本身没有需要保留的副作用。当结果没有被使用时,这次调用可以省略。
function createOptions() {
return { timeout: 1000 };
}
const unused = /*#__PURE__*/ createOptions();
console.log("ready"); // ready
unused 没有被读取,createOptions() 只创建局部对象,因此省略它不影响日志。这个简单函数也可能被工具自行分析并删除;标注的价值在于向工具提供它不一定能自行证明的信息,不是让每一处代码都机械加注释。
标注不自动覆盖参数表达式:
function buildOptions(value) {
return { value };
}
function readValue() {
console.log("read");
return 1;
}
const unused = /*#__PURE__*/ buildOptions(readValue());
console.log("ready");
即使 buildOptions 调用结果无用,readValue() 的日志仍需要保留。优化前后都应依次输出 read、ready。函数调用是否可删、实参求值是否可删,是不同判断。
若给一个真正会注册事件或安装补丁的调用添加错误标注,工具可以依据这条信息删掉它,程序就会改变。标注是作者提供的事实,不是降低体积的万能开关,也不是 JavaScript 引擎的执行语法。
7. 模块形态与分析边界
ESM 的静态导入导出让工具在执行程序前就能建立明确关系。CommonJS 可以在运行时按条件调用 require()、整体替换 module.exports 或动态增减属性,分析通常更受限制,但工具仍可能识别常见形式,不能说它完全无法优化 CommonJS。
7.1 转换链路
需要检查的是构建器实际收到什么。Babel 等转换步骤如果提前把 ESM 变成 CommonJS,后续工具可能失去部分静态结构。@babel/preset-env 的 modules: false 可以保留 ESM;默认 auto 则会结合调用方传入的能力信息决定是否转换,不能一概认定默认配置必然破坏 Tree Shaking。
还要检查依赖包最终选择的入口。项目源码使用 ESM,不代表安装的每个依赖也通过 ESM 入口被消费。重复安装多个版本、包的条件导出和转译配置,都可能让实际依赖图与预想不同。
7.2 命名空间与动态访问
import * as math from "./math.mjs" 不一定导致所有导出保留。如果只读取可静态确定的 math.double,工具仍可能知道只需要哪个导出。
如果运行时用 math[name] 访问,而 name 来自无法提前确定的输入,工具就需要考虑多个候选成员。把整个命名空间交给未知函数、枚举所有键,也可能要求保留更多导出。对象逃逸是指对象被传到当前分析无法充分控制的位置,工具难以继续证明后续只访问某个成员。
同理,一个默认导出的巨大对象,不一定能被像多个独立命名导出那样精确拆开;但是否保留全部仍取决于实际使用方式与工具能力。模块接口应先服务职责,再通过真实产物判断是否需要调整入口,不能为了形式统一就规定“禁止默认导出”或“必须深路径导入”。
8. 产物验证与代码分包
代码分包把代码组织到不同资源中,使一些资源可以晚些下载或复用。Tree Shaking 则省略不需要的代码;一个未被删除的功能即使进入异步 chunk,仍然存在于产物里。
判断体积收益时,要先说明目标是首屏下载量、一次交互的增量,还是全部资源总量。把代码移到异步 chunk,可能减少首屏资源,却没有减少总代码量。gzip 或 Brotli 进一步减少传输字节,也不等于减少相同比例的解析与执行工作。
一次有效的验证可以按下面的顺序进行:
- 明确入口、构建模式和消费到的包版本,使用与目标部署一致的生产配置。
- 查看模块与导出使用信息,确认未使用模块的引入路径,检查 Babel 等上游转换结果。
- 检查生成的 JavaScript、CSS 和所有相关 chunk,必要时用统计文件或产物分析器定位保留原因,不只搜索可能已经改名的函数名。
- 运行最小消费场景,验证初始化、样式、插件注册和异步入口的实际行为,再比较相同口径的资源体积。
例如,给注册示例设置错误的 sideEffects: false 后,包可能更小,但日志变成了 false。这不是成功优化,而是证据表明配置声明与程序行为不一致。应修正声明或初始化设计,再重新检查产物和功能。
9 · 面试题卡片
本页题目与参考回答
9.1 Tree Shaking 的基本原理是什么?
考察重点:入口、使用关系、静态分析与行为保持。
相关内容:使用关系与安全删除。
参考回答
构建器从入口分析依赖和使用关系,识别不需要的声明或模块,再在不改变约定行为的前提下删除。 ESM 的静态结构便于识别导出使用情况,语句级优化继续处理表达式、调用和分支。
未使用不自动等于可删除。代码的求值若有必须保留的副作用,就要保留相应行为;判断的是最终行为,不是必须保留原变量名或函数形态。
9.2 ESM 为什么有利于 Tree Shaking,CommonJS 就完全不能优化吗?
考察重点:静态结构、动态接口、转换链路与工具能力。
参考回答
ESM 在执行前就提供明确的导入和导出关系,CommonJS 的加载位置和导出形态可以在运行时变化。 后者更难分析,但工具可以识别部分常见模式,不能断言完全不能优化。
还要检查构建器实际收到的格式。上游转译、依赖入口选择和 Babel 的 modules 配置都会影响静态结构;源码写了 import,不足以证明完整链路保留了 ESM。
9.3 sideEffects: false 表示什么,为什么可能导致样式或初始化丢失?
考察重点:模块求值、副作用导入、函数调用与路径声明。
相关内容:副作用与模块求值、模块副作用与函数副作用、sideEffects 声明。
参考回答
它声明相关模块在导出不需要时可以跳过求值,不是运行时禁用副作用,也不是声明所有导出函数都纯净。 模块如果靠导入时安装补丁、注册插件或应用 CSS,错误标为无副作用就可能被省去。
应按实际发布路径把需要保留求值的文件写入 sideEffects 数组,并验证最小消费场景。数组不意味着文件无条件进入所有产物;未被使用组件的私有依赖仍可能被跳过。
9.4 usedExports、sideEffects 与压缩删除有什么区别?
考察重点:导出使用信息、模块级判断与语句级优化。
相关内容:webpack 的分析与删除。
参考回答
usedExports 分析导出使用情况,sideEffects 帮助判断模块求值是否可以跳过,压缩器进一步简化和删除语句。 三者可以协作,但不是同一个开关。
只标记未使用,不保证未压缩产物已经删掉函数文本。生产模式启用默认优化组合,也不负责纠正作者错误的副作用声明;排查时要同时看分析结果、删除阶段和运行功能。
9.5 PURE 标注能否把参数求值中的副作用一起删除?
考察重点:调用本身、实参求值、结果使用与标注准确性。
相关内容:纯调用标注。
参考回答
不能;标注针对紧随其后的调用或构造表达式本身,参数表达式仍要单独分析。 /*#__PURE__*/ buildOptions(readValue()) 的结果无人使用时,buildOptions 可以省略,但 readValue() 中的必要日志仍应执行。
标注也不自动证明函数纯净。给有业务副作用的调用错误标注,可能造成生产行为缺失;它只是工具读取的信息,不改变 JavaScript 原生执行规则。
9.6 使用命名空间导入或默认导出对象,一定无法 Tree Shaking 吗?
考察重点:静态属性访问、动态选择、对象逃逸与分析边界。
相关内容:命名空间与动态访问。
参考回答
不一定,关键在于工具能否确定实际使用了哪些成员。 math.double 这样的固定访问可能被精确分析;运行时计算的 math[name]、枚举键或把整体对象交给未知代码,则可能要求保留更多成员。
默认导出的大对象也受同类限制,但不同工具的分析能力不同。应检查实际产物和调用方式,不能把某种导入语法直接等同于全部保留。
9.7 怎样确认 Tree Shaking 有效,为什么只看包变小还不够?
题型:综合题
考察重点:生产配置、模块图、资源口径与功能回归。
相关内容:产物验证与代码分包、sideEffects 声明。
参考回答
需要同时证明不需要的代码被省去,以及需要的初始化、样式和业务行为仍然正确。 使用生产构建检查模块来源、导出使用信息和全部相关产物,再运行最小消费场景。
比较体积时说明是首屏、增量还是总资源。异步分包可能只是移动代码,传输压缩也有不同口径;错误删除注册代码同样会变小,所以体积数字不能代替行为验证。
10. 参考资料
- webpack:Tree Shaking
- webpack:Optimization
- webpack:Node Interface
- webpack:Code Splitting
- Babel:@babel/preset-env 的 modules 选项
- Terser:Annotations
- Rollup:Tree-shaking options
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明