模板DSL的编译器
编译器就是一段程序. 将“A语言”(源代码)翻译成“B语言”(目标代码)的过程就是编译.
Vue的模板作为DSL(领域特定语言), 源代码就是组件的模板. 目标代码是能够在浏览器平台运行的js代码或者其他拥有js运行时的平台代码.
// 1. **解析器** 将模板字符串解析为模板AST
const templateAST = parse(template)
// 2. **转换器** 将模板AST转换为JSAST
const jsAST = transformer(templateAST)
// 3. **生成器** 根据JSAST生产渲染函数
const code = generator(jsAST)
parse的实现原理与状态机
解析器逐个读取字符串模板中的字符, 并根据规则生成一个个Token(指词法记号). 随着字符的输入, 解析器在不同状态间迁移. 比如<p>Vue</p>切割为三个Token, 分别是:
- 开始标签
<p> - 文本节点
Vue - 结束标签
</p>
const tokens = tokenize(str)
[{
type: 'tag', name: 'p'
},{
type: 'text', name: 'Vue'
},{
type: 'tagEnd', name: 'p'
}]
// 定义状态机的状态
const State = {
initial: 1, // 初始状态, 可以变为标签开始、文本、结束标签等状态
tagOpen: 2, // 标签开始状态 可以变为标签名称、结束标签状态
tagName: 3, // 标签名称状态 可以保持状态或变为初始状态
text: 4, // 文本状态 可以保持状态或变为标签开始状态
tagEnd: 5, // 结束标签状态 可以变为标签结束名称状态
tagEndName: 6 // 结束标签名称状态 可以保持状态或进入初始状态
}
function isAlpah(char){
return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
}
// 标记模板, 返回token
fucntion tokenize(str) {
// 初始化状态
let currentState = State.initial
// chars 缓存字符 最终返回的 tokens
const chars = [], tokens = []
// 开启状态自动机, 开始消费字符
while(str){
// 查看第一个字符
const char = str[0]
switch(currentState) {
// 当前处于初始状态
case State.initial:
if(char === '<') {
// <p> 进去标签开始状态
currentState = State.tagOpen
str = str.slice(1)
} else if (if(isAlpah)) {
// <p>Vue 进去文本状态
currentState = State.text
chars.push(char)
str = str.slice(1)
break
}
// 当前处于标签开始状态
case State.tagOpen:
if(isAlpah) {
// <p... 进去标签名称状态
currentState = State.tagName
chars.push(char)
str = str.slice(1)
} else if (char === '/') {
// </p>、 </MyComp> 进入标签结束状态
currentState = State.tagEnd
str = str.slice(1)
}
break
// 当前处于标签名称状态
case State.tagName:
if(isAlpah) {
// <p... 保持标签名称状态
chars.push(char)
str = str.slice(1)
} else if (char === '>') {
// <p> 标签读取完毕, 进入初始状态
// 后面可能是文本也可能是其他标签, 所以切换至初始状态
currentState = State.initial
tokens.push({
type: 'tag',
name: chars.join('')
})
chars.length = 0
str = str.slice(1)
}
break
// 当前处于文本状态
case State.text:
if(isAlpah) {
// <p>Vue 保持文本状态
chars.push(char)
str = str.slice(1)
} else if (char === '<') {
// Vue</p> Vue<span> 进入标签开始状态
currentState = State.tagOpen
tokens.push({
type: 'text',
name: chars.join('')
})
chars.length = 0
str = str.slice(1)
}
break
// 当前处于标签结束状态
case State.tagEnd:
if(isAlpah) {
// </p... 进入结束标签名称状态
currentState = State.tagEndName
chars.push(char)
str = str.slice(1)
}
break
// 当前处于结束标签名称状态
case State.tagEndName:
if(isAlpah) {
// </p...> 保持状态
chars.push(char)
str = str.slice(1)
} else if (char === '>') {
// </p> 结束标签读取完毕, 进入初始状态
currentState = State.initial
tokens.push({
type: 'tagEnd',
name: chars.join('')
})
// 清空内容
chars.length = 0
str = str.slice(1)
}
break
}
}
// 返回 Tokens
return tokens
}
// 可以通过正则表达式简化 tokenize 函数, 正则表达式的本质就是有限自动机
构造AST
根据Token构建AST的过程, 就是对Token列表进行扫描的过程. 过程中需要维护一个元素栈elementStack, 用于维护元素间的父子关系.
const tokens = tokenize(`<div><p>Vue</p><p>Template</p></div>`)
[{
type: 'tag', name: 'div'
},{
type: 'tag', name: 'p'
},{
type: 'text', name: 'Vue'
},{
type: 'tagEnd', name: 'p'
},{
type: 'tag', name: 'p'
},{
type: 'text', name: 'Template'
},{
type: 'tagEnd', name: 'p'
},{
type: 'tagEnd', name: 'div'
}]
// 模板AST
const 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: 'Template'
}]
}]
}]
}
// 解析器 扫描Token并构建AST
function parse(str){
const tokens = tokenize(str)
// 构建最后并返回AST
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 = {
type: 'Element',
tag: t.name,
children: []
}
// 添加至父节点的children中
parent.children.push(elementNode)
// 当前节点入栈, 作为下轮的父节点
elementStack.push(elementNode)
break;
case 'text':
const textNode = {
type: 'text',
content: t.content,
}
// 添加至父节点的children
parent.children.push(textNode)
break;
case 'tagEnd':
// 结束标签, 弹出栈顶节点
elementStack.pop()
break;
}
// 消费当前token
tokens.shift()
}
return root
}
AST的转换与插件化架构
将模板AST转为JS AST, 转换后的AST用于生成代码. 这就是vuejs的模板编译器将模板编译为渲染函数的过程.
节点的访问
要转换AST, 需要一个深度优先的遍历算法访问模板AST的每个节点. 先编写一个工具函数用来打印当前AST中节点的信息.
function dump(node, indent = 0) {
const type = node.type;
const desc =
node.type === "Root"
? ""
: node.type === "Element"
? node.tag
: node.content;
// 打印节点信息, indent 参数控制锁进
console.log(`${"-".repeat(indent)}${type}: ${desc}`);
// 遍历打印子节点
if (node.children) {
node.children.forEach((n) => dump(n, indent + 2));
}
}
// 遍历AST中的节点, 并将对节点的操作进行解耦
function traverseNode(ast, context) {
const currentNode = ast
// 调用其中的回调
const transforms = context.nodeTransforms
const children = currentNode.children
for(let i = 0; i < transform.length; i++) {
transforms[i](currentNode, context)
}
if(children) {
for(let i = 0; i < children.length; i++) {
context.parent = context.currentNode
context.childIndex = i
traverseNode(children[i], context)
}
}
}
// 调用traverseNode完成转换
function transform(ast){
// 创建 context 对象
const context = {
nodeTransform: [
]
}
traverseNode(ast, context)
// 打印信息
console.log(dump(ast))
}
转换上下文与节点操作
上下文对象其实就是程序在某个范围内的“全局变量”. 也可以把全局变量看作全局上下文. context可以看作AST转换函数过程中的上下文数据, 所有的AST转换函数都可以通过content共享数据.
// 丰富上下文对象, 维护程序的当前状态
function transform(ast){
const context = {
// 当前正在转换的节点
currentNode: null,
//在父节点中的索引
childIndex: 0,
// 当前节点的父节点
parent: null,
// 节点替换操作
replaceNode(node){
context.parent.children[context.childIndex] = node
},
removeNode(){
if(context.parent) {
context.parent.children.splice(context.childIndex, 1)
context.childIndex = null
}
},
nodeTransform: [
]
}
traverseNode(ast, context)
}
// 设置上下文对象中的数据
function traverseNode(ast, context) {
context.currentNode = ast
const transforms = context.nodeTransforms
for(let i = 0; i < transform.length; i++) {
transforms[i](currentNode, context)
// 节点被移除, 直接return
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
traverseNode(children[i], context)
}
}
}
进入与退出
转换AST节点过程中, 需要根据其子节点的情况来决定如何对当前节点进行转换. 这就要求父节点的转化必须在子节点全部完毕后再执行. 当处于进入节点时, 转换函数会先进入父节点, 然后进入子节点. 退出阶段先退出子节点, 再退出父节点.只要在退出节点阶段对当前访问的节点进行处理, 就能保证子节点全部处理完毕
// 优化转换函数,
function traverseNode(ast, context) {
context.currentNode = ast
// 缓存退出阶段的回调
const exitFns = []
const transforms = context.nodeTransforms
for(let i = 0; i < transform.length; i++) {
// 转换函数返回的另一个函数, 作为退出阶段的回调函数
const onExit = transforms[i](currentNode, context)
if(onExit) {
exitFns.push(onExit)
}
// 节点被移除, 直接return
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
traverseNode(children[i], context)
}
}
let i = exitFns.length
// 反序执行
while(i--) {
exitFns[i]()
}
}
// 假定有两个转换函数
function transform(ast) {
const context = {
// ...
// 转换函数的注册顺序与执行顺序相反,
// A有机会等待B执行完毕后, 再根据具体情况决定如何工作
nodeTransforms: [
transformA,
transformB(){
// 进入节点
return () => {
// 退出节点
}
}
]
}
traverseNode(ast, context)
console.log(dump(ast))
}
/**
* --transformA 进入阶段执行
* ----transformB 进入阶段执行
* ----transformB 退出阶段执行
* --transformA 退出阶段执行
*/
转换逻辑编写在转换函数的退出阶段时, 不仅能够保证所有子节点全部处理完毕, 还能保证所有后续注册的转换函数执行完毕.
将模板AST转为JS AST
JS AST是js代码(渲染函数)的描述吗, 所以我们需要设计一些数据来描述渲染函数的代码.
// 描述函数声明
const FunctionDeclNode = {
type: 'FunctionDecl',
// 函数名称标识符
id: { type: 'Identifier', name: 'render' },
// 函数参数, 目前是空数组
params: [],
// 函数体内是一条条语句
body: [{
type: 'ReturnStatement',
return: null
}]
}
// 函数调用
function createCallExpression(callee, arguments){
// 包括函数签名与参数
return { type: 'CallExpression', arguments, callee: createIdentifier(callee) }
}
// 生成字符串字面量节点
function createStringLiteral(value){
// { type: 'StringLiteral', 'div' }
return { type: 'StringLiteral', value }
}
// 生成标识符节点
function createIdentifier(value){
// { type: 'Identifier', name: 'h' }
return { type: 'StringLiteral', name }
}
// 生成数组节点
function createArrayExpression(value){
// { type: 'ArrayExpression', [...] }
return { type: 'ArrayExpression', elements }
}
再设计两个转换函数用来处理文本节点与标签节点:
function transformText(node){
if(node.type !== 'Text') return
// 文本节点对应的js AST是一个字符串字面量
// 使用node.content生成一个 StringLiteral 类型的节点
node.jsNode = createStringLiteral(node.content)
}
// 转换标签节点
function transformElement(node){
// 转换标签需要在回调中执行, 保证其子节点全部处理完毕
return () => {
if(node.type !== 'Element') return
// h函数的调用, 其第一个入参为标签名称
const callExp = createCallExpression('h', [createStringLiteral(node.tag)])
// h函数的其他参数
node.children.length === 1
? callExp.arguments.push(node.children[0].jsNode)
: callExp.arguments.push(createArrayExpression(node.children.map(c => c.jsNode))
// 赋值当前节点对应的JS AST到jsNode属性
node.jsNode = callExp
}
}
// 根结点的转换
function transformRoot(node){
return () => {
if(node.type !== 'Root') return
// 拿到第一个子节点的jsNode, 就是函数体的返回语句
const vnodeJSAT = node.children[0].jsNode
node.jsNode = {
type: 'FunctionDecl',
id: { type: 'Identifier', name: 'render' },
params: [],
body: [{
type: 'ReturnStatement',
return: vnodeJSAT
}]
}
}
}
得到的JS AST:
const FunctionDeclNode = {
type: 'FunctionDecl',
id: { type: 'Identifier', name: 'render' }, // 这是一个render函数
params: [],
body: [{
// 第一个语句就是return
type: 'ReturnStatement',
return: {
// 返回 h函数 的调用
type: 'CallExpression',
callee: { type: 'Identifier', name: 'h' },
// 函数参数
arguments: [{
// 第一个参数 div
type: 'StringLiteral',
value: 'div',
},{
// 第二个参数 数组 [..., ...]
type: 'ArrayExpression',
elements: [{
// h('p', 'Vue')
type: 'CallExpression',
callee: { type: 'Identifier', name: 'h' },
arguments: [{
type: 'StringLiteral',
value: 'p',
},{
type: 'StringLiteral',
value: 'Vue',
}]
}, {
// h('p', 'Template')
type: 'CallExpression',
callee: { type: 'Identifier', name: 'h' },
arguments: [{
type: 'StringLiteral',
value: 'p',
},{
type: 'StringLiteral',
value: 'Template',
}]
}]
}]
}
}]
}
代码生成
最终的render函数为:
// 最终调用
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
tansform(ast)
const code = generate(ast.jsNode)
// 生成代码字符串
`function render(){
return h('div', [h('p', 'Vue'), h('p', 'Template')])
}`
可以看到根据js AST生成渲染函数的代码, 本质就是进行字符串拼接. 根据AST生成对应render函数字符串.
function generate(node){
// 上下文对象, 包括缩进与换行的工具函数
const context = {
code: '',
// 字符串拼接
push(code){
context.code += code
},
// 缩进级别
currentIndent: 0,
newline(){
context.code += '\n' + ` `.repeat(context.currentIndent)
},
indent(){
context.currentIndex++
context.newline()
},
deIndent(){
context.currentIndex--
context.newline()
}
}
genNode(node, context)
return context.code
}
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
}
}
// 生成函数声明语句
function genFunctionDecl(node, context){
const { push, indent, deIndent} = context
// 函数名称
push(`function ${node.id.name}`)
push(`(`)
genNodeList(node.params, context)
push(`)`)
push(`{`)
// 缩进
indent()
node.body.forEach(n => genNode(n, context))
// 取消缩进
deIndent()
push(`}`)
}
// 生成函数返回语句
function ReturnStatement(node, context){
const { push } = context
push(`return `)
genNodeList(node.return, context)
}
// 生成函数调用语句
function CallExpression(node, context){
const { push } = context
const { callee, arguments: args } = node
// 函数调用
push(`${callee.name(}`)
genNodeList(args, context)
push(`)`)
}
// 生成字符字面量
function StringLiteral(node, context){
const { push } = context
push(`'${node.value}'`)
}
// 生成数组表达式
function ArrayExpression(node, context){
const { push } = context
push('[')
genNodeList(node.element, context)
push(']')
}
// 递归处理节点数组
function genNodeList(n, context){
const { push } = context
for(let i = 0; i < n.length; i++) {
const node = n[i]
genNode(node, context)
if(i < n.length - 1) {
push(`, `)
}
}
}
总结
编译器用于将模板编译为渲染函数, 工作流程大致分为:
- 分析模板并解析为模板AST
- 将模板AST转为描述渲染函数的js AST
- 根据js AST生成渲染函数代码
parser就是用有限状态自动机构造一个词法解析器, 根据模板字符串生成Token列表. 扫描列表(维护一个开始标签栈, 记录节点的父子关系)生成一颗树形AST.
tansform采用深度优先的方式遍历模板AST, 遍历过程中对节点进行各种操作从而实现AST的转换.
generate是一个字符串拼接的过程, 为不同的AST编写对应的代码生成函数.