模板DSL编译器核心实现思路
前言
最近学习Vue源码的时候,对其模板编译这块涂生感兴趣,想多多研究,奈何编译技术是一门庞大的学科,不同用途的编译器或编译技术的难度相差可能很大,对知识的掌握要求也相差很多。但我后续知道了Vue模板是作为DSL而存在的,实现难度与GPL相比并不高,我个人认为还是有学习的必要。故梳理了以下核心实现思路,以便后续参考。
编译器
编译器简单来说其实只是一段程序,用来将一种语言A翻译成另一种语言B。其中语言A叫做源语言,语言B叫做目标语言。编译器将源语言翻译为目标语言的过程被称为编译。
DSL
DSL(Domain Specific Language)
中文翻译为领域特定语言,是一种专门针对某个领域的语言。与之相对的是GPL(General Purpose Language)
通用语言,如C
、Java
、Python
等。GPL
的实现需要掌握较多编译知识,编译难度较高;而Vue.js的模板和JSX都属于领域特定语言,他们的实现难度属于中、低级别,对于前端开发工程师而言,掌握基本的编译理论即可实现这些功能。
Vue.js 模板编译

vueexplorer.png
上图示例中,左边部分是Vue的模板,右边部分则是经由模板编译器生成的可执行函数,也就是渲染函数。Vue的模板编译就是将模板语言翻译成渲染函数的过程,而本文的目标则是实现一个简单的Vue模板编译器。
工作流程

shapes1.png
上图给出了Vue.js模板编译器的工作流程,详细而言,Vue.js模板编译器会首先对模板进行词法分析和语法分析,得到模板AST。接着,将模板AST转换(transform)成Javascript AST,最后利用Javascript AST生成Javascript代码,即渲染函数。本文重点介绍编译流程,所以假定读者已经对AST有一定的了解。
// 使用代码来概括以上流程
const templateAST = parse(template) // 模板解析
const jsAST = transform(templateAST) // 模板转换
const code = generate(jsAST) // 代码生成
词法分析&语法分析(parse)
如图所示,我们可以通过封装 parse
函数来完成对模板的词法分析和语法分析,得到模板AST。

