webpack Tree Shaking 的实现原理

image

问题背景

1
ReferenceError: XMLHttpRequest is not defined

a 同学运行一个服务端渲染(Server-side rendering,简称 SSR) 项目时抛出了如上的错误,熟悉 SSR 原理的同学应该比较了解这类问题的原因,通常是代码中未区分 Node 环境还是浏览器环境就直接使用了 window、document、XMLHttpRequest 等浏览器环境才有的变量

由于错误堆栈较少,现在需要帮忙定位到有问题的代码,首先就对他的代码进行了删减, 把 App 组件减少到了如下极简的代码发现就不再报错了

1
2
3
4
5
6
7
8
import * as React from 'react'
import { isCompanyDomain } from '@util/router'

class App extends React.Component {
render() {
return <div>hello</div>
}
}

接着通过二分法逐步恢复 App 组件的代码,最终发现如下 changeResult 函数代码补充上后就会报错

1
2
3
4
5
6
7
8
9
10
11
12
13
import * as React from 'react'
import { isCompanyDomain } from '@util/router'

class App extends React.Component {
changeResult() {
if (isCompanyDomain()) {
// xxx
}
}
render() {
return <div>hello</div>
}
}

这里的一个 ⚠️ 可疑点是 changeResult 函数并没有任何地方有调用,为何它成了报错的关键? 如果把 changeResult 函数里面的 isCompanyDomain 代码删除发现也不会再报错

问题的原因其实就已经浮出水面,删除 isCompanyDomain 函数调用的代码,那么 import { isCompanyDomain } from '@util/router' 这行代码极有可能由于 isCompanyDomain is declared but its value is never read. 在打包后就被删除了也就不再有报错,所以问题是 @util/router 文件引用了未区分环境就直接使用 XMLHttpRequest 变量的代码

谁进行了 Tree Shaking

对于 Tree Shaking | webpack 有了解的同学应该知道 webpack 默认只会在 mode: 'production' 时开启 Tree Shaking 功能。那么我们上面分析的 import { isCompanyDomain } from '@util/router' 代码又是谁在 mode: 'development'时就给删除了?

1
2
3
4
5
6
7
8
9
10
const path = require('path');

module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
mode: 'production',
};

此时 🐛 debug 的方向就是追踪 import { isCompanyDomain } from '@util/router' 代码在 webpack 里面被分析的生命周期,排查是哪一步被剔除了?

在 webpack 的分析流程中一个 import 语句首当其冲是被 webpack 内置的 Parser 生成 Dependency Graph 时给遍历与记录。即如下的 prewalkImportDeclaration 函数会把 AST 遍历到的 import 语句通过 hooks 机制给抛出,订阅了该 hooks 事件的插件再进行后续的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// webpack/lib/Parser.js

class Parser extends Tapable {
prewalkImportDeclaration(statement) {
const source = statement.source.value;
this.hooks.import.call(statement, source);
for (const specifier of statement.specifiers) {
const name = specifier.local.name;
this.scope.renames.set(name, null);
this.scope.definitions.add(name);
switch (specifier.type) {
case "ImportDefaultSpecifier":
this.hooks.importSpecifier.call(statement, source, "default", name);
break;
case "ImportSpecifier":
this.hooks.importSpecifier.call(
statement,
source,
specifier.imported.name,
name
);
break;
case "ImportNamespaceSpecifier":
this.hooks.importSpecifier.call(statement, source, null, name);
break;
}
}
}
}

奇怪的是在此处进行 debug 发现 import { isCompanyDomain } from '@util/router' 语句没有被 prewalkImportDeclaration 函数捕获? 那么说明 webpack Parser 拿到的代码就已经没有了 import { isCompanyDomain } from '@util/router' 这行代码

在 webpack Parser 之前工作的就只能是 loader 了,那么我们 debug 一下 ts-loader 编译完业务代码后的产物,发现产物中这行 import { isCompanyDomain } from '@util/router' 代码就被删除了

