解密webpack tree-starking

Tree-Shaking 简介

最近看了一篇 你的Tree-Shaking并没什么卵用 吓得我赶紧好好研究Tree-Shaking。

tree-sharkingWebpack 2 后续版本的优化功能,顾名思义,就是将多余的代码给 “摇晃” 掉,在开发中我们经常使用一些第三方库,而这些第三方库只使用了这个库的一部门功能或代码,未使用的代码也要被打包进来,这样出口文件会非常大,tree-sharking 帮我们解决了这个问题,它可以将各个模块中没有使用的方法过滤掉,只对有效代码进行打包。

Tree-Shaking在前端界由rollup首先提出并实现,后续webpack在2.x版本也借助于UglifyJS实现了。自那以后,在各类讨论优化打包的文章中,都能看到Tree-Shaking的身影。

Tree-Shaking的原理

这里我不多冗余阐述,直接贴百度外卖前端的一篇文章: Tree-Shaking性能优化实践 - 原理篇


AST 语法树分析

AST 抽象语法树简介

AST(Abstract Syntax Tree)是源代码的抽象语法结构树状表现形式,Webpack、ESLint、JSX、TypeScript 的编译和模块化规则之间的转化都是通过 AST 来实现对代码的检查、分析以及编译等操作。

JavaScript 语法的 AST 语法树

JavaScript 中想要使用 AST 进行开发,要知道抽象成语法树之后的结构是什么,里面的字段名称都代表什么含义以及遍历的规则,
我们可以通过在线转换网站http://esprima.org/demo/parse.html将 JS 代码装换成 AST 语法树。

通过在线编译工具,可以将 function fn(a, b) {}编译为下面的结构。

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
{
"type": "Program",
"body": [
{
"type": "FunctionDeclaration",
"id": {
"type": "Identifier",
"name": "fn"
},
"params": [
{
"type": "Identifier",
"name": "a"
},
{
"type": "Identifier",
"name": "b"
}
],
"body": {
"type": "BlockStatement",
"body": []
},
"generator": false,
"expression": false,
"async": false
}
],
"sourceType": "script"
}

将 JavaScript 语法编译成抽象语法树后,需要对它进行遍历、修该并重新编译,遍历树结构的过程为 “先序深度优先”。

esprima、estraverse 和 escodegen

esprimaestraverseescodegen 模块是操作 AST 的三个重要模块,也是实现 babel 的核心依赖,下面是分别介绍三个模块的作用。

1、esprima 将 JS 转换成 AST

esprima 模块的用法如下:

文件:esprima-test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const esprima = require("esprima");

let code = "function fn() {}";

// 生成语法树
let tree = esprima.parseScript(code);

console.log(tree);

// Script {
// type: 'Program',
// body:
// [ FunctionDeclaration {
// type: 'FunctionDeclaration',
// id: [Identifier],
// params: [],
// body: [BlockStatement],
// generator: false,
// expression: false,
// async: false } ],
// sourceType: 'script' }

通过上面的案例可以看出,通过 esprima 模块的 parseScript 方法将 JS 代码块转换成语法树,代码块需要转换成字符串,也可以通过 parseModule 方法转换一个模块。

2、estraverse 遍历和修改 AST

查看遍历过程:

文件:estraverse-test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const esprima = require("esprima");
const estraverse = require("estraverse");

let code = "function fn() {}";

// 遍历语法树
estraverse.traverse(esprima.parseScript(code), {
enter(node) {
console.log("enter", node.type);
},
leave() {
console.log("leave", node.type);
}
});

// enter Program
// enter FunctionDeclaration
// enter Identifier
// leave Identifier
// enter BlockStatement
// leave BlockStatement
// leave FunctionDeclaration
// leave Program

上面代码通过 estraverse 模块的 traverse 方法将 esprima 模块转换的 AST 进行了遍历,并打印了所有的 type 属性并打印,每含有一个 type 属性的对象被叫做一个节点,修改是获取对应的类型并修改该节点中的属性即可。

其实深度遍历 AST 就是在遍历每一层的 type 属性,所以遍历会分为两个阶段,进入阶段和离开阶段,在 estraversetraverse 方法中分别用参数指定的 entryleave 两个函数监听,但是我们一般只使用 entry

3、escodegen 将 AST 转换成 JS

下面的案例是一个段 JS 代码块被转换成 AST,并将遍历、修改后的 AST 重新转换成 JS 的全过程。

文件:escodegen-test.js

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
const esprima = require("esprima");
const estraverse = require("estraverse");
const escodegen = require("escodegen");

let code = "function fn() {}";

// 生成语法树
let tree = esprima.parseScript(code);

// 遍历语法树
estraverse.traverse(tree, {
enter(node) {
// 修改函数名
if (node.type === "FunctionDeclaration") {
node.id.name = "ast";
}
}
});