shapes2.png
可以使用下面的示例代码来描述模板解析的过程
const template = `
<div id="app">
<h1 v-if="ok">Vue Template</h1>
<div>`
const templateAST = parse(template)
token生成
解析器(parse)的入参是字符串模板,程序运行时,解析器会逐次读取字符串模板中的字符,并根据一定的规则将整个字符串切割为一个个Token(词法记号),那么解析器是如何对模板进行切割的呢?这就不得不提到有限自动状态机了。
有限自动状态机,指有限个状态,而"自动机"意味着随字符输入,解析器会在不同的状态之间迁移(转换)
<div>Vue</div>
举例来说,假如有上面这样一段模板,当我们分析这段模板字符串时,首先需要定义一些状态机中必要的解析状态
- 初始状态
- 标签开始状态
- 标签名称状态
- 文本状态
- 结束标签状态
- 结束标签名称状态
接着我们就可以模拟parse函数读取该字符串的过程
- 状态机处于初始状态
- 读取到
<
字符,状态机切换到标签开始状态 - 读取到下一个字符
d
,状态机切换到标签名称状态 - 读取到下一个字符
i
,状态机仍处于标签名称状态 - 读取到下一个字符
v
,状态机仍处于标签名称状态 - 读取到下一个字符
>
,状态机切换到初始状态,并记录在标签名称状态下产生的字符集合,即div
- 读取到下一个字符
V
,状态机切换到文本状态 - 读取到下一个字符
u
,状态机仍处于文本状态 - 读取到下一个字符
e
,状态机仍处于文本状态 - 读取到下一个字符
<
,状态机切换到标签开始状态,并记录在文本状态下产生的字符集合,即Vue
- 读取到下一个字符
/
,状态机切换到结束标签状态 - 读取到下一个字符
d
,状态机切换到结束标签名称状态 - 读取到下一个字符
i
,状态机仍处于结束标签名称状态 - 读取到下一个字符
v
,状态机仍处于结束标签名称状态 - 读取到下一个字符
>
,状态机切换到初始状态,并记录在结束标签名称状态下产生的字符集合,即div
以上就是状态机对模板字符串的解析过程,我们可以看到,状态机在解析模板字符串时,会根据不同的字符切换到不同的状态,而每个状态下产生的字符集合也不同,这些字符集合就是我们所说的Token(词法记号)。
[
{ type: 'tag', name: 'div' },
{ type: 'text', content: 'Vue' },
{ type: 'tagEnd', name: 'div' },
]
上述所描述的解析过程,我们可以用下面的代码来实现
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');
}
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);
// 消费字符
str = str.slice(1);
}
break;
case State.tagOpen:
if (isAlpha(char)) {
currentState = State.tagName;
chars.push(char);
str = str.slice(1);
} else if (char === '/') {
currentState = State.tagEnd;
str = str.slice(1);
}
break;
case State.tagName:
if (isAlpha(char)) {
chars.push(char);
str = str.slice(1);
} else if (char === '>') {
currentState = State.initial;
tokens.push({
type: 'tag',
name: chars.join(''),
});
chars.length = 0;
str = str.slice(1);
}
break;
case State.text:
if (isAlpha(char)) {
currentState = State.text;
chars.push(char);
str = str.slice(1);
} else if (char === '<') {
currentState = State.tagOpen;
// 此时文本已经结束,生成文本token
tokens.push({
type: 'text',
content: chars.join(''),
});
chars.length = 0;
// 消费
str = str.slice(1);
}
break;
case State.tagEnd:
// 切换到标签结束名称状态
if (isAlpha(char)) {
currentState = State.tagEndName;
chars.push(char);
str = str.slice(1);
}
break;
case State.tagEndName:
if (isAlpha(char)) {
chars.push(char);
str = str.slice(1);
} else if (char === '>') {
currentState = State.initial;
tokens.push({
type: 'tagEnd',
name: chars.join(''),
});
chars.length = 0;
str = str.slice(1);
}
break;
}
}
return tokens;
}
const tokens = tokenize(template);
console.log(tokens);
上述代码可优化的点非常多,但是为了方便理解,我没有做过多的优化,如果你对上述代码有什么疑问,可以在评论区留言。
使用上面给出的代码输出的结果如下
const ast = parse(template);
// [
// {type: 'tag', name: 'div'},
// {type: 'text', name: 'Vue'},
// {type: 'tagEnd', name: 'div'},
// ]
构造AST
我们知道,Vue的模板是类HTML的字符串,而HTML是一种树形结构,所以我们可以使用树形结构来描述模板,这样就可以方便我们对模板进行分析和转换。我们可以使用下面的代码来描述模板AST
// ... 省略tokenize函数生成的tokens
function genElementNode(tagName, children) {
return {
type: 'Element',
tag: tagName,
children,
};
}
function genTextNode(content) {
return {
type: 'Text',
content,
};
}
const root = {
type: 'Root',
children: [],
};
const elementStack = [root];
while (tokens.length) {
const parent = elementStack[elementStack.length - 1];
const t = tokens[0];
switch (t.type) {
case 'tag':
const elementNode = genElementNode(t.name, []);
parent.children.push(elementNode);
elementStack.push(elementNode);
break;
case 'text':
const textNode = genTextNode(t.content);
parent.children.push(textNode);
break;
case 'tagEnd':
elementStack.pop();
break;
}
tokens.shift();
}
return root;
整体代码
token生成+构造AST的整体代码如下
function parse(template) {
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');
}
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);
// 消费字符
str = str.slice(1);
}
break;
case State.tagOpen:
if (isAlpha(char)) {
currentState = State.tagName;
chars.push(char);
str = str.slice(1);
} else if (char === '/') {
currentState = State.tagEnd;
str = str.slice(1);
}
break;
case State.tagName:
if (isAlpha(char)) {
chars.push(char);
str = str.slice(1);
} else if (char === '>') {
currentState = State.initial;
tokens.push({
type: 'tag',
name: chars.join(''),
});
chars.length = 0;
str = str.slice(1);
}
break;
case State.text:
if (isAlpha(char)) {
currentState = State.text;
chars.push(char);
str = str.slice(1);
} else if (char === '<') {
currentState = State.tagOpen;
// 此时文本已经结束,生成文本token
tokens.push({
type: 'text',
content: chars.join(''),
});
chars.length = 0;
// 消费
str = str.slice(1);
}
break;
case State.tagEnd:
// 切换到标签结束名称状态
if (isAlpha(char)) {
currentState = State.tagEndName;
chars.push(char);
str = str.slice(1);
}
break;
case State.tagEndName:
if (isAlpha(char)) {
chars.push(char);
str = str.slice(1);
} else if (char === '>') {
currentState = State.initial;
tokens.push({
type: 'tagEnd',
name: chars.join(''),
});
chars.length = 0;
str = str.slice(1);
}
break;
}
}
return tokens;
}
function genElementNode(tagName, children) {
return {
type: 'Element',
tag: tagName,
children,
};
}
function genTextNode(content) {
return {
type: 'Text',
content,
};
}
const tokens = tokenize(template);
console.log(tokens);
const root = {
type: 'Root',
children: [],
};
const elementStack = [root];
while (tokens.length) {
const parent = elementStack[elementStack.length - 1];
const t = tokens[0];
switch (t.type) {
case 'tag':
const elementNode = genElementNode(t.name, []);
parent.children.push(elementNode);
elementStack.push(elementNode);
break;
case 'text':
const textNode = genTextNode(t.content);
parent.children.push(textNode);
break;
case 'tagEnd':
elementStack.pop();
break;
}
tokens.shift();
}
return root;
}
使用parse函数的运行结果如下
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`);
console.log(ast)

image2.png
AST转换(transform)
完成模板的构造后,我们就可以对模板AST进行转换(transform)了。转换的目的是将模板AST转换成Javascript AST,这样就可以生成可执行的Javascript代码。为了对AST进行转换,我们需要能访问AST中的每个节点,这样才能对特定的节点进行修改、替换、删除等操作。由于AST是树形结构,所以我们需要编写一个深度优先的遍历算法,从而实现对AST的访问。
深度遍历算法的实现
不过在编写遍历算法之前,为了方便调试,我们有必要编写一个工具函数,这样在调试AST时能够打印当前AST的节点信息。
function dump(node, indent = 0) {
const type = node.type;
const desc =
type === 'Root' ? '' : type === 'Element' ? node.tag : node.content;
console.log(`${'-'.repeat(indent)}${type}:${desc}`);
if (node.children) {
node.children.forEach((child) => {
dump(child, indent + 2);
});
}
}
我们沿用上一节给出的例子,看看使用dump函数打印的结构
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`);
console.log(dump(ast))
运行上述代码,将得到以下输出

