“原文链接:https://github.com/orgs/web-infra-dev/discussions/17 作者:hardfist 翻译润色:童欧巴 ”
本文主要探讨了 Webpack Tree Shaking 的基本概念,而非深入其底层代码实现。相关的代码示例可以在 这里[1] 查看。
Webpack Tree Shaking 的复杂之处在于它需要多种优化技术的共同作用。Webpack 对 “Tree Shaking” 这一术语的使用并不严格,通常泛指用于消除无用代码的优化。
Tree Shaking 的定义如下:
“Tree Shaking 是 JavaScript 中用于消除死代码的一个常用术语。 它基于 ES2015 模块语法的静态结构,即 import 和 export。 这个概念及其名称由 ES2015 模块打包工具 rollup 推广。 ”
在某些情境下,像 usedExports 这样的优化也被认为是 tree shaking 和 sideEffects[2] 的一部分:
“sideEffects 和 usedExports(通常更多地被称作 tree shaking)是两种不同的优化策略。 ”
为了避免对 Tree Shaking 的理解产生任何歧义,本文不会将焦点放在 Tree Shaking 本身,而是探讨在 Webpack Tree Shaking 类别下的多种代码优化技术。
Webpack Tree Shaking 主要包括三种优化方式:
这些优化分别作用于不同层面:usedExports 针对导出变量,sideEffects 针对整个模块,而 DCE 则针对 JavaScript 语句。
考虑以下几个例子:
lib.js
中,变量 b
没有被使用,并因 usedExports 优化而未出现在最终输出中。util.js
中,没有使用任何导出变量,因此该模块由于 sideEffects 优化而未在最终输出中出现。bootstrap.js
中,console.log
语句不会执行,因此在最终输出中被移除,这是 DCE 优化的效果。// index.js
import { a } from './lib';
import { c } from './util';
import './bootstrap';
console.log(a);
// lib.js
export const a = 1;
export const b = 2;
// util.js
export const c = 3;
export const d = 4;
// bootstrap.js
console.log('bootstrap');
if(false){
console.log('bad');
}else {
console.log('good');
}
这些优化虽然是独立进行的,但它们之间可以产生相互影响。接下来,我们将详细解释这些优化措施及它们之间的关系。
在 Webpack 中,死代码消除 (DCE) 的过程相对直接,主要涉及两个重要场景:
if(false){
false_branch;
} else {
true_branch;
}
在这种情况下,由于假分支(false_branch)根本不会被执行,因此可以直接将其删除。这样做主要有两个效果:一是减少最终代码的大小,二是改变变量的使用关系。考虑以下示例:
import { a } from './a';
if(false){
console.log(a);
}else {
}
如果不移除假分支,变量 a 会被认为是在使用中。将其删除后,a 就会被标记为未使用,这种变化还会进一步影响对 usedExports 和 sideEffects 的分析。针对这种情况,Webpack 提供了两个进行死代码消除(DCE)的机会:
相比之下,Terser 执行的死代码消除更为耗时且复杂,而 ConstPlugin 的优化过程则相对简单。例如,Terser 能成功移除处理过的假分支,但 ConstPlugin 可能做不到这一点。
function get_one(){
return 1;
}
let res = get_one() + get_one();
if(res != 2){
console.log(c);
}
在模块中,若顶层声明未被导出,则可将其移除,因为它不产生额外的副作用。例如,以下的变量 b 和函数 test(假定这是模块而非脚本;脚本会影响全局作用域,因此不能安全移除)可以被安全地删除。Webpack 的 usedExports 优化正是利用了这一点来简化其实现过程。
// index.js
export const a = 10;
const b = 20;
function test(){
}
相较于其他打包工具的类似优化措施,Webpack 的 usedExports 优化非常巧妙。它利用依赖项的活动状态来判断模块内部的变量是否被使用。然后,在代码生成阶段,如果某个导出变量未被使用,Webpack 就不会为其生成相应的导出属性,这使得依赖这些导出变量的代码段变成了死代码。这种方法在后续的死代码消除(DCE)最小化过程中得到了进一步的加强。
Webpack 通过 optimization.usedExports
配置项来启用 usedExports 优化。考虑以下示例:
// index.js
import { a } from './lib';
console.log({a});
// lib.js
export const a = 1;
export const b = 2;
未启用树摇(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 // b is not removed
});
const a = 1;
const b = 2;
} ];
当启用 optimization.usedExports
后,你会发现变量 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; // this is actually dead code
/***/ })
进一步通过启用 optimization.usedExports
来实现压缩,由于 const b = 2
是死代码,它被移除了:
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.d(__webpack_exports__, {
a: () => a
});
const a = 1;
}, __webpack_module_cache__ = {};
然而,判断 b 是否被使用并非总是简单明了。考虑以下例子:
// index.js
import { a,b } from './lib';
console.log({a});
function test(){
console.log(b);
}
function test1(){
test();
}
// lib.js
export const a = 1;
export const b = 2;
在这个示例中,b 被函数 test 所使用,因此我们看到 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
,这使得可以对代码进行更深入的静态分析。通过这种方式,可以确定 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 优化有影响。考虑以下示例:
// index.js
import { a, b, c } from './lib';
console.log({a});
if(false){
console.log(b);
}
function get_one(){
return 1;
}
let res = get_one() + get_one();
if(res != 2){
console.log(c);
}
// lib.js
export const a = 1;
export const b = 2;
export const c = 3;
依靠 Webpack 内置的 ConstPlugin 来执行死代码消除,它成功地移除了变量 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;
/***/ })
usedExports 优化虽然专注于导出变量的优化,但 sideEffects 优化则更为全面和高效,其目标是移除整个模块。为了安全地移除一个模块,必须确保该模块的所有导出变量都未被使用,并且该模块不产生任何副作用。
Webpack 通过 optimization.sideEffects
配置来启用 sideEffects 优化。以下是一个简单的例子:
// index.js
import { a } from './lib';
import { c } from './util';
console.log({a});
// lib.js
export const a = 1;
export const b = 2;
// util.js
export const c = 123;
export const d = 456;
如果未启用 optimization.sideEffects
,输出结果将保留 util 模块:
/***/ "./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
满足了删除所需的两个条件。接下来,我们来看看当违反这些条件时会发生什么:
首先,在 util.js
中引入副作用:
export const c = 123;
export const d = 456;
console.log('hello');
此更改使得 util.js
再次出现在输出结果中。现在,撤销该更改并修改 index.js
来使用 util.js
中的变量 c:
import { a } from './lib';
import { c } from './util';
console.log({a}, c);
这一修改同样使得 util.js
再次出现在输出中。这些实验显示,模块必须同时满足这两个条件才能被安全地移除。确保这些条件得到满足对于在实际应用中有效利用 sideEffect 优化非常关键。
现在,让我们回顾一下模块安全移除所必需的两个条件:
这个条件表面看来简单,但实际上遇到的挑战与 usedExports 优化中遇到的挑战相似,这可能需要进行深入的分析才能确定一个变量的使用情况。
考虑以下示例,变量 c 在函数 test 中被使用,这阻止了 util.js
的成功移除:
// index.js
import { a } from './lib';
import { c } from './util';
console.log({a});
function test(){
console.log(c);
}
// lib.js
export const a = 1;
export const b = 2;
// util.js
export const c = 123;
export constd = 456;
当启用 optimization.innerGraph
时,Webpack 会进行更深入的分析,确定到函数 test 也未被使用,这也就意味着变量 c 同样未被使用,从而允许正确移除 util.js
。
与判断一个变量是否被使用相比,判断一个模块是否具有副作用是一个更加复杂的过程。考虑对 util.js
进行以下修改:
export const c = 123;
export const d = test();
function test(){
return 456;
}
在这个例子中,尽管函数 test 是一个无副作用的函数调用,Webpack 无法确认这一点,因此它仍然将该模块视为可能具有副作用。结果是,util.js 被包括在最终输出中。
为了让 Webpack 知道 test 函数没有副作用,有两种方法可以采用:
export const c = 123;
export const d = /*#__PURE__*/ test();
function test(){
return 456;
}
sideEffects
属性来标记整个模块为无副作用的。在模块的 package.json
中添加 "sideEffects": false
,可以安全地移除 util.js
。// package.json
{
"sideEffects": false
}
然而,一个被标记为 sideEffect: false
的模块如果依赖于另一个被标记为 sideEffect: true
的模块,这会引起一些问题。考虑这样一个场景:button.js
导入了 button.css
,其中 button.js
被标记为 sideEffects: false
,而 button.css
被标记为 sideEffects: true
:
// package.json
{
"sideEffects": ["**/*.css", "**/side-effect.js"]
}
// a.js
import { Button } from 'antd';
// index.js
import { Button } from './button';
// button.js
import './button.css';
import './side-effect';
export const Button = () => {
return `<div class="button">btn</div>`
}
// button.css
.button {
background-color: red;
}
// side-effects.js
console.log('side-effect');
如果 sideEffects
只标记当前模块是否有副作用,根据 ESM 标准,因为 button.css
和 side-effect.js
都具有副作用,理应被打包。但是,Webpack 的输出结果并未包括 button.css
或 side-effect.js
。
因此,sideEffects
字段的真正意义如下:
sideEffects[3] 之所以特别有效,是因为它允许跳过整个模块/文件及其完整的子树。
如果一个模块被标记为 sideEffect: false
,这表明如果该模块的导出变量未被使用,则可以安全地移除该模块及其整个子树。这一点解释了为什么在提到的例子中,button.js
及其子树(包括 button.css
和 side-effect.js
)可以被安全删除,这在组件库的情景中尤为重要。
不幸的是,这种行为在不同的打包工具中表现不一。测试表明:
SideEffects 优化不只是针对叶节点模块,也适用于中间节点。考虑一个常见的情况,某个模块仅作为桥梁,重新导出其他模块的内容。如果这样的模块(这里称作 mid)自身没有任何导出变量被使用,仅用来重新导出其他模块的内容,那么保留这个重新导出的模块是否真的有必要?
// index.js
import { Button } from './components';
console.log('button:', Button);
// components/index.js
export * from './button';
export * from './tab';
export const mid = 'middle';
// components/button.js
export const Button = () => 'button';
测试表明,Webpack 会直接删除这种重新导出的模块,在 index.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);
})();
这种行为看起来就像直接修改了源代码的导入路径:
- import { Button } from './components';
+ import { Button } from './components/button';
像 Next.js 和 UmiJS 这类框架也提供了类似的优化功能,称为“优化包导入”[4]。他们的方法是在加载阶段重写这些路径。需要注意的是,尽管 Webpack 的 barrel 优化侧重于输出,它在构建阶段仍会构建 components/index.js
及其子依赖。然而,Next.js 等使用的技术直接修改源代码,意味着 components/index.js
不参与构建过程。这对于需要重新导出成百上千个子模块的库来说,可以显著提升优化效果。
我们还测试了 esbuild 和 Rollup 在这方面的表现:
在 on-call 时常遇到的一个问题是“为什么我的 Tree shaking 失败了?”这类问题的故障排查通常比较复杂。面临这种问题时,首先会考虑的是“哪一种 Tree shaking 优化未能成功?”这通常可以归结为三个主要类别之一:
当一个模块的导出变量未被使用仍被包含在最终的包中时,通常表示 SideEffect 优化失败。
Webpack 有一个鲜为人知的特性,能够通过 stats.optimizationBailout[7] 来调试各种优化放弃的情况,包括 SideEffect 放弃的原因。考虑以下示例:
// index.js
import { a } from './lib';
import { abc } from './util';
console.log({a});
// lib.js
export const a = 1;
export const b = 2;
// util.js
export function abc(){
console.log('abc');
}
export function def(){
console.log('def')
}
console.log('xxx');
使用 optimization.sideEffects=true
和 stats.optimizationBailout=true
编译:
Webpack 的日志清晰显示,util.js
中第7行的 console.log('xxx')
导致 SideEffect 优化失败,使得该模块被包含在最终的包中。
如果我们在 package.json
中进一步设置 sideEffects: false
,这个警告就会消失,因为一旦设置了 SideEffect 属性,Webpack 将停止副作用分析,而是直接基于 sideEffects
字段进行 SideEffect 优化。
当一个未被使用的导出变量仍然生成导出属性时,表示 usedExports 优化失败。
在这种情况下,识别这些导出属性的使用位置是必要的:
然而,确定变量的使用原因和具体位置可能并不明确,因为 Webpack 并不提供这方面的详细记录。对于 Webpack 来说,一个可能的改进方向是跟踪并报告在模块树中特定导出变量的使用情况。这将极大地帮助分析和排查 usedExports 优化的问题。
除了 sideEffect 和 usedExports 优化的问题外,大多数其它 Tree shaking 失败可以归因于 DCE 的失败。DCE 失败的常见原因包括使用了 eval
和 new Function
这样的动态代码结构,这些结构在代码压缩过程中可能导致优化失败。解决这些问题通常与所使用的压缩工具相关,经常需要对输出代码进行二分查找以定位问题。不幸的是,目前的压缩工具很少提供详细的失败原因,这是未来改进的一个重要领域。
总结来说,Webpack 中高效的 Tree Shaking 需要深入理解各种优化措施及其相互作用。通过正确配置和应用这些优化,开发者可以显著降低他们的包体积,从而提升性能和效率。随着 Webpack 和其他打包工具的不断发展,持续的学习和调整是保持最佳应用性能的关键。
参考资料
[1]
这里: https://github.com/hardfist/treeshaking-cases
[2]
tree shaking 和 sideEffects: https://webpack.js.org/guides/tree-shaking/#clarifying-tree-shaking-and-sideeffects
[3]
sideEffects: https://webpack.js.org/guides/tree-shaking/#root
[4]
“优化包导入”: https://vercel.com/blog/how-we-optimized-package-imports-in-next-js#new-solution-optimizepackageimports
[5]
示例: https://github.com/hardfist/treeshaking-cases/blob/main/packages/side-effects/side-effects-skip-barrel/esbuild-dist/index.js
[6]
示例: https://github.com/hardfist/treeshaking-cases/blob/main/packages/side-effects/side-effects-skip-barrel/rollup-dist/index.js
[7]
stats.optimizationBailout: https://webpack.js.org/configuration/stats/#statsoptimizationbailout