前言
在前端面试的过程中,前端工程化
一直是考察面试者能力的一个重点,而在我们常用的项目打包工具中,无论是webpack
还是rollup
,都具备一项很强大的能力——tree-shaking
,所以面试官也常常会问到tree-shaking
的原理是什么,这时我们该如何回答呢?其实核心原理就是AST
。
在前端开发中,其实AST
的应用有很多,比如我们前端项目的打包工具webpack
、代码检查工具Eslint
,代码转换工具babel
都依赖了AST
的语法分析和转换能力。
AST简单介绍
AST
是Abstract Syntax Tree
的缩写,这玩意儿的全称叫抽象语法树
,它可以用来描述我们代码的语法结构
。
举个例子:
1
2let a = 1;
3function add() {}
我这里创建了一个文件ast.js
,可以把整个文件理解为一个File
节点,存放程序体
,而里面就是我们javascript的语法节点了,我们javascript语法节点的根节点是Program
,而我们在里面定了了两个节点,第一个节点是let a = 1
,解析为ast是VariableDeclaration
,也就是变量声明节点,第二个节点是function add() {}
,解析为ast是FunctionDeclaration
,也就是函数声明节点。
这里给大家推荐一个平台:AST Explorer
,能很清晰看到JavaScript代码转化为AST后的结果,看下下面这张图就一目了然了:
AST的作用
那拿到了代码的ast信息
我们能做什么呢?
- 代码分析与转换。
AST
可以将我们的代码解析成一棵ast树
,我们自然可以将这棵树进行处理和转换
,这个最经典的应用莫过于babel
了,将我们的高级语法ES6
转换为ES5
后,然后再把ast树
转换成代码输出。除此之外,webpack的处理ES6的import和export
也是依赖了ast的能力,以及我们的jsx
的语法转换等。 - 语法检查和错误提示。我们把语法解析成
ast树
之后,自然就可以按照一定的语法规则去检查它的语法是否正确,一旦错误就可以抛出错误,提醒开发者去修正。比如我们使用的vscode
就是利用AST 提供实时的语法检查和错误提示。而在前端项目中,应用的最广的语法检查工具就是ESlint
了,基本就是前端项目必备。 - 静态类型检查。这个其实跟第二点有点像,不过第二点是侧重于
语法检查
,而这个是针对类型检查
的,比如我们的Typescript
会利用ast进行类型检查和推断。 - 代码重构。基于AST树,我们可以对代码进行
自动重构
。比如提取函数、变量重命名、语法升级、函数移动等。
其实在实际开发中,我们也可以利用做很多的事情,比如实现自动埋点、自动国际化、依赖分析和治理等等,有兴趣的小伙伴可以自行去探索。
而今天我主要介绍的就是AST
的一大应用,就是我们webpack强大的Tree-Shaking
能力。
Tree-shaking
Tree-shaking
翻译过来的意思就是摇树
。这棵树可以把它比喻为现实中的树,可以这样理解,摇树
就是把发黄、没有作用还要汲取养分的叶子给给摇掉
。把这个搬到javascript程序里面来就是,移除Javascript上下文中的未引用代码(dead-code)
。
废话不多说,直接看例子:
1
2function add(a, b) {
3 return a + b;
4}
5function multiple(a, b) {
6 return a * b;
7}
8let firstNum = 3, secondNum = 4;
9add(firstNum, secondNum);
在这段代码中,我们定义了两个函数add
和multiple
,两个变量firstNum
和secondNum
,然后调用了add
方法并将firstNum
和secondNum
作为参数传入。
很明显,multiple
方法是没有被调用到的,打包的时候其实是可以被删除掉的,以减少我们打包后的代码体积。
那么,如何删除multiple
呢?这时候就该我们的ast
就登场了!要实现这个功能,分三步走。
第一步:解析源代码生成ast
先看如下代码:
1const acorn = require('acorn');
2const fs = require('fs');
3const path = require('path');
4const buffer = fs.readFileSync(path.resolve(process.cwd(), 'test.js')).toString();
5const body = acorn.parse(buffer, {
6 ecmaVersion: 'latest',
7}).body;
这里我们选中acorn
(babel其实是基于acorn实现解析器的)来对我们的代码进行解析,运行前需要先执行npm install acorn
安装下acorn
,然后读取文件内容传入acorn
,得到ast
。
我们可以用AST Explorer
,来看一眼我们现在的AST
。
第二步:遍历ast,记录相关信息
一、先明确下我们要记录哪些信息?
我们的主要目的是收集到未引用的代码,然后将它们删除掉,所以我们最容易想到的需要收集的信息有两个:
- 收集所有的
函数或变量类型节点
- 收集所有使用过的
函数或变量类型节点
那我们先试试看:
1const acorn = require('acorn');
2const fs = require('fs');
3const path = require('path');
4const buffer = fs.readFileSync(path.resolve(process.cwd(), './src/index.js')).toString();
5const body = acorn.parse(buffer, {
6 ecmaVersion: 'latest',
7}).body;
8
9const Generator = require('./generator');
10
11const gen = new Generator();
12
13const decls = new Map();
14
15const calledDecls = [];
这里我引入了一个Generator
,其作用就是将每个ast节点转化成对应的代码
,来看下Generator
的实现:
- 先定义好
Generator
类,将其导出。
1
2class Generator {
3}
4
5module.exports = Generator;
- 定义
run
方法以及visitNode
和visitNodes
方法。
run
:调用visitNodes
方法生成代码。visitNode
:根据节点类型
,调用对应的方法进行对应处理。visitNodes
:就是处理数组类型的节点用的,内部循环调用了visitNode
方法。
1
2class Generator {
3 run(body) {
4 let str = '';
5 str += this.visitNodes(body);
6 return str;
7 }
8 visitNodes(nodes) {
9 let str = '';
10 for (const node of nodes) {
11 str += this.visitNode(node);
12 }
13 return str;
14 }
15 visitNode(node) {
16 let str = '';
17 switch (node.type) {
18 case 'VariableDeclaration':
19 str += this.visitVariableDeclaration(node);
20 break;
21 case 'VariableDeclarator':
22 str += this.visitVariableDeclarator(node);
23 break;
24 case 'Literal':
25 str += this.visitLiteral(node);
26 break;
27 case 'Identifier':
28 str += this.visitIdentifier(node);
29 break;
30 case 'BinaryExpression':
31 str += this.visitBinaryExpression(node);
32 break;
33 case 'FunctionDeclaration':
34 str += this.visitFunctionDeclaration(node);
35 break;
36 case 'BlockStatement':
37 str += this.visitBlockStatement(node);
38 break;
39 case 'CallExpression':
40 str += this.visitCallExpression(node);
41 break;
42 case 'ReturnStatement':
43 str += this.visitReturnStatement(node);
44 break;
45 case 'ExpressionStatement':
46 str += this.visitExpressionStatement(node);
47 break;
48 }
49 return str;
50 }
51}
- 再分别介绍下每个处理节点类型的方法实现
实现visitVariableDeclaration方法
1class Generator {
2
3 visitVariableDeclaration(node) {
4 let str = '';
5 str += node.kind + ' ';
6 str += this.visitNodes(node.declarations);
7 return str + '\n';
8 }
9
10}
visitVariableDeclaration
就是处理let firstNum = 3
这种变量声明的节点,node.kind
就是let/const/var
,然后变量可以一次声明多个,比如我们test.js
里面写的let firstNum = 3, secondNum = 4;
,这样node.declarations
就有两个节点了。
1class Generator {
2
3 visitVariableDeclaration(node) {
4 let str = '';
5 str += node.kind + ' ';
6 str += this.visitNodes(node.declarations);
7 return str + '\n';
8 }
9
10}
实现visitVariableDeclarator方法
1class Generator {
2
3 visitVariableDeclarator(node, kind) {
4 let str = kind ? (kind + ' ') : '';
5 str += this.visitNode(node.id);
6 str += '=';
7 str += this.visitNode(node.init);
8 return str + ';' + '\n';
9 }
10
11}
visitVariableDeclarator
就是上面VariableDeclaration
的子节点,可以接收到父节点的kind
,比如let firstNum = 3
它的id
就是变量名firstNum
,init就是初始化的值3
。
实现visitLiteral方法
1class Generator {
2 visitLiteral(node) {
3 return node.raw;
4 }
5}
Literal
即字面量
,比如let firstNum = 3
里面的3就是一个字符串字面量
,除此之外,还有数字字面量
,布尔字面量
等等,这个直接返回它的raw
属性就行啦。
实现visitIdentifier方法
1class Generator {
2 visitIdentifier(node) {
3 return node.name;
4 }
5}
Identifier
即标识符
,比如变量名、属性名、参数名
等都是,比如let firstNum = 3
的firstNum
,直接返回它的name
属性即可。
实现visitBinaryExpression方法
BinaryExpression
即二进制表达式
,比如我们的加减乘除运算都是,比如a + b
的ast如下:
我们需要拿到它的左右节点和中间的标识符拼接起来。
1class Generator {
2 visitBinaryExpression(node) {
3 let str = '';
4 str += this.visitNode(node.left);
5 str += node.operator;
6 str += this.visitNode(node.right);
7 return str + '\n';
8 }
9}
实现visitFunctionDeclaration方法
FunctionDeclaration
即为函数声明节点
,稍微复杂一些,因为我们要拼接一个函数出来。
1function add(a, b) {
2 return a + b;
3}
比如我们这个add
函数转成ast如下图:
1class Generator {
2 visitFunctionDeclaration(node) {
3 let str = 'function ';
4 str += this.visitNode(node.id);
5 str += '(';
6 for (let paramIndex = 0; paramIndex < node.params.length; paramIndex++) {
7 str += this.visitNode(node.params[paramIndex]);
8 str += ((node.params[paramIndex] === undefined) ? '' : ',')
9 }
10 str = str.slice(0, str.length - 1);
11 str += '){\n';
12 str += this.visitNode(node.body);
13 str += '}';
14 return str + '\n';
15 }
16}
先拿到node.id
,即add
,然后处理函数的参数params,由于存在多个params需要循环处理,用逗号拼接,然后再调用visitNode方法拼接node.body
即函数体
。
实现visitBlockStatement方法
BlockStatement
即块语句
,就是我们的大括号包裹的部分。比如add
函数里面的块语句
的ast如下:
1class Generator {
2 visitBlockStatement(node) {
3 let str = '';
4 str += this.visitNodes(node.body);
5 return str;
6 }
7}
只需要用visitNodes
函数拼接它的node.body
即可。
实现visitCallExpression方法
CallExpression
也就是函数调用
,比如add(firstNum, secondNum)
,它比较重要的属性有:
callee
:也就是add
arguments
:也就是调用的参数firstNum
和secondNum
其ast如下:
1class Generator {
2 visitCallExpression(node) {
3 let str = '';
4 str += this.visitIdentifier(node.callee);
5 str += '(';
6 for (const arg of node.arguments) {
7 str += this.visitNode(arg) + ',';
8 }
9 str = str.slice(0, -1);
10 str += ');';
11 return str + '\n';
12 }
13}
只需要将它的callee
以及参数arguments
处理好用小括号()
拼接起来即可。
实现visitReturnStatement方法
ReturnStatement
即返回语法
,比如return a + b
这种,它的ast如下:
它的实现也比较简单,直接拼接
node.argument
就好啦:
1class Generator {
2 visitReturnStatement(node) {
3 let str = '';
4 str = str + ' return ' + this.visitNode(node.argument);
5 return str + '\n';
6 }
7}
实现visitExpressionStatement方法
ExpressionStatement
即表达式语句
,它的特点是执行完之后有返回值
,比如add(firstNum, secondNum);
,它是在CallExpression
外面包裹了一层ExpressionStatement
,执行完之后返回函数的调用结果,其ast如下:
所以实现也比较简单,我们只需要处理它的expression
返回就好了。
1class Generator {
2 return this.visitNode(node.expression);
3}
这样,我们就完整实现Generator
了,接下来就可以开始遍历ast了。
1
2const acorn = require('acorn');
3const fs = require('fs');
4const path = require('path');
5const buffer = fs.readFileSync(path.resolve(process.cwd(), './src/index.js')).toString();
6const body = acorn.parse(buffer, {
7 ecmaVersion: 'latest',
8}).body;
9
10const Generator = require('./generator');
11
12const gen = new Generator();
13
14const decls = new Map();
15
16const calledDecls = [];
17
18body.forEach(node => {
19 if (node.type === 'FunctionDeclaration') {
20 const code = gen.run([node]);
21 decls.set(gen.visitNode(node.id), code);
22 return;
23 }
24 if (node.type === 'VariableDeclaration') {
25 for (const decl of node.declarations) {
26 decls.set(gen.visitNode(decl.id), gen.visitVariableDeclarator(decl, node.kind));
27 }
28 return;
29 }
30 if (node.type === 'ExpressionStatement') {
31 if (node.expression.type === 'CallExpression') {
32 const callNode = node.expression;
33 calledDecls.push(gen.visitIdentifier(callNode.callee));
34 for (const arg of callNode.arguments) {
35 if (arg.type === 'Identifier') {
36 calledDecls.push(arg.name);
37 }
38 }
39 }
40 }
41 if (node.type === 'Identifier') {
42 calledDecls.push(node.name);
43 }
44})
45console.log('decls', decls);
46console.log('calledDecls', decls);
使用node tree-shaking.js
运行一下,结果如下:
很明显,我们decls
总共有四个节点函数或变量类型节点
,而被调用的calledDecls
只有三个,很明显multiple
函数没被调用,可以被tree-shaking
掉,拿到这些信息之后,接下来我们开始生成tree-shaking
后的代码。
第三步:根据第二步得到的信息,生成新代码
1
2code = calledDecls.map(c => decls.get(c)).join('');
3console.log(code);
我们直接遍历calledDecls
生成新的源代码,打印结果如下:
咦!跟我们最开始的文件内容一比,发现
multiple
虽然被移除掉了,但我们的函数调用语句add(firstNum, secondNum);
却丢了,那我们简单处理下。
我们声明一个code
数组:
1
2const calledDecls = [];
3
4const code = [];
5
然后把不是FunctionDeclaration
和VariableDeclaration
的信息都存储一下:
1
2body.forEach(node => {
3 if (node.type === 'FunctionDeclaration') {
4 const code = gen.run([node]);
5 decls.set(gen.visitNode(node.id), code);
6 return;
7 }
8 if (node.type === 'VariableDeclaration') {
9 for (const decl of node.declarations) {
10 decls.set(gen.visitNode(decl.id), gen.visitVariableDeclarator(decl, node.kind));
11 }
12 return;
13 }
14 if (node.type === 'ExpressionStatement') {
15 if (node.expression.type === 'CallExpression') {
16 const callNode = node.expression;
17 calledDecls.push(gen.visitIdentifier(callNode.callee));
18 for (const arg of callNode.arguments) {
19 if (arg.type === 'Identifier') {
20 calledDecls.push(arg.name);
21 }
22 }
23 }
24 }
25 if (node.type === 'Identifier') {
26 calledDecls.push(node.name);
27 }
28
29 code.push(gen.run([node]));
30})
最后输出的时候,把code里面的信息也带上:
1
2code = calledDecls.map(c => decls.get(c)).concat(code).join('');
3console.log(code);
然后运行一下,打印结果如下:
这样我们一个简易版本的tree-shaking
就完成了,当然,webpack的tree-shaking
的能力远比这个强大的多,我们只是写了个最简单版本,实际项目要比这复杂得多:
- 处理文件依赖
import/export
- 作用域
scope
的处理 递归
tree-shaking,因为可能去除了某些代码后,又会产生新的未被引用的代码,所以需要递归处理- 等等..
小结
本文通过ast
的语法分析能力,分析JavaScript代码中未被引用的函数或变量
,进而实现了一个最简单版本的tree-shaking
,希望大家看完都能有所收获哦~