《Vuejs设计与实现》第 15 章(编译器核心技术)下
目录
15.5 将模板 AST 转为 JavaScript AST
15.6 代码生成
15.7 总结
15.5 将模板 AST 转为 JavaScript AST
我们最后需要将模板编译为渲染函数,而渲染函数是 JavaScript 代码描述,因此我们先需要将模板 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 代码的描述,本质上我们需要设计数据结构来描述这段渲染函数。
首先观察上面函数,它是一个函数声明,一个函数声明语句由以下几部分组成:
- id:函数名称,它是一个标识符 Identifier。
- params:函数的参数,它是一个数组。
- body:函数体,由于函数体可以包含多个语句,因此它也是一个数组。
为简化问题,我们不考虑箭头函数、生成器函数、async 函数等情况。
根据以上这些信息,我们就可以设计一个基本的数据结构来描述函数声明语句:
const FunctionDeclNode = {type: 'FunctionDecl', // 代表该节点是函数声明// 函数的名称是一个标识符,标识符本身也是一个节点id: {type: 'Identifier',name: 'render', // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render},params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组// 渲染函数的函数体只有一个语句,即 return 语句body: [{type: 'ReturnStatement',return: null, // 暂时留空,在后续讲解中补全},],
}
如上代码,我们使用一个对象来描述一个 JavaScript AST 节点。
每个节点都具有 type 字段,该字段用来代表节点的类型。对于函数声明语句来说,它的类型是 FunctionDecl。
接着,我们使用 id 字段来存储函数的名称。函数的名称应该是一个合法的标识符,因此 id 字段本身也是一个类型为 Identifier 的节点。
我们也根据实际可进行调整,例如我们们完全可以将 id 字段设计为一个字符串类型的值。这样做虽然不完全符合 JavaScript 的语义,但是能够满足我们的需求。
对于函数的参数,我们使用 params 数组来存储。目前,我们设计的渲染函数还不需要参数,因此暂时设为空数组。
最后,我们使用 body 字段来描述函数的函数体。一个函数的函数体内可以存在多个语句,所以我们使用一个数组来描述它。该数组内的每个元素都对应一条语句,对于渲染函数来说,目前它只有一个返回语句,所以我们使用一个类型为 ReturnStatement 的节点来描述该返回语句。
我们来看一下渲染函数的返回值。渲染函数返回的是虚拟 DOM 节点,体现在 h 函数的调用。
我们可以使用 CallExpression 类型的节点来描述函数调用语句:
const CallExp = {type: 'CallExpression',// 被调用函数的名称,它是一个标识符callee: {type: 'Identifier',name: 'h',},// 参数arguments: [],
}
类型为 CallExpression 的节点拥有两个属性:
- callee:用来描述被调用函数的名字称,它本身是一个标识符节点。
- arguments:被调用函数的形式参数,多个参数的话用数组来描述。
我们再次观察渲染函数的返回值:
function render() {// h 函数的第一个参数是一个字符串字面量// h 函数的第二个参数是一个数组return h('div', [/*...*/])
}
可以看到,h 函数的第一个参数是一个字符串字面量,我们可以使用类型为 StringLiteral 的节点来描述它:
const Str = {type: 'StringLiteral',value: 'div',
}
使用上述 CallExpression、StringLiteral、ArrayExpression 等节点来填充渲染函数的返回值:
const FunctionDeclNode = {type: 'FunctionDecl', // 代表该节点是函数声明// 函数的名称是一个标识符,标识符本身也是一个节点id: {type: 'Identifier',name: 'render', // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render},params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组// 渲染函数的函数体只有一个语句,即 return 语句body: [{type: 'ReturnStatement',// 最外层的 h 函数调用return: {type: 'CallExpression',callee: { type: 'Identifier', name: 'h' },arguments: [// 第一个参数是字符串字面量 'div'{type: 'StringLiteral',value: 'div',},// 第二个参数是一个数组{type: 'ArrayExpression',elements: [// 数组的第一个元素是 h 函数的调用{type: 'CallExpression',callee: { type: 'Identifier', name: 'h' },arguments: [// 该 h 函数调用的第一个参数是字符串字面量{ type: 'StringLiteral', value: 'p' },// 第二个参数也是一个字符串字面量{ type: 'StringLiteral', value: 'Vue' },],},// 数组的第二个元素也是 h 函数的调用{type: 'CallExpression',callee: { type: 'Identifier', name: 'h' },arguments: [// 该 h 函数调用的第一个参数是字符串字面量{ type: 'StringLiteral', value: 'p' },// 第二个参数也是一个字符串字面量{ type: 'StringLiteral', value: 'Template' },],},],},],},},],
}
如上这段 JavaScript AST 的代码所示,它是对渲染函数代码的完整描述。
接下来我们编写转换函数,将模板 AST 转换为上述 JavaScriptAST。
在开始之前,我们需要编写一些用来创建 JavaScript AST 节点的辅助函数:
// 用来创建 StringLiteral 节点
function createStringLiteral(value) {return {type: 'StringLiteral',value,}
}
// 用来创建 Identifier 节点
function createIdentifier(name) {return {type: 'Identifier',name,}
}
// 用来创建 ArrayExpression 节点
function createArrayExpression(elements) {return {type: 'ArrayExpression',elements,}
}
// 用来创建 CallExpression 节点
function createCallExpression(callee, arguments) {return {type: 'CallExpression',callee: createIdentifier(callee),arguments,}
}
有了这些辅助函数,我们可以更容易地编写转换代码。
为了把模板 AST 转换为 JavaScript AST,我们同样需要两个转换函数:transformElement 和 transformText,它们分别用来处理标签节点和文本节点:
// 转换文本节点
function transformText(node) {// 如果不是文本节点,则什么都不做if (node.type !== 'Text') {return}// 文本节点对应的 JavaScript AST 节点其实就是一个字符串字面量,// 因此只需要使用 node.content 创建一个 StringLiteral 类型的节点即可// 最后将文本节点对应的 JavaScript AST 节点添加到 node.jsNode 属性下node.jsNode = createStringLiteral(node.content)
}// 转换标签节点
function transformElement(node) {// 将转换代码编写在退出阶段的回调函数中,// 这样可以保证该标签节点的子节点全部被处理完毕return () => {// 如果被转换的节点不是元素节点,则什么都不做if (node.type !== 'Element') {return}// 1. 创建 h 函数调用语句,// h 函数调用的第一个参数是标签名称,因此我们以 node.tag 来创建一个字符串字面量节点// 作为第一个参数const callExp = createCallExpression('h', [createStringLiteral(node.tag)])// 2. 处理 h 函数调用的参数node.children.length === 1? // 如果当前标签节点只有一个子节点,则直接使用子节点的 jsNode 作为参数callExp.arguments.push(node.children[0].jsNode): // 如果当前标签节点有多个子节点,则创建一个 ArrayExpression 节点作为参数callExp.arguments.push(// 数组的每个元素都是子节点的 jsNodecreateArrayExpression(node.children.map(c => c.jsNode)))// 3. 将当前标签节点对应的 JavaScript AST 添加到 jsNode 属性下node.jsNode = callExp}
}
上述总体实现并不复杂。有两点需要注意:
- 在转换标签节点时,我们需要将转换逻辑编写在退出阶段的回调函数内,这样才能保证其子节点全部被处理完毕。
- 无论是文本节点还是标签节点,它们转换后的 JavaScript AST 节点都存储在节点的 node.jsNode 属性下。
使用上面两个转换函数即可完成标签节点和文本节点的转换,即把模板转换成 h 函数的调用。
但是转换后的 AST 只是描述 render 函数的返回值,我们需要补全 JavaScript AST,即把 Render 函数本身的函数声明语句节点附加到上面。
这需要我们编写 transformRoot 函数来实现对 Root 根节点的转换:
// 转换 Root 根节点
function transformRoot(node) {// 将逻辑编写在退出阶段的回调函数中,保证子节点全部被处理完毕return () => {// 如果不是根节点,则什么都不做if (node.type !== 'Root') {return}// node 是根节点,根节点的第一个子节点就是模板的根节点// 当然,这里我们暂时不考虑模板存在多个根节点的情况const vnodeJSAST = node.children[0].jsNode// 创建 render 函数的声明语句节点,将 vnodeJSAST 作为 render 函数体的返回语句node.jsNode = {type: 'FunctionDecl',id: { type: 'Identifier', name: 'render' },params: [],body: [{type: 'ReturnStatement',return: vnodeJSAST,},],}}
}
经过这一步处理后,模板 AST 将转换为对应的 JavaScript AST。
我们可以通过根节点的 node.jsNode 来访问转换后的 JavaScript AST。
15.6 代码生成
这节我们讨论如何根据 JavaScript AST 生成渲染函数代码。即代码生成。
代码生成本质上是字符串拼接的艺术。我们需要访问 JavaScript AST 中的节点,为每一种类型的节点生成相符的 JavaScript 代码。
我们将实现 generate 函数来完成编译器的最后一步,代码生成:
function compile(template) {// 模板 ASTconst ast = parse(template)// 将模板 AST 转换为 JavaScript ASTtransform(ast)// 代码生成const code = generate(ast.jsNode)return code
}
代码生成也需要上下文对象。该上下文对象用来维护代码生成过程中程序的运行状态:
function generate(node) {const context = {// 存储最终生成的渲染函数代码code: '',// 在生成代码时,通过调用 push 函数完成代码的拼接push(code) {context.code += code},}// 调用 genNode 函数完成代码生成的工作,genNode(node, context)// 返回渲染函数代码return context.code
}
上述代码,首先我们定义了上下文对象 context,它包含 context.code 属性,用来存储最终生成的渲染函数代码。
还定义了 context.push 函数,用来完成代码拼接。
接着调用 genNode 函数完成代码生成的工作,最后将最终生成的渲染函数代码返回。
另外我们可以扩展 context 对象,增加换行和缩进的工具函数,增强可读性:
function generate(node) {const context = {code: '',push(code) {context.code += code},// 当前缩进的级别,初始值为 0,即没有缩进currentIndent: 0,// 该函数用来换行,即在代码字符串的后面追加 \n 字符,// 另外,换行时应该保留缩进,所以我们还要追加 currentIndent * 2 个空格字符newline() {context.code += '\n' + ` `.repeat(context.currentIndent)},// 用来缩进,即让 currentIndent 自增后,调用换行函数indent() {context.currentIndent++context.newline()},// 取消缩进,即让 currentIndent 自减后,调用换行函数deIndent() {context.currentIndent--context.newline()},}genNode(node, context)return context.code
}
上述代码,我们增加了 context.currentIndent 属性,它代表缩进的级别,初始值为 0,代表没有缩进。
还增加了 context.newline() 函数,每次调用该函数时,都会在代码字符串后面追加换行符 \n。
由于换行时需要保留缩进,所以我们还要追加 context.currentIndent * 2 个空格字符。这里我们假设缩进为两个空格字符,后续设计成可配置。
同时,我们还增加了 context.indent() 函数用来完成代码缩进,它实现的原理是让缩进级别 context.currentIndent 进行自增,再调用 context.newline() 函数。
与之对应的 context.deIndent() 函数则用来取消缩进,即让缩进级别context.currentIndent 进行自减,再调用 context.newline() 函数。
有了这些基础能力之后,我们就可以开始编写 genNode 函数来完成代码生成的工作了。
只需要匹配各种类型的 JavaScriptAST 节点,并调用对应的生成函数即可:
function genNode(node, context) {switch (node.type) {case 'FunctionDecl':genFunctionDecl(node, context)breakcase 'ReturnStatement':genReturnStatement(node, context)breakcase 'CallExpression':genCallExpression(node, context)breakcase 'StringLiteral':genStringLiteral(node, context)breakcase 'ArrayExpression':genArrayExpression(node, context)break}
}
在 genNode 函数内部,我们使用 switch 语句来匹配不同类型的节点,并调用与之对应的生成器函数。
- 对于 FunctionDecl 节点,使用 genFunctionDecl 函数为该类型节点生成对应的 JavaScript 代码。
- 对于 ReturnStatement 节点,使用 genReturnStatement 函数为该类型节点生成对应的 JavaScript 代码。
- 对于 CallExpression 节点,使用 genCallExpression 函数为该类型节点生成对应的 JavaScript 代码。
- 对于 StringLiteral 节点,使用 genStringLiteral 函数为该类型节点生成对应的 JavaScript 代码。
- 对于 ArrayExpression 节点,使用 genArrayExpression 函数为该类型节点生成对应的 JavaScript 代码。
目前只涉及这五种类型的 JavaScript 节点,后续有需求,再添加对应逻辑即可。
接下来我们来实现函数声明语句的代码生成,即 genFunctionDecl 函数:
function genFunctionDecl(node, context) {// 从 context 对象中取出工具函数const { push, indent, deIndent } = context// node.id 是一个标识符,用来描述函数的名称,即 node.id.namepush(`function ${node.id.name} `)push(`(`)// 调用 genNodeList 为函数的参数生成代码genNodeList(node.params, context)push(`) `)push(`{`)// 缩进indent()// 为函数体生成代码,这里递归地调用了 genNode 函数node.body.forEach(n => genNode(n, context))// 取消缩进deIndent()push(`}`)
}
genFunctionDecl 函数可以为函数声明类型的节点生成对应的 JavaScript 代码。以渲染函数的声明节点为例,它最终生成的代码将会是:
function render () {... 函数体
}
另外在 genFunctionDecl 函数内调用了 genNodeList 函数,为函数参数生成对应的代码。它的实现如下:
function genNodeList(nodes, context) {const { push } = contextfor (let i = 0; i < nodes.length; i++) {const node = nodes[i]genNode(node, context)if (i < nodes.length - 1) {push(', ')}}
}
genNodeList 函数接收一个节点数组作为参数,并为每一个节点递归地调用 genNode 函数完成代码生成工作。
每处理完一个节点,都会在生成的代码后面拼接逗号字符(,):
// 如果节点数组为
const node = [节点 1, 节点 2, 节点 3]
// 那么生成的代码将类似于
'节点 1,节点 2,节点 3'
// 如果在这段代码的前后分别添加圆括号,那么它将可用于函数的参数声明
('节点 1,节点 2,节点 3')
// 如果在这段代码的前后分别添加方括号,那么它将是一个数组
['节点 1,节点 2,节点 3']
由上可知,genNodeList 函数会在节点代码之间补充逗号字符。
实际上,genArrayExpression 函数使用这个特点实现对数组表达式的代码生成:
function genArrayExpression(node, context) {const { push } = context// 追加方括号push('[')// 调用 genNodeList 为数组元素生成代码genNodeList(node.elements, context)// 补全方括号push(']')
}
由于目前渲染函数没有接收参数,所以 genNodeList 函数不会为其生成任何代码。
对于 genFunctionDecl 函数,另外注意,由于函数体本身也是一个节点数组,所以我们需要遍历它并递归地调用 genNode 函数生成代码。
对于 ReturnStatement 和 StringLiteral 类型的节点来说,实现如下:
function genReturnStatement(node, context) {const { push } = context// 追加 return 关键字和空格push(`return `)// 调用 genNode 函数递归地生成返回值代码genNode(node.return, context)
}function genStringLiteral(node, context) {const { push } = context// 对于字符串字面量,只需要追加与 node.value 对应的字符串即可push(`'${node.value}'`)
}
最后还剩下 genCallExpression 函数:
function genCallExpression(node, context) {const { push } = context// 取得被调用函数名称和参数列表const { callee, arguments: args } = node// 生成函数调用代码push(`${callee.name}(`)// 调用 genNodeList 生成参数代码genNodeList(args, context)// 补全括号push(`)`)
}
在 genCallExpression 函数内,我们也用到了 genNodeList 函数,为函数调用时的参数生成对应的代码。
配合上述生成器函数的实现,我们将得到符合预期的渲染函数代码:
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
const code = generate(ast.jsNode)
最终得到的代码字符串如下:
function render () {return h('div', [h('p', 'Vue'), h('p', 'Template')])
}
15.7 总结
我们首先讨论 Vue.js 模板编译器,它用于将模板编译为渲染函数,工作流程分为三个步骤:
- 分析模板,将其解析为模板 AST。
- 将模板 AST 转换为用于描述渲染函数的 JavaScript AST。
- 根据 JavaScript AST 生成渲染函数代码。
接着,我们讨论了 parser 的实现原理,以及如何用有限状态自动机构造一个词法分析器。词法分析的过程就是状态机在不同状态之间迁移的过程。在此过程中,状态机会产生一个个 Token,形成一个 Token 列表。我们将使用该 Token 列表来构造用于描述模板的 AST。具体做法是,扫描 Token 列表并维护一个开始标签栈。每当扫描到一个开始标签节点,就将其压入栈顶。栈顶的节点始终作为下一个扫描的节点的父节点。这样,当所有 Token 扫描完毕后,即可构建出一棵树型 AST。
然后,我们讨论了 AST 的转换与插件化架构。AST 是树型数据结构,为了访问AST 中的节点,我们采用深度优先的方式对 AST 进行遍历。在遍历过程中,我们可以对 AST 节点进行各种操作,从而实现对 AST 的转换。为了解耦节点的访问和操作,我们设计了插件化架构,将节点的操作封装到独立的转换函数中。这些转换函数可以通过 context.nodeTransforms 来注册。这里的 context 称为转换上下文。上下文对象中通常会维护程序的当前状态,例如当前访问的节点、当前访问的节点的父节点、当前访问的节点的位置索引等信息。有了上下文对象及其包含的重要信息后,我们即可轻松地实现节点的替换、删除等能力。但有时,当前访问节点的转换工作依赖于其子节点的转换结果,所以为了优先完成子节点的转换,我们将整个转换过程分为“进入阶段”与“退出阶段”。每个转换函数都分两个阶段执行,这样就可以实现更加细粒度的转换控制。
之后,我们讨论了如何将模板 AST 转换为用于描述渲染函数的 JavaScript AST。模板 AST 用来描述模板,类似地,JavaScript AST 用于描述 JavaScript 代码。只有把模板 AST 转换为 JavaScript AST 后,我们才能据此生成最终的渲染函数代码。最后,我们讨论了渲染函数代码的生成工作。代码生成是模板编译器的最后一步工作,生成的代码将作为组件的渲染函数。代码生成的过程就是字符串拼接的过程。我们需要为不同的 AST 节点编写对应的代码生成函数。为了让生成的代码具有更强的可读性,我们还讨论了如何对生成的代码进行缩进和换行。我们将用于缩进和换行的代码封装为工具函数,并且定义到代码生成过程中的上下文对象中。