AST 转换:AST 节点内部做了哪些转换?
上一节课,我们学习了 template 的解析过程,最终拿到了一个 AST 节点对象。这个对象是对模板的完整描述,但是它还不能直接拿来生成代码,因为它的语义化还不够,没有包含和编译优化的相关属性,所以还需要进一步转换。
AST 转换过程非常复杂,有非常多的分支逻辑,为了方便你理解它的核心流程,我精心准备了一个示例,我们只分析示例场景在 AST 转换过程中的相关代码逻辑,不过我希望你在学习完之后,可以举一反三,对示例做一些修改,学习更多场景的代码逻辑。
<div class="app">
<hello v-if="flag"></hello>
<div v-else>
<p>>hello {{ msg + test }}</p>
<p>static</p>
<p>static</p>
</div>
</div>
示例中,我们有普通的 DOM 节点,有组件节点,有 v-bind 指令,有 v-if 指令,有文本节点,也有表达式节点。
对于这个模板,我们通过 parse 生成一个 AST 对象,接下来我们就来分析这个 AST 对象的转换都做了哪些事情。
我们会先通过 getBaseTransformPreset 方法获取节点和指令转换的方法,然后调用 transform 方法做 AST 转换,并且把这些节点和指令的转换方法作为配置的属性参数传入。
// 获取节点和指令转换的方法
const [nodeTransforms, directiveTransforms] = getBaseTransformPreset()
// AST 转换
transform(ast, extend({}, options, {
prefixIdentifiers,
nodeTransforms: [
...nodeTransforms,
...(options.nodeTransforms || []) // 用户自定义 transforms
],
directiveTransforms: extend({}, directiveTransforms, options.directiveTransforms || {} // 用户自定义 transforms
)
}))
我们先来看一下 getBaseTransformPreset 返回哪些节点和指令的转换方法:
function getBaseTransformPreset(prefixIdentifiers) {
return [
[
transformOnce,
transformIf,
transformFor,
transformExpression,
transformSlotOutlet,
transformElement,
trackSlotScopes,
transformText
],
{
on: transformOn,
bind: transformBind,
model: transformModel
}
]
}
这里并不需要你进一步去看每个转换函数的实现,只要大致了解有哪些转换函数即可,这些转换函数会在后续执行 transform 的时候调用。
注意这里我们只分析在 Node.js 环境下的编译过程。Web 环境的编译结果可能会有一些差别,我们会在后续章节说明。
我们主要来看 transform 函数的实现:
function transform(root, options) {
const context = createTransformContext(root, options)
traverseNode(root, context)
if (options.hoistStatic) {
hoistStatic(root, context)
}
if (!options.ssr) {
createRootCodegen(root, context)
}
root.helpers = [...context.helpers]
root.components = [...context.components]
root.directives = [...context.directives]
root.imports = [...context.imports]
root.hoists = context.hoists
root.temps = context.temps
root.cached = context.cached
}
transform 的核心流程主要有四步:创建 transform 上下文、遍历 AST 节点、静态提升以及创建根代码生成节点。接下来,我们就好好分析一下每一步主要做了什么。
创建 transform 上下文
首先,我们来看创建 transform 上下文的过程,其实和 parse 过程一样,在 transform 阶段会创建一个上下文对象,它的实现过程是这样的:
function createTransformContext(root, { prefixIdentifiers = false, hoistStatic = false, cacheHandlers = false, nodeTransforms = [], directiveTransforms = {}, transformHoist = null, isBuiltInComponent = NOOP, expressionPlugins = [], scopeId = null, ssr = false, onError = defaultOnError }) {
const context = {
// 配置
prefixIdentifiers,
hoistStatic,
cacheHandlers,
nodeTransforms,
directiveTransforms,
transformHoist,
isBuiltInComponent,
expressionPlugins,
scopeId,
ssr,
onError,
// 状态数据
root,
helpers: new Set(),
components: new Set(),
directives: new Set(),
hoists: [],
imports: new Set(),
temps: 0,
cached: 0,
identifiers: {},
scopes: {
vFor: 0,
vSlot: 0,
vPre: 0,
vOnce: 0
},
parent: null,
currentNode: root,
childIndex: 0,
// methods
helper(name) {
context.helpers.add(name)
return name
},
helperString(name) {
return `_${helperNameMap[context.helper(name)]}`
},
replaceNode(node) {
context.parent.children[context.childIndex] = context.currentNode = node
},
removeNode(node) {
const list = context.parent.children
const removalIndex = node
? list.indexOf(node)
: context.currentNode
? context.childIndex
: -1
if (!node || node === context.currentNode) {
// 移除当前节点
context.currentNode = null
context.onNodeRemoved()
}
else {
// 移除兄弟节点
if (context.childIndex > removalIndex) {
context.childIndex--
context.onNodeRemoved()
}
}
// 移除节点
context.parent.children.splice(removalIndex, 1)
},
onNodeRemoved: () => { },
addIdentifiers(exp) {
},
removeIdentifiers(exp) {
},
hoist(exp) {
context.hoists.push(exp)
const identifier = createSimpleExpression(`_hoisted_${context.hoists.length}`, false, exp.loc, true)
identifier.hoisted = exp
return identifier
},
cache(exp, isVNode = false) {
return createCacheExpression(++context.cached, exp, isVNode)
}
}
return context
}
其实,这个上下文对象 context 维护了 transform 过程的一些配置,比如前面提到的节点和指令的转换函数等;还维护了 transform 过程的一些状态数据,比如当前处理的 AST 节点,当前 AST 节点在子节点中的索引,以及当前 AST 节点的父节点等。此外,context 还包含了在转换过程中可能会调用的一些辅助函数,和一些修改 context 对象的方法。
你现在也没必要去了解它的每一个属性和方法的含义,只需要你大致有一个印象即可,未来分析某个具体场景,再回过头了解它们的实现即可。
创建完上下文对象后,接下来就需要遍历 AST 节点。
遍历 AST 节点
遍历 AST 节点的过程很关键,因为核心的转换过程就是在遍历中实现的:
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)
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) {
// 需要导入 createComment 辅助函数
context.helper(CREATE_COMMENT)
}
break
case 5 /* INTERPOLATION */:
// 需要导入 toString 辅助函数
if (!context.ssr) {
context.helper(TO_DISPLAY_STRING)
}
break
case 9 /* IF */:
// 递归遍历每个分支节点
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
}
// 执行转换函数返回的退出函数
let i = exitFns.length
while (i--) {
exitFns[i]()
}
}
这里,traverseNode 函数的基本思路就是递归遍历 AST 节点,针对每个节点执行一系列的转换函数,有些转换函数还会设计一个退出函数,当你执行转换函数后,它会返回一个新函数,然后在你处理完子节点后再执行这些退出函数,这是因为有些逻辑的处理需要依赖子节点的处理结果才能继续执行。
Vue.js 内部大概内置了八种转换函数,分别处理指令、表达式、元素节点、文本节点等不同的特性。限于篇幅,我不会介绍所有转换函数,感兴趣的同学可以后续自行分析。
下面我会介绍四种类型的转换函数,并结合前面的示例来分析。
Element 节点转换函数
首先,我们来看一下 Element 节点转换函数的实现:
const transformElement = (node, context) => {
if (!(node.type === 1 /* ELEMENT */ &&
(node.tagType === 0 /* ELEMENT */ ||
node.tagType === 1 /* COMPONENT */))) {
return
}
// 返回退出函数,在所有子表达式处理并合并后执行
return function postTransformElement() {
// 转换的目标是创建一个实现 VNodeCall 接口的代码生成节点
const { tag, props } = node
const isComponent = node.tagType === 1 /* COMPONENT */
const vnodeTag = isComponent
? resolveComponentType(node, context)
: `"${tag}"`
const isDynamicComponent = isObject(vnodeTag) && vnodeTag.callee === RESOLVE_DYNAMIC_COMPONENT
// 属性
let vnodeProps
// 子节点
let vnodeChildren
// 标记更新的类型标识,用于运行时优化
let vnodePatchFlag
let patchFlag = 0
// 动态绑定的属性
let vnodeDynamicProps
let dynamicPropNames
let vnodeDirectives
// 动态组件、svg、foreignObject 标签以及动态绑定 key prop 的节点都被视作一个 Block
let shouldUseBlock =
isDynamicComponent ||
(!isComponent &&
(tag === 'svg' ||
tag === 'foreignObject' ||
findProp(node, 'key', true)))
// 处理 props
if (props.length > 0) {
const propsBuildResult = buildProps(node, context)
vnodeProps = propsBuildResult.props
patchFlag = propsBuildResult.patchFlag
dynamicPropNames = propsBuildResult.dynamicPropNames
const directives = propsBuildResult.directives
vnodeDirectives =
directives && directives.length
? createArrayExpression(directives.map(dir => buildDirectiveArgs(dir, context)))
: undefined
}
// 处理 children
if (node.children.length > 0) {
if (vnodeTag === KEEP_ALIVE) {
// 把 KeepAlive 看做是一个 Block,这样可以避免它的子节点的动态节点被父 Block 收集
shouldUseBlock = true
// 2. 确保它始终更新
patchFlag |= 1024 /* DYNAMIC_SLOTS */
if ((process.env.NODE_ENV !== 'production') && node.children.length > 1) {
context.onError(createCompilerError(42 /* X_KEEP_ALIVE_INVALID_CHILDREN */, {
start: node.children[0].loc.start,
end: node.children[node.children.length - 1].loc.end,
source: ''
}))
}
}
const shouldBuildAsSlots = isComponent &&
// Teleport不是一个真正的组件,它有专门的运行时处理
vnodeTag !== TELEPORT &&
vnodeTag !== KEEP_ALIVE
if (shouldBuildAsSlots) {
// 组件有 children,则处理插槽
const { slots, hasDynamicSlots } = buildSlots(node, context)
vnodeChildren = slots
if (hasDynamicSlots) {
patchFlag |= 1024 /* DYNAMIC_SLOTS */
}
}
else if (node.children.length === 1 && vnodeTag !== TELEPORT) {
const child = node.children[0]
const type = child.type
const hasDynamicTextChild = type === 5 /* INTERPOLATION */ ||
type === 8 /* COMPOUND_EXPRESSION */
if (hasDynamicTextChild && !getStaticType(child)) {
patchFlag |= 1 /* TEXT */
}
// 如果只是一个普通文本节点、插值或者表达式,直接把节点赋值给 vnodeChildren
if (hasDynamicTextChild || type === 2 /* TEXT */) {
vnodeChildren = child
}
else {
vnodeChildren = node.children
}
}
else {
vnodeChildren = node.children
}
}
// 处理 patchFlag 和 dynamicPropNames
if (patchFlag !== 0) {
if ((process.env.NODE_ENV !== 'production')) {
if (patchFlag < 0) {
vnodePatchFlag = patchFlag + ` /* ${PatchFlagNames[patchFlag]} */`
}
else {
// 获取 flag 对应的名字,生成注释,方便理解生成代码对应节点的 pathFlag
const flagNames = Object.keys(PatchFlagNames)
.map(Number)
.filter(n => n > 0 && patchFlag & n)
.map(n => PatchFlagNames[n])
.join(`, `)
vnodePatchFlag = patchFlag + ` /* ${flagNames} */`
}
}
else {
vnodePatchFlag = String(patchFlag)
}
if (dynamicPropNames && dynamicPropNames.length) {
vnodeDynamicProps = stringifyDynamicPropNames(dynamicPropNames)
}
}
node.codegenNode = createVNodeCall(context, vnodeTag, vnodeProps, vnodeChildren, vnodePatchFlag, vnodeDynamicProps, vnodeDirectives, !!shouldUseBlock, false /* disableTracking */, node.loc)
}
}
可以看到,只有当 AST 节点是组件或者普通元素节点时,才会返回一个退出函数,而且它会在该节点的子节点逻辑处理完毕后执行。
分析这个退出函数前,我们需要知道节点函数的转换目标,即创建一个实现 VNodeCall 接口的代码生成节点,也就是说,生成这个代码生成节点后,后续的代码生成阶段可以根据这个节点对象生成目标代码。
知道了这个目标,我们再去理解 transformElement 函数的实现就不难了。
首先,判断这个节点是不是一个 Block 节点。
为了运行时的更新优化,Vue.js 3.0 设计了一个 Block tree 的概念。Block tree 是一个将模版基于动态节点指令切割的嵌套区块,每个区块只需要以一个 Array 来追踪自身包含的动态节点。借助 Block tree,Vue.js 将 vnode 更新性能由与模版整体大小相关提升为与动态内容的数量相关,极大优化了 diff 的效率,模板的动静比越大,这个优化就会越明显。
因此在编译阶段,我们需要找出哪些节点可以构成一个 Block,其中动态组件、svg、foreignObject 标签以及动态绑定的 prop 的节点都被视作一个 Block。
其次,是处理节点的 props。
这个过程主要是从 AST 节点的 props 对象中进一步解析出指令 vnodeDirectives、动态属性 dynamicPropNames,以及更新标识 patchFlag。patchFlag 主要用于标识节点更新的类型,在组件更新的优化中会用到,我们在后续章节会详细讲。
接着,是处理节点的 children。
对于一个组件节点而言,如果它有子节点,则说明是组件的插槽,另外还会有对一些内置组件比如 KeepAlive、Teleport 的处理逻辑。
对于一个普通元素节点,我们通常直接拿节点的 children 属性给 vnodeChildren 即可,但有一种特殊情况,如果节点只有一个子节点,并且是一个普通文本节点、插值或者表达式,那么直接把节点赋值给 vnodeChildren。
然后,会对前面解析 props 求得的 patchFlag 和 dynamicPropNames 做进一步处理。
在这个过程中,我们会根据 patchFlag 的值从 PatchFlagNames 中获取 flag 对应的名字,从而生成注释,因为 patchFlag 本身就是一个个数字,通过名字注释的方式,我们就可以一眼从最终生成的代码中了解到 patchFlag 代表的含义。
另外,我们还会把数组 dynamicPropNames 转化生成 vnodeDynamicProps 字符串,便于后续对节点生成代码逻辑的处理。
最后,通过 createVNodeCall 创建了实现 VNodeCall 接口的代码生成节点,我们来看它的实现:
function createVNodeCall(context, tag, props, children, patchFlag, dynamicProps, directives, isBlock = false, disableTracking = false, loc = locStub) {
if (context) {
if (isBlock) {
context.helper(OPEN_BLOCK)
context.helper(CREATE_BLOCK)
}
else {
context.helper(CREATE_VNODE)
}
if (directives) {
context.helper(WITH_DIRECTIVES)
}
}
return {
type: 13 /* VNODE_CALL */,
tag,
props,
children,
patchFlag,
dynamicProps,
directives,
isBlock,
disableTracking,
loc
}
}
createVNodeCall 的实现很简单,它最后返回了一个对象,包含了传入的参数数据。这里要注意 context.helper 函数的调用,它会把一些 Symbol 对象添加到 context.helpers 数组中,目的是为了后续代码生成阶段,生成一些辅助代码。
对于我们示例中的根节点:
<div class="app">
// ...
</div>
它转换后生成的 node.codegenNode :
{
"children": [
// 子节点
],
"directives": undefined,
"dynamicProps": undefined,
"isBlock": false,
"isForBlock": false,
"patchFlag": undefined,
"props": {
// 属性相关
},
"tag": "div",
"type": 13
}
这个 codegenNode 相比之前的 AST 节点对象,多了很多和编译优化相关的属性,它们会在代码生成阶段会起到非常重要作用,在后续的章节你就可以深入了解到。
表达式节点转换函数
接下来,我们来看一下表达式节点转换函数的实现:
const transformExpression = (node, context) => {
if (node.type === 5 /* INTERPOLATION */) {
// 处理插值中的动态表达式
node.content = processExpression(node.content, context)
}
else if (node.type === 1 /* ELEMENT */) {
// 处理元素指令中的动态表达式
for (let i = 0; i < node.props.length; i++) {
const dir = node.props[i]
// v-on 和 v-for 不处理,因为它们都有各自的处理逻辑
if (dir.type === 7 /* DIRECTIVE */ && dir.name !== 'for') {
const exp = dir.exp
const arg = dir.arg
if (exp &&
exp.type === 4 /* SIMPLE_EXPRESSION */ &&
!(dir.name === 'on' && arg)) {
dir.exp = processExpression(exp, context, dir.name === 'slot')
}
if (arg && arg.type === 4 /* SIMPLE_EXPRESSION */ && !arg.isStatic) {
dir.arg = processExpression(arg, context)
}
}
}
}
}
由于表达式本身不会再有子节点,所以它也不需要退出函数,直接在进入函数时做转换处理即可。
需要注意的是,只有在 Node.js 环境下的编译或者是 Web 端的非生产环境下才会执行 transformExpression,原因我稍后会告诉你。
transformExpression 主要做的事情就是转换插值和元素指令中的动态表达式,把简单的表达式对象转换成复合表达式对象,内部主要是通过 processExpression 函数完成。举个例子,比如这个模板:{{ msg + test }}
,它执行 parse 后生成的表达式节点 node.content 值为一个简单的表达式对象:
{
"type": 4,
"isStatic": false,
"isConstant": false,
"content": "msg + test"
}
经过 processExpression 处理后,node.content 的值变成了一个复合表达式对象:
{
"type": 8,
"children": [
{
"type": 4,
"isConstant": false,
"content": "_ctx.msg",
"isStatic": false
},
" + ",
{
"type": 4,
"isConstant": false,
"content": "_ctx.test",
"isStatic": false
}
],
"identifiers": []
}
这里,我们重点关注对象中的 children 属性,它是一个长度为 3 的数组,其实就是把表达式msg + test拆成了三部分,其中变量 msg 和 test 对应都加上了前缀 _ctx。
那么为什么需要加这个前缀呢?
我们就要想到模板中引用的的 msg 和 test 对象最终都是在组件实例中访问的,但为了书写模板方便,Vue.js 并没有让我们在模板中手动加组件实例的前缀,例如:{{ this.msg + this.test }}
,这样写起来就会不够方便,但如果用 JSX 写的话,通常要手动写 this。
你可能会有疑问,为什么 Vue.js 2.x 编译的结果没有 _ctx 前缀呢?这是因为 Vue.js 2.x 的编译结果使用了”黑魔法“ with,比如上述模板,在 Vue.js 2.x 最终编译的结果:with(this){return _s(msg + test)}
。
它利用 with 的特性动态去 this 中查找 msg 和 test 属性,所以不需要手动加前缀。
但是,Vue.js 3.0 在 Node.js 端的编译结果舍弃了 with,它会在 processExpression 过程中对表达式动态分析,给该加前缀的地方加上前缀。
processExpression 的详细实现我们不会分析,但你需要知道,这个过程肯定有一定的成本,因为它内部依赖了 @babel/parser 库去解析表达式生成 AST 节点,并依赖了 estree-walker 库去遍历这个 AST 节点,然后对节点分析去判断是否需要加前缀,接着对 AST 节点修改,最终转换生成新的表达式对象。
@babel/parser 这个库通常是在 Node.js 端用的,而且这库本身体积非常大,如果打包进 Vue.js 的话会让包体积膨胀 4 倍,所以我们并不会在生产环境的 Web 端引入这个库,Web 端生产环境下的运行时编译最终仍然会用 with 的方式。
因为用 with 的话就完全不需要对表达式做转换了,这也就回答我前面的问题:只有在 Node.js 环境下的编译或者是 Web 端的非生产环境下才会执行 transformExpression。
这部分内容比较多,所以本课时的内容就先到这。下节课,我们接着分析遍历 AST 节点中的 Text 节点的转换函数。
上一节课,我们已经知道了 transform 的核心流程主要有四步:创建 transform 上下文、遍历 AST 节点、静态提升以及创建根代码生成节点。这节课我们接着分析遍历 AST 节点中的 Text 节点的转换函数。
Text 节点转换函数
接下来,我们来看一下 Text 节点转换函数的实现:
const transformText = (node, context) => {
if (node.type === 0 /* ROOT */ ||
node.type === 1 /* ELEMENT */ ||
node.type === 11 /* FOR */ ||
node.type === 10 /* IF_BRANCH */) {
// 在节点退出时执行转换,保证所有表达式都已经被处理
return () => {
const children = node.children
let currentContainer = undefined
let hasText = false
// 将相邻文本节点合并
for (let i = 0; i < children.length; i++) {
const child = children[i]
if (isText(child)) {
hasText = true
for (let j = i + 1; j < children.length; j++) {
const next = children[j]
if (isText(next)) {
if (!currentContainer) {
// 创建复合表达式节点
currentContainer = children[i] = {
type: 8 /* COMPOUND_EXPRESSION */,
loc: child.loc,
children: [child]
}
}
currentContainer.children.push(` + `, next)
children.splice(j, 1)
j--
}
else {
currentContainer = undefined
break
}
}
}
}
if (!hasText ||
// 如果是一个带有单个文本子元素的纯元素节点,什么都不需要转换,因为这种情况在运行时可以直接设置元素的 textContent 来更新文本。
(children.length === 1 &&
(node.type === 0 /* ROOT */ ||
(node.type === 1 /* ELEMENT */ &&
node.tagType === 0 /* ELEMENT */)))) {
return
}
// 为子文本节点创建一个调用函数表达式的代码生成节点
for (let i = 0; i < children.length; i++) {
const child = children[i]
if (isText(child) || child.type === 8 /* COMPOUND_EXPRESSION */) {
const callArgs = []
// 为 createTextVNode 添加执行参数
if (child.type !== 2 /* TEXT */ || child.content !== ' ') {
callArgs.push(child)
}
// 标记动态文本
if (!context.ssr && child.type !== 2 /* TEXT */) {
callArgs.push(`${1 /* TEXT */} /* ${PatchFlagNames[1 /* TEXT */]} */`)
}
children[i] = {
type: 12 /* TEXT_CALL */,
content: child,
loc: child.loc,
codegenNode: createCallExpression(context.helper(CREATE_TEXT), callArgs)
}
}
}
}
}
}
transformText 函数只处理根节点、元素节点、 v-for 以及 v-if 分支相关的节点,它也会返回一个退出函数,因为 transformText 要保证所有表达式节点都已经被处理才执行转换逻辑。
transformText 主要的目的就是合并一些相邻的文本节点,然后为内部每一个文本节点创建一个代码生成节点。
在内部,静态文本节点和动态插值节点都被看作是一个文本节点,所以函数首先遍历节点的子节点,然后把子节点中的相邻文本节点合并成一个。
比如示例中的文本节点:<p>hello {{ msg + test }}</p>
。
在转换之前,p 节点对应的 children 数组有两个元素,第一个是纯文本节点,第二个是一个插值节点,这个数组也是前面提到的表达式节点转换后的结果:
[
{
"type": 2,
"content": "hello ",
},
{
"type": 5,
"content": {
"type": 8,
"children": [
{
"type": 4,
"isConstant": false,
"content": "_ctx.msg",
"isStatic": false
},
" + ",
{
"type": 4,
"isConstant": false,
"content": "_ctx.test",
"isStatic": false
}
],
"identifiers": []
}
}
]
转换后,这两个文本节点被合并成一个复合表达式节点,结果如下:
[
{
"type": 8,
"children": [
{
"type": 2,
"content": "hello ",
},
" + ",
{
"type": 5,
"content": {
"type": 8,
"children": [
{
"type": 4,
"isConstant": false,
"content": "_ctx.msg",
"isStatic": false
},
" + ",
{
"type": 4,
"isConstant": false,
"content": "_ctx.test",
"isStatic": false
}
],
"identifiers": []
}
}
]
}
]
合并完子文本节点后,接着判断如果是一个只带有单个文本子元素的纯元素节点,则什么都不需要转换,因为这种情况在运行时可以直接设置元素的 textContent 来更新文本。
最后就是去处理节点包含文本子节点且多个子节点的情况,举个例子:
<p>
hello {{ msg + test }}
<a href="foo"/>
hi
</p>
上述 p 标签的子节点经过前面的文本合并流程后,还有 3 个子节点。针对这种情况,我们可以遍历子节点,找到所有的文本节点或者是复合表达式节点,然后为这些子节点通过 createCallExpression 创建一个调用函数表达式的代码生成节点。
我们来看 createCallExpression 的实现:
function createCallExpression(callee, args = [], loc = locStub) {
return {
type: 14 /* JS_CALL_EXPRESSION */,
loc,
callee,
arguments: args
}
}
createCallExpression 的实现很简单,就是返回一个类型为 JS_CALL_EXPRESSION 的对象,它包含了执行的函数名和参数。
这里,针对我们创建的函数表达式所生成的节点,它对应的函数名是 createTextVNode,参数 callArgs 是子节点本身 child,如果是动态插值节点,那么参数还会多一个 TEXT 的 patchFlag。
v-if 节点转换函数
接下来,我们来看一下 v-if 节点转换函数的实现:
const transformIf = createStructuralDirectiveTransform(/^(if|else|else-if)$/, (node, dir, context) => {
return processIf(node, dir, context, (ifNode, branch, isRoot) => {
return () => {
// 退出回调函数,当所有子节点转换完成执行
}
})
})
在分析函数的实现前,我们先来看一下 v-if 节点转换的目的,为了方便你的理解,我还是通过示例来说明:
<hello v-if="flag"></hello>
<div v-else>
<p>hello {{ msg + test }}</p>
<p>static</p>
<p>static</p>
</div>
在 parse 阶段,这个模板解析生成的 AST 节点如下:
[
{
"children": [],
"codegenNode": undefined,
"isSelfClosing": false,
"ns": 0,
"props": [{
"type": 7,
"name": "if",
"exp": {
"type": 4,
"content": "flag",
"isConstant": false,
"isStatic": false
},
"arg": undefined,
"modifiers": []
}],
"tag": "hello",
"tagType": 1,
"type": 1
},
{
"children": [
// 子节点
],
"codegenNode": undefined,
"isSelfClosing": false,
"ns": 0,
"props": [{
"type": 7,
"name": "else",
"exp": undefined,
"arg": undefined,
"modifiers": []
}],
"tag": "div",
"tagType": 0,
"type": 1
}
]
v-if 指令用于条件性地渲染一块内容,显然上述 AST 节点对于最终去生成条件的代码而言,是不够语义化的,于是我们需要对它们做一层转换,使其成为语义化强的代码生成节点。
现在我们回过头看 transformIf 的实现,它是通过 createStructuralDirectiveTransform 函数创建的一个结构化指令的转换函数,在 Vue.js 中,v-if、v-else-if、v-else 和 v-for 这些都属于结构化指令,因为它们能影响代码的组织结构。
我们来看一下 createStructuralDirectiveTransform 的实现:
function createStructuralDirectiveTransform(name, fn) {
const matches = isString(name)
? (n) => n === name
: (n) => name.test(n)
return (node, context) => {
// 只处理元素节点
if (node.type === 1 /* ELEMENT */) {
const { props } = node
// 结构化指令的转换与插槽无关,插槽相关处理逻辑在 vSlot.ts 中
if (node.tagType === 3 /* TEMPLATE */ && props.some(isVSlot)) {
return
}
const exitFns = []
for (let i = 0; i < props.length; i++) {
const prop = props[i]
if (prop.type === 7 /* DIRECTIVE */ && matches(prop.name)) {
// 删除结构指令以避免无限递归
props.splice(i, 1)
i--
const onExit = fn(node, prop, context)
if (onExit)
exitFns.push(onExit)
}
}
return exitFns
}
}
}
可以看到,createStructuralDirectiveTransform 接受 2 个参数,第一个 name 是指令的名称,第二个 fn 是构造转换退出函数的方法。
createStructuralDirectiveTransform 最后会返回一个函数,在我们的场景下,这个函数就是 transformIf 转换函数。
我们进一步看这个函数的实现,它只处理元素节点,这个很好理解,因为只有元素节点才会有 v-if 指令,接着会解析这个节点的 props 属性,如果发现 props 包含 if 属性,也就是节点拥有 v-if 指令,那么先从 props 删除这个结构化指令防止无限递归,然后执行 fn 获取对应的退出函数,最后将这个退出函数返回。
接着我们来看 fn 的实现,在我们这个场景下 fn 对应的是前面传入的匿名函数:
(node, dir, context) => {
return processIf(node, dir, context, (ifNode, branch, isRoot) => {
return () => {
// 退出回调函数,当所有子节点转换完成执行
}
})
}
可以看出,这个匿名函数内部执行了 processIf 函数,它会先对 v-if 和它的相邻节点做转换,然后返回一个退出函数,在它们的子节点都转换完毕后执行。
我们来看 processIf 函数的实现:
function processIf(node, dir, context, processCodegen) {
if (dir.name === 'if') {
// 创建分支节点
const branch = createIfBranch(node, dir)
// 创建 IF 节点,替换当前节点
const ifNode = {
type: 9 /* IF */,
loc: node.loc,
branches: [branch]
}
context.replaceNode(ifNode)
if (processCodegen) {
return processCodegen(ifNode, branch, true)
}
}
else {
// 处理 v-if 相邻节点,比如 v-else-if 和 v-else
}
}
processIf 主要就是用来处理 v-if 节点以及 v-if 的相邻节点,比如 v-else-if 和 v-else,并且它们会走不同的处理逻辑。
我们先来看 v-if 的处理逻辑。首先,它会执行 createIfBranch 去创建一个分支节点:
function createIfBranch(node, dir) {
return {
type: 10 /* IF_BRANCH */,
loc: node.loc,
condition: dir.name === 'else' ? undefined : dir.exp,
children: node.tagType === 3 /* TEMPLATE */ ? node.children : [node]
}
}
这个分支节点很好理解,因为 v-if 节点内部的子节点可以属于一个分支,v-else-if 和 v-else 节点内部的子节点也都可以属于一个分支,而最终页面渲染执行哪个分支,这取决于哪个分支节点的 condition 为 true。
所以分支节点返回的对象,就包含了 condition 条件,以及它的子节点 children。注意,如果节点 node 不是 template,那么 children 指向的就是这个单个 node 构造的数组。
接下来它会创建 IF 节点替换当前节点,IF 节点拥有 branches 属性,包含我们前面创建的分支节点,显然,相对于原节点,IF 节点的语义化更强,更利于后续生成条件表达式代码。
最后它会执行 processCodegen 创建退出函数。我们先不着急去分析退出函数的创建过程,先把 v-if 相邻节点的处理逻辑分析完:
function processIf(node, dir, context, processCodegen) {
if (dir.name === 'if') {
// 处理 v-if 节点
}
else {
// 处理 v-if 相邻节点,比如 v-else-if 和 v-else
const siblings = context.parent.children
let i = siblings.indexOf(node)
while (i-- >= -1) {
const sibling = siblings[i]
if (sibling && sibling.type === 9 /* IF */) {
// 把节点移动到 IF 节点的 branches 中
context.removeNode()
const branch = createIfBranch(node, dir)
sibling.branches.push(branch)
const onExit = processCodegen && processCodegen(sibling, branch, false)
// 因为分支已被删除,所以它的子节点需要在这里遍历
traverseNode(branch, context)
// 执行退出函数
if (onExit)
onExit()
// 恢复 currentNode 为 null,因为它已经被移除
context.currentNode = null
}
else {
context.onError(createCompilerError(28 /* X_V_ELSE_NO_ADJACENT_IF */, node.loc))
}
break
}
}
}
这段处理逻辑就是从当前节点往前面的兄弟节点遍历,找到 v-if 节点后,把当前节点删除,然后根据当前节点创建一个分支节点,把这个分支节点添加到前面创建的 IF 节点的 branches 中。此外,由于这个节点已经删除,那么需要在这里把这个节点的子节点通过 traverseNode 遍历一遍。
这么处理下来,就相当于完善了 IF 节点的信息了,IF 节点的 branches 就包含了所有分支节点了。
那么至此,进入 v-if、v-else-if、v-else 这些节点的转换逻辑我们就分析完毕了,即最终创建了一个 IF 节点,它包含了所有的分支节点。
接下来,我们再来分析这个退出函数的逻辑:
(node, dir, context) => {
return processIf(node, dir, context, (ifNode, branch, isRoot) => {
// 退出回调函数,当所有子节点转换完成执行
return () => {
if (isRoot) {
// v-if 节点的退出函数
// 创建 IF 节点的 codegenNode
ifNode.codegenNode = createCodegenNodeForBranch(branch, 0, context)
}
else {
// v-else-if、v-else 节点的退出函数
// 将此分支的 codegenNode 附加到 上一个条件节点的 codegenNode 的 alternate 中
let parentCondition = ifNode.codegenNode
while (parentCondition.alternate.type ===
19 /* JS_CONDITIONAL_EXPRESSION */) {
parentCondition = parentCondition.alternate
}
// 更新候选节点
parentCondition.alternate = createCodegenNodeForBranch(branch, ifNode.branches.length - 1, context)
}
}
})
}
可以看到,当 v-if 节点执行退出函数时,会通过 createCodegenNodeForBranch 创建 IF 分支节点的 codegenNode,我们来看一下它的实现:
function createCodegenNodeForBranch(branch, index, context) {
if (branch.condition) {
return createConditionalExpression(branch.condition, createChildrenCodegenNode(branch, index, context),
createCallExpression(context.helper(CREATE_COMMENT), [
(process.env.NODE_ENV !== 'production') ? '"v-if"' : '""',
'true'
]))
}
else {
return createChildrenCodegenNode(branch, index, context)
}
}
当分支节点存在 condition 的时候,比如 v-if、和 v-else-if,它通过 createConditionalExpression 返回一个条件表达式节点:
function createConditionalExpression(test, consequent, alternate, newline = true) {
return {
type: 19 /* JS_CONDITIONAL_EXPRESSION */,
test,
consequent,
alternate,
newline,
loc: locStub
}
}
其中 consequent 在这里是 IF 主 branch 的子节点对应的代码生成节点,alternate 是后补 branch 子节点对应的代码生成节点。
接着,我们来看一下 createChildrenCodegenNode 的实现:
function createChildrenCodegenNode(branch, index, context) {
const { helper } = context
// 根据 index 创建 key 属性
const keyProperty = createObjectProperty(`key`, createSimpleExpression(index + '', false))
const { children } = branch
const firstChild = children[0]
const needFragmentWrapper = children.length !== 1 || firstChild.type !== 1 /* ELEMENT */
if (needFragmentWrapper) {
if (children.length === 1 && firstChild.type === 11 /* FOR */) {
const vnodeCall = firstChild.codegenNode
injectProp(vnodeCall, keyProperty, context)
return vnodeCall
}
else {
return createVNodeCall(context, helper(FRAGMENT), createObjectExpression([keyProperty]), children, `${64 /* STABLE_FRAGMENT */} /* ${PatchFlagNames[64 /* STABLE_FRAGMENT */]} */`, undefined, undefined, true, false, branch.loc)
}
}
else {
const vnodeCall = firstChild
.codegenNode;
// 把 createVNode 改变为 createBlock
if (vnodeCall.type === 13 /* VNODE_CALL */ &&
// 组件节点的 children 会被视为插槽,不需要添加 block
(firstChild.tagType !== 1 /* COMPONENT */ ||
vnodeCall.tag === TELEPORT)) {
vnodeCall.isBlock = true
// 创建 block 的辅助代码
helper(OPEN_BLOCK)
helper(CREATE_BLOCK)
}
// 给 branch 注入 key 属性
injectProp(vnodeCall, keyProperty, context)
return vnodeCall
}
}
createChildrenCodegenNode 主要就是判断每个分支子节点是不是一个 vnodeCall,如果这个子节点不是组件节点的话,则把它转变成一个 BlockCall,也就是让 v-if 的每一个分支都可以创建一个 Block。
这个行为是很好理解的,因为 v-if 是条件渲染的,我们知道在某些条件下某些分支是不会渲染的,那么它内部的动态节点就不能添加到外部的 Block 中的,所以它就需要单独创建一个 Block 来维护分支内部的动态节点,这样也就构成了 Block tree。
为了直观让你感受 v-if 节点最终转换的结果,我们来看前面示例转换后的结果,最终转换生成的 IF 节点对象大致如下:
{
"type": 9,
"branches": [{
"type": 10,
"children": [{
"type": 1,
"tagType": 1,
"tag": "hello"
}],
"condition": {
"type": 4,
"content": "_ctx.flag"
}
},{
"type": 10,
"children": [{
"type": 1,
"tagType": 0,
"tag": "hello"
}],
"condition": {
"type": 4,
"content": "_ctx.flag"
}
}],
"codegenNode": {
"type": 19,
"consequent": {
"type": 13,
"tag": "_component_hello",
"children": undefined,
"directives": undefined,
"dynamicProps": undefined,
"isBlock": false,
"patchFlag": undefined
},
"alternate": {
"type": 13,
"tag": "_component_hello",
"children": [
// 子节点
],
"directives": undefined,
"dynamicProps": undefined,
"isBlock": false,
"patchFlag": undefined
}
}
}
可以看到,相比原节点,转换后的 IF 节点无论是在语义化还是在信息上,都更加丰富,我们可以依据它在代码生成阶段生成所需的代码。
静态提升
节点转换完毕后,接下来会判断编译配置中是否配置了 hoistStatic,如果是就会执行 hoistStatic 做静态提升:
if (options.hoistStatic) {
hoistStatic(root, context)
}
静态提升也是 Vue.js 3.0 在编译阶段设计了一个优化策略,为了便于你理解,我先举个简单的例子:
<p>hello {{ msg + test }}</p>
<p>static</p>
<p>static</p>
我们为它配置了 hoistStatic,经过编译后,它的代码就变成了这样:
import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"
const _hoisted_1 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
export function render(_ctx, _cache) {
return (_openBlock(), _createBlock(_Fragment, null, [
_createVNode("p", null, "hello " + _toDisplayString(_ctx.msg + _ctx.test), 1 /* TEXT */),
_hoisted_1,
_hoisted_2
], 64 /* STABLE_FRAGMENT */))
}
这里,我们先忽略 openBlock、Fragment ,我会在代码生成章节详细说明,重点看一下 _hoisted_1 和 _hoisted_2 这两个变量,它们分别对应模板中两个静态 p 标签生成的 vnode,可以发现它的创建是在 render 函数外部执行的。 这样做的好处是,不用每次在 render 阶段都执行一次 createVNode 创建 vnode 对象,直接用之前在内存中创建好的 vnode 即可。
那么为什么叫静态提升呢?
因为这些静态节点不依赖动态数据,一旦创建了就不会改变,所以只有静态节点才能被提升到外部创建。
了解以上背景知识后,我们接下来看一下静态提升的实现:
function hoistStatic(root, context) {
walk(root, context, new Map(),
// Root node is unfortunately non-hoistable due to potential parent fallthrough attributes.
isSingleElementRoot(root, root.children[0]));
}
function walk(node, context, resultCache, doNotHoistNode = false) {
let hasHoistedNode = false
// 是否包含运行时常量
let hasRuntimeConstant = false
const { children } = node
for (let i = 0; i < children.length; i++) {
const child = children[i]
// 只有普通元素和文本节点才能被静态提升
if (child.type === 1 /* ELEMENT */ &&
child.tagType === 0 /* ELEMENT */) {
let staticType
if (!doNotHoistNode &&
// 获取静态节点的类型,如果是元素,则递归检查它的子节点
(staticType = getStaticType(child, resultCache)) > 0) {
if (staticType === 2 /* HAS_RUNTIME_CONSTANT */) {
hasRuntimeConstant = true
}
// 更新 patchFlag
child.codegenNode.patchFlag =
-1 /* HOISTED */ + ((process.env.NODE_ENV !== 'production') ? ` /* HOISTED */` : ``)
// 更新节点的 codegenNode
child.codegenNode = context.hoist(child.codegenNode)
hasHoistedNode = true
continue
}
else {
// 节点可能会包含一些动态子节点,但它的静态属性还是可以被静态提升
const codegenNode = child.codegenNode
if (codegenNode.type === 13 /* VNODE_CALL */) {
const flag = getPatchFlag(codegenNode)
if ((!flag ||
flag === 512 /* NEED_PATCH */ ||
flag === 1 /* TEXT */) &&
!hasDynamicKeyOrRef(child) &&
!hasCachedProps()) {
const props = getNodeProps(child)
if (props) {
codegenNode.props = context.hoist(props)
}
}
}
}
}
else if (child.type === 12 /* TEXT_CALL */) {
// 文本节点也可以静态提升
const staticType = getStaticType(child.content, resultCache)
if (staticType > 0) {
if (staticType === 2 /* HAS_RUNTIME_CONSTANT */) {
hasRuntimeConstant = true
}
child.codegenNode = context.hoist(child.codegenNode)
hasHoistedNode = true
}
}
if (child.type === 1 /* ELEMENT */) {
// 递归遍历子节点
walk(child, context, resultCache)
}
else if (child.type === 11 /* FOR */) {
walk(child, context, resultCache, child.children.length === 1)
}
else if (child.type === 9 /* IF */) {
for (let i = 0; i < child.branches.length; i++) {
walk(child.branches[i], context, resultCache, child.branches[i].children.length === 1)
}
}
}
if (!hasRuntimeConstant && hasHoistedNode && context.transformHoist) {
// 如果编译配置了 transformHoist,则执行
context.transformHoist(children, context, node)
}
}
可以看到,hoistStatic 主要就是从根节点开始,通过递归的方式去遍历节点,只有普通元素和文本节点才能被静态提升,所以针对这些节点,这里通过 getStaticType 去获取静态类型,如果节点是一个元素类型,getStaticType 内部还会递归判断它的子节点的静态类型。
虽然有的节点包含一些动态子节点,但它本身的静态属性还是可以被静态提升的。
注意,如果 getStaticType 返回的 staticType 的值是 2,则表明它是一个运行时常量,由于它的值在运行时才能被确定,所以是不能静态提升的。
如果节点满足可以被静态提升的条件,节点对应的 codegenNode 会通过执行 context.hoist 修改为一个简单表达式节点:
function hoist(exp) {
context.hoists.push(exp);
const identifier = createSimpleExpression(`_hoisted_${context.hoists.length}`, false, exp.loc, true)
identifier.hoisted = exp
return identifier
}
child.codegenNode = context.hoist(child.codegenNode)
改动后的 codegenNode 会在生成代码阶段帮助我们生成静态提升的相关代码。
createRootCodegen
完成静态提升后,我们来到了 AST 转换的最后一步,即创建根节点的代码生成节点。我们先来看一下 createRootCodegen 的实现:
function createRootCodegen(root, context) {
const { helper } = context;
const { children } = root;
const child = children[0];
if (children.length === 1) {
// 如果子节点是单个元素节点,则将其转换成一个 block
if (isSingleElementRoot(root, child) && child.codegenNode) {
const codegenNode = child.codegenNode;
if (codegenNode.type === 13 /* VNODE_CALL */) {
codegenNode.isBlock = true;
helper(OPEN_BLOCK);
helper(CREATE_BLOCK);
}
root.codegenNode = codegenNode;
}
else {
root.codegenNode = child;
}
}
else if (children.length > 1) {
// 如果子节点是多个节点,则返回一个 fragement 的代码生成节点
root.codegenNode = createVNodeCall(context, helper(FRAGMENT), undefined, root.children, `${64 /* STABLE_FRAGMENT */} /* ${PatchFlagNames[64 /* STABLE_FRAGMENT */]} */`, undefined, undefined, true);
}
}
createRootCodegen 做的事情很简单,就是为 root 这个虚拟的 AST 根节点创建一个代码生成节点,如果 root 的子节点 children 是单个元素节点,则将其转换成一个 Block,把这个 child 的 codegenNode 赋值给 root 的 codegenNode。
如果 root 的子节点 children 是多个节点,则返回一个 fragement 的代码生成节点,并赋值给 root 的 codegenNode。
这里,创建 codegenNode 就是为了后续生成代码时使用。
createRootCodegen 完成之后,接着把 transform 上下文在转换 AST 节点过程中创建的一些变量赋值给 root 节点对应的属性,在这里可以看一下这些属性:
root.helpers = [...context.helpers]
root.components = [...context.components]
root.directives = [...context.directives]
root.imports = [...context.imports]
root.hoists = context.hoists
root.temps = context.temps
root.cached = context.cached
这样后续在代码生成节点时,就可以通过 root 这个根节点访问到这些变量了。