此时我们可以使用 tsc 命令来验证一下 TypeScript 编译器的行为, 发现编译后的 output.js 确实把未使用到的 import { testtest1 } from './test' 代码给删除了

1
2
3
4
5
// input.js

import { testtest1 } from './test'

console.log(123)
1
2
3
4
// output.js

console.log(123);
export {};

webpack Tree Shaking

上面说了半天还只是编译器 tsc 的剔除未使用代码的行为,那么 webpack 自己的 Tree Shaking 是如何实现的了?

接下来我们通过如下 demo 代码对 Tree Shaking 的实现原理进行探索,预期是 webpack 打包后的代码会删除 src/test.js 中没有使用到的 testtest1 的代码,而 testtest2 因为有实际被使用到会保留

1
2
3
4
5
// src/index.js

import { testtest1, testtest2 } from './test'

console.log(testtest2, 123)
1
2
3
4
5
// src/test.js

export const testtest1 = '1oooooooooooooooooo';

export const testtest2 = '2oooooooooooooooooo';

为了防止 ts-loader 编译 src/index.js 时直接把import { testtest1, testtest2 } from './test' 编译成了 import { testtest2 } from './test',这样会让 webpack 过于轻松的分析到未使用的代码, 所以我们可以篡改下 ts-loader 的产物,使得产物依然是import { testtest1, testtest2 } from './test'

第一步静态分析

webpack 第一步先对入口文件 src/index.js 进行静态分析,通过 AST 遍历拿到所有的 import 语句,然后通过 hooks.importSpecifier, hooks.importSpecifier, hooks.importSpecifier 勾子抛出每一个 import 语句的信息,最后真正订阅了这些勾子的插件再开始工作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// webpack/lib/Parser.js

class Parser extends Tapable {
prewalkImportDeclaration(statement) {
const source = statement.source.value;
this.hooks.import.call(statement, source);
for (const specifier of statement.specifiers) {
const name = specifier.local.name;
this.scope.renames.set(name, null);
this.scope.definitions.add(name);
switch (specifier.type) {
case "ImportDefaultSpecifier":
this.hooks.importSpecifier.call(statement, source, "default", name);
break;
case "ImportSpecifier":
this.hooks.importSpecifier.call(
statement,
source,
specifier.imported.name,
name
);
break;
case "ImportNamespaceSpecifier":
this.hooks.importSpecifier.call(statement, source, null, name);
break;
}
}
}
}

第二步记录信息

如下 HarmonyImportDependencyParserPlugin 插件就订阅了hooks.importSpecifier事件,它会把 import 信息都通过 parser.state.harmonySpecifier 这个 Map 给记录了下来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// webpack/lib/dependencies/HarmonyImportDependencyParserPlugin.js

