深入理解 webpack 的 Tree Shaking
这篇文章主要关注于理解 webpack Tree Shaking 的概念,而不是深入探讨其底层代码实现。代码示例可以在这里找到,代码仓库。
webpack Tree Shaking 的实现是具有挑战性的,其中涉及到多种优化方式之间的协同工作。webpack 对 Tree Shaking 这个术语的使用有些不一致,通常广泛地指代用于消除无用代码的优化。Tree Shaking 被定义为:
Tree shaking is a term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax, i.e. import and export. The name and concept have been popularized by the ES2015 module bundler rollup.
翻译如下:
Tree Shaking 是一个在 JavaScript 上下文中常用的术语,用于消除无用代码。它依赖于 ES2015 模块语法的静态结构,即
import和export。这个名称和概念由 ES2015 模块打包工具rollup推广。
在某些情况下,像 usedExports 这样的优化被归类在 Tree Shaking 和 sideEffects 的范畴下:
The sideEffects and usedExports (more known as tree shaking) optimizations are two different things.
翻译如下:
sideEffects和usedExports(更广为人知的称为Tree Shaking)是两种不同的优化方式。
为了避免对 Tree Shaking 的理解产生歧义,这篇讨论将不专注于 Tree Shaking 本身,而是关注于 webpack Tree Shaking 类别下的各种代码优化。
webpack Tree Shaking 主要涉及三种类型的优化:
usedExports优化:这涉及从模块中移除未使用的导出变量,从而进一步消除相关的无副作用语句。sideEffects优化:这会从模块图中移除未使用导出变量的模块。DCE(无用代码消除)优化:这通常由压缩工具来进行实现,通过压缩工具来移除无用代码。功能也可以如webpack的ConstPlugin类似的工具实现,不过压缩工具会进行更深入的解析及其优化。
上述的优化涉及在 不同维度 上操作:
usedExports关注于模块的导出变量。sideEffects关注于整个模块。DCE关注于模块中的JavaScript语句。
考虑以下示例,其中 index.js 作为入口模块:
- 在
lib.js中,变量b未被使用,那么b变量所关联的代码会通过usedExports优化而缺失在最终产物中。 - 在
util.js中,没有导出变量被使用,那么util模块会通过sideEffects优化而缺失在最终输出中。 - 在
bootstrap.js中,if语句的决策逻辑永远为false,那么相关的代码块console.log('bad')语句会通过DCE优化而缺失在最终产物中。
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');
}上述的优化虽然是被独立实现的,但之间会相互影响。下面,我们详细介绍这些优化以及如何相互影响。
DCE 优化
在 webpack 中,DCE 相对简单,有两个重要的场景:
- False Branch
if (false) {
false_branch;
} else {
true_branch;
}在这里,因为 false_branch 永远不会执行,所以可以直接移除。移除 false_branch 可能会产生两个影响:
- 减少最终产物的大小。
- 影响引用关系。
考虑以下示例:
import { a } from './a';
if (false) {
console.log(a);
} else {
}如果 false_branch 未被移除,那么变量 a 将被视为已使用。当 false_branch 通过 DCE 移除后,那么变量 a 将被视为未使用。这就间接影响到 usedExports 和 sideEffects 的分析,也就是说仅通过 usedExports 和 sideEffects 移除得并不彻底,需要通过 DCE 来进一步移除。为了解决这个问题,webpack 提供了两次 DCE 的机会:
- 通过
parse阶段的ConstPlugin,执行基本的DCE来尽可能多地判断模块的导入和导出引用被其他模块使用的情况,从而增强后续的sideEffect和usedExport优化。 - 通过
processAssets阶段的Terser的minify进行更复杂的DCE,主要旨在减少代码大小。
Terser 的 DCE 更耗时且复杂,而 ConstPlugin 的优化更简单。例如,Terser 处理以下 false 分支可以成功移除,但 ConstPlugin 可能无法处理。
function get_one() {
return 1;
}
const res = get_one() + get_one();
if (res != 2) {
console.log(c);
}- 未使用的顶层语句
在模块中,如果顶层语句未被导出,或被当前模块使用时内部不存在副作用,那么也可以被移除。因为它不会带来额外的副作用。例如,以下的 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;在未启用 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 的引用并未被移除。当启用 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;
/***/
};那么在此之上再启用代码压缩,由于 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,因此我们发现 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;
}
const 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 进行 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模块重新出现在产物中。模块的导出变量被使用:
现在,撤销上述更改并修改 index.js 以使用 util.js 中的变量 c:
import { a } from './lib';
import { c } from './util';
console.log({ a }, c);此修改也导致 util.js 模块重新出现在产物中。
这些实验表明,必须同时满足 sideEffects 的两个条件才能安全地移除一个模块。确保这些条件得到满足对于在实际应用中有效利用 sideEffect 优化至关重要。
让我们重新审视 sideEffects 优化安全移除模块所需的两个前置条件:
- 模块的导出变量未被使用
这个条件虽然看似简单,但遇到的挑战与 usedExports 优化中类似,可能需要广泛的分析来确定变量的使用情况。
考虑以下示例,其中 c 在函数 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 Property
与确认模块导出变量是否被使用相比,确定模块内部是否存在副作用是一个更复杂的过程。考虑以下对 util.js 的修改:
// util.js
export const c = 123;
export const d = test();
function test() {
return 456;
}在这种情况下,尽管 test() 语句是一个无副作用的函数调用,但 webpack 仍然无法确定这一点,仍然认为模块可能存在副作用。因此,util.js 被包含在最终输出中:
为了告知 webpack 将 test 标记为无副作用,有两种方法可用:
纯注释:通过在函数调用上标记纯注释,表明函数没有副作用:
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
// package.json
{
"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 模块)可以被安全删除,这在组件库的上下文中特别有用。
不幸的是,这种行为在不同的打包工具中表现不一致。测试显示:
webpack:安全删除子树中的带副作用的CSS模块和JS模块。esbuild:删除子树中的带副作用的JS模块,但不删除CSS模块。rollup:安全删除子树中的带副作用的CSS模块和JS模块。
桶模块
sideEffects 优化不仅可以优化叶节点模块,还可以优化中间节点。考虑一种常见场景,其中一个模块重新导出其他模块的内容。如果这样的模块本身(这里称为 mid)没有任何导出变量被使用,并且仅用于重新导出其他模块的内容,那么是否有必要保留重新导出模块呢?
import { Button } from './components';
console.log('button:', Button);export * from './button';
export * from './tab';
export const mid = 'middle';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 和 umi.js 这样的框架也提供了类似的优化 Optimize Package Imports。它们会在 loader 阶段重写这些路径。需要注意的是,虽然 webpack 的桶优化专注于输出,但它仍然在构建阶段编译 components/index.js 及其子依赖项模块。然而,Next.js 等是采用直接修改源代码,这意味着 components/index.js 不参与编译。这可以显著优化例如桶模块需要重新导出数百或数千个子模块的库的场景。
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字段。
调查 webpack Tree Shaking 问题
在 onCall 期间经常会遇到的问题是,"为什么我的 tree shaking 实效呢?"。
排查这些问题可能相当具有挑战性。当面临这种问题时,首先想到的通常是哪种 tree shaking 优化失败了?这通常分为三类:
sideEffect优化失败sideEffect优化的失败通常表现为一个模块,其导出变量未被使用,且模块中并没有包含副作用,但模块却被包含在最终产物中。webpack的一个鲜为人知的功能是能够通过stats.optimizationBailout来调试各种优化失败,包括sideEffect失败的原因。考虑以下示例:jsimport { a } from './lib'; import { abc } from './util'; console.log({ a });jsexport const a = 1; export const b = 2;jsexport 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 和其他打包工具的发展,持续的学习和调整将是保持应用程序性能优化的必要条件。
