Vue3 源码解读之 JavaScript AST 转换器
JavaScript AST
转换器 transform
在编译器的编译过程中负责将 模板AST 转换为 JavaScript AST
,如下图所示:
JavaScript AST
转换器是编译器编译过程的第二步,如下面的源码所示:
js
// packages/compiler-core/src/compile.ts
export function baseCompile(
template: string | RootNode,
options: CompilerOptions = {}
): CodegenResult {
// 省略部分代码
// 1. 将模板字符串解析为成模板AST
const ast = isString(template) ? baseParse(template, options) : template
// 省略部分代码
// 2. 将 模板AST 转换成 JavaScript AST
transform(
ast,
extend({}, options, {
prefixIdentifiers,
nodeTransforms: [
...nodeTransforms,
...(options.nodeTransforms || []) // user transforms
],
directiveTransforms: extend(
{},
directiveTransforms,
options.directiveTransforms || {} // user transforms
)
})
)
// 3. 将JavaScript AST 转换成渲染函数,generate函数会将渲染函数的代码以字符串的形式返回。
return generate(
ast,
extend({}, options, {
prefixIdentifiers
})
)
}
下面,我们从 JavaScript AST
转换器的入口函数 transform
入手,来探究转换器的工作方式。
1、transform 转换器
transform
转换器负责将 模板AST
转换为 JavaScript AST
,源码如下:
js
// packages/compiler-core/src/transform.ts
// 将 模板AST 转换为 JavaScript AST
export function transform(root: RootNode, options: TransformOptions) {
// 1. 创建转换上下文
const context = createTransformContext(root, options)
// 2. 遍历所有节点,执行转换
traverseNode(root, context)
// 3. 如果编译选项中打开了 hoistStatic 选项,则进行静态提升
if (options.hoistStatic) {
hoistStatic(root, context)
}
// 4. 创建 Block
if (!options.ssr) {
createRootCodegen(root, context)
}
// finalize meta information
// 5. 确定最终的元信息
root.helpers = [...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
if (__COMPAT__) {
root.filters = [...context.filters!]
}
}
可以看到,transform
函数的实现十分简单,其所做的事情如下:
- 首先调用
createTransformContext
函数创建一个转换上下文对象。 - 然后调用
traverseNode
函数,通过深度遍历的方式遍历模板AST
,将其转换成JavaScript AST
。 - 接着判断编译选项中是否打开了
hoistStatic
选项,若是打开了则对节点进行静态提升。 - 接下来判断当前渲染是否是服务端渲染,如果不是,那么就是浏览器端渲染,此时调用
createRootCodegen
函数收集所有的动态节点。 - 最后确定一些元信息。
解下来,我们就对转换器所做的事情进行详细的分析。
2、context 转换上下文
上下文对象其实就是程序在某个范围内的 "全局变量" 。换句话说,我们也可以把全局变量看作全局上下文。在 transform
函数中的 context
对象,就可以看作是 AST
转换函数过程中的上下文数据。所有 AST
转换函数都可以通过 context
来共享数据。在 transform
函数中通过 createTransformContext
函数来创建一个上下文对象。源码实现如下:
js
// packages/compiler-core/src/transform.ts
export function createTransformContext(
root: RootNode,
{
filename = '',
prefixIdentifiers = false,
hoistStatic = false,
cacheHandlers = false,
nodeTransforms = [],
directiveTransforms = {},
transformHoist = null,
isBuiltInComponent = NOOP,
isCustomElement = NOOP,
expressionPlugins = [],
scopeId = null,
slotted = true,
ssr = false,
inSSR = false,
ssrCssVars = ``,
bindingMetadata = EMPTY_OBJ,
inline = false,
isTS = false,
onError = defaultOnError,
onWarn = defaultOnWarn,
compatConfig
}: TransformOptions
): TransformContext {
const nameMatch = filename.replace(/?.*$/, '').match(/([^/\]+).\w+$/)
const context: TransformContext = {
// options
selfName: nameMatch && capitalize(camelize(nameMatch[1])),
prefixIdentifiers,
// 用于存储静态提升的节点
hoistStatic,
cacheHandlers,
// 注册 nodeTransforms 数组,用于存储节点转换函数
nodeTransforms,
// 注册 directiveTransforms 数组,用于存储指令转换函数
directiveTransforms,
transformHoist,
isBuiltInComponent,
isCustomElement,
expressionPlugins,
scopeId,
slotted,
ssr,
inSSR,
ssrCssVars,
bindingMetadata,
inline,
isTS,
onError,
onWarn,
compatConfig,
// state
root,
helpers: new Map(),
components: new Set(),
directives: new Set(),
hoists: [],
imports: [],
constantCache: new Map(),
temps: 0,
cached: 0,
identifiers: Object.create(null),
scopes: {
vFor: 0,
vSlot: 0,
vPre: 0,
vOnce: 0
},
// 用来存储当前转换节点的父节点
parent: null,
// 用来存储当前正在转换的节点
currentNode: root,
// 用来存储当前节点在父节点的 children 中的位置索引
childIndex: 0,
inVOnce: false,
// methods
helper(name) {
const count = context.helpers.get(name) || 0
context.helpers.set(name, count + 1)
return name
},
removeHelper(name) {
const count = context.helpers.get(name)
if (count) {
const currentCount = count - 1
if (!currentCount) {
context.helpers.delete(name)
} else {
context.helpers.set(name, currentCount)
}
}
},
helperString(name) {
return `_${helperNameMap[context.helper(name)]}`
},
// 用于替换节点,接收新节点作为参数
replaceNode(node) {
/* istanbul ignore if */
if (__DEV__) {
if (!context.currentNode) {
throw new Error(`Node being replaced is already removed.`)
}
if (!context.parent) {
throw new Error(`Cannot replace root node.`)
}
}
// 为了替换节点,我们需要修改 AST
// 找到当前节点在父节点的 children 中的位置:context.childIndx
// 然后使用新节点替换即可
context.parent!.children[context.childIndex] = context.currentNode = node
},
// 用于删除节点
removeNode(node) {
if (__DEV__ && !context.parent) {
throw new Error(`Cannot remove root node.`)
}
const list = context.parent!.children
const removalIndex = node
? list.indexOf(node)
: context.currentNode
? context.childIndex
: -1
/* istanbul ignore if */
if (__DEV__ && removalIndex < 0) {
throw new Error(`node being removed is not a child of current parent`)
}
// 重置 转换上下文 context 对象上的 currentNode 和 childIndex
if (!node || node === context.currentNode) {
// current node removed
context.currentNode = null
context.onNodeRemoved()
} else {
// sibling node removed
if (context.childIndex > removalIndex) {
context.childIndex--
context.onNodeRemoved()
}
}
// 调用数组的 splice 方法,根据当前节点的索引删除当前节点
context.parent!.children.splice(removalIndex, 1)
},
// 节点删除回调
onNodeRemoved: () => {},
addIdentifiers(exp) {
// 编译器会将模板中的表达式转换为相应的节点,并在生成的代码中使用它们。在表达式中,可能会包含变量、函数、对象属性等标识符,这些标识符需要在生成的代码中进行引用。
if (!__BROWSER__) {
if (isString(exp)) {
addId(exp)
} else if (exp.identifiers) {
exp.identifiers.forEach(addId)
} else if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
addId(exp.content)
}
}
},
removeIdentifiers(exp) {
if (!__BROWSER__) {
if (isString(exp)) {
removeId(exp)
} else if (exp.identifiers) {
exp.identifiers.forEach(removeId)
} else if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
removeId(exp.content)
}
}
},
hoist(exp) {
if (isString(exp)) exp = createSimpleExpression(exp)
context.hoists.push(exp)
const identifier = createSimpleExpression(
`_hoisted_${context.hoists.length}`,
false,
exp.loc,
ConstantTypes.CAN_HOIST
)
identifier.hoisted = exp
return identifier
},
// 缓存处理
cache(exp, isVNode = false) {
return createCacheExpression(context.cached++, exp, isVNode)
}
}
if (__COMPAT__) {
context.filters = new Set()
}
function addId(id: string) {
const { identifiers } = context
if (identifiers[id] === undefined) {
identifiers[id] = 0
}
identifiers[id]!++
}
function removeId(id: string) {
context.identifiers[id]!--
}
return context
}
在 createTransformContext
函数中,定义了一个 context
对象并将其返回。这个 context
对象就是转换器中的转换上下文,在 context
对象中定义了转换器转换过程中的转换选项、状态以及一些辅助函数。我们来看看其中的一些转换上下文信息:
- currentNode:用来存储当前正在转换的节点
- childIndex:用来存储当前节点在父节点的
children
中的位置索引 - parent:用来存储当前转换节点的父节点
- nodeTransforms:注册
nodeTransforms
数组,用于存储节点转换函数 - directiveTransforms:注册
directiveTransforms
数组,用于存储指令转换函数 - replaceNode(node):用于替换节点,接收新节点作为参数
- removeNode(node):用于删除节点
下面,我们对 replaceNode(node)
和 removeNode(node)
两个函数进行分析。
2.1 replaceNode(node) 替换节点
replaceNode
函数,用于替换节点,它接收新的AST节点作为参数,并使用新的AST节点替换当前正在转换的AST节点,如下面的代码所示:
js
// 用于替换节点,接收新节点作为参数
replaceNode(node) {
/* istanbul ignore if */
if (__DEV__) {
if (!context.currentNode) {
throw new Error(`Node being replaced is already removed.`)
}
if (!context.parent) {
throw new Error(`Cannot replace root node.`)
}
}
// 为了替换节点,我们需要修改 AST
// 找到当前节点在父节点的 children 中的位置:context.childIndex
// 然后使用新节点替换即可
context.parent!.children[context.childIndex] = context.currentNode = node
},
在 replaceNode
函数中,首先通过 context.childIndex
属性取得当前节点的位置索引,然后通过 context.parent.children
取得当前节点所在集合,最后配合使用 context.childIndex
与 context.parent.children
即可完成节点替换。另外,由于当前节点已经替换为新节点了,所以我们应该使用新节点更新 context.currentNode
属性的值。
2.2 removeNode(node) 移除节点
js
// 用于删除节点
removeNode(node) {
if (__DEV__ && !context.parent) {
throw new Error(`Cannot remove root node.`)
}
const list = context.parent!.children
// 获取移除节点的位置索引
const removalIndex = node
? list.indexOf(node)
: context.currentNode
? context.childIndex
: -1
/* istanbul ignore if */
if (__DEV__ && removalIndex < 0) {
throw new Error(`node being removed is not a child of current parent`)
}
// 由于当前节点已经被删除,
// 因此要重置 转换上下文context对象上的 currentNode 和 childIndex
if (!node || node === context.currentNode) {
// current node removed
context.currentNode = null
context.onNodeRemoved()
} else {
// sibling node removed
if (context.childIndex > removalIndex) {
context.childIndex--
context.onNodeRemoved()
}
}
// 调用数组的 splice 方法,根据当前节点的索引删除当前节点
context.parent!.children.splice(removalIndex, 1)
},
由上面的代码可以知道,移除当前访问的节点非常简单,只需要取得其位置索引 removalIndex
,再调用数组的 splice
方法将其从所属的 children
列表中移除即可。另外,当节点被移除后,context.currentNode
需要置为空。
2.3 nodeTransforms 存储转换函数
js
// packages/compiler-core/src/compile.ts
export function baseCompile(
template: string | RootNode,
options: CompilerOptions = {}
): CodegenResult {
// 省略部分代码
// 2. 将 模板AST 转换成 JavaScript AST
transform(
ast,
extend({}, options, {
prefixIdentifiers,
// 注册 nodeTransforms 数组
nodeTransforms: [
...nodeTransforms,
...(options.nodeTransforms || []) // user transforms
],
directiveTransforms: extend(
{},
directiveTransforms,
options.directiveTransforms || {} // user transforms
)
})
)
// 省略部分代码
}
为了实现对节点操作和访问进行解耦 ,在上下文对象中定义了 nodeTransforms
数组来存储回调函数,即节点转换函数。在上面的代码中,transform
转换器执行时,会将vue内置的节点转换函数和用户自定义的转换函数注册到 nodeTransforms
数组中。
3、traverseNode 执行转换
traverseNode
函数用于将 模板AST 转换为 JavaScript AST ,它会从 模板AST 的根节点开始,以深度遍历 的方式遍历 模板AST,如下面的代码所示:
js
// packages/compiler-core/src/transform.ts
// 将 模板 AST 转换为 JavaScript AST
export function traverseNode(
node: RootNode | TemplateChildNode,
context: TransformContext
) {
// 将当前正在转换的节点存储到转换上下文 context 的 currentNode 属性上
context.currentNode = node
// apply transform plugins
// nodeTransforms 是一个数组,用来注册节点的转换函数,其中的每一个元素都是一个函数
const { nodeTransforms } = context
// exitFns 用来存储转换函数返回的另外一个函数,
// 在 转换AST节点的退出阶段会执行存储在exitFns中的函数
const exitFns = []
// 遍历注册在 nodeTransforms 中的转换函数,将转换函数返回的函数添加到 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) {
// node was removed
return
} else {
// node may have been replaced
node = context.currentNode
}
}
switch (node.type) {
case NodeTypes.COMMENT:
if (!context.ssr) {
// 注释节点表示模板中的注释内容,不会在生成的代码中产生任何实际的节点。在处理注释节点时,编译器需要在生成的代码中使用 createComment 函数来创建注释节点。
// 使用 context.helper 函数引入 TO_DISPLAY_STRING 常量。该常量表示 toDisplayString 函数的名称,在生成的代码中会被使用
context.helper(CREATE_COMMENT)
}
break
case NodeTypes.INTERPOLATION:
// 判断当前是否为服务器端渲染(SSR)模式。如果不是服务器端渲染模式,则需要在生成的代码中引入 toDisplayString 函数
if (!context.ssr) {
context.helper(TO_DISPLAY_STRING)
}
break
// for container types, further traverse downwards
case NodeTypes.IF:
for (let i = 0; i < node.branches.length; i++) {
traverseNode(node.branches[i], context)
}
break
case NodeTypes.IF_BRANCH:
case NodeTypes.FOR:
case NodeTypes.ELEMENT:
case NodeTypes.ROOT:
traverseChildren(node, context)
break
}
// exit transforms
// 在节点处理的最后阶段执行缓存到 exitFns 中的回调函数
// 注意,这里我们要反序执行
context.currentNode = node
let i = exitFns.length
while (i--) {
exitFns[i]()
}
}
traverseNode
函数接收两个参数,第一个参数是需要转换的节点,第二个参数是转换上下文对象。为什么traverseNode函数设计成要传入第二个参数呢?其原因是为了通过使用回调函数的机制来实现对节点操作和访问进行解耦,并同时维护转换上下文信息。
js
export function traverseNode(
node: RootNode | TemplateChildNode,
context: TransformContext
)
我们可以看到,在 traverseNode
函数中,首先将当前正在转换的AST节点
存储到转换上下文 context
的 currentNode
属性上,就是为了维护当前正在转换的AST节点
,以便于在移除节点或替换节点时可以快速找到当前节点。如下代码:
js
// 将当前正在转换的节点存储到转换上下文 context 的 currentNode 属性是上
context.currentNode = node
接着,从上下文对象中取出 nodeTransforms
数组,该数组用来注册节点的转换函数,其中每一个元素都是一个函数。然后遍历该数组,逐个调用注册在其中的转换函数,并将转换函数执行后返回的函数添加到 exitFns
数组中。由于任何转换函数都可能移除当前节点,因此每个转换函数执行完毕后,都要检查当前节点是否已经被移除,如果被移除了,直接返回即可。这部分逻辑的代码如下:
js
// nodeTransforms 是一个数组,用来注册节点的转换函数,其中的每一个元素都是一个函数
const { nodeTransforms } = context
// exitFns 用来存储转换函数返回的另外一个函数,
// 在 转换AST节点的退出阶段会执行存储在exitFns中的函数
const exitFns = []
// 遍历注册在 nodeTransforms 中的转换函数,将转换函数返回的函数添加到 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) {
// node was removed
return
} else {
// node may have been replaced
node = context.currentNode
}
}
为什么还需要将转换函数执行后返回的函数添加到 exitFns
数组中呢?在转换模板AST节点
的过程中,往往需要根据其子节点的情况来决定如何对当前节点进行转换。这就要求父节点的转换操作必须等待其所有子节点全部转换完毕后再执行。如下图的工作流所示:
由上图可知,对节点的访问分为两个阶段,即进入阶段 和退出阶段 。当转换函数处于进入阶段 时,它会先进入父节点,再进入子节点 。而当转换函数处于退出阶段 时,则会先退出子节点,再退出父节点。这样,只要我们在退出节点阶段对当前访问的节点进行处理,就一定能够保证其子节点全部处理完毕。
因此,在开始处理节点前,将转换函数返回的函数添加到 exitFns
数组中,那么就可以在节点处理的最后阶段执行这些缓存在 exitFns
数组中的回调函数。这样就保证了:当退出阶段的回调函数执行时,当前访问的节点的子节点已经全部处理过了。有一点需要注意的是,退出阶段的回调函数是反序执行的。如下面的代码所示:
js
// exit transforms
// 在节点处理的最后阶段执行缓存到 exitFns 中的回调函数
// 注意,这里我们要反序执行
context.currentNode = node
let i = exitFns.length
while (i--) {
exitFns[i]()
}
在 traverseNode
函数中,通过判断当前转换节点的节点类型从而做不同的处理,如果节点类型为 NodeTypes.IF
,则递归调用 traverseNode
函数进行遍历。如果节点类型为 NodeTypes.IF_BRANCH、NodeTypes.FOR、NodeTypes.ELEMENT、NodeTypes.ROOT
时,则调用 traverseChildren
函数来转换AST
节点。如下面的代码所示:
js
switch (node.type) {
case NodeTypes.COMMENT:
if (!context.ssr) {
// 注释节点表示模板中的注释内容,不会在生成的代码中产生任何实际的节点。在处理注释节点时,编译器需要在生成的代码中使用 createComment 函数来创建注释节点。
context.helper(CREATE_COMMENT)
}
break
case NodeTypes.INTERPOLATION:
// 判断当前是否为服务器端渲染(SSR)模式。如果不是服务器端渲染模式,则需要在生成的代码中引入 toDisplayString 函数
if (!context.ssr) {
context.helper(TO_DISPLAY_STRING)
}
break
// for container types, further traverse downwards
case NodeTypes.IF:
for (let i = 0; i < node.branches.length; i++) {
traverseNode(node.branches[i], context)
}
break
case NodeTypes.IF_BRANCH:
case NodeTypes.FOR:
case NodeTypes.ELEMENT:
case NodeTypes.ROOT:
traverseChildren(node, context)
break
}
traverseChildren 转换子节点
js
// packages/compiler-core/src/transform.ts
export function traverseChildren(
parent: ParentNode,
context: TransformContext
) {
let i = 0
const nodeRemoved = () => {
i--
}
for (; i < parent.children.length; i++) {
const child = parent.children[i]
if (isString(child)) continue
// 递归地调用 traverseNode 转换子节点之前,将当前节点设置为父节点
context.parent = parent
// 设置位置索引
context.childIndex = i
// 设置节点移除函数
context.onNodeRemoved = nodeRemoved
// 递归地调用时,传递 context
traverseNode(child, context)
}
}
traverseChildren
函数做的事情很简单,就是递归地调用 traverseNode
函数对子节点进行转换。上面代码的关键点在于,在递归地调用 traverseNode
函数进行子节点的转换之前,必须设置 context.parent
和 context.childIndex
的值,这样才能保证在接下来的递归转换中,context
对象所存储的信息是正确的。
4、hoistStatic 静态提升
转换器做的第三件事情,就是判断编译选项中是否打开了 hoistStatic
选项,若打开,则调用 hoistStatic
函数对静态节点进行静态提升。关于静态提升,这里先不展开介绍,在《Vue3 源码解读之静态提升》一文以做了详细解析。
5、createRootCodegen 创建Block
转换器接下来做的事情,则是创建 Block
节点。在 Vue
的设计中,一个带有 dynamicChildren
属性的虚拟节点称为 "块",即 Block
。 Block
本质上也是一个虚拟 DOM
节点,它的 dynamicChildren
属性用来存储动态子节点。一个 Block
不仅能够收集它的直接动态子节点,还能够收集所有动态子节点。createRootCodegen
函数的源码如下所示:
js
// packages/compiler-core/src/transform.ts
// 创建 Block 节点
function createRootCodegen(root: RootNode, context: TransformContext) {
const { helper } = context
const { children } = root
if (children.length === 1) {
const child = children[0]
// if the single child is an element, turn it into a block.
// 转换为 Block
if (isSingleElementRoot(root, child) && child.codegenNode) {
// single element root is never hoisted so codegenNode will never be
// SimpleExpressionNode
const codegenNode = child.codegenNode
if (codegenNode.type === NodeTypes.VNODE_CALL) {
makeBlock(codegenNode, context)
}
root.codegenNode = codegenNode
} else {
// 插槽、v-if 指令的节点、v-for 指令的节点 本身就是 Block
root.codegenNode = child
}
} else if (children.length > 1) {
// 模板中存在多个根节点,返回一个 Fragment 类型的 Block
let patchFlag = PatchFlags.STABLE_FRAGMENT
let patchFlagText = PatchFlagNames[PatchFlags.STABLE_FRAGMENT]
// 使用 filter 函数过滤子节点中非注释节点的数量,并判断是否等于 1。如果等于 1,则说明片段节点只包含一个有效的子节点,其余为注释节点。
if (
__DEV__ &&
children.filter(c => c.type !== NodeTypes.COMMENT).length === 1
) {
patchFlag |= PatchFlags.DEV_ROOT_FRAGMENT
patchFlagText += `, ${PatchFlagNames[PatchFlags.DEV_ROOT_FRAGMENT]}`
}
root.codegenNode = createVNodeCall(
context,
helper(FRAGMENT),
undefined,
root.children,
patchFlag + (__DEV__ ? ` /* ${patchFlagText} */` : ``),
undefined,
undefined,
true,
undefined,
false /* isComponent */
)
} else {
// no children = noop. codegen will return null.
}
}
从注释中我们可以知道,如果一个子节点是一个元素,那么就将其转换为 Block
节点。对于插槽、v-if/v-else-if/v-else
指令的节点、v-for
指令的节点本身已经是 Block
节点,则直接将其添加到 root
节点的 codegenNode
属性上。如果模板中存在多个根节点,则返回一个 Fragment 类型的 Block
。如下面的模板所示:
html
<template>
<div></div>
<p></p>
<i></i>
</template>
上面的模板中存在多个根节点,会创建一个 Fragment
类型的 Block
。
6、JavaScript AST 节点描述
JavaScript AST
是 JavaScript
代码的描述,因此,需要设计一些数据结构来描述 JavaScript AST
节点。Vue 中对 JavaScript AST
节点的描述定义在 packages/compiler-core/src/ast.ts
文件中。我们来看几个重要的JavaScript AST
节点描述。
6.1 使用 JS_FUNCTION_EXPRESSION
类型的节点描述函数声明语句
js
// packages/compiler-core/src/ast.ts
export interface FunctionExpression extends Node {
type: NodeTypes.JS_FUNCTION_EXPRESSION
params: ExpressionNode | string | (ExpressionNode | string)[] | undefined
returns?: TemplateChildNode | TemplateChildNode[] | JSChildNode
body?: BlockStatement | IfStatement // 函数的函数体
newline: boolean
/**
* This flag is for codegen to determine whether it needs to generate the
* withScopeId() wrapper
*/
isSlot: boolean
/**
* __COMPAT__ only, indicates a slot function that should be excluded from
* the legacy $scopedSlots instance property.
*/
isNonScopedSlot?: boolean
}
export function createFunctionExpression(
params: FunctionExpression['params'],
returns: FunctionExpression['returns'] = undefined,
newline: boolean = false,
isSlot: boolean = false,
loc: SourceLocation = locStub
): FunctionExpression {
return {
type: NodeTypes.JS_FUNCTION_EXPRESSION, // 代表该节点是函数声明
params, // 函数的参数
returns, // 函数的返回值
newline,
isSlot,
loc
}
}
如上面的代码所示:
- 使用
type
为JS_FUNCTION_EXPRESSION
类型的节点来描述函数声明语句。 - 对于函数的参数,使用
params
数组来存储。 returns
是函数的返回值,一个函数可以有返回值,也可以没有返回值,因此returns
是可选的。- 对于函数中的函数体,则使用
body
来存储。一个函数同样可以有函数体,也可以没有函数体,因此body
同样是可选的。
6.2 使用 JS_CALL_EXPRESSION 类型的节点描述函数调用语句
js
// packages/compiler-core/src/ast.ts
export interface CallExpression extends Node {
type: NodeTypes.JS_CALL_EXPRESSION
callee: string | symbol
arguments: (
| string
| symbol
| JSChildNode
| SSRCodegenNode
| TemplateChildNode
| TemplateChildNode[]
)[]
}
type InferCodegenNodeType<T> = T extends typeof RENDER_SLOT
? RenderSlotCall
: CallExpression
export function createCallExpression<T extends CallExpression['callee']>(
callee: T,
args: CallExpression['arguments'] = [],
loc: SourceLocation = locStub
): InferCodegenNodeType<T> {
return {
type: NodeTypes.JS_CALL_EXPRESSION,
loc,
callee, // 函数的名称,值的类型是字符串或 symbol
arguments: args // 被调用函数的形式参数
} as InferCodegenNodeType<T>
}
如上面的代码所示,使用了 type
为 JS_CALL_EXPRESSION
类型的节点来描述函数调用语句。该类型节点主要有以下属性:
callee
:用来描述被调用函数的名称,它本身是一个标识符节点arguments
:被调用函数的形式参数,多个参数的话用数组来描述
6.3 使用 JS_ARRAY_EXPRESSION
类型的节点描述数组类型的参数
js
// packages/compiler-core/src/ast.ts
export interface ArrayExpression extends Node {
type: NodeTypes.JS_ARRAY_EXPRESSION
elements: Array<string | Node>
}
export function createArrayExpression(
elements: ArrayExpression['elements'],
loc: SourceLocation = locStub
): ArrayExpression {
return {
type: NodeTypes.JS_ARRAY_EXPRESSION,
loc,
elements
}
}
如上面的代码所示,使用了 type
为 JS_ARRAY_EXPRESSION
类型的节点来描述数组类型的参数。它的 elements
属性是一个数组,用来存储参数。
6.4 使用 JS_OBJECT_EXPRESSION
类型的节点描述Object类型的参数
js
// packages/compiler-core/src/ast.ts
export interface ObjectExpression extends Node {
type: NodeTypes.JS_OBJECT_EXPRESSION
properties: Array<Property>
}
export function createObjectExpression(
properties: ObjectExpression['properties'],
loc: SourceLocation = locStub
): ObjectExpression {
return {
type: NodeTypes.JS_OBJECT_EXPRESSION,
loc,
properties
}
}
如上面的代码所示,使用了 type 为 JS_OBJECT_EXPRESSION
类型的节点来描述Object
类型的参数。它的 properties
属性是一个数组,用来存储参数。
7、JavaScript AST 转换函数
7.1 transformElement 转换标签节点
js
// packages/compiler-core/src/transforms/transformText.ts
// 生成一个 JavaScript AST 的标签节点
export const transformElement: NodeTransform = (node, context) => {
// 将转换代码编写在退出节点的回调函数中
// 这样可以保证该标签节点的子节点全部被处理完毕
return function postTransformElement() {
// 从转换上下文中获取当前转换的的节点
node = context.currentNode!
// 如果被转换的节点不是原生节点,那就什么都不做
if (
!(
node.type === NodeTypes.ELEMENT &&
(node.tagType === ElementTypes.ELEMENT ||
node.tagType === ElementTypes.COMPONENT)
)
) {
return
}
const { tag, props } = node
const isComponent = node.tagType === ElementTypes.COMPONENT
// The goal of the transform is to create a codegenNode implementing the
// VNodeCall interface.
// 如果当前转换的节点是一个组件,那么 vnodeTag 为 component,否则为普通的标签名
let vnodeTag = isComponent
? resolveComponentType(node as ComponentNode, context)
: `"${tag}"`
// 判断是否是动态组件
const isDynamicComponent =
isObject(vnodeTag) && vnodeTag.callee === RESOLVE_DYNAMIC_COMPONENT
let vnodeProps: VNodeCall['props']
let vnodeChildren: VNodeCall['children']
let vnodePatchFlag: VNodeCall['patchFlag']
let patchFlag: number = 0
let vnodeDynamicProps: VNodeCall['dynamicProps']
let dynamicPropNames: string[] | undefined
let vnodeDirectives: VNodeCall['directives']
// 动态组件、TELEPORT 组件、SUSPENSE 组件应该转换为 Block
let shouldUseBlock =
// 如果动态组件的值解析为普通的元素,那么就需要将其转换为 block。这是因为普通的元素无法进行组件的更新,而 block 可以用来优化动态节点的更新。
isDynamicComponent ||
vnodeTag === TELEPORT ||
vnodeTag === SUSPENSE ||
(!isComponent &&
// <svg> and <foreignObject> must be forced into blocks so that block
// updates inside get proper isSVG flag at runtime. (#639, #643)
// This is technically web-specific, but splitting the logic out of core
// leads to too much unnecessary complexity.
// svg 标签和 foreignObject 标签会被强制转换为 Block
(tag === 'svg' || tag === 'foreignObject'))
// props
if (props.length > 0) {
const propsBuildResult = buildProps(node, context)
// 节点的 props
vnodeProps = propsBuildResult.props
patchFlag = propsBuildResult.patchFlag
// 从他属性
dynamicPropNames = propsBuildResult.dynamicPropNames
// 指令
const directives = propsBuildResult.directives
vnodeDirectives =
directives && directives.length
? (createArrayExpression(
directives.map(dir => buildDirectiveArgs(dir, context))
) as DirectiveArguments)
: undefined
if (propsBuildResult.shouldUseBlock) {
shouldUseBlock = true
}
}
// children
// 处理 h 函数调用的参数
if (node.children.length > 0) {
// block 的处理
// 内建组件 KeepAlive
// KeepAlive 组件需要强制转换为 Block
if (vnodeTag === KEEP_ALIVE) {
// 为什么需要使用原始子节点而不是插槽函数来编译 KeepAlive 组件?这是因为,如果使用插槽函数,KeepAlive 组件的子节点可能会被父级 block 收集,从而影响更新性能。
// 接下来,为了确保在进行块优化时能够正确更新 KeepAlive 组件,需要将其强制转换为 block。这样可以避免其子节点被收集到父级 block 中,从而保证更新的正确性。
shouldUseBlock = true // 需要转换为 Block
// 插槽是一种特殊的组件内容分发机制,用于将组件的内容分发到指定的位置。在 KeepAlive 组件中,由于其子节点使用原始的子节点而不是插槽函数,因此需要强制使用动态插槽来确保正确的更新。
patchFlag |= PatchFlags.DYNAMIC_SLOTS
if (__DEV__ && node.children.length > 1) {
context.onError(
createCompilerError(ErrorCodes.X_KEEP_ALIVE_INVALID_CHILDREN, {
start: node.children[0].loc.start,
end: node.children[node.children.length - 1].loc.end,
source: ''
})
)
}
}
// slots 的处理
const shouldBuildAsSlots =
isComponent &&
// 为什么在处理 Teleport 组件时不应该将其构建为插槽形式?因为 Teleport 组件不是一个真正的组件,而是一个具有专用运行时处理的特殊节点。
vnodeTag !== TELEPORT &&
// 为什么在处理 KeepAlive 组件时不应该将其构建为插槽形式?因为 KeepAlive 组件的子节点使用原始的子节点而不是插槽函数,因此需要使用动态插槽来确保正确的更新。
vnodeTag !== KEEP_ALIVE
if (shouldBuildAsSlots) {
const { slots, hasDynamicSlots } = buildSlots(node, context)
vnodeChildren = slots
if (hasDynamicSlots) {
patchFlag |= PatchFlags.DYNAMIC_SLOTS
}
} else if (node.children.length === 1 && vnodeTag !== TELEPORT) {
const child = node.children[0]
const type = child.type
// 动态文本节点
// 节点类型为 INTERPOLATION 和 COMPOUND_EXPRESSION 为动态文本
const hasDynamicTextChild =
type === NodeTypes.INTERPOLATION || // 插值
type === NodeTypes.COMPOUND_EXPRESSION
if (
hasDynamicTextChild &&
getConstantType(child, context) === ConstantTypes.NOT_CONSTANT
) {
patchFlag |= PatchFlags.TEXT
}
// pass directly if the only child is a text node
// (plain / interpolation / expression)
if (hasDynamicTextChild || type === NodeTypes.TEXT) {
vnodeChildren = child as TemplateTextChildNode
} else {
vnodeChildren = node.children
}
} else {
vnodeChildren = node.children
}
}
// patchFlag & dynamicPropNames
// 动态属性名称的处理
if (patchFlag !== 0) {
if (__DEV__) {
if (patchFlag < 0) {
// special flags (negative and mutually exclusive)
vnodePatchFlag = patchFlag + ` /* ${PatchFlagNames[patchFlag]} */`
} else {
// bitwise flags
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)
}
}
// 将当前标签节点对应的 JavaScript AST 添加到 codegenNode 属性下
node.codegenNode = createVNodeCall(
context,
vnodeTag,
vnodeProps,
vnodeChildren,
vnodePatchFlag,
vnodeDynamicProps,
vnodeDirectives,
!!shouldUseBlock,
false /* disableTracking */,
isComponent,
node.loc
)
}
}
可以看到,transformElement
转换函数返回了一个 postTransformElement
的函数,转换逻辑都编写在该函数中。在 《traverseNode 执行转换》小节中,我们介绍到:转换函数返回的函数会被添加到 exitFns 数组中,在节点处理的最后阶段执行这些缓存在 exitFns
数组中的回调函数。将转换逻辑编写在 transformElement
的返回函数中,即将转换逻辑编写在退出阶段的回调函数内,保证了其子节点是全部被处理完毕的。经过转换过的 JavaScript AST
节点最后被存储到节点的 node.codegenNode
属性下。
7.2 transformText 转换文本节点
transformText
函数用于转换文本节点,它会将相邻的文本节点和表达式合并为一个简单表达式。该函数的源码实现如下:
js
// packages/compiler-core/src/transforms/transformText.ts
// Merge adjacent text nodes and expressions into a single expression
// e.g. <div>abc {{ d }} {{ e }}</div> should have a single expression node as child.
// 将相邻的文本节点和表达式合并为一个简单表达式
export const transformText: NodeTransform = (node, context) => {
// 节点类型为 NodeTypes.ROOT、NodeTypes.ELEMENT、NodeTypes.FOR、NodeTypes.IF_BRANCH 的节点才有子节点
if (
node.type === NodeTypes.ROOT ||
node.type === NodeTypes.ELEMENT ||
node.type === NodeTypes.FOR ||
node.type === NodeTypes.IF_BRANCH
) {
// 将转换代码编写在退出节点的回调函数中
// 这样可以保证该标签节点的子节点全部都被处理完毕
return () => {
const children = node.children
let currentContainer: CompoundExpressionNode | undefined = undefined
let hasText = false
// 遍历模板AST的子节点
for (let i = 0; i < children.length; i++) {
const child = children[i]
// 判断子节点是否是文本节点或插值节点
if (isText(child)) {
hasText = true
// 第二层for循环用于处理相邻的子节点
for (let j = i + 1; j < children.length; j++) {
const next = children[j]
// 相邻的节点是否为文本节点或插值节点
if (isText(next)) {
if (!currentContainer) {
currentContainer = children[i] = {
type: NodeTypes.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 === NodeTypes.ROOT ||
(node.type === NodeTypes.ELEMENT &&
node.tagType === ElementTypes.ELEMENT &&
// #3756
// 判断当前节点是否为普通元素,并且不含有自定义指令。如果是上述情况之一,则不需要对其进行特殊处理,因为在运行时不会有其他 DOM 元素被添加到该元素中。
!node.props.find(
p =>
p.type === NodeTypes.DIRECTIVE &&
!context.directiveTransforms[p.name]
) &&
// 如果是兼容模式,并且当前节点为 <template> 标签,并且没有特殊指令。如果是上述情况之一,则需要将其子节点转换为 vnode
!(__COMPAT__ && node.tag === 'template'))))
) {
return
}
// 将文本节点预转换为 createTextVNode(text) 调用以避免运行时规范化。
for (let i = 0; i < children.length; i++) {
const child = children[i]
if (isText(child) || child.type === NodeTypes.COMPOUND_EXPRESSION) {
const callArgs: CallExpression['arguments'] = []
// createTextVNode 默认为单个空格,因此如果它是单个空格,则代码可能是用于保存字节的空调用
if (child.type !== NodeTypes.TEXT || child.content !== ' ') {
callArgs.push(child)
}
// 标记动态的文本节点
if (
!context.ssr &&
getConstantType(child, context) === ConstantTypes.NOT_CONSTANT
) {
callArgs.push(
PatchFlags.TEXT +
(__DEV__ ? ` /* ${PatchFlagNames[PatchFlags.TEXT]} */` : ``)
)
}
children[i] = {
type: NodeTypes.TEXT_CALL,
content: child,
loc: child.loc,
codegenNode: createCallExpression(
context.helper(CREATE_TEXT),
callArgs
)
}
}
}
}
}
}
可以看到,transformText
函数同样也是把转换文本节点的逻辑放在了一个回调函数中,这样就能保证其当前转换节点的所有子节点都是被处理完毕的。
在转换文本节点时,如果相邻节点都是文本节点,则会将相邻文本节点合并到当前节点中。如果不是文本节点,则直接返回,不做处理。
其它类型节点的转换函数在 packages/compiler-core/src/transforms
文件夹下,由于篇幅原因,本文不再一一解读,后续文章会对其进行单独解读。
总结
本文深入分析了transform
转换器的工作方式。它主要负责将模板AST 转换为 JavaScript AST 。在转换的过程中,主要做了四件事情:
- 调用
createTransformContext
函数创建转换上下文对象 - 调用
traverseNode
函数遍历模板AST,将其转换成JavaScript AST
- 如果编译选项中打开了
hoistStatic
选项,则对节点进行静态提升 - 如果是浏览器端渲染,则调用
createRootCodegen
函数收集所有的动态节点
在完成 JavaScript AST
的转换时,设计了一些数据结构来描述 JavaScript AST
节点。如使用 JS_FUNCTION_EXPRESSION
类型的节点描述函数声明语句 ,使用 JS_CALL_EXPRESSION
类型的节点描述函数调用语句 ,使用 JS_ARRAY_EXPRESSION
类型的节点描述数组类型的参数 ,使用 JS_OBJECT_EXPRESSION
类型的节点描述Object类型的参数等。
为了把模板AST转换为 JavaScript AST
,定义了相应的转换函数。如 transformElement
转换函数和 transformText
转换函数,它们分别用来处理标签节点 和文本节点。