image.png
可以看到,dump函数能够清晰展示当前的AST节点,在接下来的AST转换过程中,我们可以使用dump函数来调试AST。
下面是深度遍历算法上的实现
// 深度遍历节点
function traverseNode(ast, context) {
// 当前节点 AST本身就是Root节点
const currentNode = (context.currentNode = ast);
// 增加退出阶段的回调函数数组
const exitFns = [];
const transforms = context.nodeTransforms;
for (let i = 0; i < transforms.length; i++) {
const transform = transforms[i];
const onExit = transform(currentNode, context);
if (onExit) {
exitFns.push(onExit);
}
if (!context.currentNode) return;
}
const children = currentNode.children;
if (children) {
for (let i = 0; i < children.length; i++) {
context.parent = currentNode;
context.childIndex = i;
const child = children[i];
traverseNode(child, context);
}
}
let i = exitFns.length;
while (i--) {
exitFns[i]();
}
}
traverseNode将以深度优先的方式遍历AST,对于每个节点,它都会执行一系列的转换函数,这些转换函数会对当前节点进行修改、替换、删除等操作。当遍历到某个节点时,如果该节点的子节点已经遍历完毕,那么它会执行一系列的退出函数,这些退出函数会对当前节点的父节点进行修改、替换、删除等操作。
接着 我们可以通过调用它,并传入AST,和一系列的转换函数来实现对AST的转换
function transform(templateAST) {
function dump(node, indent = 0) {
const type = node.type;
const desc =
type === 'Root' ? '' : type === 'Element' ? node.tag : node.content;
console.log(`${'-'.repeat(indent)}${type}:${desc}`);
if (node.children) {
node.children.forEach((child) => {
dump(child, indent + 2);
});
}
}
// 深度遍历节点
function traverseNode(ast, context) {
const currentNode = (context.currentNode = ast);
// 增加退出阶段的回调函数数组
const exitFns = [];
const transforms = context.nodeTransforms;
for (let i = 0; i < transforms.length; i++) {
const transform = transforms[i];
const onExit = transform(currentNode, context);
if (onExit) {
exitFns.push(onExit);
}
if (!context.currentNode) return;
}
const children = currentNode.children;
if (children) {
for (let i = 0; i < children.length; i++) {
context.parent = currentNode;
context.childIndex = i;
const child = children[i];
traverseNode(child, context);
}
}
let i = exitFns.length;
while (i--) {
exitFns[i]();
}
}
// 转换文本节点
function transformText(node, context) {
console.log('这里可以进行文本节点的转换')
}
function transformElement(node, context) {
console.log('这里可以进行元素节点的转换')
return () => {
// 将转换代码编写到退出阶段中可保证该标签节点下的所有子节点都被处理完毕
};
}
function transformRoot(node) {
console.log('这里可以进行根节点的转换')
return () => {
if (node.type !== 'Root') return;
// 将转换代码编写到退出阶段中可保证该标签节点下的所有子节点都被处理完毕
};
}
const context = {
// 增加 currentNode 用来存储当前正在转换的节点
currentNode: null,
// 增加 childIndex 用来存储当前节点在父节点的children中的索引
childIndex: 0,
// 增加 parent 用来存储当前节点的父节点
parent: null,
replaceNode(node) {
if (this.parent) {
this.parent.children[this.childIndex] = node;
}
this.currentNode = node;
},
removeNode(node) {
if (this.parent) {
this.parent.children.splice(this.childIndex, 1);
}
this.currentNode = null;
},
nodeTransforms: [transformRoot, transformElement, transformText],
};
// 深度优先遍历节点
traverseNode(templateAST, context);
dump(templateAST);
return templateAST;
}
将模板AST转为JavaScript AST的过程
由上一节给出的模板为例
<div><p>Vue</p><p>Template</p></div>
与其对应的渲染函数是
function render(){
return h("div",[h("p","Vue"),h("p","Template")])
}
上面这段渲染函数所对应的Javascript AST 就是我们的转换目标,那么,它对应的Javascript AST是长什么样子呢?与模板AST是模板的描述一样,Javascript AST是Javascript代码的描述,所以本质上我们需要设计一些数据结构来描述渲染函数的代码。
首先观察上面这段渲染函数的构成。他是一个函数声明,所以我们首先要描述Javascript中的函数声明,函数声明由函数名、参数、函数体组成,所以我们可以使用下面的数据结构来描述函数声明
const FunctionDeclNode = {
type: 'FunctionDecl', // 代表该节点是函数声明
id: {
name: 'render', // name用来表示标识符名称
type: 'Identifier',
},
params: [], // 函数参数,由于render函数没有参数,所以为空数组
body: [{
type: 'ReturnStatement', // 代表该节点是return语句
return: null
}],
};
type 用来表示节点类型,这里我们使用FunctionDecl来表示函数声明,id用来表示函数名,params用来表示函数参数,body用来表示函数体,由于render函数的函数体只有一条语句,所以body是一个只有一个元素的数组,该元素是一个ReturnStatement节点,代表return语句。当然目前我们还没有描述ReturnStatement节点,所以我们可以使用null来占位。
接着我们需要描述ReturnStatement节点,也就是return语句,渲染函数其实返回的是h函数的调用结果,也就是虚拟DOM节点。我们可以使用CallExpression节点来描述h函数的调用,如下面的代码所示。
const CallExpression = {
type: 'FunctionDecl', // 代表该节点是函数声明
callee: { // callee用来表示函数调用的函数名
type: 'Identifier',
name: 'h'
},
arguments: [] // 参数
};
我们再看最外层的h函数的第一个参数是一个字符串字面量
function render(){
return h("div",[h("p","Vue"),h("p","Template")])
}
我们可以使用类型为StringLiteral的节点来描述它:
const Str = {
type: 'StringLiteral',
value: 'div'
};
接着我们再看h函数的第二个参数,它是一个数组,数组的元素是两个h函数的调用,所以我们可以使用ArrayExpression节点来描述它
const Arr = {
type: 'ArrayExpression',
// 数组中的元素
elements: []
};
使用上述组合的节点来填充渲染函数的返回值即可得到Javascript AST
const FunctionDeclNode = {
type: 'FunctionDecl', // 代表该节点是函数声明
id: {
name: 'render', // name用来表示标识符名称
type: 'Identifier',
},
params: [], // 函数参数,由于render函数没有参数,所以为空数组
body: [{
type: 'ReturnStatement', // 代表该节点是return语句
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: 'Template'
}
]
}
]
}
]
}
}],
};
转换函数的实现
利用上述深度遍历算法,我们可以对AST进行转换,下面是转换函数的实现
function transform(templateAST) {
function dump(node, indent = 0) {
const type = node.type;
const desc =
type === 'Root' ? '' : type === 'Element' ? node.tag : node.content;
console.log(`${'-'.repeat(indent)}${type}:${desc}`);
if (node.children) {
node.children.forEach((child) => {
dump(child, indent + 2);
});
}
}
// 深度遍历节点
function traverseNode(ast, context) {
const currentNode = (context.currentNode = ast);
// 增加退出阶段的回调函数数组
const exitFns = [];
const transforms = context.nodeTransforms;
for (let i = 0; i < transforms.length; i++) {
const transform = transforms[i];
const onExit = transform(currentNode, context);
if (onExit) {
exitFns.push(onExit);
}
if (!context.currentNode) return;
}
const children = currentNode.children;
if (children) {
for (let i = 0; i < children.length; i++) {
context.parent = currentNode;
context.childIndex = i;
const child = children[i];
traverseNode(child, context);
}
}
let i = exitFns.length;
while (i--) {
exitFns[i]();
}
}
function createStringLiteral(value) {
return {
type: 'StringLiteral',
value,
};
}
function createIdentifier(name) {
return {
type: 'Identifier',
name,
};
}
function createArrayExpression(elements) {
return {
type: 'ArrayExpression',
elements,
};
}
function createCallExpression(callee, args) {
return {
type: 'CallExpression',
callee,
arguments: args,
};
}
// 转换文本节点
function transformText(node, context) {
if (node.type !== 'Text') return;
node.jsNode = createStringLiteral(node.content);
}
function transformElement(node, context) {
// 将转换代码编写到退出阶段中可保证该标签节点下的所有子节点都被处理完毕
return () => {
if (node.type !== 'Element') return;
const callExp = createCallExpression('h', [
createStringLiteral(node.tag),
]);
node.children.length === 1
? callExp.arguments.push(node.children[0].jsNode)
: callExp.arguments.push(
createArrayExpression(node.children.map((c) => c.jsNode))
);
node.jsNode = callExp;
};
}
function transformRoot(node) {
return () => {
if (node.type !== 'Root') return;
const vnodeAst = node.children[0].jsNode;
node.jsNode = {
type: 'FunctionDecl',
id: {
type: 'Identifier',
name: 'render',
},
params: [],
body: [
{
type: 'ReturnStatement',
return: vnodeAst,
},
],
};
};
}
const context = {
// 增加 currentNode 用来存储当前正在转换的节点
currentNode: null,
// 增加 childIndex 用来存储当前节点在父节点的children中的索引
childIndex: 0,
// 增加 parent 用来存储当前节点的父节点
parent: null,
replaceNode(node) {
if (this.parent) {
this.parent.children[this.childIndex] = node;
}
this.currentNode = node;
},
removeNode(node) {
if (this.parent) {
this.parent.children.splice(this.childIndex, 1);
}
this.currentNode = null;
},
nodeTransforms: [transformRoot, transformElement, transformText],
};
// 深度优先遍历节点
traverseNode(templateAST, context);
dump(templateAST);
return templateAST;
}
调用上述transform函数得到的结果如下
const jsAst = transform(ast).jsNode;
console.log(jsAst);

