前端编译原理-编译器流程

  目录

简单介绍前端编译器的工作流程

引言

本篇文章主要针对前端的JSX语法标签进行编译解析。
接下来将使用 Esprima 结合一个简单的 Demo 来实现串通整个编译器的工作流程。

解析阶段 (Parsing)

首先,在编译器的初始阶段会接受一段代码,通常会是一串字符串。
如下JSX代码:

1
<div id="app"><p>hello</p>Jue Jin</div>

编译器拿到这段字符串代码之后会进入解析阶段,在解析阶段主要会做以下两件事:词法分析和语法分析

词法分析

当编译器接受到上边的字符串时,首先会将传入的字符串按照词法效果分割成为一系列被称为 Token 的东西,这一步通常被称为分词。
先来看看利用 Esprima Api 查看将上述代码进行词法分析后的结果。

1
2
3
4
5
// parse1.js
const esprima = require('esprima');
// 配置支持jsx和tokens 利用parseScript Api 打印对应的tokens
const { tokens } = esprima.parseScript('<div id="app"><p>hello</p>Jue Jin</div>', { jsx: true, tokens: true });
console.log(tokens,'tokens')

此时上方的语句经过词法分析会被一步一步拆分成为这样的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[
{"type": "Punctuator", "value": "<"},
{"type": "JSXIdentifier","value": "div"},
{"type": "JSXIdentifier","value": "id"},
{"type": "Punctuator","value": "="},
{"type": "String","value": "\"app\""},
{"type": "Punctuator","value": ">"},
{"type": "Punctuator","value": "<"},
{"type": "JSXIdentifier","value": "p"},
{"type": "Punctuator","value": ">"},
{"type": "JSXText","value": "Hello"},
{"type": "Punctuator","value": "<"},
{"type": "Punctuator","value": "/"},
{"type": "JSXIdentifier","value": "p"},
{"type": "Punctuator","value": ">"},
{"type": "JSXText","value": "Jue Jin"},
{"type": "Punctuator","value": "<"},
{"type": "Punctuator","value": "/"},
{"type": "JSXIdentifier","value": "div"},
{"type": "Punctuator","value": ">"}
]

可以看到针对上方传入的 JSX 语法被解析成为了一个 Token 组成的数组,数组中每一个对象即代表一个 Token 。
每个 Token 都是拥有对应的 type 属性表示它的类型以及 value 属性表示它的值。
这一步通过解析阶段的词法分析将传入的代码分割成为了一个个 Token ,通常使用有限状态机是词法分析的最佳途径。

语法分析

上一步通过词法分析将输入的代码分割成为了一个 tokens 的数组,在这之后需要将 tokens 进行语法分析从而转化成为真正的抽象语法树(AST)形式。
所谓抽象语法树,你可以将它理解成为一颗圣诞树。上述 tokens 中每一个 token 都可以看作成为该圣诞树中的一个节点。
语法分析正式将上述分成的每个 Token 抽象成为一棵树,从而描述每个 Token 节点之间的关系。

1
2
3
4
5
// parse2.js
const esprima = require('esprima');
// 调用parseScript获得输入代码生成的抽象语法树
const ast = esprima.parseScript('<div id="app"><p>hello</p>Jue Jin</div>', { jsx: true });
console.log(ast, 'ast')

上述的 Token 在经过语法分析后会变成这样的数据结构:

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
{
"type": "Program",
"body": [{
"type": "ExpressionStatement",
"expression": {
"type": "JSXElement",
"openingElement": {
"type": "JSXOpeningElement",
"name": {
"type": "JSXIdentifier",
"name": "div"
},
"selfClosing": false,
"attributes": [{
"type": "JSXAttribute",
"name": {
"type": "JSXIdentifier",
"name": "id"
},
"value": {
"type": "Literal",
"value": "app",
"raw": "\"app\""
}
}]
},
"children": [{
"type": "JSXElement",
"openingElement": {
"type": "JSXOpeningElement",
"name": {
"type": "JSXIdentifier",
"name": "p"
},
"selfClosing": false,
"attributes": []
},
"children": [{
"type": "JSXText",
"value": "Hello",
"raw": "Hello"
}],
"closingElement": {
"type": "JSXClosingElement",
"name": {
"type": "JSXIdentifier",
"name": "p"
}
}
}, {
"type": "JSXText",
"value": "Jue Jin",
"raw": "Jue Jin"
}],
"closingElement": {
"type": "JSXClosingElement",
"name": {
"type": "JSXIdentifier",
"name": "div"
}
}
}
}],
"sourceType": "script"
}

