2024-09-11 10:55:44 +08:00

1054 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 模板编译器
>面试题:说一下 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')
])
}
```
这里整个过程并非一触而就的,而是经历一个又一个步骤一点一点转换而来的。
整体来讲,整个编译过程如下图所示:
![image-20231113095532166](https://xiejie-typora.oss-cn-chengdu.aliyuncs.com/2023-11-13-015532.png)
可以看到,在编译器的内部,实际上又分为了:
- 解析器:负责将模板解析为所对应的 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 为:
![image-20231120143716229](https://xiejie-typora.oss-cn-chengdu.aliyuncs.com/2023-11-20-063716.png)
这里有几个比较关键的部分:
- 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-