Vue 模板编译原理解析 - 完整版
目录
1. 模板编译流程
1.1 编译的定义
编译是指:将一种语言 A(source code,源代码)翻译成另外一种语言 B(target code,目标代码)的过程。
1.2 完整的编译过程
传统的编译流程通常包括以下六个步骤:
源代码
↓ 1. 词法解析
Tokens(词法单元)
↓ 2. 语法解析
AST(抽象语法树)
↓ 3. 语义解析
优化后的 AST
↓ 4. 中间代码生成
中间代码
↓ 5. 优化
优化后的中间代码
↓ 6. 目标代码生成
目标代码
词法解析示例
以 const i = 5; 为例,解析后的 token:
const(关键字) i(变量) =(运算符) 5(操作数)
1.3 Vue 模板编译
在 Vue 中,模板编译指的是将 Vue 模板转换为渲染函数的过程。
输入(source code - 模板):
<div :id="someId">
<h1>Hello</h1>
</div>
输出(target code - 渲染函数):
function render() {
return h('div', { id: someId }, [
h('h1', 'Hello')
])
}
1.4 Vue 编译器三大部分
模板
↓ 解析器
模板 AST
↓ 转换器
JS AST
↓ 生成器
渲染函数
| 组件 | 输入 | 输出 | 核心作用 |
|---|---|---|---|
| 解析器 | 模板字符串 | 模板 AST | 根据模板生成对应的模板 AST |
| 转换器 | 模板 AST | JS AST | 将模板 AST 转换为 JavaScript AST |
| 生成器 | JS AST | 渲染函数代码 | 根据 JS AST 生成最终的目标代码 |
2. 解析器
解析器的核心作用是:负责将模板解析为所对应的 AST。
用户所书写的模板:
<p>Vue</p>
对于解析器来讲就是一段字符串:
'<p>Vue</p>'
2.1 有限状态机(FSM)
概念介绍
FSM(Finite State Machine,有限状态机) 是一种数学计算模型,它包含以下基本要素:
- 状态:当前系统处于什么状态,系统只能有一种状态
- 事件:通过事件从一种状态转换为另一种状态
- 转移:从一个状态转换到另一个状态的过程
- 初始状态:状态机开始时的状态
- 终止状态:状态机结束时的状态
状态迁移示例
以解析字符串 '<p>Vue</p>' 为例:
输入:
'<p>Vue</p>'
状态迁移过程:
| 字符 | 当前状态 | 转换后状态 |
|---|---|---|
< | 初始状态 | 标签开始状态 |
p | 标签开始状态 | 标签名称状态 |
> | 标签名称状态 | 初始状态 |
V | 初始状态 | 文本状态 |
u | 文本状态 | 文本状态 |
e | 文本状态 | 文本状态 |
< | 文本状态 | 标签开始状态 |
/ | 标签开始状态 | 标签结束状态 |
p | 标签结束状态 | 标签结束名称状态 |
> | 标签结束名称状态 | 初始状态 |
浏览器引擎内部在进行 HTML 解析的时候,也是通过有限状态机的方式来进行解析的。
详细的规范可以参考:HTML 规范 13.2.5.1
输出示例
输入:
'<p>Vue</p>'
输出(Tokens):
[
{ type: 'tag', name: 'p' }, // 开始标签
{ type: 'text', content: 'Vue' }, // 文本节点
{ type: 'tagEnd', name: 'p' } // 结束标签
]
2.2 Token 解析实现
状态定义
// 首先定义一些状态
const State = {
initial: 1, // 初始状态
tagOpen: 2, // 标签开始状态
tagName: 3, // 标签名称开始状态
text: 4, // 文本状态
tagEnd: 5, // 标签结束状态
tagEndName: 6 // 标签名称结束状态
}
Tokenize 函数实现
/**
* 将模板字符串解析为 tokens 数组
* @param {string} str - 模板字符串
* @returns {Array} - tokens 数组
*/
function tokenize(str) {
let currentState = State.initial; // 初始状态
const chars = []; // 用于存储字符
const tokens = []; // 用于存储 token
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); // 将字符存储到 chars 中
str = str.slice(1); // 消费一个字符
}
break;
case State.tagOpen:
// 标签开启状态:检查当前字符
if (isAlpha(char)) {
currentState = State.tagName; // 进入标签名称状态
chars.push(char); // 将字符添加到 chars 数组
str = str.slice(1); // 移除已处理的字符
} else if (char === "/") {
currentState = State.tagEnd; // 进入结束标签开始状态
str = str.slice(1); // 移除已处理的字符
}
break;
case State.tagName:
// 解析标签名称状态:检查当前字符
if (isAlpha(char)) {
chars.push(char); // 继续添加到 chars 数组
str = str.slice(1); // 移除已处理的字符
} else if (char === ">") {
currentState = State.initial; // 标签名称结束,返回初始状态
tokens.push({
type: "tag",
name: chars.join("")
}); // 创建标签类型的 token
chars.length = 0; // 清空 chars 数组
str = str.slice(1); // 移除已处理的字符
}
break;
case State.text:
// 解析文本节点状态:检查当前字符
if (isAlpha(char)) {
chars.push(char); // 继续添加到 chars 数组
str = str.slice(1); // 移除已处理的字符
} else if (char === "<") {
currentState = State.tagOpen; // 遇到新的标签,返回标签开启状态
tokens.push({
type: "text",
content: chars.join("")
}); // 创建文本类型的 token
chars.length = 0; // 清空 chars 数组
str = str.slice(1); // 移除已处理的字符
}
break;
case State.tagEnd:
// 结束标签的开始状态:检查当前字符
if (isAlpha(char)) {
currentState = State.tagEndName; // 进入结束标签名称状态
chars.push(char); // 将字符添加到 chars 数组
str = str.slice(1); // 移除已处理的字符
}
break;
case State.tagEndName:
// 解析结束标签名称状态:检查当前字符
if (isAlpha(char)) {
chars.push(char); // 继续添加到 chars 数组
str = str.slice(1); // 移除已处理的字符
} else if (char === ">") {
currentState = State.initial; // 结束标签名称结束,返回初始状态
tokens.push({
type: "tagEnd",
name: chars.join("")
}); // 创建结束标签类型的 token
chars.length = 0; // 清空 chars 数组
str = str.slice(1); // 移除已处理的字符
}
break;
}
}
return tokens;
}
/**
* 判断字符是否为字母
* @param {string} char - 字符
* @returns {boolean}
*/
function isAlpha(char) {
return /[a-zA-Z]/.test(char);
}
2.3 构造 AST
上一步已经完成了 token 的解析。接下来需要根据这些 token 来创建模板的 AST。
核心思路
扫描整个 token 列表,使用栈结构来维护元素间的父子关系:
示例输入:
'<div><p>Vue</p><p>React</p></div>'
输出(Tokens):
[
{ 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" }
]
AST 构造过程
使用 elementStack 栈来维护元素间的父子关系:
| 步骤 | Token | 操作 | 栈状态(从底到顶) |
|---|---|---|---|
| 1 | 初始化 | 创建 Root 节点 | [Root] |
| 2 | div tag | 创建 Element 节点,压栈 | [Root, div] |
| 3 | p tag | 创建 Element 节点,压栈 | [Root, div, p] |
| 4 | Vue text | 创建 Text 节点,作为 p 的子节点 | [Root, div, p] |
| 5 | p tagEnd | 弹出栈顶节点 | [Root, div] |
| 6 | p tag | 创建 Element 节点,压栈 | [Root, div, p] |
| 7 | React text | 创建 Text 节点,作为 p 的子节点 | [Root, div, p] |
| 8 | p tagEnd | 弹出栈顶节点 | [Root, div] |
| 9 | div tagEnd | 弹出栈顶节点 | [Root] |
Parse 函数实现
/**
* 将模板字符串解析为模板 AST
* @param {string} str - 模板字符串
* @returns {Object} - 模板 AST
*/
function parse(str) {
// 1. 首先对模板进行 token 解析,得到对应的 tokens 数组
const tokens = tokenize(str);
// 2. 创建 Root 根 AST 节点
const root = {
type: 'Root',
children: []
};
// 3. 创建 elementStack 栈,一开始只有 Root 根节点
const elementStack = [root];
// 4. 扫描 tokens 数组
while (tokens.length > 0) {
// 获取当前栈顶节点作为父节点
const parent = elementStack[elementStack.length - 1];
// 获取当前扫描的 token
const t = tokens[0];
// 根据 token 的不同类型,创建不同的 AST 节点
switch (t.type) {
case 'tag':
// 创建 Element 类型的 AST 节点
const elementNode = {
type: 'Element',
tag: t.name,
children: []
};
// 将其添加到父级节点的 children 中
parent.children.push(elementNode);
// 将当前节点压入栈
elementStack.push(elementNode);
break;
case 'text':
// 创建 Text 类型的 AST 节点
const textNode = {
type: 'Text',
content: t.content
};
// 将其添加到父级节点的 children 中
parent.children.push(textNode);
break;
case 'tagEnd':
// 遇到结束标签,将当前栈顶的节点弹出
elementStack.pop();
break;
}
// 消费已经扫描过的 token
tokens.shift();
}
return root;
}
最终生成的 AST
{
"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"
}
]
}
]
}
]
}
3. 转换器
转换器的核心作用就是:负责将模板 AST 转换为 JavaScript AST。
整体来讲,转换器的编写分为两大部分:
- 模板 AST 的遍历与转换
- 生成 JS AST
3.1 模板 AST 的遍历与转换
步骤一:AST 节点信息打印工具
首先书写一个简单的工具方法,方便查看一个模板 AST 中的节点信息。
/**
* 打印 AST 节点信息
* @param {Object} node - AST 节点
* @param {number} indent - 缩进级别
*/
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 树,在遍历的途中可以做修改。
/**
* 遍历并转换 AST 节点(基础版本)
* @param {Object} ast - 模板 AST
*/
function traverseNode(ast) {
const currentNode = ast;
// 检查当前节点是否为元素节点,并且标签名为"p"
if (currentNode.type === "Element" && currentNode.tag === "p") {
currentNode.tag = "h1"; // 将标签名从"p"改为"h1"
}
// 获取当前节点的子节点
const children = currentNode.children;
if (children) {
// 如果当前节点有子节点,遍历它们
for (let i = 0; i < children.length; i++) {
// 递归调用 traverseNode 函数来处理每个子节点
traverseNode(children[i]);
}
}
}
/**
* 转换模板 AST
* @param {Object} ast - 模板 AST
*/
function transform(ast) {
traverseNode(ast);
console.log(dump(ast));
}
步骤三:遍历与转换解耦
让遍历和转换进行解耦,通过 context 上下文对象来实现。
/**
* 转换模板 AST(解耦版本)
* @param {Object} ast - 模板 AST
*/
function transform(ast) {
const context = {
// 用于存储当前正在转换的节点
currentNode: null,
// 用于存储当前正在转换的子节点在父节点的 children 数组中的索引
childIndex: 0,
// 用于存储当前正在转换的父节点
parent: null,
// 用于存储具体的转换函数
nodeTransforms: [transformElement, transformText],
};
// 调用 traverseNode 函数来遍历和转换 AST
traverseNode(ast, context);
// 打印转换后的 AST 结构
console.log(dump(ast));
}
/**
* 遍历 AST 节点(带上下文)
* @param {Object} ast - 模板 AST
* @param {Object} context - 转换上下文
*/
function traverseNode(ast, context) {
context.currentNode = ast;
// 拿到转换方法的数组
const transforms = context.nodeTransforms;
// 遍历数组中的每个转换方法
for (let i = 0; i < transforms.length; i++) {
transforms[i](context.currentNode, context);
}
// 获取当前节点的子节点
const children = context.currentNode.children;
if (children) {
// 如果当前节点有子节点,遍历它们
for (let i = 0; i < children.length; i++) {
// 更新当前上下文中的 parent 父节点
context.parent = context.currentNode;
// 索引也需要更新
context.childIndex = i;
// 递归调用 traverseNode 函数来处理每个子节点
traverseNode(children[i], context);
}
}
}
转换函数实现
/**
* 转换元素节点
* @param {Object} node - AST 节点
*/
function transformElement(node) {
if (node.type === "Element" && node.tag === "p") {
node.tag = "h1"; // 将标签名从"p"改为"h1"
}
}
/**
* 转换文本节点
* @param {Object} node - AST 节点
*/
function transformText(node) {
if (node.type === "Text") {
node.content = node.content.toUpperCase(); // 将文本内容转换为大写
}
}
步骤四:增强上下文对象
继续完善 context 上下文对象,添加替换节点和删除节点的方法。
function transform(ast) {
const context = {
// 用于存储当前正在转换的节点
currentNode: null,
// 用于存储当前正在转换的子节点在父节点的 children 数组中的索引
childIndex: 0,
// 用于存储当前正在转换的父节点
parent: null,
// 新增:替换节点的方法
replaceNode(node) {
// 找到当前节点在父节点的 children 中的位置,并将其替换为新节点
context.parent.children[context.childIndex] = node;
// 将 currentNode 也更新为新节点
context.currentNode = node;
},
// 新增:删除节点方法
removeNode() {
if (context.parent) {
// 根据当前节点的索引删除当前节点
context.parent.children.splice(context.childIndex, 1);
// 将 currentNode 置为 null
context.currentNode = null;
}
},
// 用于存储具体的转换函数
nodeTransforms: [transformElement, transformText],
};
traverseNode(ast, context);
console.log(dump(ast));
}
步骤五:处理节点生命周期(进入和退出阶段)
不仅仅是在进入节点的时候处理一次,在退出节点的时候,也需要处理一次。
/**
* 遍历 AST 节点(支持进入和退出阶段)
* @param {Object} ast - 模板 AST
* @param {Object} context - 转换上下文
*/
function traverseNode(ast, context) {
console.log("处理节点:", ast.type, ast.tag || ast.content);
context.currentNode = ast;
// 对节点的访问分为两个阶段:进入阶段和退出阶段
// 当转换函数处于进入阶段时,它会先进入父节点,再进入子节点
// 而当转换函数处于退出阶段时,则会先退出子节点,再退出父节点
// 这样,只要我们在退出节点阶段对当前访问的节点进行处理
// 就一定能够保证其子节点全部处理完毕
// 1. 增加退出阶段的回调函数数组
const exitFns = [];
// 拿到转换方法的数组
const transforms = context.nodeTransforms;
// 遍历数组中的每个转换方法
for (let i = 0; i < transforms.length; i++) {
// 2. 转换函数可以返回另外一个函数,该函数作为退出阶段的回调函数
const onExit = transforms[i](context.currentNode, context);
if (onExit) {
exitFns.push(onExit);
}
// 由于任何转换函数都可能移除当前节点,因此每个转换函数执行完毕后
// 都应该检查当前节点是否已经被移除,如果被移除了,直接返回即可
if (!context.currentNode) return;
}
// 获取当前节点的子节点
const children = context.currentNode.children;
if (children) {
// 如果当前节点有子节点,遍历它们
for (let i = 0; i < children.length; i++) {
// 更新当前上下文中的 parent 父节点
context.parent = context.currentNode;
// 另外索引也需要更新
context.childIndex = i;
// 递归调用 traverseNode 函数来处理每个子节点
traverseNode(children[i], context);
}
}
// 3. 在节点处理的最后阶段执行缓存在 exitFns 中的回调函数
let i = exitFns.length;
while (i--) {
exitFns[i]();
}
}
修改转换函数支持退出阶段
/**
* 转换文本节点(支持退出阶段)
* @param {Object} node - AST 节点
* @param {Object} context - 转换上下文
* @returns {Function|null} - 退出阶段回调函数
*/
function transformText(node, context) {
return () => {
console.log("可以再次处理节点:", node.type, node.tag || node.content);
};
}
3.2 生成 JS AST
JS AST 结构分析
假设有如下代码:
function render() {
return null;
}
对应的 JS AST 结构如下:
| 属性 | 类型 | 说明 |
|---|---|---|
id | Identifier | 函数名称 |
params | Array | 函数参数列表 |
body | Array | 函数体(可能包含多条语句) |
函数声明节点结构
const FunctionDeclNode = {
type: 'FunctionDecl', // 代表该节点是一个函数声明
id: {
type: 'Identifier',
name: 'render' // name 用来存储函数名称
},
params: [], // 函数参数
body: [
{
type: 'ReturnStatement',
return: null
}
]
}
关键 AST 节点类型
1. 标识符(Identifier)
{
type: 'Identifier',
name: 'h' // 变量或函数名
}
2. 字符串字面量(StringLiteral)
{
type: 'StringLiteral',
value: 'div' // 字符串值
}
3. 数组表达式(ArrayExpression)
{
type: 'ArrayExpression',
elements: [] // 数组元素列表
}
4. 函数调用表达式(CallExpression)
{
type: 'CallExpression',
callee: {
type: 'Identifier',
name: 'h' // 被调用的函数名
},
arguments: [] // 调用参数列表
}
完整的渲染函数 AST
从模板 <div><p>Vue</p><p>React</p></div> 生成的渲染函数:
function render() {
return h('div', [
h('p', 'Vue'),
h('p', 'React')
])
}
对应的完整 JS AST:
{
"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"
}
]
}
]
}
]
}
}
]
}
辅助函数设计
/**
* 创建字符串字面量节点
* @param {string} value - 字符串值
*/
function createStringLiteral(value) {
return {
type: "StringLiteral",
value,
};
}
/**
* 创建标识符节点
* @param {string} name - 标识符名称
*/
function createIdentifier(name) {
return {
type: "Identifier",
name,
};
}
/**
* 创建数组表达式节点
* @param {Array} elements - 数组元素
*/
function createArrayExpression(elements) {
return {
type: "ArrayExpression",
elements,
};
}
/**
* 创建函数调用表达式节点
* @param {string} callee - 被调用的函数名
* @param {Array} args - 调用参数列表
*/
function createCallExpression(callee, args) {
return {
type: "CallExpression",
callee: createIdentifier(callee),
arguments: args,
};
}
节点转换函数实现
transformText - 文本节点转换
/**
* 转换文本节点
* @param {Object} node - AST 节点
* @param {Object} context - 转换上下文
*/
function transformText(node, context) {
if (node.type !== "Text") return;
// 在模板 AST 节点上添加 jsNode 属性
// 存储转换后的 JS AST 节点
node.jsNode = createStringLiteral(node.content);
}
transformElement - 元素节点转换
/**
* 转换元素节点
* @param {Object} node - AST 节点
* @param {Object} context - 转换上下文
* @returns {Function} - 退出阶段回调函数
*/
function transformElement(node, context) {
return () => {
if (node.type !== "Element") return;
// 1. 创建 h 函数调用的 AST 节点
// 第一个参数是标签名(字符串)
const callExp = createCallExpression("h", [
createStringLiteral(node.tag),
]);
// 2. 处理 h 函数的第二个参数(子节点)
if (node.children.length === 1) {
// 如果只有一个子节点,直接将子节点的 jsNode 作为参数
callExp.arguments.push(node.children[0].jsNode);
} else {
// 如果有多个子节点,将子节点的 jsNode 组成数组传入
callExp.arguments.push(
createArrayExpression(
node.children.map((child) => child.jsNode)
)
);
}
// 将生成的 JS 节点挂载到模板 AST 节点上
node.jsNode = callExp;
};
}
transformRoot - 根节点转换
/**
* 转换根节点
* @param {Object} node - AST 节点
* @param {Object} context - 转换上下文
* @returns {Function} - 退出阶段回调函数
*/
function transformRoot(node, context) {
return () => {
if (node.type !== "Root") return;
// 获取根节点的第一个子节点(模板根元素)的 JS 节点
const vnodeJSAST = node.children[0].jsNode;
// 创建 render 函数声明的 JS AST 节点
node.jsNode = {
type: "FunctionDecl",
id: {
type: "Identifier",
name: "render",
},
params: [],
body: [
{
type: "ReturnStatement",
return: vnodeJSAST,
},
],
};
};
}
完整的转换函数
function transform(ast) {
const context = {
currentNode: null,
childIndex: 0,
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: [transformRoot, transformElement, transformText],
};
traverseNode(ast, context);
}
4. 生成器
生成器的作用:根据 JS AST 生成最终的渲染函数代码。
/**
* Vue 模板编译器主函数
* @param {string} template - 模板字符串
* @returns {string} - 生成的渲染函数代码
*/
function compile(template) {
// 1. 得到模板 AST
const ast = parse(template);
// 2. 将模板 AST 转换为 JavaScript AST
transform(ast);
// 3. 代码生成
const code = generate(ast.jsNode);
return code;
}
4.1 生成器上下文设计
和上一步转换器类似,在生成器中也需要维护一个上下文对象,该上下文对象用于维护代码生成过程中程序的运行状态。
/**
* 代码生成函数
* @param {Object} node - JS AST 节点
* @returns {string} - 生成的代码字符串
*/
function generate(node) {
// 上下文对象
const context = {
// 存储最终所生成的代码
code: "",
// 在生成代码的时候,通过调用 push 方法来进行拼接
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(node, context);
return context.code;
}
4.2 代码生成实现
genNode - 节点分发函数
根据节点类型调用不同的生成方法。
/**
* 根据节点类型生成代码
* @param {Object} node - JS AST 节点
* @param {Object} context - 生成器上下文
*/
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;
default:
throw new Error(`未知的节点类型: ${node.type}`);
}
}
生成函数声明
/**
* 生成函数声明代码
* @param {Object} node - FunctionDecl 节点
* @param {Object} 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(`}`);
}
生成节点列表
/**
* 生成节点列表(如函数参数、数组元素)
* @param {Array} nodes - 节点数组
* @param {Object} context - 生成器上下文
*/
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(", ");
}
}
}
生成 return 语句
/**
* 生成 return 语句代码
* @param {Object} node - ReturnStatement 节点
* @param {Object} context - 生成器上下文
*/
function genReturnStatement(node, context) {
const { push } = context;
// 添加 "return "
push(`return `);
// 生成 return 语句后面的代码
genNode(node.return, context);
}
生成函数调用表达式
/**
* 生成函数调用表达式代码
* @param {Object} node - CallExpression 节点
* @param {Object} context - 生成器上下文
*/
function genCallExpression(node, context) {
const { push } = context;
const { callee, arguments: args } = node;
// 添加 "函数名("
push(`${callee.name}(`);
// 生成参数列表
genNodeList(args, context);
// 添加 ")"
push(`)`);
}
生成字符串字面量
/**
* 生成字符串字面量代码
* @param {Object} node - StringLiteral 节点
* @param {Object} context - 生成器上下文
*/
function genStringLiteral(node, context) {
const { push } = context;
// 添加 "'字符串值'"
push(`'${node.value}'`);
}
生成数组表达式
/**
* 生成数组表达式代码
* @param {Object} node - ArrayExpression 节点
* @param {Object} context - 生成器上下文
*/
function genArrayExpression(node, context) {
const { push } = context;
// 添加 "["
push("[");
// 生成数组元素
genNodeList(node.elements, context);
// 添加 "]"
push("]");
}
5. 完整编译流程整合
完整的 compile 函数
/**
* Vue 模板编译器主函数
* @param {string} template - 模板字符串
* @returns {string} - 生成的渲染函数代码
*/
function compile(template) {
// 1. 解析:将模板字符串解析为模板 AST
const ast = parse(template);
// 2. 转换:将模板 AST 转换为 JS AST
transform(ast);
// 3. 生成:将 JS AST 生成为渲染函数代码
const code = generate(ast.jsNode);
return code;
}
测试生成的结果
const code = generate(ast.jsNode);
console.log(code);
/* 输出:
function render () {
return h('div', [h('p', 'Vue'), h('p', 'React')])
}
*/
总结
编译流程回顾
模板字符串
↓ 解析器
Tokens
↓ AST 构造
模板 AST
↓ 转换器
JS AST
↓ 生成器
渲染函数代码
核心技术点
| 阶段 | 核心技术 | 关键数据结构 |
|---|---|---|
| 解析 | 有限状态机 | Token、Stack |
| 转换 | AST 遍历、生命周期钩子 | Context、转换函数 |
| 生成 | 字符串拼接 | Code Context |
关键设计模式
- 有限状态机(FSM):用于 token 识别和解析
- 栈结构:维护元素间的父子关系
- 上下文对象:存储转换/生成过程中的状态
- 访问者模式:遍历 AST 并对节点执行操作
- 生命周期钩子:进入和退出节点的处理
至此,Vue 模板编译器的完整实现原理已经全部讲解完毕! 🎉🎉🎉
-EOF-