深入理解 Webpack 的 Tree Shaking
致读者
本译文在忠于原文的基础上,融入了译者基于自身领域知识所添加的解释性内容和本地化表达。这些精心添加的内容旨在增进读者对原文核心信息的理解。如对内容有任何疑问,欢迎您参与评论区讨论,或查阅原文以作参考。
这篇文章主要关注于理解 webpack
的 tree shaking
概念,并非深入探讨其底层代码是如何实现。代码示例可以在这里找到,代码仓库。
webpack
的 tree shaking
实现是具有挑战性的,其中会涉及到多种与 tree shaking
相关的优化方式之间的协同工作。webpack
对 tree shaking
这个术语的使用有些不一致,通常广泛地指代用于消除无用代码的优化。tree shaking
被定义为:
Tree shaking
is a term commonly used in theJavaScript
context for dead-code elimination. It relies on the static structure ofES2015
module syntax, i.e.import
andexport
. The name and concept have been popularized by theES2015
module bundlerrollup
.
tree shaking
是一个在javascript
上下文中常用的术语,用于消除无用代码。它依赖于ES2015
模块语法的静态结构(例如,import
和export
),这个名称和概念由ES2015
模块打包工具rollup
推广。
在某些上下文中,像 usedExports
优化被统称为 tree shaking
和 sideEffects
。
The
sideEffects
andusedExports
(more known astree shaking
) optimizations are two different things.
sideEffects
和usedExports
(更广为人知的称为tree shaking
)是两种不同的优化方式。
为了避免对 tree shaking
的理解产生歧义,这篇讨论将不专注于 tree shaking
本身,而是关注于 webpack
的 tree shaking
特性的各种优化方式。
webpack
中的 tree shaking
特性主要涉及如下三种优化方式:
usedExports
优化:这涉及从模块中移除未使用的导出变量,从而进一步消除相关的无副作用语句。jsimport { a } from './a'; console.log(a);
js// further eliminate relevant side-effect-free statements. function unused() { return 1; } // unused export export const a = unused; // used export export const b = 2;
sideEffects
优化:这会从模块图中移除未使用导出变量的模块。js// sideEffects: true import { a } from './a'; console.log(a);
js// sideEffects: true export const a = 1;
js/** * sideEffects: true * ⚠️ Even if the module is set to sideEffects:true, * it is unreachable in the application, so the side effects of * the module cannot be triggered. */ export const b = 3; console.log(b);
DCE
(无用代码消除)优化:这通常由压缩工具来进行实现,通过压缩工具来移除无用代码。功能也可以如webpack
的ConstPlugin
类似的工具实现,不过压缩工具会进行更深入的解析及其优化。
上述的优化涉及在 不同维度 上操作:
usedExports
涉及的维度是 模块的导出变量 的tree shaking
优化。sideEffects
涉及的维度是 整个模块 的tree shaking
优化。DCE
涉及的维度是 模块中的javascript
语句 的tree shaking
优化。
考虑以下示例,其中 main.js
作为 入口模块:
import { a } from './lib';
import { c } from './util';
import './bootstrap';
console.log(a);
export const a = 1;
export const b = 2;
export const c = 3;
export const d = 4;
console.log('bootstrap');
if (false) {
console.log('bad');
} else {
console.log('good');
}
- 在
lib.js
模块中,变量b
未被使用,那么b
变量所关联的代码会通过usedExports
优化而在最终产物中被移除。 - 在
util.js
模块中,没有导出变量被使用,那么util
模块会通过sideEffects
优化而在最终产物中被移除。 - 在
bootstrap.js
模块中,if
语句的决策逻辑永远为false
值,那么相关的代码块console.log('bad')
语句会通过DCE
优化而在最终产物中被移除。
上述的优化特性虽然是被独立实现的,但在 tree shaking
期间,相互之间会相互影响。接下来,详细介绍这些优化以及如何相互影响。
DCE
优化
在 webpack
中,DCE
相对简单,有两个重要的场景:
False 分支优化
if (false) {
false_branch;
} else {
true_branch;
}
在这里,因为 false_branch
永远不会执行,所以可以直接移除。移除 false_branch
可能会产生两个影响:
- 减少最终产物的大小。
- 影响引用关系。
考虑以下示例:
import { a } from './a';
if (false) {
console.log(a);
} else {
}
在未实施 DCE
的前提下,由于 false_branch
未被移除,那么变量 a
将被视为已使用。
当 DCE
起作用,false_branch
被移除后,那么变量 a
将被视为未使用。这就间接影响到 usedExports
和 sideEffects
的分析,也就是说仅通过 usedExports
和 sideEffects
两种优化特性所优化后的代码并不彻底,需要再通过 DCE
来进一步移除。
为了解决这个问题,webpack
提供了两次 DCE
的机会:
- 通过
parse
阶段的ConstPlugin
,执行基本的DCE
来尽可能多地判断模块的导入和导出引用被其他模块使用的情况,从而增强后续的sideEffect
和usedExport
优化。 - 通过
processAssets
阶段的Terser
的minify
进行更复杂的DCE
,主要旨在减少代码大小。
Terser
插件的 DCE
优化更耗时且复杂,而 ConstPlugin
插件相比则较为简单。例如,Terser
插件处理以下 false_branch
可以成功移除,但 ConstPlugin
插件可能无法处理。
function get_one() {
return 1;
}
const res = get_one() + get_one();
if (res != 2) {
console.log(c);
}
未使用的顶层语句
在模块中,若顶层语句未被导出或导出变量未被消费,同时模块中不存在顶层副作用语句,那么这个模块被视为无副作可以被安全移除。
function add(a: number, b: number) {
return a + b;
}
/**
* module is not exported or the exported variable is not consumed.
*
* not top level side-effect statements
* e.g. console.log('hello')
*/
例如,以下的 b
和 test
可以安全删除(假设这是一个模块而不是脚本,因为脚本会污染全局作用域,无法安全移除)。
// index.js
export const a = 10;
const b = 20;
function test() {}
webpack
的 usedExports
优化利用了这一特性来简化其实现。
usedExports
优化
webpack
的 usedExports
优化使用依赖的活动状态来确定模块内的变量是否被使用。之后,在生成代码阶段,若导出变量未被使用,它不会生成对应的导出属性,使得依赖于这些导出变量的代码段成为无用代码段。通过后续的压缩进一步辅助 DCE
优化。
webpack
通过 optimization.usedExports
配置项来启用 usedExports
优化。考虑以下示例:
import { a } from './lib';
console.log({ a });
export const a = 1;
export const b = 2;
在 webpack
未启用 tree shaking
的情况下,输出产物中会包含关于 b
的信息:
var __webpack_modules__ = [
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, {
a: () => a,
b: () => b
});
const a = 1;
const b = 2;
}
];
可见 b
的引用信息并未被 webpack
移除。
当 webpack
启用了 optimization.usedExports
优化时,b
的导出将会被移除。
但需要注意的是与 b
相关的代码段 const b = 2
仍然存在。由于 b
未被使用,const b = 2
将被视为 无用代码段。
/***/ (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) => {
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ a: () => /* binding */ a
/* harmony export */
});
/* unused harmony export b */
const a = 1;
const b = 2;
/***/
};
那么后续可以通过启用 代码压缩 来移除上述阶段产生的 无用代码段。
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.d(__webpack_exports__, {
a: () => a
});
const a = 1;
},
(__webpack_module_cache__ = {});
还需要注意的是,分析 b
是否被使用并不总是那么简单。考虑以下情况:
import { a, b } from './lib';
console.log({ a });
function test() {
console.log(b);
}
function test1() {
test();
}
export const a = 1;
export const b = 2;
上述例子中,函数 test
使用了变量 b
。
webpack
构建后的产物中可以发现 b
并没有直接被移除,这是因为 webpack
的 默认策略 并不执行 深度静态分析。换句话说,即使 test
没有被使用,b
没有被使用,webpack
也未能推断出是否可以安全删除这些 无用代码段。
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.d(__webpack_exports__, {
a: () => a,
b: () => b
});
const a = 1,
b = 2;
},
(__webpack_module_cache__ = {});
幸运的是,webpack
提供了另一个配置项 optimization.innerGraph
。它允许对代码进行更深层次的静态分析。那么 webpack
就可以判断 b
变量并未被使用,可以安全将 b
变量移除:
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ a: () => /* binding */ a
/* harmony export */
});
/* unused harmony export b */
const a = 1;
const b = 2;
/***/
};
DCE
也影响 usedExports
优化。
考虑以下情况:
import { a, b, c } from './lib';
console.log({ a });
if (false) {
console.log(b);
}
function get_one() {
return 1;
}
const res = get_one() + get_one();
if (res != 2) {
console.log(c);
}
export const a = 1;
export const b = 2;
export const c = 3;
通过 webpack
内部的 ConstPlugin
插件的 DCE
优化,b
变量被成功移除掉。但由于 ConstPlugin
插件的能力有限,c
变量并未被移除。
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ a: () => /* binding */ a,
/* harmony export */ c: () => /* binding */ c
/* harmony export */
});
/* unused harmony export b */
const a = 1;
const b = 2;
const c = 3;
/***/
};
sideEffects
优化
上述的 usedExports
优化专注于模块中导出的变量,sideEffects
优化更为彻底和高效,它专注于移除整个没有副作用的模块。
要安全地移除一个模块,必须满足以下两个条件:
- 模块的导出变量未被入口模块的所有可达模块集合所包含的所有副作用语句所关联。
- 模块顶层执行语句中不包括副作用的语句。
webpack
通过 optimization.sideEffects
配置来启用 sideEffects
优化。
让我们看一个简单的例子:
import { a } from './lib';
import { c } from './util';
console.log({ a });
export const a = 1;
export const b = 2;
export const c = 123;
export const d = 456;
在未启用 optimization.sideEffects
的情况下,产物中保留了 util
模块:
const modules = {
/***/ './src/lib.js': /***/ (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ a: () => /* binding */ a,
/* harmony export */ b: () => /* binding */ b
/* harmony export */
});
const a = 1;
const b = 2;
/***/
},
/***/ './src/util.js': /***/ (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ c: () => /* binding */ c,
/* harmony export */ d: () => /* binding */ d
/* harmony export */
});
const c = 123;
const d = 456;
/***/
}
};
当启用 optimization.sideEffects
时,util.js
模块将从产物中移除。这是因为 util
模块满足 sideEffects
移除所需的两个条件。
如果违反 sideEffects
的任意一个条件会发生什么:
在
util.js
中引入顶层副作用语句:jsexport const c = 123; export const d = 456; console.log('hello');
那么将导致
util.js
模块重新出现在产物中。util.js
模块的导出变量被入口模块的所有可达模块集合所包含的所有副作用语句所关联:现在,撤销上述更改并修改
main.js
以使用util.js
中的变量c
:jsimport { a } from './lib'; import { c } from './util'; console.log({ a }, c);
此修改也导致
util.js
模块重新出现在产物中。
这些实验表明,必须同时满足 sideEffects
的两个条件才能安全地移除一个模块。确保上述两个条件得到满足在实际应用中有效利用 sideEffect
优化启着至关重要的作用。
让我们重新审视 sideEffects
优化安全移除模块所需的两个前置条件:
- 模块的导出变量未被入口模块的所有可达模块集合所包含的所有副作用语句所关联
这个条件虽然看似简单,但遇到的挑战与 usedExports
优化中类似,可能需要大量的语义分析来确定模块中的变量的实际使用情况。
考虑以下示例,其中 c
在 main.js
模块的 test
函数中被使用,阻止了 util.js
的成功移除:
import { a } from './lib';
import { c } from './util';
console.log({ a });
function test() {
console.log(c);
}
export const a = 1;
export const b = 2;
export const c = 123;
export const d = 456;
sideEffects
属性
与确认模块导出变量是否被使用相比,确定模块内部是否存在副作用是一个更复杂的过程。考虑以下对 util.js
的修改:
// util.js
export const c = 123;
export const d = test();
function test() {
return 456;
}
在这种情况下,尽管 test()
语句是一个无副作用的函数调用,但 webpack
仍然无法确定这一点,仍然认为模块可能存在副作用。因此,util.js
被包含在最终输出中:
为了告知 webpack
将 test
标记为无副作用,有两种方法可用:
纯注释:通过在函数调用上标记纯注释,显示告知
webpack
该函数中并不存在副作用,可以安全的移除:js// util.js export const c = 123; export const d = /*#__PURE__*/ test(); function test() { return 456; }
sideEffects
属性:当一个模块包含大量顶级语句时,为每个语句标记纯注释可能繁琐且容易出错。因此,webpack
引入了sideEffects
属性来将整个模块标记为无副作用。在模块的package.json
中添加"sideEffects": false
可以安全地移除util.js
。
{
"sideEffects": false
}
然而,当一个标记为 sideEffect: false
的模块依赖于另一个标记为 sideEffect: true
的模块时,会出现一个挑战。
考虑 button.js
模块导入 button.css
模块的场景,其中 button.js
模块配置为 sideEffects: false
而 button.css
模块配置为 sideEffects: true
。
import { Button } from 'antd';
import { Button } from './button';
import './button.css';
import './side-effect';
export const Button = () => {
return `<div class="button">btn</div>`;
};
.button {
background-color: red;
}
console.log('side-effect');
{
"sideEffects": ["**/*.css", "**/side-effect.js"]
}
如果 sideEffects
仅标记当前模块具有副作用。由于 button.css
和 side-effect.js
模块具有了副作用,根据 ESM 标准,它们应该被打包。然而,webpack
的产物中并不包括 button.css
或 side-effect.js
模块。
因此,sideEffects
字段的真正含义是:
sideEffects
is much more effective since it allows to skip whole modules/files and the complete subtree.
sideEffects
是更为强大和有效的,因为它引导打包工具跳过整个模块/文件及其完整的子树。
如果一个模块被标记为 sideEffect: false
,若这个模块的导出变量均未被入口模块的所有可达模块集合所包含的所有副作用语句所关联。这意味着这个模块及其整个依赖子树模块均可以被安全的移除。
这一解释澄清了为什么在上述的示例中,button.js
及其所有的依赖子树模块(包括 button.css
和 side-effect.js
模块)可以被安全移除,这在组件库的上下文中特别有用。
{
"name": "antd",
"version": "5.23.3",
"description": "An enterprise-class UI design language and React components implementation",
"sideEffects": ["*.css"],
"main": "lib/index.js",
"module": "es/index.js",
"unpkg": "dist/antd.min.js",
"typings": "es/index.d.ts"
}
{
"name": "vuetify",
"description": "Vue Material Component Framework",
"version": "3.7.9",
"main": "lib/framework.mjs",
"module": "lib/framework.mjs",
"jsdelivr": "dist/vuetify.js",
"unpkg": "dist/vuetify.js",
"types": "lib/index.d.mts",
"sass": "lib/styles/main.sass",
"styles": "lib/styles/main.css",
"sideEffects": ["*.sass", "*.scss", "*.css", "*.vue"]
}
不幸的是,这种行为在不同的打包工具中表现不一致。测试显示:
webpack
:安全删除子树中的带副作用的css
模块和js
模块。rollup
:安全删除子树中的带副作用的css
模块和js
模块。esbuild
:删除子树中的带副作用的js
模块,但不删除css
模块。
对 barrel files
的影响
barrel files
的具体介绍和优化方案可以参考 Barrel Files。其中会涉及到与 sideEffects
优化相关的优化方案。
通常情况下,barrel files
模块本身是 side effect free
的,本身仅用作相关功能集合的统一接口,与 npm
包提供的入口模块(i.e. index.js
、main.js
)有着相似意义。
当启用 barrel files
优化时,sideEffects
优化不仅可以优化叶节点模块,还可以优化中间节点(barrel files
)。
考虑以下常见场景:
import { Button } from './components';
console.log('button:', Button);
export * from './button';
export * from './tab';
export const mid = 'middle';
export const Button = () => 'button';
测试表明,webpack
将直接删除了重新导出的模块(barrel files
),并在 main.js
中直接导入 button.js
模块的内容。
(() => {
__webpack_require__.r(__webpack_exports__);
var _components__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
'./src/components/button.js'
);
console.log('button:', _components__WEBPACK_IMPORTED_MODULE_0__.Button);
})();
上述的行为看起来就像是 webpack
将目标模块的导入路径(barrel files
)修改为目标模块的具体路径(button.js
):
import { Button } from './components';
import { Button } from './components/button';
像 next.js
和 umi.js
这样的框架也提供了类似的优化 Optimize Package Imports
。
它们会在 loader
阶段重写这些路径。需要注意的是,虽然 webpack
的桶优化专注于输出,但它仍然在构建阶段编译 components/index.js
模块及其所有的子依赖模块。
next.js
在解析 barrel files
时会借助 esbuild
的能力来快速扫描导入的模块是否为 barrel files
模块,针对 barrel files
模块,会在内部实现目标模块与具体路径的映射表,用于后续的路径重写工作。
具体实现可参考 Optimize Feature - Barrel Files,这显著优化了桶模块重新导出数百或数千个依赖模块的库的耗时场景。
esbuild
和 rollup
在这方面的行为保持一致:
rollup sideEffects 注意事项
rollup
自身并不尊重 package.json
中的 sideEffects
字段,参考:rollup/rollup#2593。如果需要让 sideEffects
生效,有几种解决方案:
- 在
rollup.config.js
中设置treeshake.moduleSideEffects
,作为全局模块的副作用配置。 - 在用户插件的
resolveId
、load
、transform
钩子中返回moduleSideEffects
字段来为特定模块指定副作用。 - 官方的
@rollup/plugin-node-resolve
插件会尊重package.json
中的sideEffects
字段。
排查与 tree shaking
相关的问题
在 onCall
期间经常会遇到的问题是,"为什么我的 tree shaking
实效呢?"。
排查这些问题可能相当具有挑战性。当面临这种问题时,首先想到的通常是哪种 tree shaking
优化失败了?这通常分为三类:
sideEffect
优化失败
sideEffect
优化的失败通常表现为一个模块,其导出变量未被使用,且模块中并没有包含副作用,但模块却被包含在最终产物中。webpack
的一个鲜为人知的功能是能够通过 stats.optimizationBailout
来调试各种优化失败,包括 sideEffect
失败的原因。考虑以下示例:
import { a } from './lib';
import { abc } from './util';
console.log({ a });
export const a = 1;
export const b = 2;
export function abc() {
console.log('abc');
}
export function def() {
console.log('def');
}
console.log('xxx');
配置项中添加 optimization.sideEffects=true
和 stats.optimizationBailout:true
后,webpack
提示的信息如下:
可见 webpack
会在日志中清楚地显示,util.js
文件第 7
行的 console.log('xxx')
存在副作用,sideEffect
优化失败,导致该模块的副作用被包含在产物包中。
如果我们在 package.json
中进一步配置 sideEffects: false
,则上述警告将会消失,因为设置了 sideEffect
属性后,webpack
停止副作用分析,并直接基于 sideEffects
字段进行副作用优化。
usedExports
优化失败
usedExports
优化失败将出现模块中未使用的导出变量仍会包含在最终的产物中。在这种情况下,排查问题时就有必要先确定模块的导出引用被使用的位置。
然而,确定变量为何被使用以及被哪位 importer
模块使用可能并不容易,因为 webpack
没有提供关于导出变量以及被哪些模块消费的详细记录。
webpack
的一个可能改进是跟踪并报告特定导出变量在模块树中的使用情况。这将极大地促进对 usedExports
优化问题进行分析和故障排除。
DCE
优化失败
除了 sideEffect
和 usedExports
优化存在失败的问题外,大多数其他 tree shaking
失败的原因可以归因于 DCE
优化失败。
DCE
失败的常见原因包括使用动态代码,例如 eval
、new Function
,这将可能导致在压缩期间异常。排查这些问题通常与所用的 压缩工具 有关,通常需要对输出代码进行 二分查找 来检索问题。不幸的是,当前的压缩工具很少会提供详细的失败原因,这是一个未来可以改进的领域。
总之,在 webpack
中实现有效的 tree shaking
需要对涉及的各种优化(sideEffect
、usedExports
、DCE
)及其执行优化过程中如何相互影响需要有深入的理解。通过正确配置和应用这些优化,开发人员可以显著减少包的大小,提高性能和效率。随着 webpack
和其他打包工具的发展,持续的学习和调整将是保持应用程序性能优化的必要条件。