白话vue3——模板语法(第三期)

219 阅读13分钟

众所周知,vue3使用一种基于HTML的模板语法,也就是在template代码块中定义的模板字符串,在template圈定的字符串中,我们可以使用vue3提供的各种语法,如mustache表达式(也就是双括号语法)、v-指令,直接绑定事件处理函数等,让开发者能够以编写HTML文档的方式来编写vue,开发体验相当友好。在我们惊讶于vue3优雅的开发方式时,同时也需要知道底层其实是vue3的编译器帮我们做了很多机械化的操作。这一期就深入vue3的编译方式,探究探究这神奇的template模板语法。

1、template编译

template的编译会根据vue的使用场景不同,而分为构建时编译和运行时编译。构建时编译通常是我们以SPA的形式使用vue时,借助webpackloaderviteplugins实现的,而运行时编译是我们直接通过cdn的形式引入vue模块,直接在html文档中使用vuetemplate语法。虽然两者的时机有所不同,但其底层都是调用的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方法内部会先初始化组件的propsslots,也就是属性和插槽相关的预处理,随后会判断当前组件对象上有没有定义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的参数值为compileToFunctioncompileToFunction函数的定义如下:

// 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类的构造函数十分简单,就是将参数的值设置为stackcbs变量,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)
    // 兼容早期浏览器对特殊符号的处理,例如&lt、&gt;
    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.prototypetoString方法或者参数的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-bindv-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.ATTRIBUTENodeTypes.DIRECTIVE节点,方便在transformgenerate时进行处理。从NodeTypes.DIRECTIVE节点的转换结果类型我们可以看出,通过transform后,NodeTypes.DIRECTIVE节点会被转换成props 属性:

export interface DirectiveTransformResult {
  props: Property[]
  needRuntime?: boolean | symbol
  ssrTagParts?: TemplateLiteral['elements']
}

porps属性作为元素的一部分,在进行transformElement时,会被统一处理,处理的方法是buildProps,方法内部调用了针对属性和指令的transform函数,在transformElement函数中,最终会被转换成节点的一部分,经过buildProps,指令会被转换成对应的keyvalue,如v-bind:title="title",最终会被转换成节点props中的一个对象,这个对象的keytitlevalue$setup.title,方便最后一步打印代码时,vue3可以将对应的值直接填入。

2、总结

这篇文章我们分析了vue3是如何对template中的内容进行编译的,它的执行时机在setup函数执行之后,因为这样才能拿到setup绑定的值,它一样符合编译原理的流程:编译、转换、打印。经过了词法分析、语法分析生成抽象语法树。在生成语法树的过程中,指令属性就已经进行了替换,方便vue3在generate打印代码时生成对应的代码。