Vue3源码——AST编译为JS AST
前言
关于模板编译,从模板字符串template到最终呈现页面,Vue对于这部分的处理过程是:
- 将编写好的 template模板,转换为AST
- 将 AST 转换为JS AST
- 将JS AST转换为render函数
- 执行render函数,将DOM结构呈现在页面上
上一节,我们介绍了模板字符串template如何编译为AST,有所遗忘的朋友可以再回顾一下《Vue3源码——模板template如何编译为AST》。
大家会不会有这样一个疑惑,为什么 Vue 不将 AST 直接编译为render函数,而是在他们中间又插入了一个 JS AST呢? 这一节,我们继续探究模板编译,看一看这个 JS AST 相比于 AST 到底多了些什么,它的存在到底有什么作用呢?
AST转换JS AST
我们先看一眼上一节提到的baseCompile函数:
function baseCompile(template, options = {}) {
...
// 将template转换为AST
const ast = isString(template) ? baseParse(template, options) : template;
// 获取节点和指令转换的方法
const [nodeTransforms, directiveTransforms] = getBaseTransformPreset(prefixIdentifiers);
// 将AST转换为JS AST
transform(
ast,
extend({}, options, {
prefixIdentifiers,
nodeTransforms: [
...nodeTransforms,
...options.nodeTransforms || []
],
directiveTransforms: extend(
{},
directiveTransforms,
options.directiveTransforms || {}
)
})
);
// 将JS AST生成render函数
...
}
我们调用 transform 将 AST 转换为 JS AST 之前,先获取了一下转换元素和指令的方法——nodeTransforms 和 directiveTransforms。
function getBaseTransformPreset(prefixIdentifiers) {
return [
[
transformOnce,
transformIf,
transformMemo,
transformFor,
transformExpression,
transformSlotOutlet,
transformElement,
trackSlotScopes,
transformText
],
{
on: transformOn,
bind: transformBind,
model: transformModel
}
];
}
我们通过变量的命名可以很容易猜到,getBaseTransformPreset函数就是将一系列转换方法进行了整合。
接下来我们正式进入transform函数:
function transform(root, options) {
// 生成转换上下文
const context = createTransformContext(root, options);
// 遍历AST节点进行转换
traverseNode(root, context);
// 静态提升
if (options.hoistStatic) {
hoistStatic(root, context);
}
// 创建根节点
if (!options.ssr) {
createRootCodegen(root, context);
}
root.helpers = /* @__PURE__ */ new Set([...context.helpers.keys()]);
root.components = [...context.components];
root.directives = [...context.directives];
root.imports = context.imports;
root.hoists = context.hoists;
root.temps = context.temps;
root.cached = context.cached;
}
创建转换上下文
首先通过createTransformContext函数创建了一个转换上下文context
:
function createTransformContext(root, ...options) {
const context = {
// options
prefixIdentifiers,
hoistStatic,
cacheHandlers,
...
// state
root,
helpers: /* @__PURE__ */ new Map(),
components: /* @__PURE__ */ new Set(),
directives: /* @__PURE__ */ new Set(),
...
// methods
helper(name) {},
removeHelper() {},
helperString() {},
...
};
return context;
}
可以看到,创建的转换上下文主要分为三块:options,state,methods。
- options 主要表示转换过程中的一些配置项。
- state 主要用来维护转换过程中用到的一些状态数据。
- methods 主要是一些工具函数,我们上面提到的转换元素和指令的方法
nodeTransforms
和directiveTransforms
中就会用到这些工具函数。
遍历AST节点进行转换
上下文创建完毕,接下来,通过执行traverseNode函数,对AST节点进行转换:
function traverseNode(node, context) {
// 保存ast
context.currentNode = node;
// 从上下文中取出转换方法
const { nodeTransforms } = context;
// 退出函数数组
const exitFns = [];
// 遍历转换方法数组,并维护退出函数数组
for (let i = 0; i < nodeTransforms.length; i++) {
const onExit = nodeTransforms[i](node, context);
if (onExit) {
if (isArray(onExit)) {
exitFns.push(...onExit);
} else {
exitFns.push(onExit);
}
}
if (!context.currentNode) {
// 节点被移除
return;
} else {
node = context.currentNode;
}
}
switch (node.type) {
case 3 /* COMMENT */:
if (!context.ssr) {
// context的helper中添加CREATE_COMMENT辅助函数
context.helper(CREATE_COMMENT);
}
break;
case 5 /* INTERPOLATION */:
if (!context.ssr) {
// context的helper中添加TO_DISPLAY_STRING辅助函数
context.helper(TO_DISPLAY_STRING);
}
break;
case 9 /* IF */:
// 递归遍历ast的分支节点
for (let i = 0; i < node.branches.length; i++) {
traverseNode(node.branches[i], context);
}
break;
case 10 /* IF_BRANCH */:
case 11 /* FOR */:
case 1 /* ELEMENT */:
case 0 /* ROOT */:
// 遍历子节点
traverseChildren(node, context);
break;
}
context.currentNode = node;
let i = exitFns.length;
// 执行退出函数
while (i--) {
exitFns[i]();
}
}
traverseNode函数的逻辑:
- 首先,从上下文context中取出转换函数组成的数组nodeTransforms,遍历nodeTransforms数组,并维护一个由退出函数组成的数组exitFns。
- 然后,根据ast的类型不同执行不同的逻辑:
COMMENT
,INTERPOLATION
类型时,维护对应的辅助函数。IF
类型时,递归调用traverseNode函数,遍历ast的分支节点。IF_BRANCH
,FOR
,ELEMENT
,ROOT
类型时,调用traverseChildren函数遍历子节点。
- 最后,统一执行第一步中维护的退出函数。
这里,我们着重关注一下转换函数组成的数组nodeTransforms。根据前面的逻辑,我们可以分析出nodeTransforms数组的内容为:
[
transformOnce,
transformIf,
transformMemo,
transformFor,
transformExpression,
transformSlotOutlet,
transformElement,
trackSlotScopes,
transformText
]
根据每个转换函数的名字,我们也能大概猜测出对应函数的处理对象是什么。
而且,还有一个很关键的地方在于,我们在对ast进行转换的时候,是依次遍历nodeTransforms数组的,这些转换函数的执行是有很明确的先后顺序的。
从这一层面,我们也就能更进一步明确vue中的一个经典题目:v-if和v-for谁的优先级高?
关于转换函数,每个函数展开之后,内容都十分庞杂,深入去看内部逻辑会非常的头秃。这里我想用一个简单的说法来带大家认识这一部分到底都是做什么的:
- 首先,我们可以把 ast 想象成一个产品的初始状态,把转换函数数组nodeTransforms想象成一条流水线,而每个转换函数就是流水线上负责不同模块的工人或者车间。
- 我们将 ast 放上流水线,然后每个转换函数也就是工人在 ast 走到自己面前的时候都会对ast进行加工,向ast上去添加或者修改不同的信息。
- 最开始的 ast 不过是将整个模板字符串进行切割然后堆在一起,而经过转换操作之后,将这些繁杂切片进行了整合,汇聚成一个拥有更多更直观信息的ast。
举个例子:
<span>{{x}}</span>
上面的模板字符串被解析为ast之后的样子是这样的:
我们可以看到,最初的ast经过traverseNode转换之后,多了不少信息,最核心的就是向ast上添加了codegenNode属性,这个属性上包含了非常丰富并且直观的用于描述这个标签的字段,我们后面也就可以参照codegenNode属性来更加便捷的生成render函数了。
静态提升
静态提升的函数为hoistStatic:
function hoistStatic(root, context) {
walk(
root,
context,
// 根节点不可被静态提升
isSingleElementRoot(root, root.children[0])
);
}
我们可以看到hoistStatic函数就是执行以下walk函数:
function walk(node, context, doNotHoistNode = false) {
const { children } = node;
const originalCount = children.length;
// 记录被静态提升的节点数量
let hoistedCount = 0;
for (let i = 0; i < children.length; i++) {
const child = children[i];
// 如果child的type是ELEMENT
if (child.type === 1 /* ELEMENT */ && child.tagType === 0 /* ELEMENT */) {
// 根据元素是否为只包含单一子节点的根节点,决定constantType的值,如果是,则标记为NOT_CONSTANT 不可静态提升
const constantType = doNotHoistNode ? 0 /* NOT_CONSTANT */ : getConstantType(child, context);
if (constantType > 0 /* NOT_CONSTANT */) {
// 根据constantType的取值,判断出可以被静态提升的节点
if (constantType >= 2 /* CAN_HOIST */) {
// 为patchFlag属性赋值为 -1 /* HOISTED */
child.codegenNode.patchFlag = -1 /* HOISTED */ + ` /* HOISTED */`;
// 将被静态提升后的codegenNode收集到上下文context的hoist中
child.codegenNode = context.hoist(child.codegenNode);
// 静态提升节点数量加一
hoistedCount++;
continue;
}
} else {
// 如果为动态节点的话
const codegenNode = child.codegenNode;
if (codegenNode.type === 13 /* VNODE_CALL */) {
const flag = getPatchFlag(codegenNode);
// 判断是否有可动态提升的属性props
if ((!flag || flag === 512 /* NEED_PATCH */ || flag === 1 /* TEXT */) && getGeneratedPropsConstantType(child, context) >= 2 /* CAN_HOIST */) {
const props = getNodeProps(child);
if (props) {
// 将静态提升的属性进行收集
codegenNode.props = context.hoist(props);
}
}
// 收集节点的动态属性
if (codegenNode.dynamicProps) {
codegenNode.dynamicProps = context.hoist(codegenNode.dynamicProps);
}
}
}
}
if (child.type === 1 /* ELEMENT */) {
const isComponent = child.tagType === 1 /* COMPONENT */;
if (isComponent) {
context.scopes.vSlot++;
}
// 递归遍历子节点
walk(child, context);
if (isComponent) {
context.scopes.vSlot--;
}
} else if (child.type === 11 /* FOR */) {
// 递归对子节点调用walk方法,子节点只有一个时不需要静态提升
walk(child, context, child.children.length === 1);
} else if (child.type === 9 /* IF */) {
// 如果是使用if指令的节点,则会对不同分支递归调用walk方法
for (let i = 0; i < child.branches.length; i2++) {
walk(
child.branches[i],
context,
child.branches[i].children.length === 1
);
}
}
}
// 预字符串化
...
}
walk函数代码很杂,但在做的工作还是比较清晰,我们简单总结一下:
- 如果节点是可以静态提升的节点,则维护对应
codegenNode
的patchFlag
属性,并将其维护进上下文context
的hoist容器
中。 - 如果节点不是可静态提升的节点,则判断是否有可静态提升的属性,如果有,则将其维护进上下文
context
的hoist容器
中。 - 分情况,对子节点进行递归调用walk方法。
创建根代码节点
AST转换JS AST还差最后一个步骤——创建根代码节点。
function createRootCodegen(root, context) {
const { helper } = context;
const { children } = root;
// 如果子节点是单一节点
if (children.length === 1) {
const child = children[0];
if (isSingleElementRoot(root, child) && child.codegenNode) {
const codegenNode = child.codegenNode;
if (codegenNode.type === 13 /* VNODE_CALL */) {
convertToBlock(codegenNode, context);
}
root.codegenNode = codegenNode;
} else {
root.codegenNode = child;
}
} else if (children.length > 1) {
// 如果子节点是多个节点,也就是没有根标签
let patchFlag = 64 /* STABLE_FRAGMENT */;
let patchFlagText = PatchFlagNames[64 /* STABLE_FRAGMENT */];
if (children.filter((c) => c.type !== 3 /* COMMENT */).length === 1) {
patchFlag |= 2048 /* DEV_ROOT_FRAGMENT */;
patchFlagText += `, ${PatchFlagNames[2048 /* DEV_ROOT_FRAGMENT */]}`;
}
// 创建一个fragment
root.codegenNode = createVNodeCall(
context,
helper(FRAGMENT),
void 0,
root.children,
patchFlag + (true ? ` /* ${patchFlagText} */` : ``),
void 0,
void 0,
true,
void 0,
false
/* isComponent */
);
}
}
最后的createRootCodegen函数就比较简单,判断模板template是否有根节点,如果没有根节点,则创建一个fragment节点,来接收前面所维护的信息。
其他
上面我们已经介绍完了AST转换JS AST的整个过程,但是,可能在看的过程中可能会有很多的疑问,比如像静态提升到底是做什么的?上下文中一直维护的helper又是做什么的?这里提前剧透一下,下一节再具体研究:
静态提升
我们在写模板template代码的时候,有一些节点是不涉及动态变量的。而我们大概知道,每次页面的刷新其实是通过执行render函数,然后通过操作dom把改变的内容反映在页面上的。
那么,对于一些并不会改变的节点,我们更新dom的时候,是不是就可以避开他们,这样也是对性能的一大优化呢?
这里关于静态提升举个例子吧,比如我有这样的一段模板代码:
<div>
<span>{{x}}</span>
<div>123</div>
</div>
我们可以发现,子节点中<div>123</div>
不管动态属性 x怎么改变,它始终就是一个简单的div
,内容为123,所以它就是一个静态节点,是可以被静态提升的。
最终我们生成的JS AST中维护的hoists长这个样子:

最终生成的render函数,则会将静态节点提取出来:

这样就最大程度的做到了复用,在动态内容发生更新重新调用render的时候,也无需再重新执行创建静态节点相关的函数了。
辅助函数容器helpers
我们在前面的逻辑中,会看到这样的一些代码:

context.helper
函数的逻辑,就是将传入的辅助函数名维护进helpers容器中,helpers
是一个Map容器,它的最终目的就是方便我们在生成render函数时,从Vue上去获取对应的方法。
举个例子:
<div>
<span>{{x}}</span>
<div>123</div>
</div>
我们最终得到的helpers长这个样子:

有了它,我们最终在生成render函数时,去从Vue上引入方法就非常容易了:

总结
到这里,我们总算是了解了Vue3到底是如何将AST转换为JS AST的,而这一步骤,关键就在于维护节点的codegenNode信息,同时做一些静态提升等操作,最终维护出一份信息更加明确的数据出来。
总之,一切的操作都是为了能够更加准确的描述相应的节点,进而最后根据JS AST创建出相应的render函数。
参考文章:
转载自:https://juejin.cn/post/7254043722946379833