module.exports = class HarmonyImportDependencyParserPlugin {
apply(parser) {
parser.hooks.importSpecifier.tap(
"HarmonyImportDependencyParserPlugin",
(statement, source, id, name) => {
parser.scope.definitions.delete(name);
parser.scope.renames.set(name, "imported var");
if (!parser.state.harmonySpecifier) {
parser.state.harmonySpecifier = new Map();
}
parser.state.harmonySpecifier.set(name, {
source,
id,
sourceOrder: parser.state.lastHarmonyImportOrder
});
return true;
}
);

第三步静态分析

此时 webpack 的静态分析到了console.log(testtest2, 123)语句,发现有代码引用到了 testtest2 变量,于是继续通过hooks.expression勾子抛出当前变量的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// webpack/lib/Parser.js

class Parser extends Tapable {
walkIdentifier(expression) {
if (!this.scope.definitions.has(expression.name)) {
const hook = this.hooks.expression.get(
this.scope.renames.get(expression.name) || expression.name
);
if (hook !== undefined) {
const result = hook.call(expression);
if (result === true) return;
}
}
}
}

第四步新增一个 Dependency

订阅了hooks.expression事件的 HarmonyImportDependencyParserPlugin 插件接收到了 testtest2 变量的信息,并且发现第二步记录信息的 parser.state.harmonySpecifier Map 中刚好记录了 testtest2 的信息, 于是确定了这是一个合法的 import 引用, 最后为 src/index.js 模块新增了一个类型为 HarmonyImportSpecifierDependency 的 Dependency

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// webpack/lib/dependencies/HarmonyImportDependencyParserPlugin.js

module.exports = class HarmonyImportDependencyParserPlugin {
apply(parser) {
parser.hooks.expression
.for("imported var")
.tap("HarmonyImportDependencyParserPlugin", expr => {
const name = expr.name;
const settings = parser.state.harmonySpecifier.get(name);
const dep = new HarmonyImportSpecifierDependency(
settings.source,
parser.state.module,
settings.sourceOrder,
parser.state.harmonyParserScope,
settings.id,
name,
expr.range,
this.strictExportPresence
);
dep.shorthand = parser.scope.inShorthand;
dep.directImport = true;
dep.loc = expr.loc;
parser.state.module.addDependency(dep);
return true;
});

第五步开始 Tree Shaking

到第四步我们已经知道 webpack 静态分析 src/index.js 模块时会给被 import 了且被使用到的 testtest2 变量分配一个 HarmonyImportSpecifierDependency。而当 webpack 开始静态分析 src/test.js 模块时又会给 testtest1, testtest2 都分配一个 HarmonyExportSpecifierDependency

  • 对于 src/index.js 模块, 因为 import 引用且使用到了 testtest2,所以为其分配了一个指向 testtest2 变量的 HarmonyImportSpecifierDependency
  • 对于 src/test.js 模块, 因为源码包含了两个 export 导出语句,所以为导出语句 testtest1, testtest2 变量各分配了一个 HarmonyExportSpecifierDependency

src/test.js 模块最后生成的代码是按照它包含的两个 HarmonyExportSpecifierDependency 的模版函数的规则决定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// webpack/lib/dependencies/HarmonyExportSpecifierDependency.js

HarmonyExportSpecifierDependency.Template = class HarmonyExportSpecifierDependencyTemplate {
getContent(dep) {
const used = dep.originModule.isUsed(dep.name);
if (!used) {
return `/* unused harmony export ${dep.name || "namespace"} */\n`;
}
const exportsName = dep.originModule.exportsArgument;

return `/* harmony export (binding) */ __webpack_require__.d(${exportsName}, ${JSON.stringify(
used
)}, function() { return ${dep.id}; });\n`;
}
};

因为 src/test.js 模块的代码是导出的 testtest2 有被使用(used 的值为 true),导出的 testtest1 未被使用(used 的值为 false),那么按照 getContent 函数生成的代码就是类似如下

1
2
3
4
5
6
7
8
9
// src/test.js

const testtest1 = '1oooooooooooooooooo';

const testtest2 = '2oooooooooooooooooo';

/* unused harmony export testtest1 */

/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "testtest2", function() { return testtest2; });

那么为什么 testtest2 变量 used 的值为 true,下面追溯一下为 true 的原因

  1. 因为 src/test.js 模块的 usedExports 的值为 ["testtest2"], 所以isUsed("testtest2") 函数返回为 true,即 used 的值为 true
  2. 模块的 usedExports 的值是 processDependency 函数运行时进行的赋值,processDependency 函数会对 webpack 分析到的所有文件模块 module 进行遍历。当 processDependency 函数遍历到的 module 是 src/index.js,且该 module 有一个 Dependency 即第二个参数 dep 是指向 testtest2 变量的 HarmonyImportSpecifierDependency 时,而 testtest2 变量是 src/test.js 模块(referenceModule)的导出, 故后续继续调用 processModule 函数给 src/test.js 模块(referenceModule)的 usedExports 赋值为了 ["testtest2"](importedNames )
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    // 1
    // webpack/lib/Module.js
    isUsed(exportName) {
    // ...
    let idx = this.usedExports.indexOf(exportName);
    if (idx < 0) return false;
    // ...
    return exportName;
    }

    // 2
    // webpack/lib/FlagDependencyUsagePlugin.js
    // 当遍历到 module 为 src/index.js, dep 为指向 testtest2 变量的 HarmonyImportSpecifierDependency 时
    const processDependency = (module, dep) => {
    const reference = compilation.getDependencyReference(module, dep)
    if (!reference) return
    // 此时的 referenceModule 为 src/test.js 模块, 即导出 testtest2 变量的模块
    const referenceModule = reference.module
    // importedNames 为第四步新增的 HarmonyImportSpecifierDependency 的 name 值为 testtest2
    const importedNames = reference.importedNames
    const oldUsed = referenceModule.used
    const oldUsedExports = referenceModule.usedExports
    if (
    !oldUsed ||
    (importedNames &&
    (!oldUsedExports || !isSubset(oldUsedExports, importedNames)))
    ) {
    // 调用 processModule 函数
    processModule(referenceModule, importedNames)
    }
    }
    // 给 src/test.js 模块的 usedExports 数组新增 "testtest2" 元素, 即 usedExports 的值为 ["testtest2"]
    const processModule = (module, usedExports) => {
    module.used = true;
    if (module.usedExports === true) return;
    if (usedExports === true) {
    module.usedExports = true;
    } else if (Array.isArray(usedExports)) {
    const old = module.usedExports ? module.usedExports.length : -1;
    module.usedExports = addToSet(
    module.usedExports || [],
    usedExports
    );
    if (module.usedExports.length === old) {
    return;
    }
    }
    // ...
    };

第六步删除未使用代码

到了第五步我们发现 webpack 已经确定了哪些导出有被使用到,哪些导出没有被使用,且为生成的代码作了标记。那么未使用到的const testtest1 = '1oooooooooooooooooo';这行代码是被谁给删除的了?

其实这里是 TerserPlugin 进行的删除, 因为 Terser 的功能本身也是压缩代码,删除冗余代码,这也体现了单一职责原则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// webpack/lib/WebpackOptionsDefaulter.js

this.set("optimization.minimizer", "make", options => [
{
apply: compiler => {
// Lazy load the Terser plugin
// const TerserPlugin = require("terser-webpack-plugin");
// const SourceMapDevToolPlugin = require("./SourceMapDevToolPlugin");
new TerserPlugin({
cache: true,
parallel: true,
sourceMap:
(options.devtool && /source-?map/.test(options.devtool)) ||
(options.plugins &&
options.plugins.some(p => p instanceof SourceMapDevToolPlugin))
}).apply(compiler);
}
}
]);

让我们手写一个简版的打包后的代码来看清楚 webpack 与 Terser 的行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// input.js

const exports = {
'./src/test.js': (m) => {
const testtest1 = '1oooooooooooooooooo'
const testtest2 = '2oooooooooooooooooo'

// 如下模拟 webpack 的标记代码
Object.defineProperty(m, 'testtest2', {
value: testtest2
})
}
}

console.log(exports)

接着我们运行如下 Terser 命令来压缩一下 input.js

1
terser input.js --compress ecma=2015,computed_props=false -o output.js

最后我们发现const testtest1 = '1oooooooooooooooooo';这行代码已经不见了,而有 Object.defineProperty 引用的 testtest2 变量的代码就被保留了下来

1
2
3
4
5
6
7
8
// output.js

const exports = {
'./src/test.js': (m) => {
Object.defineProperty(m, 'testtest2', { value: '2oooooooooooooooooo' })
}
}
console.log(exports)

小结

  • tsc, esbuild, babel 等编译器会自动删除未使用到的代码
  • webpack Tree Shaking 的实现原理是 webpack 依赖分析时记录模块的导出是否有被使用,然后在生成后的代码中进行了标记,最后 Terser 把未被标记的导出即未使用到的代码给删除