// 编译语法树
let result = escodegen.generate(tree);

console.log(result);

// function ast() {
// }

在遍历 AST 的过程中 params 值为数组,没有 type 属性。


实现 Babel 语法转换插件

实现语法转换插件需要借助 babel-corebabel-types 两个模块,其实这两个模块就是依赖 esprimaestraverseescodegen 的。

使用这两个模块需要安装,命令如下:

1
npm install babel-core babel-types

1、plugin-transform-arrow-functions

plugin-transform-arrow-functions 是 Babel 家族成员之一,用于将箭头函数转换 ES5 语法的函数表达式。

文件:plugin-transform-arrow-functions.js

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
50
51
52
53
54
55
56
const babel = require("babel-core");
const types = require("babel-types");

// 箭头函数代码块
let sumCode = `
const sum = (a, b) => {
return a + b;
}`;
let minusCode = `const minus = (a, b) => a - b;`;

// 转化 ES5 插件
let ArrowPlugin = {
// 访问者(访问者模式)
visitor: {
// path 是树的路径
ArrowFunctionExpression(path) {
// 获取树节点
let node = path.node;

// 获取参数和函数体
let params = node.params;
let body = node.body;

// 判断函数体是否是代码块,不是代码块则添加 return 和 {}
if (!types.isBlockStatement(body)) {
let returnStatement = types.returnStatement(body);
body = types.blockStatement([returnStatement]);
}

// 生成一个函数表达式树结构
let func = types.functionExpression(null, params, body, false, false);

// 用新的树结构替换掉旧的树结构
types.replaceWith(func);
}
}
};

// 生成转换后的代码块
let sumResult = babel.transform(sumCode, {
plugins: [ArrowPlugin]
});

let minusResult = babel.transform(minusCode, {
plugins: [ArrowPlugin]
});

console.log(sumResult.code);
console.log(minusResult.code);

// let sum = function (a, b) {
// return a + b;
// };
// let minus = function (a, b) {
// return a - b;
// };

我们主要使用 babel-coretransform 方法将 AST 转化成代码块,第一个参数为转换前的代码块(字符串),第二个参数为配置项,其中 plugins 值为数组,存储修改 babal-core 转换的 AST 的插件(对象),使用 transform 方法将旧的 AST 处理成新的代码块后,返回值为一个对象,对象的 code 属性为转换后的代码块(字符串)。

内部修改通过 babel-types 模块提供的方法实现,API 可以到 https://github.com/babel/babel/tree/6.x/packages/babel-types 中查看。

ArrowPlugin 就是传入 transform 方法的插件,必须含有 visitor 属性(固定),值同为对象,用于存储修改语法树的方法,方法名要严格按照 API,对应的方法会修改 AST 对应的节点。

types.functionExpression 方法中参数分别代表,函数名(匿名函数为 null)、函数参数(必填)、函数体(必填)、是否为 generator 函数(默认 false)、是否为 async 函数(默认 false),返回值为修改后的 AST,types.replaceWith 方法用于替换 AST,参数为新的 AST。

2、plugin-transform-classes

plugin-transform-classes 也是 Babel 家族中的成员之一,用于将 ES6 的 class 类转换成 ES5 的构造函数。

文件:plugin-transform-classes.js

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
const babel = require("babel-core");
const types = require("babel-types");

// 类
let code = `
class Person {
constructor(name) {
this.name = name;
}
getName () {
return this.name;
}
}`;

// 将类转化 ES5 构造函数插件
let ClassPlugin = {
visitor: {
ClassDeclaration(path) {
let node = path.node;
let classList = node.body.body;

// 将取到的类名转换成标识符 { type: 'Identifier', name: 'Person' }
let className = types.identifier(node.id.name);
let body = types.blockStatement([]);
let func = types.functionDeclaration(className, [], body, false, false);
path.replaceWith(func);

// 用于存储多个原型方法
let es5Func = [];

// 获取 class 中的代码体
classList.forEach((item, index) => {
// 函数的代码体
let body = classList[index].body;

// 获取参数
let params = item.params.length ? item.params.map(val => val.name) : [];

// 转化参数为标识符
params = types.identifier(params);

// 判断是否是 constructor,如果构造函数那就生成新的函数替换
if (item.kind === "constructor") {
// 生成一个构造函数树结构
func = types.functionDeclaration(className, [params], body, false, false);
} else {
// 其他情况是原型方法
let proto = types.memberExpression(className, types.identifier("prototype"));

// 左侧层层定义标识符 Person.prototype.getName
let left = types.memberExpression(proto, types.identifier(item.key.name));

// 右侧定义匿名函数
let right = types.functionExpression(null, [params], body, false, false);

// 将左侧和右侧进行合并并存入数组
es5Func.push(types.assignmentExpression("=", left, right));
}
});

// 如果没有原型方法,直接替换
if (es5Func.length === 0) {
path.replaceWith(func);
} else {
es5Func.push(func);
// 替换 n 个节点
path.replaceWithMultiple(es5Func);
}
}
}
};

