1054 lines
28 KiB
Markdown
1054 lines
28 KiB
Markdown
# 模板编译器
|
||
|
||
>面试题:说一下 Vue 中 Compiler 的实现原理是什么?
|
||
|
||
**Vue中的编译器**
|
||
|
||
Vue 里面的编译器,主要负责将开发者所书写的模板转换为渲染函数。例如:
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<h1 :id="someId">Hello</h1>
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
编译后的结果为:
|
||
|
||
```js
|
||
function render(){
|
||
return h('div', [
|
||
h('h1', {id: someId}, 'Hello')
|
||
])
|
||
}
|
||
```
|
||
|
||
这里整个过程并非一触而就的,而是经历一个又一个步骤一点一点转换而来的。
|
||
|
||
整体来讲,整个编译过程如下图所示:
|
||
|
||

|
||
|
||
可以看到,在编译器的内部,实际上又分为了:
|
||
|
||
- 解析器:负责将模板解析为所对应的 AST
|
||
- 转换器:负责将模板 AST 转换为 JavaScript AST
|
||
- 生成器:根据 JavaScript 的 AST 生成最终的渲染函数
|
||
|
||
|
||
|
||
**解析器**
|
||
|
||
解析器的核心作用是负责将模板解析为所对应的模板 AST。
|
||
|
||
首先用户所书写的模板,例如:
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<h1 :id="someId">Hello</h1>
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
对于解析器来讲仍然就是一段字符串而已,类似于:
|
||
|
||
```js
|
||
'<template><div><h1 :id="someId">Hello</h1></div></template>'
|
||
```
|
||
|
||
那么解析器是如何进行解析的呢?这里涉及到一个 <u>有限状态机</u> 的概念。
|
||
|
||
### FSM
|
||
|
||
FSM,英语全称为 Finite State Machine,翻译成中文就是有限状态机,它首先定义了**一组状态**,然后还定义了状态之间的转移以及触发这些转移的事件。然后就会去解析字符串里面的每一个字符,根据字符做状态的转换。
|
||
|
||
举一个例子,假设我们要解析的模板内容为:
|
||
|
||
```js
|
||
'<p>Vue</p>'
|
||
```
|
||
|
||
那么整个状态的迁移过程如下:
|
||
|
||
1. 状态机一开始处于 **初始状态**。
|
||
2. 在 **初始状态** 下,读取字符串的第一个字符 < ,然后状态机的状态会更新为 **标签开始状态**。
|
||
3. 接下来继续读取下一个字符 p,由于 p 是字母,所以状态机的状态会更新为 **标签名称开始状态**。
|
||
4. 接下来读取的下一个字符为 >,状态机的状态会回到 **初始状态**,并且会记录在标签状态下产生的标签名称 p。
|
||
5. 读取下一个字符 V,此时状态机会进入到 **文本状态**。
|
||
6. 读取下一个字符 u,状态机仍然是 **文本状态**。
|
||
7. 读取下一个字符 e,状态机仍然是 **文本状态**。
|
||
8. 读取下一个字符 <,此时状态机会进入到 **标签开始状态**。
|
||
9. 读取下一个字符 / ,状态机会进入到 **标签结束状态**。
|
||
10. 读取下一个字符 p,状态机进入 **标签名称结束状态**。
|
||
11. 读取下一个字符 >,状态机进重新回到 **初始状态**。
|
||
|
||
具体如下图所示:
|
||
|
||
<img src="https://xiejie-typora.oss-cn-chengdu.aliyuncs.com/2023-11-13-060437.png" alt="image-20231113140436969" style="zoom:60%;" />
|
||
|
||
```js
|
||
let x = 10 + 5;
|
||
```
|
||
|
||
```
|
||
token:
|
||
let(关键字) x(标识符) =(运算符) 10(数字) +(运算符) 5(数字) ;(分号)
|
||
```
|
||
|
||
对应代码:
|
||
|
||
```js
|
||
const template = '<p>Vue</p>';
|
||
// 首先定义一些状态
|
||
const State = {
|
||
initial: 1, // 初始状态
|
||
tagOpen: 2, // 标签开始状态
|
||
tagName: 3, // 标签名称开始状态
|
||
text: 4, // 文本状态
|
||
tagEnd: 5, // 标签结束状态
|
||
tagEndName: 6 // 标签名称结束状态
|
||
}
|
||
|
||
// 判断字符是否为字母
|
||
function isAlpha(char) {
|
||
return (char >= "a" && char <= "z") || (char >= "A" && char <= "Z");
|
||
}
|
||
|
||
// 将字符串解析为 token
|
||
function tokenize(str){
|
||
// 初始化当前状态
|
||
let currentState = State.initial;
|
||
// 用于缓存字符
|
||
const chars = [];
|
||
// 存储解析出来的 token
|
||
const tokens = [];
|
||
|
||
while(str){
|
||
const char = str[0]; // 获取字符串里面的第一个字符
|
||
|
||
switch(currentState){
|
||
case State.initial:{
|
||
if(char === '<'){
|
||
currentState = State.tagOpen;
|
||
// 消费一个字符
|
||
str = str.slice(1);
|
||
} else if(isAlpha(char)){
|
||
// 判断是否为字母
|
||
currentState = State.text;
|
||
chars.push(char);
|
||
// 消费一个字符
|
||
str = str.slice(1);
|
||
}
|
||
break;
|
||
}
|
||
case State.tagOpen: {
|
||
// 相应的状态处理
|
||
}
|
||
case State.tagName: {
|
||
// 相应的状态处理
|
||
}
|
||
}
|
||
}
|
||
|
||
return tokens;
|
||
}
|
||
tokenize(template);
|
||
```
|
||
|
||
最终解析出来的 token:
|
||
|
||
```js
|
||
[
|
||
{type: 'tag', name: 'p'}, // 开始标签
|
||
{type: 'text', content: 'Vue'}, // 文本节点
|
||
{type: 'tagEnd', name: 'p'}, // 结束标签
|
||
]
|
||
```
|
||
|
||
|
||
|
||
**构造模板AST**
|
||
|
||
根据 token 列表创建模板 AST 的过程,其实就是对 token 列表进行扫描的过程。从列表的第一个 token 开始,按照顺序进行扫描,直到列表中所有的 token 处理完毕。
|
||
|
||
在这个过程中,我们需**要维护一个栈**,这个栈将用于维护元素间的父子关系。每遇到一个开始标签节点,就构造一个 Element 类型的 AST 节点,并将其压入栈中。
|
||
|
||
类似的,每当遇到一个结束标签节点,我们就将当前栈顶的节点弹出。
|
||
|
||
举个例子,假设我们有如下的模板内容:
|
||
|
||
```vue
|
||
'<div><p>Vue</p><p>React</p></div>'
|
||
```
|
||
|
||
经过上面的 tokenize 后能够得到如下的数组:
|
||
|
||
```js
|
||
[
|
||
{"type": "tag","name": "div"},
|
||
{"type": "tag","name": "p"},
|
||
{"type": "text","content": "Vue"},
|
||
{"type": "tagEnd","name": "p"},
|
||
{"type": "tag","name": "p"},
|
||
{"type": "text","content": "React"},
|
||
{"type": "tagEnd","name": "p"},
|
||
{"type": "tagEnd","name": "div"}
|
||
]
|
||
```
|
||
|
||
那么接下来会遍历这个数组(也就是扫描 tokens 列表)
|
||
|
||
1. 一开始有一个 elementStack 栈,刚开始有一个 Root 节点,[ Root ]
|
||
|
||
2. 首先是一个 **div tag**,创建一个 Element 类型的 AST 节点,并将其压栈到 elementStack,当前的栈为 `[ Root, div ]`,div 会作为 Root 的子节点
|
||
|
||
<img src="https://xiejie-typora.oss-cn-chengdu.aliyuncs.com/2023-11-13-070249.png" alt="image-20231113150248725" style="zoom:50%;" />
|
||
|
||
3. 接下来是 **p tag**,创建一个 Element 类型的 AST 节点,同样会压栈到 elementStack,当前的栈为 `[ Root, div, p ]`,p 会作为 div 的子节点
|
||
|
||
<img src="https://xiejie-typora.oss-cn-chengdu.aliyuncs.com/2023-11-13-070335.png" alt="image-20231113150335866" style="zoom:50%;" />
|
||
|
||
4. 接下来是 **Vue text**,此时会创建一个 Text 类型的 AST 节点,作为 p 的子节点。
|
||
|
||
<img src="https://xiejie-typora.oss-cn-chengdu.aliyuncs.com/2023-11-13-070356.png" alt="image-20231113150356416" style="zoom:50%;" />
|
||
|
||
5. 接下来是 **p tagEnd**,发现是一个结束标签,所以会将 p 这个 AST 节点弹出栈,当前的栈为 `[ Root, div ]`
|
||
|
||
6. 接下来是 **p tag**,同样创建一个 Element 类型的 AST 节点,压栈后栈为 `[ Root, div, p ]`,p 会作为 div 的子节点
|
||
|
||
<img src="https://xiejie-typora.oss-cn-chengdu.aliyuncs.com/2023-11-13-070442.png" alt="image-20231113150442450" style="zoom:50%;" />
|
||
|
||
7. 接下来是 **React text**,此时会创建一个 Text 类型的 AST 节点,作为 p 的子节点。
|
||
|
||
<img src="https://xiejie-typora.oss-cn-chengdu.aliyuncs.com/2023-11-13-070537.png" alt="image-20231113150537351" style="zoom:50%;" />
|
||
|
||
8. 接下来是 **p tagEnd**,发现是一个结束标签,所以会将 p 这个 AST 节点弹出栈,当前的栈为 `[ Root, div ]`
|
||
|
||
9. 最后是 **div tagEnd**,发现是一个结束标签,将其弹出,栈区重新为 `[ Root ]`,至此整个 AST 构建完毕
|
||
|
||
落地到具体的代码,大致就是这样的:
|
||
|
||
```js
|
||
// 解析器
|
||
function parse(str){
|
||
const tokens = tokenize(str);
|
||
|
||
// 创建Root根AST节点
|
||
const root = {
|
||
type: 'Root',
|
||
children: []
|
||
}
|
||
|
||
// 创建一个栈
|
||
const elementStack = [root]
|
||
|
||
while(tokens.length){
|
||
// 获取当前栈顶点作为父节点,也就是栈数组最后一项
|
||
const parent = elementStack[elementStack.length - 1];
|
||
// 从 tokens 列表中依次取出第一个 token
|
||
const t = tokens[0];
|
||
|
||
switch(t.type){
|
||
// 根据不同的type做不同的处理
|
||
case 'tag':{
|
||
// 创建一个Element类型的AST节点
|
||
const elementNode = {
|
||
type: 'Element',
|
||
tag: t.name,
|
||
children: []
|
||
}
|
||
// 将其添加为父节点的子节点
|
||
parent.children.push(elementNode)
|
||
// 将当前节点压入栈里面
|
||
elementStack.push(elementNode)
|
||
break;
|
||
}
|
||
case 'text':
|
||
// 创建文本类型的 AST 节点
|
||
const textNode = {
|
||
type: 'Text',
|
||
content: t.content
|
||
}
|
||
// 将其添加到父级节点的 children 中
|
||
parent.children.push(textNode)
|
||
break
|
||
case 'tagEnd':
|
||
// 遇到结束标签,将当前栈顶的节点弹出
|
||
elementStack.pop()
|
||
break
|
||
}
|
||
// 将处理过的 token 弹出去
|
||
tokens.shift();
|
||
}
|
||
}
|
||
```
|
||
|
||
最终,经过上面的处理,就得到了模板的抽象语法树:
|
||
|
||
```
|
||
{
|
||
"type": "Root",
|
||
"children": [
|
||
{
|
||
"type": "Element",
|
||
"tag": "div",
|
||
"children": [
|
||
{
|
||
"type": "Element",
|
||
"tag": "p",
|
||
"children": [
|
||
{
|
||
"type": "Text",
|
||
"content": "Vue"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"type": "Element",
|
||
"tag": "p",
|
||
"children": [
|
||
{
|
||
"type": "Text",
|
||
"content": "React"
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
|
||
|
||
**转换器**
|
||
|
||
目前为止,我们已经得到了模板的 AST,回顾一下 Vue 中整个模板的编译过程,大致如下:
|
||
|
||
```js
|
||
// 编译器
|
||
function compile(template){
|
||
// 1. 解析器对模板进行解析,得到模板的AST
|
||
const ast = parse(template)
|
||
// 2. 转换器:将模板AST转换为JS AST
|
||
transform(ast)
|
||
// 3. 生成器:在 JS AST 的基础上生成 JS 代码
|
||
const code = genrate(ast)
|
||
|
||
return code;
|
||
}
|
||
```
|
||
|
||
转换器的核心作用就是负责将模板 AST 转换为 JavaScript AST。
|
||
|
||
整体来讲,转换器的编写分为两大部分:
|
||
|
||
- 模板 AST 的遍历与转换
|
||
- 生成 JavaScript AST
|
||
|
||
|
||
|
||
**模板AST的遍历与转换**
|
||
|
||
步骤一:先书写一个简单的工具方法,方便查看一个模板 AST 中的节点信息。
|
||
|
||
```js
|
||
function dump(node, indent = 0) {
|
||
// 获取当前节点的类型
|
||
const type = node.type;
|
||
// 根据节点类型构建描述信息
|
||
// 对于根节点,描述为空;对于元素节点,使用标签名;对于文本节点,使用内容
|
||
const desc =
|
||
node.type === "Root"
|
||
? ""
|
||
: node.type === "Element"
|
||
? node.tag
|
||
: node.content;
|
||
|
||
// 打印当前节点信息,包括类型和描述
|
||
// 使用重复的"-"字符来表示缩进(层级)
|
||
console.log(`${"-".repeat(indent)}${type}: ${desc}`);
|
||
|
||
// 如果当前节点有子节点,递归调用dump函数打印每个子节点
|
||
if (node.children) {
|
||
node.children.forEach((n) => dump(n, indent + 2));
|
||
}
|
||
}
|
||
```
|
||
|
||
步骤二:接下来下一步就是遍历整棵模板 AST 树,并且能够做一些改动
|
||
|
||
```js
|
||
function tranverseNode(ast){
|
||
// 获取到当前的节点
|
||
const currentNode = ast;
|
||
|
||
// 将p修改为h1
|
||
if(currentNode.type === 'Element' && currentNode.tag === 'p'){
|
||
currentNode.tag = 'h1';
|
||
}
|
||
|
||
// 新增需求:将文本节点全部改为大写
|
||
if(currentNode.type === 'Text'){
|
||
currentNode.content = currentNode.content.toUpperCase();
|
||
}
|
||
|
||
// 获取当前节点的子节点
|
||
const children = currentNode.children;
|
||
if(children){
|
||
for(let i = 0;i< children.length; i++){
|
||
tranverseNode(children[i])
|
||
}
|
||
}
|
||
}
|
||
|
||
function transform(ast){
|
||
// 在遍历模板AST树的时候,可以针对部分节点作出一些修改
|
||
tranverseNode(ast);
|
||
|
||
console.log(dump(ast));
|
||
}
|
||
```
|
||
|
||
目前tranverseNode虽然能够正常工作,但是内部有两个职责:遍历、转换,接下来需要将这两个职责进行解耦。
|
||
|
||
步骤三:在 transform 里面维护一个上下文对象(环境:包含执行代码时用到的一些信息)
|
||
|
||
```js
|
||
// 需要将之前的转换方法全部提出来,每一种转换提取成一个单独的方法
|
||
function transformElement(node){
|
||
if(node.type === 'Element' && node.tag === 'p'){
|
||
node.tag = 'h1';
|
||
}
|
||
}
|
||
|
||
function transformText(node){
|
||
if(node.type === 'Text'){
|
||
node.content = node.content.toUpperCase();
|
||
}
|
||
}
|
||
|
||
// 该方法只负责遍历,转换的工作交给转换函数
|
||
// 转换函数是存放于上下文对象里面的
|
||
function tranverseNode(ast, context) {
|
||
// 获取到当前的节点
|
||
context.currentNode = ast;
|
||
|
||
// 从上下文对象里面拿到所有的转换方法
|
||
const transforms = context.nodeTransforms;
|
||
|
||
for (let i = 0; i < transforms.length; i++) {
|
||
transforms[i](context.currentNode);
|
||
}
|
||
|
||
// 获取当前节点的子节点
|
||
const children = context.currentNode.children;
|
||
if (children) {
|
||
for (let i = 0; i < children.length; i++) {
|
||
// 更新上下文里面的信息
|
||
context.parent = context.currentNode;
|
||
context.childIndex = i;
|
||
tranverseNode(children[i], context);
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
function transform(ast){
|
||
// 上下文对象:包含一些重要信息
|
||
const context = {
|
||
currentNode: null, // 存储当前正在转换的节点
|
||
childIndex: 0, // 子节点在父节点的 children 数组中的索引
|
||
parent: null, // 存储父节点
|
||
nodeTransforms: [transformElement, transformText], // 存储具体的转换方法
|
||
}
|
||
|
||
// 在遍历模板AST树的时候,可以针对部分节点作出一些修改
|
||
tranverseNode(ast, context);
|
||
|
||
|
||
}
|
||
```
|
||
|
||
步骤四:完善 context 上下文对象,这里主要是添加2个方法
|
||
|
||
1. 替换节点方法
|
||
2. 删除节点方法
|
||
|
||
```js
|
||
const context = {
|
||
currentNode: null, // 存储当前正在转换的节点
|
||
childIndex: 0, // 子节点在父节点的 children 数组中的索引
|
||
parent: null, // 存储父节点
|
||
// 替换节点
|
||
replaceNode(node){
|
||
context.parent.children[context.childIndex] = node;
|
||
context.currentNode = node;
|
||
},
|
||
// 删除节点
|
||
removeNode(){
|
||
if(context.parent){
|
||
context.parent.children.splice(context.childIndex, 1);
|
||
context.currentNode = null;
|
||
}
|
||
},
|
||
nodeTransforms: [transformElement, transformText], // 存储具体的转换方法
|
||
}
|
||
```
|
||
|
||
注意因为存在删除节点的操作,所以在tranverseNode方法里面执行转换函数之后,需要进行非空的判断:
|
||
|
||
```js
|
||
function tranverseNode(ast, context) {
|
||
// 获取到当前的节点
|
||
context.currentNode = ast;
|
||
|
||
// 从上下文对象里面拿到所有的转换方法
|
||
const transforms = context.nodeTransforms;
|
||
|
||
for (let i = 0; i < transforms.length; i++) {
|
||
transforms[i](context.currentNode, context);
|
||
// 由于删除节点的时候,当前节点会被置为null,所以需要判断
|
||
// 如果当前节点为null,直接返回
|
||
if(!context.currentNode) return;
|
||
}
|
||
|
||
// 获取当前节点的子节点
|
||
const children = context.currentNode.children;
|
||
if (children) {
|
||
for (let i = 0; i < children.length; i++) {
|
||
// 更新上下文里面的信息
|
||
context.parent = context.currentNode;
|
||
context.childIndex = i;
|
||
tranverseNode(children[i], context);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
步骤五:解决节点处理的次数问题
|
||
|
||
目前来讲,遍历的顺序是深度遍历,从父节点到子节点。但是我们的需求是:子节点处理完之后,重新回到父节点,对父节点进行处理。
|
||
|
||
首先需要对转换函数进行改造:返回一个函数
|
||
|
||
```js
|
||
function transformText(node, context) {
|
||
// 省略第一次处理....
|
||
|
||
return ()=>{
|
||
// 对节点再次进行处理
|
||
}
|
||
}
|
||
```
|
||
|
||
tranverseNode需要拿一个数组存储转换函数返回的函数:
|
||
|
||
```js
|
||
function tranverseNode(ast, context) {
|
||
// 获取到当前的节点
|
||
context.currentNode = ast;
|
||
|
||
// 1. 增加一个数组,用于存储转换函数返回的函数
|
||
const exitFns = []
|
||
|
||
// 从上下文对象里面拿到所有的转换方法
|
||
const transforms = context.nodeTransforms;
|
||
|
||
for (let i = 0; i < transforms.length; i++) {
|
||
// 执行转换函数的时候,接收其返回值
|
||
const onExit = transforms[i](context.currentNode, context);
|
||
if(onExit){
|
||
exitFns.push(onExit)
|
||
}
|
||
// 由于删除节点的时候,当前节点会被置为null,所以需要判断
|
||
// 如果当前节点为null,直接返回
|
||
if(!context.currentNode) return;
|
||
}
|
||
|
||
// 获取当前节点的子节点
|
||
const children = context.currentNode.children;
|
||
if (children) {
|
||
for (let i = 0; i < children.length; i++) {
|
||
// 更新上下文里面的信息
|
||
context.parent = context.currentNode;
|
||
context.childIndex = i;
|
||
tranverseNode(children[i], context);
|
||
}
|
||
}
|
||
|
||
// 在节点处理完成之后,执行exitFns里面所有的函数
|
||
// 执行的顺序是从后往前依次执行
|
||
let i = exitFns.length;
|
||
while(i--){
|
||
exitFns[i]()
|
||
}
|
||
}
|
||
```
|
||
|
||
|
||
|
||
**生成JS AST**
|
||
|
||
要生成 JavaScript 的 AST,我们首先需要知道 JavaScript 的 AST 是如何描述代码的。
|
||
|
||
假设有这么一段代码:
|
||
|
||
```js
|
||
function render(){
|
||
return null
|
||
}
|
||
```
|
||
|
||
那么所对应的 JS AST 为:
|
||
|
||

|
||
|
||
这里有几个比较关键的部分:
|
||
|
||
- id:对应函数的名称,类型为 Identifier
|
||
- params:对应的是函数的参数,是一个数组
|
||
- body:对应的是函数体,由于函数体可以有多条语句,因此是一个数组
|
||
|
||
要查看一段 JS 代码所对应的 AST 结构,可以在 [这里](https://astexplorer.net/) 进行查看。
|
||
|
||
于是,我们可以仿造上面的样子,**自己设计一个基本的数据结构**来描述函数声明语句,例如:
|
||
|
||
```js
|
||
const FunctionDeclNode = {
|
||
type: 'FunctionDecl', // 代表该节点是一个函数声明
|
||
id: {
|
||
type: 'Identifier'
|
||
name: 'render' // name 用来存储函数名称
|
||
},
|
||
params: [], // 函数参数
|
||
body: [
|
||
{
|
||
type: 'ReturnStatement',
|
||
return: null
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
> 对比真实的 AST,这里去除了箭头函数、生成器函数、async 函数等情况。
|
||
|
||
接下来回到我们上面的模板,假设模板内容仍然为:
|
||
|
||
```html
|
||
<div><p>Vue</p><p>React</p></div>
|
||
```
|
||
|
||
那么转换出来的渲染函数应该是:
|
||
|
||
```js
|
||
function render(){
|
||
return h('div', [
|
||
h('p', 'Vue'),
|
||
h('p', 'React'),
|
||
])
|
||
}
|
||
```
|
||
|
||
这里出现了 h 函数的调用以及数组表达式还有字符串表达式,仍然可以去参阅这段代码真实的 AST。
|
||
|
||
这里 h 函数对应的应该是:
|
||
|
||
```js
|
||
// 我们自己设计一个节点表示 h 函数的调用
|
||
const callExp = {
|
||
type: 'CallExpression',
|
||
callee: {
|
||
type: 'Identifier',
|
||
name: 'h'
|
||
}
|
||
}
|
||
```
|
||
|
||
字符串对应的是:
|
||
|
||
```js
|
||
// 我们自己设计字符串对应的节点
|
||
const Str = {
|
||
type: 'StringLiteral',
|
||
value: 'div'
|
||
}
|
||
```
|
||
|
||
> 这里以最外层的 div 字符串为例
|
||
|
||
数组对应的是:
|
||
|
||
```js
|
||
const Arr = {
|
||
type: 'ArrayExpression',
|
||
// 数组中的元素
|
||
elements: []
|
||
}
|
||
```
|
||
|
||
因此按照我们所设计的 AST 数据结构,上面的模板最终转换出来的 JavaScript AST 应该是这样的:
|
||
|
||
```js
|
||
{
|
||
"type": "FunctionDecl",
|
||
"id": {
|
||
"type": "Identifier",
|
||
"name": "render"
|
||
},
|
||
"params": [],
|
||
"body": [
|
||
{
|
||
"type": "ReturnStatement",
|
||
"return": {
|
||
"type": "CallExpression",
|
||
"callee": {"type": "Identifier", "name": "h"},
|
||
"arguments": [
|
||
{"type": "StringLiteral", "value": "div"},
|
||
{"type": "ArrayExpression","elements": [
|
||
{
|
||
"type": "CallExpression",
|
||
"callee": {"type": "Identifier", "name": "h"},
|
||
"arguments": [
|
||
{"type": "StringLiteral", "value": "p"},
|
||
{"type": "StringLiteral", "value": "Vue"}
|
||
]
|
||
},
|
||
{
|
||
"type": "CallExpression",
|
||
"callee": {"type": "Identifier", "name": "h"},
|
||
"arguments": [
|
||
{"type": "StringLiteral", "value": "p"},
|
||
{"type": "StringLiteral", "value": "React"}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
我们需要一些辅助函数,这些辅助函数都很简单,一并给出如下:
|
||
|
||
```js
|
||
function createStringLiteral(value) {
|
||
return {
|
||
type: 'StringLiteral',
|
||
value
|
||
}
|
||
}
|
||
|
||
function createIdentifier(name) {
|
||
return {
|
||
type: 'Identifier',
|
||
name
|
||
}
|
||
}
|
||
|
||
function createArrayExpression(elements) {
|
||
return {
|
||
type: 'ArrayExpression',
|
||
elements
|
||
}
|
||
}
|
||
|
||
function createCallExpression(callee, arguments) {
|
||
return {
|
||
type: 'CallExpression',
|
||
callee: createIdentifier(callee),
|
||
arguments
|
||
}
|
||
}
|
||
```
|
||
|
||
有了这些辅助函数后,接下来我们来修改转换函数。
|
||
|
||
首先是文本转换
|
||
|
||
```js
|
||
function transformText(node, context){
|
||
if(node.type !== 'Text'){
|
||
return
|
||
}
|
||
// 创建文本所对应的 JS AST 节点
|
||
// 将创建好的 AST 节点挂到节点的 jsNode 属性上面
|
||
node.jsNode = createStringLiteral(node.content);
|
||
}
|
||
```
|
||
|
||
Element元素转换
|
||
|
||
```js
|
||
function transformElement(node, context){
|
||
// 这里应该是所有的子节点处理完毕后,再进行处理
|
||
return ()=>{
|
||
if(node.type !== 'Element'){
|
||
return;
|
||
}
|
||
|
||
// 创建函数调用的AST节点
|
||
const callExp = createCallExpression('h', [
|
||
createStringLiteral(node.tag),
|
||
])
|
||
|
||
// 处理函数调用的参数
|
||
node.children.length === 1
|
||
? // 如果长度为1说明只有一个子节点,直接将子节点的 jsNode 作为参数
|
||
callExp.arguments.push(node.children[0].jsNode)
|
||
: // 说明有多个子节点
|
||
callExp.arguments.push(
|
||
createArrayExpression(node.children.map(c=>c.jsNode))
|
||
)
|
||
|
||
node.jsNode = callExp
|
||
}
|
||
}
|
||
```
|
||
|
||
transformRoot转换:
|
||
|
||
```js
|
||
function transformRoot(node, context){
|
||
// 在退出的回调函数中书写处理逻辑
|
||
// 因为要保证所有的子节点已经处理完毕
|
||
return ()=>{
|
||
if(node.type !== 'Root'){
|
||
return;
|
||
}
|
||
|
||
const vnodeJSAST = node.children[0].jsNode;
|
||
|
||
node.jsNode = {
|
||
type: 'FunctionDecl',
|
||
id: {type: 'Identifier', name: 'render'},
|
||
params: [],
|
||
body: [{
|
||
type: 'ReturnStatement',
|
||
return: vnodeJSAST
|
||
}]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
最后修改 nodeTransforms,将这几个转换函数放进去:
|
||
|
||
```js
|
||
nodeTransforms: [
|
||
transformRoot,
|
||
transformElement,
|
||
transformText
|
||
]
|
||
```
|
||
|
||
至此,我们就完成模板 AST 转换为 JS AST 的工作。
|
||
|
||
通过 ast.jsNode 能够拿到转换出来的结果。
|
||
|
||
|
||
|
||
**生成器**
|
||
|
||
目前编译器的整体流程:
|
||
|
||
```js
|
||
// 编译器
|
||
function compile(template){
|
||
// 1. 解析器对模板进行解析,得到模板的AST
|
||
const ast = parse(template)
|
||
// 2. 转换器:将模板AST转换为JS AST
|
||
transform(ast)
|
||
// 3. 生成器:在 JS AST 的基础上生成 JS 代码
|
||
const code = genrate(ast)
|
||
|
||
return code;
|
||
}
|
||
```
|
||
|
||
在生成器里面需要维护一个上下文对象,用于存储一些重要的状态信息。
|
||
|
||
```js
|
||
function generate(ast){
|
||
const context = {
|
||
code: "", // 存储最终生成的代码
|
||
// 生成代码本质上就是字符串的拼接
|
||
push(code){
|
||
context.code += code;
|
||
},
|
||
// 当前缩进的级别,初始值为0,没有缩进
|
||
currentIndent: 0,
|
||
// 用于换行的,并且会根据缩进的级别添加对应的缩进
|
||
newLine(){
|
||
context.code += "\n" + ` `.repeat(context.currentIndent);
|
||
},
|
||
// 增加缩进级别
|
||
indent(){
|
||
context.currentIndent++;
|
||
context.newLine();
|
||
},
|
||
// 降低缩进级别
|
||
deIndent(){
|
||
context.currentIndent--;
|
||
context.newLine();
|
||
}
|
||
}
|
||
|
||
genNode(ast, context);
|
||
|
||
return context.code;
|
||
}
|
||
```
|
||
|
||
genNode 方法:根据不同的节点类型,调用不同的方法:
|
||
|
||
```js
|
||
function genNode(node, context){
|
||
switch(node.type){
|
||
case 'FunctionDecl':
|
||
genFunctionDecl(node, context)
|
||
break
|
||
case 'ReturnStatement':
|
||
genReturnStatement(node, context)
|
||
break
|
||
case 'CallExpression':
|
||
genCallExpression(node, context)
|
||
break
|
||
case 'StringLiteral':
|
||
genStringLiteral(node, context)
|
||
break
|
||
case 'ArrayExpression':
|
||
genArrayExpression(node, context)
|
||
break
|
||
}
|
||
}
|
||
```
|
||
|
||
最后就是各种生成方法:本质上就是根据不同的节点类型,做不同的字符串拼接
|
||
|
||
```js
|
||
// 生成字符串字面量
|
||
function genStringLiteral(node, context){
|
||
const { push } = context;
|
||
push(`'${node.value}'`)
|
||
}
|
||
// 生成返回语句
|
||
function genReturnStatement(node, context){
|
||
const { push } = context;
|
||
push(`return `)
|
||
genNode(node.return, context);
|
||
}
|
||
// 生成函数声明
|
||
function genFunctionDecl(node, context) {
|
||
// 从上下文中获取一些实用函数
|
||
const { push, indent, deIndent } = context;
|
||
// 向输出中添加 "function 函数名"
|
||
push(`function ${node.id.name} `);
|
||
// 添加左括号开始参数列表
|
||
push(`(`);
|
||
// 生成参数列表
|
||
genNodeList(node.params, context);
|
||
// 添加右括号结束参数列表
|
||
push(`) `);
|
||
// 添加左花括号开始函数体
|
||
push(`{`);
|
||
// 缩进,为函数体的代码生成做准备
|
||
indent();
|
||
// 遍历函数体中的每个节点,生成相应的代码
|
||
node.body.forEach((n) => genNode(n, context));
|
||
// 减少缩进
|
||
deIndent();
|
||
// 添加右花括号结束函数体
|
||
push(`}`);
|
||
}
|
||
|
||
// 生成节点列表
|
||
function genNodeList(nodes, context) {
|
||
const { push } = context;
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
const node = nodes[i];
|
||
|
||
// 生成当前节点的代码
|
||
genNode(node, context);
|
||
|
||
// 如果当前节点不是最后一个节点,添加逗号分隔
|
||
if (i < nodes.length - 1) {
|
||
push(", ");
|
||
}
|
||
}
|
||
}
|
||
|
||
// 生成函数调用表达式
|
||
function genCallExpression(node, context) {
|
||
const { push } = context;
|
||
const { callee, arguments: args } = node;
|
||
|
||
// 添加 "函数名("
|
||
push(`${callee.name}(`);
|
||
// 生成参数列表
|
||
genNodeList(args, context);
|
||
// 添加 ")"
|
||
push(`)`);
|
||
}
|
||
|
||
// 生成数组表达式
|
||
function genArrayExpression(node, context) {
|
||
const { push } = context;
|
||
// 添加 "["
|
||
push("[");
|
||
// 生成数组元素
|
||
genNodeList(node.elements, context);
|
||
// 添加 "]"
|
||
push("]");
|
||
}
|
||
```
|
||
|
||
|
||
|
||
> 面试题:说一下 Vue 中 Compiler 的实现原理是什么?
|
||
>
|
||
> 参考答案:
|
||
>
|
||
> 在 Vue 中,Compiler 主要用于将开发者的模板编译为渲染函数,内部可以分为 3 个大的组件:
|
||
>
|
||
> 1. 解析器:负责将模板解析为对应的模板 AST
|
||
>
|
||
> - 内部用到了有限状态机来进行解析,这是解析标记语言的常用方式,浏览器内部解析 HTML 也是通过有限状态机的方式进行解析的。
|
||
>
|
||
> - 解析的结果能够获取到一个 token 的数组
|
||
>
|
||
> - 紧接着扫描 token 列表,通过栈的方式将 token 压入和弹出栈,发现是起始标记时就入栈,发现是结束标记时就出栈,最终能够得到模板 AST 树结构
|
||
>
|
||
> 2. 转换器:负责将模板 AST 转换为 JS AST
|
||
>
|
||
> - 内部会维护一个上下文对象,用于存储一些关键的信息
|
||
>
|
||
> - 当前正在转换的节点
|
||
> - 当前正在转换的子节点在父节点的 children 数组中的索引
|
||
> - 当前正在转换的父节点
|
||
> - 具体的转换函数
|
||
>
|
||
> - 对节点的处理分为进入阶段处理一次和退出阶段处理一次
|
||
> - 这种思想在各个地方都非常常见,例如:
|
||
>
|
||
> - React 中的 beginWork、completeWork
|
||
> - Koa 中间件所采用的洋葱模型
|
||
>
|
||
> - 生成 JS AST
|
||
> - 不同的节点对应不同的节点对象,对象里面会包含节点的 type、name、value 一类的信息
|
||
> - 主要就是遍历模板的 AST,根据不同的节点,返回对应的对象
|
||
>
|
||
> 3. 生成器:根据 JS AST 生成最终的渲染函数
|
||
> - 主要就是遍历 JS AST,根据不同的节点对象,拼接不同的字符
|
||
>
|
||
>
|
||
> 当然,整个 Compiler 内部还会做很多的优化,从而带来性能上的提升。不知道这一块儿需不需要我展开讲一下?
|
||
|
||
---
|
||
|
||
-EOF-
|