所谓的语法分析阶段其实就是将 Tokens 经过一系列语法分析成为这颗树,树中的每个节点都会保存各自节点对应的信息。
同时因为树形的数据结构也很好的反应出了各个节点之间的关系。

转化阶段 (Transformaiton)

编译器首先经过转移阶段后将输入的代码转变成为 AST 。之后会进入转化阶段,所谓转化阶段本质上就是对于抽象语法树的一个深度遍历过程。
在转化阶段,会遍历这颗抽象语法树从而对于匹配节点进行增删改查从而修改树形结构。
比如想为 p 节点上添加一个 id 为 text 的属性,那么此时在遍历 AST 的过程中遍历到对应节点时修改对应的节点属性即可,当然也可以直接粗暴的替换整个节点。

关于 Estraverse ,它是针对 Esprima 生成的抽象语法树进行深度遍历的一个工具库。因为 Estraverse 这个库不支持 JSX 语法,所以这里使用它的一个拓展工具库 estraverse-fb 来实现 JSX 转化的抽象语法树的遍历。

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
// transform.js
const esprima = require('esprima');
// 深度遍历AST的工具库
const esTraverseFb = require('estraverse-fb')
// 生成AST节点的工具
const { builders } = require('ast-types')

const ast = esprima.parseScript('<div id="app"><p>hello</p>Jue Jin</div>', { jsx: true });

// 深度优先的方式
esTraverseFb.traverse(ast, {
// 进入每个节点时都会出发enter函数
enter: function (node) {
const { type, openingElement } = node
// 判断当前进入的节点是否是匹配的p节点
if (type === 'JSXElement' && openingElement.name.name === 'p') {
// 生成当前需要添加的属性节点
const attribute = builders.jsxAttribute(
// 第一个参数是name
builders.jsxIdentifier('id'),
// 第二个参数是value
builders.literal('text')
)
// 为该节点的开始标签中添加生成的属性 id='text'
openingElement.attributes.push(attribute)
}
},
// 离开每个节点时会触发leave函数
leave: function () {
// nothing
}
});
console.log(ast);

此时经过上述的转化,我们更改了原本的 AST 结构。我们将原始的 p 标签对应的节点修改成为了这样的结构:

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
{
"type": "JSXElement",
"openingElement": {
"type": "JSXOpeningElement",
"name": {
"type": "JSXIdentifier",
"name": "p"
},
"selfClosing": false,
// 这里我们为attributes中添加了一个属性节点
"attributes": [{
"name": {
"name": "id",
"loc": null,
"type": "JSXIdentifier",
"comments": null,
"optional": false,
"typeAnnotation": null
},
"value": {
"value": "text",
"loc": null,
"type": "Literal",
"comments": null,
"regex": null
},
"loc": null,
"type": "JSXAttribute",
"comments": null
}]
}
}

生成阶段 (Code Generation)

上述经过解析阶段 (Parsing) 将输入的字符串转化成了抽象语法树 AST 结构。
之后经过转化阶段 (Transformaiton) 对于生成的抽象语法树进行深度遍历节点,从而对于某些节点进行了修改。‘
此时编译器拥有了经过处理后的抽象语法树,此时需要做的当然是将所谓的树形结构的抽象语法树转化成为新的代码。
这一步通常称为生成阶段(Code Generation):通过抽象语法树反向转化成为生成的代码,此时最新的代码是根据修改后的 AST 生成的代码。
在生成阶段本质上就是遍历抽象语法树,根据抽象语法树上每个节点的类型和属性递归调用从而生成对应的字符串代码。
在代码生成阶段,可以借助 EscodeGen 将 AST 转化成为新的字符串代码。

因为 EscodeGen 对于 JSX 语法并不支持,所以这里具体就不详细演示用法了,有兴趣的朋友可以自行尝试。

上方将代码修改的抽象语法树会生成新的代码:

1
<div id="app"><p id="text">hello</p>Jue Jin</div>

总结

综上所述,一次编译器工作流程中包括解析、转化、生成这三个步骤。
如果自己想实现,请参考本站的【tiny编译工具】
上面例子代码