// 生成转换后的代码块
result = babel.transform(code, {
plugins: [ClassPlugin]
});

console.log(result.code);

// Person.prototype.getName = function () {
// return this.name;
// }
// function Person(name) {
// this.name = name;
// }

上面这个插件的实现要比 plugin-transform-arrow-functions 复杂一些,归根结底还是将要互相转换的 ES6 和 ES5 语法树做对比,找到他们的不同,并使用 babel-types 提供的 API 对语法树对应的节点属性进行修改并替换语法树,值得注意的是 path.replaceWithMultiplepath.replaceWith 不同,参数为一个数组,数组支持多个语法树结构,可根据具体修改语法树的场景选择使用,也可根据不同情况使用不同的替换方法。

通过本节我们了解了什么是 AST 抽象语法树、抽象语法树在 JavaScript 中的体现以及在 NodeJS 中用于生成、遍历和修改 AST 抽象语法树的核心依赖,并通过使用 babel-corebabel-types 两个模块简易模拟了 ES6 新特性转换为 ES5 语法的过程,希望可以为后面自己实现一些编译插件提供了思路。


假设我们现在使用了 ElementUI 库的两个组件,通常会使用解构赋值来引入。

优化前

1
import { Button, Alert } from"element-ui";

这样引用资源, Webpack 在打包的时候会找到 element-ui 并把里面所有的代码全部打包到出口文件,我们只使用了两个组件,全部打包不是我们所希望的,tree-sharking 是通过在 Webpack 中配置 babel-plugin-import 插件来实现的,它可以将解构的代码转换成下面的形式。

优化后

1
2
import Button from"element-ui/lib/button";
import Alert from"element-ui/lib/Alert";

转化后会去 node_modules 中的 element-ui 模块找到 ButtonAlert 两个组件对应的文件,并打包到出口文件中。

通过上面的转换可以看出,其实 tree-sharking 的实现原理是通过改变 AST 语法树的结构来实现的,如果不了解抽象语法树可以参考 AST 抽象语法树

优化前的 AST 语法树

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
{
"type": "Program",
"body": [
{
"type": "ImportDeclaration",
"specifiers": [
{
"type": "ImportSpecifier",
"local": {
"type": "Identifier",
"name": "Button"
},
"imported": {
"type": "Identifier",
"name": "Button"
}
},
{
"type": "ImportSpecifier",
"local": {
"type": "Identifier",
"name": "Alert"
},
"imported": {
"type": "Identifier",
"name": "Alert"
}
}
],
"source": {
"type": "Literal",
"value": "element-ui",
"raw": "\"element-ui\""
}
}
],
"sourceType": "module"
}

优化后的 AST 语法树

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
 {
"type": "Program",
"body": [
{
"type": "ImportDeclaration",
"specifiers": [
{
"type": "ImportDefaultSpecifier",
"local": {
"type": "Identifier",
"name": "Button"
}
}
],
"source": {
"type": "Literal",
"value": "element-ui/lib/button",
"raw": "\"element-ui/lib/button\""
}
},
{
"type": "ImportDeclaration",
"specifiers": [
{
"type": "ImportDefaultSpecifier",
"local": {
"type": "Identifier",
"name": "Alert"
}
}
],
"source": {
"type": "Literal",
"value": "element-ui/lib/Alert",
"raw": "\"element-ui/lib/Alert\""
}
}
],
"sourceType": "module"
}

从上面的语法树对比,可以看出在优化前 body 里面只有一个对象,使用的组件信息存在 specifiers 里,source 指向了 element-ui,而在优化后,将两个组件分别拆成了两个对象存在 body 中,每个对象的的 specifiers 只存储一个组件,并在 source 里面指向了当前组件对应的路径。


模拟 tree-starking

既然我们已经清楚要修改语法树的位置,下面就使用 AST 来模拟 tree-sharking 功能,对语法树的操作是依赖于 babel-corebabel-types 两个核心模块的,下面先安装依赖。

1
npm install babel-core babel-types

文件:babel-plugin-my-import.js

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
const babel = require("babel-core");
const types = require("babel-types");

let code = `import { Button, Alert } from "element-ui"`;

let importPlugin = {
visitor: {
ImportDeclaration(path) {
let node = path.node;
let source = node.source.value;
let specifiers = node.specifiers;

// 判断是否是默认导出,其中一个不是默认导出,则都不是默认导出
if (!types.isImportDefaultSpecifier(specifiers[0])) {
// 如果不是默认导出,则需要转换
specifiers = specifiers.map(specifier => {
// 数组内容:当前默认导出的标识、从哪里导入
return types.importDeclaration(
[types.importDefaultSpecifier(specifier.local)],
types.stringLiteral(`${source}/lib/${specifier.local.name.toLowerCase()}`)
);
});

// 替换树结构
path.replaceWithMultiple(specifiers);
}
}
}
};

