在上篇文章 《Vue3 编译原理直通车💥------parser 篇》 中,我们介绍了 Vue 的解析器 parser
是如何将我们的模板字符串编译成模板 AST
的;这次,我们再来 look look 模板 AST
转换为 JavaScript AST
的过程。
JavaScript AST
老规矩,我们先来看看什么是 JavaScript AST
。
其实,和模板 AST
是使用 JavaScript
来描述我们的模板结构一样;JavaScript AST
实际上就是用 JavaScript
对我们的最终代码进行一层抽象。
比如下面这样一段模板代码:
vue
<template>
<div>
<h1>{{ message }}</h1>
</div>
</template>
最终生成的 render
函数如下:
js
function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("h1", null, _toDisplayString(message), 1 /* TEXT */)
]))
}
在 Vue3 中,对应的用来描述这段渲染函数的 Javascript AST
如下:
js
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 函数
}
通过上面的 Javascript AST
,再配合 generate
函数使用就能生成对应的渲染函数。
例如:针对我们的节点当 type
为 VNODE_CALL
时,会调用 genVNodeCall
函数,而 genVNodeCall
函数的逻辑就是根据 isBlock
的值来判断是否要拼接 openBlock
函数,以及将 tag
、props
等属性的值拼接起来。
js
// 最终生成的 render 函数字符串
let code = ''
function generate (node) {
switch (node.type) {
case 'VNODE_CALL':
genVNodeCall(node, context)
break
}
}
function genVNodeCall(node) {
const {
tag,
props,
children,
patchFlag,
isBlock,
} = node
// 如果 isBlock 属性为 true, 字符串拼接 (_openBlock()
if (isBlock) {
code += '(_openBloack(),'
}
// 字符串拼接 _createElementBlock 部分
code += '_createElementBlock('
// 遍历这些属性,将其依次拼接起来
const nodes = [tag, props, children, patchFlag]
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
code += node
if (i < nodes.length - 1) {
code += ','
}
}
code += ')'
// 补充 _openBlock 的半边括号
if (isBlock) {
code += ')'
}
}
其实 generate
生成最终渲染函数的过程,本质就是一个字符串的拼接的过程 。所以对于 JavaScript AST
的结构该如何设计我认为是没有一个标准答案的,只要保证前后的逻辑能够自洽就 ok 啦。
Vue3 的性能的很大一部分提升归功于编译时优化 ,而这里的
_openBlock
、_createElementBlock
函数,可以简单理解为是包含了编译优化信息的h
函数,作用也是用于创建虚拟节点。
模板 AST 转换为 JavaScript AST
为了将模板 AST
转换成 JavaScript AST
;就需要访问模板 AST
中的每一个节点,从而对这些节点执行一些特定的操作。
从代码中可以看出,无论是我们的模板 AST
还是 JavaScript AST
都是树型结构,而树型结构访问节点的过程,其实就是对树的深度优先遍历的过程,在 Vue3 中也是通过递归的方式来实现的:
js
funciton transform (ast) {
traverseNode(ast)
}
function traverseNode (node) {
// 拿到当前节点
const currentNode = node
// 当前节点的 children
const children = currentNode.children
// 如果有 children 的话,递归调用 traverseNode,实现对 children 的访问
if (children) {
for (let i = 0; i < children.length; i++) {
traverseNode(children[i])
}
}
}
通过递归调用 traverseNode
函数,Vue3 就能实现对整棵 AST
节点的访问 ;但是仅仅是访问还不够,因为我们的最终目的是生成 JavaScript AST
,因此还需要对访问到的节点进行相关的转换。
那么都要进行哪些转换呢?比如说下面这段模板 AST
:
js
const templateAst = {
type: 'Element',
tag: 'div',
children: [
{
type: 'Text',
content: 'hello'
}
]
}
它对应的 JavaScript AST
如下:
js
const ast = {
type: 'VNODE_CALL', // 表示需要创建一个虚拟节点
tag: 'div', // 虚拟节点对应的标签名称
children: [
{
type: 'TEXT', // 表示是文本
content: 'hello' // 文本内容
}
],
isBlock: true // 表示需要调用 openBlock 函数
}
transformElement 函数
针对 Element
类型的节点就可以编写一个 transformElement
函数来进行转换:
js
function transformElement (node) {
// 只有节点类型为 Element 时才进行处理
if (node.type !== 'Element') return
// 拿到节点标签名称,针对我们的例子就是 'div'
const { tag } = node
// 判断是否使用 openBlock 函数
let shouldUseBlock = false
if (hasChildren) {
shouldUseBlock = true
}
// 调用 createVNodeCall 返回对应的节点属性
// 返回的值挂载在 codegen 属性上
node.codegen = createVNodeCall(
tag,
children,
shouldUseBlock
)
}
// 辅助函数,用于创建一个 vnodeCall 类型的节点
function createVNodeCall (
tag,
children,
isBlock
) {
return {
type: 'VNODE_CALL',
tag,
isBlock,
children
}
}
在 traverseNode
函数中就可以去调用 transformElement
函数,来对指定的节点进行转换:
js
function traverseNode (node) {
const currentNode = node
// 调用 `transformElement` 函数,对 node 实现转换
transformElement(node)
const children = currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
traverseNode(children[i])
}
}
}
这样一来,就能将 Element
类型的模板 AST
转换为对应的 JavaScript AST
。
针对不同
type
做处理的相关transform
转换函数逻辑在目录: core-main/packages/compiler-core/src/transforms 下;而创建
JavaScript AST
辅助函数相关逻辑在:core-main/packages/compiler-core/src/ast.ts 文件中。
插件化架构
实际上,像 transformElement
这样针对不同 type
做处理的转换函数还有很多;如果将其调用逻辑全都写在 traverseNode
函数中代码就不那么优雅了。
所以 Vue3 源码中是通过插件的形式将这些转换函数注册到 transform
函数中:
js
function baseCompile (template) {
const ast = baseParse(template)
// 创建一个共享上下文 context
const context = {
// 将 transform 相关函数注册到这个全局上下文中
nodeTransforms: [
transformIf,
transformFor,
transformSlotOutlet,
transformElement,
trackSlotScopes,
transformText
]
}
transform(ast, context)
}
在 baseCompile
函数中,我们创建了一个 context
对象,将转换的相关函数注册到这个 context
对象中,然后再传递给 transform
:
js
funciton transform (ast, options) {
// 创建一个共享上下文 context
const context = createTransformContext(root, options)
// 将 context 作为第二个参数传递给 traverseNode 函数
traverseNode(ast, context)
}
// 通过 createTransformContext 方法对 context 对象进一步增强,混入一些节点通用的属性和方法
function createTransformContext (root, {
nodeTransforms = []
}) {
const context = {
nodeTransforms, // 节点转换方法
currentNode: root, // 当前节点
parent: null, // 当前节点的父节点
removeNode: () => {}, // 移除节点
replaceNode: () => {}, // 替换节点
}
return context
}
在 transform
函数中,通过调用 createTransformContext
方法对 context
对象进一步增强,混入一些节点通用的属性和方法 ;最后再将 context
对象传递给 traverseNode
函数:
js
function traverseNode (node, context) {
// 更新当前节点
context.currentNode = node
// 拿到节点转换方法
const { nodeTransforms } = context
// 遍历节点转换方法
for (let i = 0; i < nodeTransforms.length; i++) {
// 执行节点转换方法
nodeTransforms[i](node, context)
}
const currentNode = node
const children = currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
// 递归前更新当前节点的父节点
context.parent = context.currentNode
traverseNode(children[i])
}
}
}
在 traverseNode
函数中会去更新 context
中的 currentNode
属性,也就是当前节点的指向。
接着遍历了预先注册好的节点转换方法,并将当前节点传入处理。
最后在递归之前,会更新 context
中的 parent
属性值为当前节点,这样在递归到子节点之后就能通过 context.parent
来访问到它的父节点。
节点的访问与退出阶段
之前的部分我们简单介绍了一下如何递归访问模板 AST
上的每个节点 ;其实在递归的过程中,针对每个节点都有一个进入阶段 以及退出阶段;
比如一个模板这段模板: <div><p>hello</p></div>
,它的访问过程用一张图来表示的话就像下面这样:
简单来说一个节点的进入阶段,就是这个节点的子节点还没开始递归访问的时候;而节点的结束阶段,则是这个节点的子节点都被访问过的时候 ,也就是 traverseNode
函数的前后;
js
function traverseNode (node, context) {
// traverseNode 上面的代码,代表了节点访问的进入阶段
traverseNode(children[i])
// traverseNode 往下的代码,代表了节点访问的退出阶段
}
在前面的代码中,我们的转换逻辑都是写在节点 进入阶段 的;但是,一些节点的转换逻辑是需要在 退出阶段再执行 ;例如前面介绍的 transformElement
转换 Element
类型节点的这个函数。
对于父节点的转换依赖于子节点提供的信息,所以父节点的转换逻辑应该在子节点全部被处理完毕后再执行。
因此,我们需要改造一下 transformElement
函数:
js
function transformElement (node) {
// 通过 transformElement 返回一个函数
return () => {
if (node.type !== 'Element') return
const { tag } = node
let shouldUseBlock = false
if (hasChildren) {
shouldUseBlock = true
}
node.codegen = createVNodeCall(
tag,
children,
shouldUseBlock
)
}
}
通过上面的改造,transformElement
函数被调用时会返回另外一个匿名函数 ;接下来还需要对 traverseNode
函数进行改造:
js
function traverseNode (node, context) {
context.currentNode = node
const { nodeTransforms } = context
// 节点进入阶段
// 通过一个数组来接收退出阶段要执行的函数
const exitFns = []
for (let i = 0; i < nodeTransforms.length; i++) {
// 接收转换函数的返回值
const onExit = nodeTransforms[i](node, context)
// 如果转换函数存在返回值,将其放入 exitFns 数组中
if (onExit) {
exitFns.push(onExit)
}
}
const currentNode = node
const children = currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
context.parent = context.currentNode
traverseNode(children[i])
}
}
// 节点退出阶段
// 反序遍历 exitFns 数组,并执行其中的函数
let i = exitFns.length
while (i--) {
exitFns[i]()
}
}
上面的代码中,在节点进入阶段 增加了一个数组 exitFns
来储存转换函数调用后的返回值 ;然后在节点退出阶段倒序执行 exitFns
数组中的函数,这个时候当前节点的子节点已经全部处理完毕了,就能保证当前节点也能够被正确处理啦。
总结
不知不觉又啰里吧嗦了近一万字,最后我们一起来简单总结一下从模板 AST
到 JavaScript AST
的这个过程吧~
首先,Vue3 通过递归函数来对模板 AST
进行 深度优先遍历 ;在遍历的过程中就能够访问到对应的节点,从而通过各种转换函数将节点转换为相应 JavaScript AST
;
同时,在递归过程中会维护一个 context
对象,这个对象上会挂载全局共享的一些信息,比如:当前节点、当前节点的父节点等等;而 Vue3 通过插件化的架构,将转换函数注册到 context
对象中,从而实现了代码的解耦;
最后,递归过程中的每个节点都存在 进入阶段和退出阶段 ,处理那些依赖子节点信息的节点时,需要将它们的处理逻辑放到 退出阶段 来执行。
That's all!小伙伴们学废了吗 😁