众所周知,vue3使用一种基于HTML的模板语法,也就是在template代码块中定义的模板字符串,在template圈定的字符串中,我们可以使用vue3提供的各种语法,如mustache表达式(也就是双括号语法)、v-指令,直接绑定事件处理函数等,让开发者能够以编写HTML文档的方式来编写vue,开发体验相当友好。在我们惊讶于vue3优雅的开发方式时,同时也需要知道底层其实是vue3的编译器帮我们做了很多机械化的操作。这一期就深入vue3的编译方式,探究探究这神奇的template模板语法。
1、template编译
template的编译会根据vue的使用场景不同,而分为构建时编译和运行时编译。构建时编译通常是我们以SPA的形式使用vue时,借助webpack的loader和vite的plugins实现的,而运行时编译是我们直接通过cdn的形式引入vue模块,直接在html文档中使用vue的template语法。虽然两者的时机有所不同,但其底层都是调用的vue提供的编译器,为了排除编译打包工具的干扰,我们从运行时的template编译流程开始分析。
1.1 编译时机
从上一期createApp的秘密那篇文章中,我们分析了在创建vue实例时,vue的内部究竟做了哪些工作,我们知道createApp方法改写了mount挂载组件的方法(其余代码已经省略):
export const createApp = ((...args) => {
const app = ensureRenderer().createApp(...args)
// 不是强相关的代码已经被删除
const { mount } = app
app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
const container = normalizeContainer(containerOrSelector)
if (!container) return
const component = app._component
const proxy = mount(container, false, resolveRootNamespace(container))
return proxy
}
return app
}) as CreateAppFunction<Element>
改写的逻辑其实是在执行mount函数前做了一些预处理工作,例如确保挂载容器的类型。在mount函数中,主要进行了vnode的创建和组件的渲染:
mount(
rootContainer: HostElement,
isHydrate?: boolean,
namespace?: boolean | ElementNamespace,
): any {
// 不是强相关的代码已经被删除
if (!isMounted) {
// 创建根组件的vnode
const vnode = app._ceVNode || createVNode(rootComponent, rootProps
// 将根组件的vnode渲染到挂载容器上
render(vnode, rootContainer, namespace)
isMounted = true
app._container = rootContainer
return getComponentPublicInstance(vnode.component!)
}
}
而在执行render函数时,会调用patch方法对组件进行更新,patch方法内部封装了对vue组件及html的元素进行操作的方法,会根据传入的目标vnode类型进行不同方法的调用:
const patch: PatchFn = (
n1,
n2,
container,
anchor = null,
parentComponent = null,
parentSuspense = null,
namespace = undefined,
slotScopeIds = null,
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren,
) => {
if (n1 === n2) {
return
}
// ...
const { type, ref, shapeFlag } = n2
switch (type) {
// 如果是文本节点
case Text:
// ...
break
// 如果是注释
case Comment:
// ...
break
// 如果是静态节点
case Static:
// ...
break
// 如果是Fragment节点,就是用于包裹其他元素的节点,为啥vue3不需要像vue2一样用div包裹,就是有它
case Fragment:
// ...
break
default:
// 如果是其他dom元素
if (shapeFlag & ShapeFlags.ELEMENT) {
// ...
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// 如果是组件
processComponent(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
} else if (shapeFlag & ShapeFlags.TELEPORT) {
// 如果是传送门组件(vue3特有的组件)
// ...
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
// 如果是异步占位组件
// ...
}
}
}
众所周知,template是vue组件的一个属性,所以我们需要看patch函数是如何对组件进行更新的,从该方法的内部逻辑可以很清晰的看出,对于组件的处理主要是调用了processComponent方法,该方法内部逻辑如下:
const processComponent = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
namespace: ElementNamespace,
slotScopeIds: string[] | null,
optimized: boolean,
) => {
n2.slotScopeIds = slotScopeIds
if (n1 == null) {
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
// ... 需要缓存的组件
} else {
// 需要重新挂载
mountComponent(
n2,
container,
anchor,
parentComponent,
parentSuspense,
namespace,
optimized,
)
}
} else {
// 需要更新
updateComponent(n1, n2, optimized)
}
}
从processComponent方法内部可以看出,vue在处理组件的渲染时,会根据原先的vnode进行判断,由于在调用createApp时,此时根组件为第一次挂载,所以我们需要看看mountComponent函数做了哪些工作。mountComponent函数内部逻辑如下,开发环境的分支和非主逻辑代码已经被删除:
const mountComponent: MountComponentFn = (
initialVNode,
container,
anchor,
parentComponent,
parentSuspense,
namespace: ElementNamespace,
optimized,
) => {
// 这里是为了兼容vue2情况下,组件实例可能在挂载之前就已经被创建了,那么就不需要再次创建组件实例了
const compatMountInstance =
__COMPAT__ && initialVNode.isCompatRoot && initialVNode.component
const instance: ComponentInternalInstance =
compatMountInstance ||
(initialVNode.component = createComponentInstance(
initialVNode,
parentComponent,
parentSuspense,
))
if (!(__COMPAT__ && compatMountInstance)) {
// 如果不是兼容vue2的情况
setupComponent(instance, false, optimized)
}
// 如果是异步组件挂载,则该组件依赖异步的逻辑完成后,需要特殊处理
if (__FEATURE_SUSPENSE__ && instance.asyncDep) {
// ... 异步组件逻辑
} else {
// 执行渲染函数的副作用函数
setupRenderEffect(
instance,
initialVNode,
container,
anchor,
parentSuspense,
namespace,
optimized,
)
}
}
mountComponent函数的逻辑也十分清晰,第一步创建一个组件实例,第二步执行组件的setup,第三步执行setup渲染函数的副作用函数。第二步在执行完setup时,setupComponent方法内部会先初始化组件的props和slots,也就是属性和插槽相关的预处理,随后会判断当前组件对象上有没有定义setup方法,如果有,会真正地调用setup函数,并在获取到setup函数返回的结果后,执行finishComponentSetup方法,而如果组件对象没有定义setup函数,则会直接执行finishComponentSetup,该函数内部逻辑如下:
// component.ts
function finishComponentSetup(
instance: ComponentInternalInstance,
isSSR: boolean,
skipOptions?: boolean,
): void {
// 不是强相关的代码已经被删除
const Component = instance.type as ComponentOptions
// 将template和render属性规范化
// 下方的判断是因为组件实例可能直接编写了render函数
if (!instance.render) {
if (!isSSR && compile && !Component.render) {
// 获取模板字符串
const template = Component.template ||
(__FEATURE_OPTIONS_API__ && resolveMergedOptions(instance).template)
if (template) {
const { isCustomElement, compilerOptions } = instance.appContext.config
const { delimiters, compilerOptions: componentCompilerOptions } = Component
// 组装编译器的参数对象,对象内部包含了组件类提供的编译选项,也包含了vue实例上的编译选项
const finalCompilerOptions: CompilerOptions = extend(
extend(
{
isCustomElement,
delimiters,
},
compilerOptions,
),
componentCompilerOptions,
)
// 将模板字符串编译成render函数
Component.render = compile(template, finalCompilerOptions)
}
}
// 给组件实例的render函数赋值
instance.render = (Component.render || NOOP) as InternalRenderFunction
}
}
}
千呼万唤始出来,template的编译竟然位于setup函数执行之后,因为vue3最终都是通过调用组件的render函数进行的渲染,所以在组件挂载过程中,template语法也会被编译成render函数的形式。接下来我们就可以深入compile函数的内部,探究模板语法是如何被编译的。
1.2.编译过程
第一步我们先看complier来自何处,首先我们知道了complier函数的执行是在component.ts文件中,而complier是位于该文件最顶级作用域的一个变量,该变量值的设置是通过registerRuntimeCompiler 函数来实现的,该函数内部逻辑简单,就是简单的将参数的值赋值给了文件中的complier变量。而registerRuntimeCompiler 函数的真正执行位置在vue的入口文件中,也就是vue/src/index.ts,赋值给complier的参数值为compileToFunction,compileToFunction函数的定义如下:
// vue/src/index.ts
function compileToFunction(
template: string | HTMLElement,
options?: CompilerOptions,
): RenderFunction {
// 如果传入的template是dom元素,也就是取自html中的dom
if (!isString(template)) {
if (template.nodeType) {
// 如果dom元素是有元素类型的,则将内部的html字符串赋值给template变量
template = template.innerHTML
} else {
return NOOP
}
}
// template缓存
const key = genCacheKey(template, options)
const cached = compileCache[key]
if (cached) {
return cached
}
// 如果传入的template是个字符串,并且是个选择器
if (template[0] === '#') {
// 看起来只支持id选择器,则将这个dom元素的内部html字符串赋值给template
const el = document.querySelector(template)
template = el ? el.innerHTML : ``
}
// 定义编译函数需要的参数
const opts = extend(
{
hoistStatic: true,
onError: __DEV__ ? onError : undefined,
onWarn: __DEV__ ? e => onError(e, true) : NOOP,
} as CompilerOptions,
options,
)
// 给opts上添加isCustomElement方法,该方法用于检验如果不是html的标签,是否是web component自定义标签
if (!opts.isCustomElement && typeof customElements !== 'undefined') {
opts.isCustomElement = tag => !!customElements.get(tag)
}
// 执行编译函数并获取编译后的code
const { code } = compile(template, opts)
// 定义错误处理函数
function onError(err: CompilerError, asWarning = false) {
const message = asWarning
? err.message
: `Template compilation error: ${err.message}`
const codeFrame =
err.loc &&
generateCodeFrame(
template as string,
err.loc.start.offset,
err.loc.end.offset,
)
warn(codeFrame ? `${message}\n${codeFrame}` : message)
}
// 将编译器编译后的code字符串实例化为函数对象,并执行
const render = (
__GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom)
) as RenderFunction
// 增加运行时编译的标志位
;(render as InternalRenderFunction)._rc = true
// 返回render函数,并缓存起来
return (compileCache[key] = render)
}
正如其名,compileToFunction函数的功能就是将输入的template字符串结合传入的工具参数options,编译成render函数,而render变量的赋值,是通过new Function(code)这个API来实现的,熟悉JS的同学都知道,new Function构造函数接收函数代码字符串,并将其转换成可以执行的函数详见MDN,所以code字符串的生成就是template编译过程的核心,即complie函数执行的结果:
/// package/complier-dom/src/index.ts
export function compile(
src: string | RootNode,
options: CompilerOptions = {},
): CodegenResult {
return baseCompile(
src,
extend({}, parserOptions, options, {
nodeTransforms: [
ignoreSideEffectTags,
...DOMNodeTransforms,
...(options.nodeTransforms || []),
],
directiveTransforms: extend(
{},
DOMDirectiveTransforms,
options.directiveTransforms || {},
),
transformHoist: __BROWSER__ ? null : stringifyStatic,
}),
)
}
compile函数内部其实是处理了baseCompile函数的参数,通过extend方法给用于编译的工具对象扩展了很多参数,如node转换方法、vue指令转换方法等。让我们深入baseCompile探究其具体的逻辑:
export function baseCompile(
source: string | RootNode,
options: CompilerOptions = {},
): CodegenResult {
const isModuleMode = options.mode === 'module'
// prefixIdentifiers 用于解决命名冲突问题,大部分情况下其值为false。
const prefixIdentifiers =
!__BROWSER__ && (options.prefixIdentifiers === true || isModuleMode)
const resolvedOptions = extend({}, options, {
prefixIdentifiers,
})
// 将template代码转换为抽象语法树
const ast = isString(source) ? baseParse(source, resolvedOptions) : source
// 获取语法转换的工具函数,node转换工具、vue指令转换工具
const [nodeTransforms, directiveTransforms] =
getBaseTransformPreset(prefixIdentifiers)
// 使用转换方法处理抽象语法树
transform(
ast,
extend({}, resolvedOptions, {
nodeTransforms: [
...nodeTransforms,
...(options.nodeTransforms || []), // user transforms
],
directiveTransforms: extend(
{},
directiveTransforms,
options.directiveTransforms || {}, // user transforms
),
}),
)
// 根据处理后的抽象语法树生成code代码
return generate(ast, resolvedOptions)
}
baseCompile函数内部的逻辑也较为清晰,该函数的主要功能就是将传入的template字符串转换成了抽象语法树ast,然后将vue工具库默认的node转换工具方法和vue指令转换工具方法传给transform转换函数,从该函数名中可以看着,transform函数的作用就是使用转换工具对ast做处理,我们首先来看template字符串是如何被转换成ast的:
export function baseParse(input: string, options?: ParserOptions): RootNode {
reset()
// 缓存输入的待编译的字符串
currentInput = input
// 缓存编译的工具方法参数
currentOptions = extend({}, defaultParserOptions)
// 如果外部传入了其他的工具方法参数,也将其缓存,从前文看主要是一些vue相关的转换函数
if (options) {
let key: keyof ParserOptions
for (key in options) {
if (options[key] != null) {
// @ts-expect-error
currentOptions[key] = options[key]
}
}
}
// 给tokenizer设置参数值
tokenizer.mode =
currentOptions.parseMode === 'html'
? ParseMode.HTML
: currentOptions.parseMode === 'sfc'
? ParseMode.SFC
: ParseMode.BASE
tokenizer.inXML =
currentOptions.ns === Namespaces.SVG ||
currentOptions.ns === Namespaces.MATH_ML
const delimiters = options && options.delimiters
if (delimiters) {
tokenizer.delimiterOpen = toCharCodes(delimiters[0])
tokenizer.delimiterClose = toCharCodes(delimiters[1])
}
// 创建根节点
const root = (currentRoot = createRoot([], input))
// 使用tokenizer对输入字符串进行转换
tokenizer.parse(currentInput)
// 获取根组件在template字符串中的位置
root.loc = getLoc(0, input.length)
// 压缩空白字符
root.children = condenseWhitespace(root.children)
currentRoot = null
return root
}
baseParse函数就是用于将template字符串转换成ast的函数,编译原理中,将代码字符串转换成ast通常需要2步,第一步是词法分析,该步骤用于将代码字符串转换成tokens,也就是有意义的文本片段,比如v-if、<div>、{{ abc }} ,他们一般都是代码的基本单元。第二步是语法分析,也就是各个编译系统根据自己的语法规则,将第一步生成的tokens转换成抽象语法树的节点,形成一个大的语法对象。
baseParse函数很好的诠释了编译原理的步骤,其中tokenizer实例用于将template字符串转换成tokens,一个个的有意义的语法词,而createRoot函数就是创建ast对象的根节点,tokenizer函数在进行词法分析时,是支持钩子函数的,也就是支持在词法分析各个阶段进行操作:
export default class Tokenizer {
constructor(
private readonly stack: ElementNode[],
private readonly cbs: Callbacks,
) {
}
}
tokenizer类的构造函数十分简单,就是将参数的值设置为stack和cbs变量,cbs很简单理解,就是外部传入的回调函数,也就是前文说的支持在词法分析的各个阶段执行,而stack,顾名思义就是栈,因为template字符串类似于html的结构,也就是采用闭合标签和非闭合标签的声明式语言,为了构造出树形的层次结构,就需要使用栈来维护树形结构的上下层次关系(类似于算法题——有效括号)。tokenizer在处理ast树形结构的每层信息时,会根据分析出的单词调用不同的回调函数,而这些回调函数的逻辑正是将单词字符串转换成ast各个节点的代码信息。我们以最典型的oninterpolation函数为例,该函数会在tokenizer实例遍历到{{ content }}这样的单词时执行,用于将content转换成JS表达式:
// parser.ts
oninterpolation(start, end) {
if (inVPre) {
// v-pre 指令用于跳过这个元素和它的子元素的编译过程,直接展示字符串
return onText(getSlice(start, end), start, end)
}
// tokenizer.delimiterOpen的值其实就是 '{{'
// tokenizer.delimiterClose的值其实就是 '}}'
let innerStart = start + tokenizer.delimiterOpen.length
let innerEnd = end - tokenizer.delimiterClose.length
// 下面两个while在进行trim
while (isWhitespace(currentInput.charCodeAt(innerStart))) {
innerStart++
}
while (isWhitespace(currentInput.charCodeAt(innerEnd - 1))) {
innerEnd--
}
// 截取mustache语法中的表达式字符串
let exp = getSlice(innerStart, innerEnd)
// 兼容早期浏览器对特殊符号的处理,例如<、>
if (exp.includes('&')) {
if (__BROWSER__) {
exp = currentOptions.decodeEntities!(exp, false)
} else {
exp = decodeHTML(exp)
}
}
// 向当层的语法树层级加入这个节点
addNode({
type: NodeTypes.INTERPOLATION,
content: createExp(exp, false, getLoc(innerStart, innerEnd)),
loc: getLoc(start, end),
})
},
从上述代码可以清晰看出,oninterpolation函数就是对tokenizer截取到的字符串做了对应的语法分析,并使用createExp方法对mustache表达式中的语句进行转译。其实createExp方法底层是调用了babel/parser的方法对JS表达式字符串进行了编译,感兴趣的同学可以进一步查看。
oninterpolation函数很好地反映了tokenizer和回调函数的工作形式,与此同理,tokenizer函数在遍历完整个template字符串时,使用了多种针对不同类型的ast结点生成方法,最终构建出了ast抽象语法树。
ast抽象语法树构建完后,此时的ast仍然不能直接转换成渲染函数,这是因为通过tokenizer和回调函数的处理之后,此时ast只是包含了当前template字符串的树形结构,树形结构的每个结点上只是具备简单的信息,例如该结点是组件、元素还是表达式。需要针对不同的结点类型进一步处理,这也就是transform函数存在的原因。
根据transform内部逻辑,需要使用各种nodeTransform方法对抽象语法树的结点进行转换,这里以transformElement函数为例,该函数用于对元素类型的结点进行转换:
// 生成元素类型的JS抽象语法树
export const transformElement: NodeTransform = (node, context) => {
return function postTransformElement() {
node = context.currentNode!
// 判断党员结点的类型事否是组件or元素
if (
!(
node.type === NodeTypes.ELEMENT &&
(node.tagType === ElementTypes.ELEMENT ||
node.tagType === ElementTypes.COMPONENT)
)
) {
return
}
const { tag, props } = node
const isComponent = node.tagType === ElementTypes.COMPONENT
// transform函数的目的是为了创建一个代码结点,该节点实现了 VNodeCall(虚拟结点) 的接口
// VNodeCall 接口继承自 VNode接口,都被用于描述虚拟结点,但是VNodeCall接口多了一些属性,如是否是组件,组件插槽等
let vnodeTag = isComponent
? resolveComponentType(node as ComponentNode, context)
: `"${tag}"`
// 如果是动态组件 component
const isDynamicComponent =
isObject(vnodeTag) && vnodeTag.callee === RESOLVE_DYNAMIC_COMPONENT
// 处理props
if (props.length > 0) {
}
// 处理子节点
if (node.children.length > 0) {
}
// 生成当前结点的JS抽象语法树对象
node.codegenNode = createVNodeCall(
context,
vnodeTag,
vnodeProps,
vnodeChildren,
patchFlag === 0 ? undefined : patchFlag,
vnodeDynamicProps,
vnodeDirectives,
!!shouldUseBlock,
false /* disableTracking */,
isComponent,
node.loc,
)
}
transformElement函数只是transform方法执行过程中,对于ast结点的其中一种类型的处理函数,transform函数中还有其他类型的处理函数,如处理各种vue指令,处理文本等,这些处理函数都是为了最后generate函数做预处理,方便生成对应的代码。generate函数的执行逻辑较为简单,其内部只是对transform函数处理后的ast做了遍历,在遍历过程中根据节点的不同类型添加对应的代码字符串,相同于采用拼接字符串的形式写代码。有兴趣的同学可以在/packages/compiler-core/codegen.ts找到该函数进行查看。
1.2.1 mustache语法
由于我们文章的重点在于分析模板语法,所以这里会重点分析genNode方法中对模板语法节点使用的genInterpolation方法:
function genInterpolation(node: InterpolationNode, context: CodegenContext) {
// 获取当前运行环境的方法
// push是往文件里写入代码的方法
// helper是一些可以执行运行时逻辑的函数名
// pure 表示是否为纯函数,如果是纯函数,则打上标记,vue在收集依赖时会忽略,从而提高性能
const { push, helper, pure } = context
// 打当前函数打标记
if (pure) push(PURE_ANNOTATION)
push(`${helper(TO_DISPLAY_STRING)}(`)
// genInterpolation函数正是在genNode方法中执行的,此处进行递归生成,相当于只解析mustache表达式内部的代码
genNode(node.content, context)
// 函数执行
push(`)`)
}
从这个代码结构可以很清晰的看到,genInterpolation就是生成了一个函数的执行语句,函数名是由helper函数决定的,helper函数执行的逻辑如下:
helper(key) {
return `_${helperNameMap[key]}`
},
该函数其实就是根据helperNameMap映射,获取对应的函数名,其中TO_DISPLAY_STRING对应的函数名为toDisplayString,我们找到该函数的定义
/**
* For converting {{ interpolation }} values to displayed strings.
* @private
*/
export const toDisplayString = (val: unknown): string => {
return isString(val)
? val
: val == null
? ''
: isArray(val) ||
(isObject(val) &&
(val.toString === objectToString || !isFunction(val.toString)))
? isRef(val)
? toDisplayString(val.value)
: JSON.stringify(val, replacer, 2)
: String(val)
}
toDisplayString函数的逻辑比较简单,就是根据传入参数的类型进行字符串的转换,例如如果参数是数组或者是对象,且该对象的toString方法是原生Object.prototype的toString方法或者参数的toString方法非函数,则会进行ref的拆包判断。
目前知道了模板语法在展示数据时,其实是依托了toDisplayString这个函数来实现的,而这个函数接收一个参数,从genInterpolation函数的内部可以发现,toDisplayString这个函数的参数来自于genNode(node.content, context),也就是根据当前抽象语法树节点的内容,生成对应的代码,我们知道模板语法内部一般是JS表达式,所以这里的类型为SIMPLE_EXPRESSION,其实在生成模板语法节点时,节点的content内容就被赋值成了SIMPLE_EXPRESSION。在对SIMPLE_EXPRESSION进行解析时,使用的是genExpression方法
function genExpression(node: SimpleExpressionNode, context: CodegenContext) {
const { content, isStatic } = node
// 将代码
context.push(
isStatic ? JSON.stringify(content) : content,
NewlineType.Unknown,
node,
)
}
该方法逻辑也很简单,就是将抽象语法树节点的代码文本写入整棵语法树生成的代码中,可以预想到最后生成的函数,其实就是将JS表达式的结果转换成可展示的文本。
1.2.2 vue指令与属性
在vue的template语法中,使用最多的就是mustache语法、vue指令、属性和自定义组件了。上节我们主要对mustache语法的编译进行了解析,这里对vue指令和属性进行分析。从前面的分析可知,vue3在解析template的代码字符时,利用了tokenizer这个类,而这个类就是从头到尾遍历每个字符,然后根据当前字符的类型来决定解析状态,在遍历过程中,会采用栈和回调函数的形式将整个template转换为一棵语法树。对于vue指令和属性也是如此,vue的指令如v-bind、v-on等,和class这些属性一样,都是写在元素标签上的,所以vue3也是在stateInDirName(遍历元素标签)状态时,对指令和属性进行收集:
private stateInDirName(c: number): void {
// 如果遍历到当前字符为等号或者是标签的结尾(如/>这种符号)
if (c === CharCodes.Eq || isEndOfTagSection(c)) {
// 调用ondirname回调函数
this.cbs.ondirname(this.sectionStart, this.index)
// 当前属性名遍历完毕,进行当前遍历状态的切换和处理
this.handleAttrNameEnd(c)
} else if (c === CharCodes.Colon) {
// 如果当前字符为冒号:
this.cbs.ondirname(this.sectionStart, this.index)
// 设置当前遍历状态为在 标签参数中
this.state = State.InDirArg
this.sectionStart = this.index + 1
} else if (c === CharCodes.Dot) {
// 如果当前字符为点
this.cbs.ondirname(this.sectionStart, this.index)
// 说明当前状态为标签修饰符状态
this.state = State.InDirModifier
this.sectionStart = this.index + 1
}
}
该状态下的遍历较为简单,就是针对不同的字符采用不同的回调函数和状态切换,用于收集指令和属性,已经切换当前的遍历状态,方便做其他处理。这里每个分支都调用了ondirname回调函数,我们看看这里逻辑:
ondirname(start, end) {
// 获取当前截取字符
const raw = getSlice(start, end)
// 根据当前字符转译为对应的指令或者属性
const name =
raw === '.' || raw === ':'
? 'bind'
: raw === '@'
? 'on'
: raw === '#'
? 'slot'
: raw.slice(2)
// 如果当前节点已经被打上了不需要解析的标签或者当前属性名为空,则直接将当前语法树节点的props设置为标签属性
if (inVPre || name === '') {
currentProp = {
type: NodeTypes.ATTRIBUTE,
name: raw,
nameLoc: getLoc(start, end),
value: undefined,
loc: getLoc(start),
}
} else {
// 当前语法树节点的props为指令
currentProp = {
type: NodeTypes.DIRECTIVE,
name,
rawName: raw,
exp: undefined,
arg: undefined,
modifiers: raw === '.' ? [createSimpleExpression('prop')] : [],
loc: getLoc(start),
}
// 处理v-pre的场景
if (name === 'pre') {
// 将该标签节点的所有props中的指令转换成标签属性
inVPre = tokenizer.inVPre = true
currentVPreBoundary = currentOpenTag
const props = currentOpenTag!.props
for (let i = 0; i < props.length; i++) {
if (props[i].type === NodeTypes.DIRECTIVE) {
props[i] = dirToAttr(props[i] as DirectiveNode)
}
}
}
}
}
ondirname函数的作用也是在语法分析过程中,对遍历到的标签属性进行分析并收集。指令和属性收集后的结果就是在NodeTypes.ELEMENT语法树节点的props属性中,包含着各种各样的NodeTypes.ATTRIBUTE和NodeTypes.DIRECTIVE节点,方便在transform和generate时进行处理。从NodeTypes.DIRECTIVE节点的转换结果类型我们可以看出,通过transform后,NodeTypes.DIRECTIVE节点会被转换成props
属性:
export interface DirectiveTransformResult {
props: Property[]
needRuntime?: boolean | symbol
ssrTagParts?: TemplateLiteral['elements']
}
porps属性作为元素的一部分,在进行transformElement时,会被统一处理,处理的方法是buildProps,方法内部调用了针对属性和指令的transform函数,在transformElement函数中,最终会被转换成节点的一部分,经过buildProps,指令会被转换成对应的key和value,如v-bind:title="title",最终会被转换成节点props中的一个对象,这个对象的key为title,value为$setup.title,方便最后一步打印代码时,vue3可以将对应的值直接填入。
2、总结
这篇文章我们分析了vue3是如何对template中的内容进行编译的,它的执行时机在setup函数执行之后,因为这样才能拿到setup绑定的值,它一样符合编译原理的流程:编译、转换、打印。经过了词法分析、语法分析生成抽象语法树。在生成语法树的过程中,指令和属性就已经进行了替换,方便vue3在generate打印代码时生成对应的代码。