let result = babel.transform(code, {
plugins: [importPlugin]
});

console.log(result.code);

// import Button from "element-ui/lib/button";
// import Alert from "element-ui/lib/alert";

通过上面的代码可以发现我们使用 babel-corebabel-types 两个模块的核心方法对语法书进行了遍历、修改和替换,更详细的 API 可以查看 https://github.com/babel/babel/tree/6.x/packages/babel-types


结合 Webpack 使用插件

前面只是验证了 tree-sharking 中 JS 语法的转换过程,接下来将上面的代码转换成插件配合 Webpack 使用,来彻底感受 tree-sharking 的工作过程。

文件:~node_modules/babel-plugin-my-import.js

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
const babel = require("babel-core");
const types = require("babel-types");

let importPlugin = {
visitor: {
ImportDeclaration(path) {
let node = path.node;
let source = node.source.value;
let specifiers = node.specifiers;

// 判断是否是默认导出,其中一个不是默认导出,则都不是默认导出
if (!types.isImportDefaultSpecifier(specifiers[0])) {
// 如果不是默认导出,则需要转换
specifiers = specifiers.map(specifier => {
// 数组内容:当前默认导出的标识、从哪里导入
return types.importDeclaration(
[types.importDefaultSpecifier(specifier.local)],
types.stringLiteral(`${source}/lib/${specifier.local.name.toLowerCase()}`)
);
});

// 替换树解构
path.replaceWithMultiple(specifiers);
}
}
}
};

module.exports = importPlugin;

上面删掉了多余的测试代码,将模块中的 importPlugin 插件导出,并把 babel-plugin-my-import.js 移入了 node_modules 当中。

接下来安装需要的依赖:

1
2
npm install webpack webpack-cli babel-loader babel-presets-env
npm install vue element-ui --save

安装完依赖,写一个要编译的文件,使用 Webpack 进行打包,查看使用插件前和使用插件后出口文件的大小。
文件:import.js

1
2
import Vue from"vue";
import { Button, Alert } from"element-ui";

下面来写一个简单的 Webpack 配置文件。

文件:webpcak.config.js

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
    module.exports = {
mode: "development",
entry: "import.js",
output: {
filename: "bundle.js",
path: __dirname
},
module: {
rules: [{
test: /\.js$/,
use: {
loader: "babel-loader",
options: {
presets: [
"env",
],
plugins: [
// 插件:不使用插件打包注释掉该行即可
["my-import", { libararyName: "element-ui" }]
]
}
},
exclude: /node_modules/
}]
}
};

为了防止 babel 相关的依赖升级 7.0 后出现一些问题导致 Webpack 无法启动,再此贴出 package.json 文件,按照对应版本下载依赖保证上面 Webpack 配置生效。

文件:package.json

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
{
"name": "ast-lesson",
"version": "1.0.0",
"description": "tree-starking",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"babel-core": "^6.26.3",
"babel-loader": "^7.1.5",
"babel-preset-env": "^1.7.0",
"babel-types": "^6.26.0",
"escodegen": "^1.10.0",
"esprima": "^4.0.0",
"estraverse": "^4.2.0",
"webpack": "^4.16.0",
"webpack-cli": "^3.0.8"
},
"devDependencies": {
"vue": "^2.5.17",
"element-ui": "^2.4.6"
}
}

对比使用插件前后的出口文件

接下来分别在使用插件和不使用插件时执行打包命令,查看出口文件 bondle.js 的大小。

1
npx webpack

使用 babel-plugin-my-import 前:

使用 tree-starking 之前

使用 babel-plugin-my-import 后:

使用 tree-starking 之后

通过对比,可以看到使用 tree-sharking 即我们自己实现的 babel-plugin-my-import 插件后,打包的出口文件大大减小,其原因是将引入第三方库没有使用的代码全都过滤掉了,只打包了有效代码。


总结

上面对 Webpack 的 tree-sharking 进行了分析,并模拟 babel-plugin-import 简易的实现了一版 tree-sharking 的优化插件,这个过程中相信大家已经了解了 tree-sharking 的原理以及实现类似插件的思路,并已经具备了开发类似插件的基本条件,最后还有一点需要补充,tree-sharking 优化的方式是根据 ES6 语法 import “静态” 引入的特性实现的,如果要说 tree-sharking 很强大,还不如说 ES6 模块化规范 “静态” 引入的特性强大,正由于是基于 “静态” 引入,所以目前 tree-sharking 只支持遍历一层 import 关键字。