在 Vue3
编译原理之前的文章中,分别介绍了 Vue
模板编译成模板 AST
的过程 《Vue3 编译原理直通车💥——parser 篇》;以及模板 AST
是如何转换成 JavaScript AST
的 《Vue3 编译原理直通车💥——transform 篇》;
现在终于到了最后一个环节 —— 通过 generate
函数将 JavaScript AST
生成目标 render
函数,用一张图来表示就是这样的:
下面我们一起看看具体的逻辑。
代码生成
相比较 parser
和 transform
,generate
的逻辑算是整个编译过程中最简单的了;它主要做的就是根据 JavaScript AST
的结构不断进行字符串拼接。
假设我们目前有模板如下:
<template>
<div>
<h1>{{ message }}</h1>
</div>
</template>
这段模板对应的 JavaScript AST
如下:
const ast = {
type: 'VNODE_CALL', // 表示需要创建一个虚拟节点
tag: 'div', // 虚拟节点对应的标签名称
children: [
{
type: 'VNODE_CALL', // 表示需要创建一个虚拟节点
tag: 'h1', // 虚拟节点对应的标签名称
children: {
type: 'INTERPOLATION', // h1 的 children 类型是一个插值表达式
content: { // 对 INTERPOLATION 类型内容的补充
type: 'SIMPLE_EXPRESSION', // 是个简单表达式
content: 'message' // 表达式内容为 message
}
},
patchFlag: 'TEXT', // patchFlag 属性用于编译优化
isBlock: false,
}
],
isBlock: true // 表示需要调用 openBlock 函数
}
那么,generate
具体做了哪些事呢?接着往下看。
创建上下文对象
首先,与 parser
和 transform
相同,在 generate
的过程中,同样使用了一个上下文对象 context
:
function generate(ast) {
// 创建一个上下文对象
const context = createCodegenContext()
}
function createCodegenContext() {
const context = {
code: ``, // 生成的代码
mode: 'function', // 生成代码的模式
// 用于拼接代码
push(code) {
context.code += code
},
// 当前缩进的级别,初始为 0
indentLevel: 0,
// 用于换行
newline(n) {
context.push('\n' + ` `.repeat(n))
},
// 用于代码生成过程中的缩进,并换行
indent() {
newline(++context.indentLevel)
},
// 用于取消码生成过程中的缩进,并换行
deindent() {
newline(--context.indentLevel)
}
}
return context
}
从上述代码中可以看到,context
对象中 维护了代码生成过程中的一些状态,以及用于拼接字符串的 push
函数、代码格式化的辅助函数等等。
有了这些工具,接下来就可以愉快地生成渲染函数啦。
浏览器端 generate
我们知道 Vue
是一个跨端的 UI 框架,因此 generate
代码中还会 根据当前的运行环境等来用于确定代码生成的模式和是否需要特定的优化。
例如:如果是服务端渲染,那么最终生成的渲染函数名称就是 ssrRender
,否则为 render
;并且浏览器环境与非浏览器环境下最终生成的代码也是有所差异。
这部分逻辑都是为了磨平差异而做的特殊处理,其余部分就不展开讲了;相关源码在 packages/compiler-core/src/codegen.ts 下,有兴趣的小伙伴可以自行阅读。
我们这里以 生成的是浏览器端的渲染函数为例,伪代码如下:
function generate(ast) {
const context = createCodegenContext(ast)
const {
mode,
push
} = context
// 生成 render 函数基本的函数体
push(`function render(_ctx, _cache) {`)
// 缩进并换行
indent()
// 拼接 with 语句
push(`with (_ctx) {`)
// 缩进并换行
indent()
// 将 helpers 数组中的函数名称用逗号做一个拼接
// 比如:const { createElementVNode: _createElementVNode } = _Vue
if (ast.helpers.length > 0) {
push(`const { ${ast.helpers.map(aliasHelper).join(', ')} } = _Vue`)
push(`\n`)
// 换行
newline()
}
// 调用 genNode,传入 ast 和 context
genNode(ast, context)
// 减少缩进
deindent()
// 拼接 with 语句的剩下半边括号
push(`}`)
// 减少缩进
deindent()
// 拼接 render 函数的剩下半边括号
push(`}`)
}
值得一提的是,在源码中对 ast.helpers
做了一个判断;它的作用是什么呢?
实际上,在之前 transform
生成 JavaScript AST
的过程中会去 分析后续需要用到哪些函数,并存储在 JavaScript AST
中的 helpers
数组中,这样一来只有使用到的函数才会从 Vue
中正常导入,从而减小代码体积。
genNode 函数
而对于 JavaScript AST
的主要处理逻辑,放在 genNode
函数中;针对我们开头的模板例子,genNode
的逻辑如下:
function genNode(node, context) {
switch (node.type) {
case 'ELEMENT':
genNode(node, context)
break
case 'SIMPLE_EXPRESSION':
genExpression(node, context)
break
case 'INTERPOLATION':
genInterpolation(node, context)
break
case 'VNODE_CALL':
genVNodeCall(node, context)
break
}
}
可以看到,genNode
函数做的事情就是 根据 JavaScript AST
中 node
的类型来调用不同的函数进行字符串拼接处理。
下面我们再分别看看这些处理函数的具体逻辑:
genExpression 函数
genExpression
函数的逻辑也很简单,就是拿到 content
的内容,并判断是不是静态节点,是的话直接转化成 JSON
字符串:
function genExpression(node, context) {
// 从 node 上获取 content 和 isStatic
const { content, isStatic } = node
// 调用 push 进行字符串拼接
// 如果 isStatic 为 true,说明是静态节点,直接将其转化成 JSON 字符串
context.push(isStatic ? JSON.stringify(content) : content)
}
genInterpolation 函数
genInterpolation
函数代码如下:
function genInterpolation(node, context) {
const { push } = context
// 拼接一个 toDisplayString 函数
push(`toDisplayString(`)
// 开启递归调用 genNode
genNode(node.content, context)
// 递归完成后拼接剩余的 }
push(`)`)
}
genVNodeCall 函数
genVNodeCall
函数代码如下:
function genVNodeCall(node: VNodeCall, context: CodegenContext) {
const { push, indent, deindent } = context
const {
tag,
children,
patchFlag,
isBlock
} = node
// 如果 isBlock 为 true,拼接 openBlock 函数
if (isBlock) {
push(`(openBlock(), `)
}
// 根据 isBlock 的值,判断拼接 createElementBlock 还是 createElementVNode
const callHelper = isBlock ? 'createElementBlock' : 'createElementVNode'
push(`${callHelper}(`)
// 拼接 createElementBlock 函数的入参,这里以标签、children 和 patchFlag 为例
const nodes = [tag, children, patchFlag]
// 遍历这些入参
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (typeof node === 'string') { // 如果节点是字符串,直接进行拼接
push(node)
} else if (Array.isArray(node)) { // 如果节点是个数组
// 需要额外进行 [] 的拼接
push(`[`)
indent()
genNode(node, context) // 递归调用 genNode 函数
deindent()
push(`]`)
} else {
// 递归调用 genNode 函数
genNode(node, context)
}
// 每个参数直接的部分拼接逗号
if (i < nodes.length - 1) {
push(',')
}
}
// 拼接 createElementBlock 函数的剩余半边括号
push(`)`)
// 拼接 openBlock 函数的剩余半边括号
if (isBlock) {
push(`)`)
}
}
看起来很长,但是一句话总结还是:字符串拼接拼接再拼接。
这部分逻辑主要涉及到了创建虚拟节点时需要调用的一些方法,比如 createElementBlock
和 createElementVNode
实际上和 h
函数差不多,不过比起普通的 h
函数,其中包含了一些编译优化的相关信息。
关于编译优化又是个大工程哇,埋个坑有机会再开一篇来唠唠吧🧐。
render 函数
那么,以上就是 generate
函数的部分相关逻辑啦;而经过 generate
函数的一系列处理,开头的那段 Javascript AST
最终生成的渲染函数如下:
const _Vue = Vue
return function render(_ctx, _cache) {
with (_ctx) {
const { toDisplayString: _toDisplayString, createElementVNode: _createElementVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue
return (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("h1", null, _toDisplayString(message), 1 /* TEXT */)
]))
}
}
总结
至此,Vue3 编译原理系列这班车就到终点站啦,我们来简单总结一下整个过程:
- 分析模板,将模板解析成对应的
模板 AST
; - 深度优先遍历
模板 AST
,访问其中节点并转换为Javascript AST
; - 根据
Javascript AST
上的节点类型,进行字符串拼接,最终生成模板渲染函数。
Over!👏🏻👏🏻👏🏻