本文偏重 Webpack TreeShaking 概念上的理解,不对底层代码实现进行过多讲解, 代码用例在 github.com/hardfist/tr...
Webpack Treeshaking 一个很难以理解的地方在于其是 n 种优化的一起工作的效果,Webpack 自身对 Tree Shaking 术语的使用也很不规范,有些地址泛指死代码删除优化的统称。Tree Shaking
vbnet
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.
某些地方指 usedExports 优化 tree shaking & sideEffects
csharp
The sideEffects and usedExports (more known as tree shaking)
optimizations are two different things.
因此为了避免大家对理解 Treeshaking 产生歧义,我们不再讨论 TreeShaking 本身,而是讨论 Webpack Treeshaking 范畴下的各种代码优化。
Webpack 的 Treeshaking 其实主要是 三种优化的综合即
- usedExports 优化:即删除模块中未使用的导出变量,从而进一步删除相关的无副作用语句
- sideEffects 优化: 删除模块图中未被使用导出变量的模块
- DCE(dead code elimination) 优化: 即一般的 minify 工具实现的死代码删除,其他工具也可以实现类似功能如 webpack 的 ConstPlugin
这三种优化实际工作在不同维度,usedExports 专注导出变量,sideEffects 专注整个模块,DCE 专注 Javascript 语句。
以下面的例子为例
- 这里的 lib.js 的 b 未被使用,最终 lib.js 的产物里不包含 b 的相关代码,即为 usedExports 优化(删除导出变量 b)
- 这里的 util.js 的任何导出变量都未被使用,最终产物里没有 util 模块,则为 sideEffects 优化 (删除未使用模块 util)
- 这里的 bootstrap.js 里的 console.log 是不会被执行的代码,最终产物里会被删除,则为 DCE(删除不会被执行的语句)
javascript
// 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');
}
这三种优化的实现彼此独立,但是实际上又会相互影响,下面详细介绍三种优化以及互相的影响关系。
DCE 优化
DCE 的概念相对简单,在 Webpack 两种 DCE 的场景比较重要
false branch
arduino
if(false){
false_branch;
} else {
true_branch;
}
这里因为 false_branch 在运行期不可能被执行,因此可以直接删除 false_branch(JavaScript 因为变量可以 hoist,所以更复杂一些,需要额外处理变量的 hoist,不能直接简单删除)
这里对 false_branch 实际带来了两个副作用,第一个就是最终代码体积的减小,另一个则是这会影响变量的使用关系,我们看下面这个例子
javascript
import { a } from './a';
if(false){
console.log(a);
}else {
}
如果没有进行 false_branch 的删除,那么这个变量 a 则会被视为 used,如果进行了删除则会被视为 unused,变量 a 是否被 used 会进一步影响 后续的 usedExports 分析和 sideEffects 分析,后面会详细介绍。同时因为这里的 false_branch 优化影响了后续的 usedExports 和 sideEffects,因此不能交给最后的 minify 再进行优化,实际上 webpack 为了解决这个问题,提供了两个进行 DCE 的时机。
- 通过constPlugin 在 parse 阶段进行简单的 DCE,主要是为了尽可能的判断出导入导出变量的使用情况,从而尽可能的提高后续 sideEffect 和 usedExport 优化的效果
- 通过 terser 的 minify 在 processAssets 阶段进行复杂的 DCE,主要是为了减小代码的体积
terser 的 DCE 耗时更长优化更复杂,而 constPlugin 的优化则更简单。如下面这个 false_branch 在 terser 里可以成功删除,但是 constPlugin 却不行。
scss
function get_one(){
return 1;
}
let res = get_one() + get_one();
if(res != 2){
console.log(c);
}
unused top level statement
在模块中,如果一个 top level statement 没有被导出,则其同样可以进行删除,因为其本身执行并不会带来额外的副作用,如下面的 b
和test
则可以被安全的删除(安全删除的前提是这是个 module 而非 script,因为对于 script 这些全局声明会污染全局作用域,因此不能安全删除), webpack 的 usedExports 优化实际利用这个性质,简化了其实现。
ini
// index.js
export const a = 10;
const b = 20;
function test(){
}
usedExports 优化
相比其他 bundler 的类似优化,webpack 的 usedExports 优化则比较巧妙,通过 dependency 的 active 状态来判断模块内的变量是否被使用,然后在代码生成阶段如果导出变量未被使用,则代码生成不生成相应变量的导出属性,这样就会进一步使得导出变量依赖的代码片段成为死代码,再借助后续的 minify 的 DCE 功能进行删除。
webpack 通过optimization.usedExports
配置启用usedExports
优化
我们看下如下的例子
javascript
// index.js
import { a } from './lib';
console.log({a});
// lib.js
export const a = 1;
export const b = 2;
此时在没有开启 treeshaking 的情况下,我们可以看到产物里包含 b 的信息
javascript
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 =1
仍然存在,但是此时因为 b 没有地方使用,导致const b = 1
也成了死代码
css
/***/ ((__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 a dead code
/***/ })
我们在开启 optimization.usedExports
的情况下,进一步去开启压缩,这时候const b = 1
因为是死代码,就自动被删除了。
ini
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.d(__webpack_exports__, {
a: () => a
});
const a = 1;
} ], __webpack_module_cache__ = {};
这个方案存在的一个问题就是,分析 b 是否被使用,并不是显而易见的,考虑下面的 case
javascript
// 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 并没有分析出这个关系
ini
(__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 的导出属性
css
((__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 会造成影响,考虑下面的 case
javascript
// 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 的 DCE 作用,其成功删除了 b,但是因为 ConstPlugin 作用有限,但是没有成功删除 C。
javascript
((__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 优化则更为彻底和高效,关注的是整个模块的删除。sideEffect 的删除整个模块必须满足两个条件
- 该模块的任何导出变量都没有被使用
- 该模块是 side-effect-free
webpack 通过optimization.sideEffects
配置启用 sideEffects 优化。先简单看个例子
吐槽一下,这个优化的关注点是删除 unusedModule,其叫做 optimization.unusedModule 或许更为合理,sideEffect 只是其中实现的涉及到的一个细节。
javascript
// 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 的产物
css
/***/ "./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 同时满足了上述的两个条件,接下来我们分别破坏其中一个条件看看结果
css
var __webpack_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;
/***/ })
/******/ });
首先在 util.js 里引入副作用,修改 util.js 内容如下,我们发现 util 又出现在产物里了
ini
export const c = 123;
export const d = 456;
console.log('hello');
撤回上述修改,修改 index.js,使用 util 导出的变量 c,我们同样发现 util 又出现在产物里了
javascript
import { a } from './lib';
import { c } from './util';
console.log({a},c)
上面的实验证明,如果想要安全的删除一个模块,两个条件缺一不可,那么在实际应用中的核心就是,如何保障在我们需要安全的依靠 sideEffect 删除不需要的模块时,满足上述两个条件。
让我们重新 review 上述两个条件
导出变量未被使用
这个看似简单的条件,其实碰到和 usedExports 同样的问题,可能需要很深入的优化才能分析出一个变量如何使用。
考察下面的例子,此时因为 c 在 test 中被使用,因此导致 util 没被成功删除
javascript
// 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 const d = 456;
当我们开启 optimization.innerGraph
的时候,webpack 进行深度的分析后,分析 test 也没被使用,从而 c 没被使用,从而正确的删除了 util。
sideEffects Property
相比于变量是否使用,module 是否具有副作用是个更为复杂的过程,修改上面的 dist 如下
javascript
// util.js
export const c = 123;
export const d = test();
function test(){
return 456;
}
此时的 test 虽然是个无副作用的函数调用,但是 webpack 并不能分析出来,因此他仍然把当前模块视为可能含有副作用,我们发现 util 因为具有副作用被打包进最终产物里。
那么有没有什么办法告诉 webpack test 没有副作用呢,webpack 提供了两种方式
- pure annotation: 我们在函数调用的地方通过 标记该函数没有副作用,
javascript
export const c = 123;
export const d = /*#__PURE__*/ test();
function test(){
return 456;
}
- sideEffects Property: 当我们的模块里含有大量的 top level statement 的情况下,为每个 statement 标记 pure annotation 是个很麻烦和易错的事情,因此 webpack 引入了 sideEffect 来将整个 module 标记为 side-effect-free,我们给上述的模块的 package.json 加上
sideEffects: false
后,util 可以被安全删除了
json
// package.json
{
"sideEffects": false
}
当我们给一个模块标记为sideEffect:false
后,一个问题就出现了,如果一个模块本身自身是sideEffect:false
的,但是其依赖的模块被标记为 sideEffect:true
,那么移除该模块的同时,是否也应该移除其子模块。考虑如下场景 我们在 button.js 里引入了 button.css, button.js 是 sideEffects:false,而 button.css 是 sideEffects:true。
javascript
// 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-effcts.js
console.log('side-effect');
如果 sideEffects 只是标记当前模块是副作用,那么按照 ESM 的规范,因为 button.css 和 side-effect 具有副作用,因此应该打包 button.css, 但是 webpack 的打包产物并不包含 button.css 和 side-effect.js。
所以 sideEffects 字段的真正语义是
sideEffects
is much more effective since it allows to skip whole modules/files and the complete subtree. -> sideEffect
如果一个 module 被标记为 sideEffect: false,那么意味着如果这个模块的导出变量未被使用,那么该模块及其子树 可以安全的删除
因此这样上面的结果就可以解释了,因为 button.js 被标记为 sideEffect:false,那么 button.js 及其子树(button.css,side-effect.js)均可以被安全删除,这在组件库场景下十分有用。
很不幸这个行为在不同的 bundler 下有差异,经过测试
- webpack: 会安全删除子树里的副作用 css 和副作用 js
- esbuild: 会删除子树里的副作用 js 但不会删除子树的副作用 css
- rollup: 不会删除子树里的副作用 js(不处理 css)
barrel module
sideEffects 优化不仅能够优化叶子节点的 module,其实也可以优化中间节点,考虑一个如下常见的模式,即在一个模里重导出其他模块的内容,那么如果该模块自身没有任何导出变量被使用(即这里的 mid),只是使用被重导出模块的内容,那么 reexport 模块本身需要保留吗?
javascript
// 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 也会直接删除 reexport 模块,而在 index.js 里直接导入 lib.js 的内容
ini
(() => {
__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);
})();
其表现形式就很像直接修改了源码里的导入路径
javascript
- import { Button } from './components';
+ import { Button } from './components/button';
next.js 和 umijs 等框架也提供了类似的优化 vercel.com/blog/how-we... ,其方式则是在 loader 阶段对这些路径进行重写,注意到 webpack 的 barrel 优化关注的是产物生成,其虽然产物里没包含 components/index.js 的信息,但是在构建阶段仍然构建了 components/index.js 及其子依赖模块,但是 next.js 等方式则是直接通过 loader 修改了源码,因此 components/index.js 不参与构建,这对于包含了几百个上千个重导出子模块的库,能起到很好的优化。
我们测试了下 esbuild 和 rollup 的行为,其也同样存在差异
- esbuild 会删除 barrel module 里的副作用 github.com/hardfist/tr...
- rollup 不会删除 barrel module 里的副作用 github.com/hardfist/tr...
Webpack Tree shaking 排查
我们日常 Oncall 的一个高频问题就是 "我的 treeshaking 为什么失效了",这类问题的排查难度通常都比较大,当我们接到这个问题时,第一个念头就是 "是 treeshaking 的哪个优化失效了",其大致分为如下三种情况
暂时无法在飞书文档外展示此内容
sideEffect 优化失效
sideEffect 优化失效的表现形式是一个导出变量未被使用的模块被导入,
Webpack 一个鲜为人知的功能就是其支持通过 stats.optimizationBailout 调试各种优化 bailout 的原因,其中包括 sideEffect bailout 的原因。看下面的例子
javascript
// 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') 导致了 util 模块的 sideEffect 失效,被打包进产物里。
此时如果我们进一步在 package.json 里配置 sideEffects:false,那么该提示将消失,因为在配置了 sideEffect Property 的情况下,webpack 将停止副作用分析,而是直接基于 sideEffect 字段做 sideEffect 优化。
usedExports 失效
usedExports 失效的表现形式是一个未使用的导出变量仍然被生成了导出属性
这个时候我们应该查找哪些地方使用这个导出属性
但是一个变量为什么被使用在哪里被使用,webpack 似乎并没有数据记录, 这个可能是可以 webpack 优化的点。我们只能根据 module 的引用树关系一层层的向上查找。如果 webpack 可以直接记录在哪个模块里使用了某个指定模块的导出变量,那么这对于 usedExports 优化分析将会十分方便。
DCE 失效
除开上面两种优化,其他的 treeshaking 失效大部分可以归到 DCE 失效上,常见的 DCE 的失效原因包括 eva
new Function
等动态优化手段导致的 minify 的 bailout,这部分的排查往往和 minifier 有关,通常需要二分产物代码来排查(目前的 minify 貌似也很少提供 bailout 原因,这是目前 minifier 可以优化的点)。