image1.png
代码生成(generate)
代码生成的目的是将Javascript AST转换成可执行的Javascript代码
function generate(jsAST) {
const context = {
code: '',
currentIndent: 0,
newLine() {
context.code += '\n' + ` `.repeat(context.currentIndent);
},
indent() {
context.currentIndent++;
context.newLine();
},
deIdent() {
context.currentIndent--;
context.newLine();
},
push(code) {
context.code += code;
},
};
function genFunctionDecl(node, context) {
const { push, indent, deIdent } = context;
push(`function ${node.id.name}`);
push(`(`);
genNodeList(node.params, context);
push(`)`);
push(`{`);
// 缩进
indent();
node.body.forEach((node) => genNode(node, context));
// 取消缩进
deIdent();
push(`}`);
}
function genReturnStatement(node, context) {
const { push } = context;
push(`return `);
genNode(node.return, context);
}
function genStringLiteral(node, context) {
const { push } = context;
push(`"${node.value}"`);
}
function genCallExpression(node, context) {
const { push } = context;
const { callee, arguments: args } = node;
push(`${callee}(`);
genNodeList(args, context);
push(`)`);
}
function genArrayExpression(node, context) {
const { push } = context;
push(`[`);
genNodeList(node.elements, context);
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 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;
}
}
genNode(jsAST, context);
return context.code;
}
最终生成的代码如图
image4.png