Vue3 源码解读之模板AST 解析器(二)

模板在《Vue3 源码解读之模板AST 解析器(一) 》一文中,我们介绍了解析器的实现原理与状态机有关,并介绍了解析器的核心处理函数 parseChildren 的解析过程。在本文中,我们将详细介绍解析器解析过程中是如何完成不同节点的解析。

parseElement 解析标签节点

解析器一开始处于 DATA 模式。开始执行解析解析后,解析器遇到的第一个字符为 <,并且第二个字符能够匹配正则表达式 /a-z/i,那么解析器会进入标签节点状态,并调用 parseElement 函数进行解析,源码如下所示:

parseElement源码

js 复制代码
// packages/compiler-core/src/parse.ts

function parseElement(
  context: ParserContext,
  ancestors: ElementNode[]
): ElementNode | undefined {
  __TEST__ && assert(/^<[a-z]/i.test(context.source))

  // Start tag.
  // 1. 解析开始标签
  const wasInPre = context.inPre
  const wasInVPre = context.inVPre
  // 获取父级节点栈中的栈顶元素,即当前解析节点的父节点
  const parent = last(ancestors)
  // 调用 parseTag 解析开始标签
  const element = parseTag(context, TagType.Start, parent)
  const isPreBoundary = context.inPre && !wasInPre
  const isVPreBoundary = context.inVPre && !wasInVPre

  if (element.isSelfClosing || context.options.isVoidTag(element.tag)) {
    // #4030 自闭和标签 <br />
    if (isPreBoundary) {
      context.inPre = false
    }
    if (isVPreBoundary) {
      context.inVPre = false
    }
    return element
  }

  // Children.
  // 2. 递归地调用 parseChildren 解析子节点

  // 将解析处理的标签节点压入父级节点栈
  ancestors.push(element)
  // 获取正确的文本模式
  const mode = context.options.getTextMode(element, parent)
  // 解析子节点
  const children = parseChildren(context, mode, ancestors)
  // 解析完当前标签节点后,需要弹出父节点栈中的栈顶元素,即与当前解析的同名的标签
  ancestors.pop()

  // Vue.js 2.x 中的 inline-template 选项适配
  // 如果组件的选项中包含 inline-template 选项,则将其值作为组件的模板;否则,将组件的 template 选项作为模板。
  if (__COMPAT__) {
    const inlineTemplateProp = element.props.find(
      p => p.type === NodeTypes.ATTRIBUTE && p.name === 'inline-template'
    ) as AttributeNode
    if (
      inlineTemplateProp &&
      checkCompatEnabled(
        CompilerDeprecationTypes.COMPILER_INLINE_TEMPLATE,
        context,
        inlineTemplateProp.loc
      )
    ) {
      const loc = getSelection(context, element.loc.end)
      inlineTemplateProp.value = {
        type: NodeTypes.TEXT,
        content: loc.source,
        loc
      }
    }
  }

  element.children = children

  // End tag.
  // 3. 解析结束标签
  if (startsWithEndTagOpen(context.source, element.tag)) {
    parseTag(context, TagType.End, parent)
  } else {
    // 报错,缺少闭合标签
    emitError(context, ErrorCodes.X_MISSING_END_TAG, 0, element.loc.start)
    if (context.source.length === 0 && element.tag.toLowerCase() === 'script') {
      const first = children[0]
      if (first && startsWith(first.loc.source, '<!--')) {
        emitError(context, ErrorCodes.EOF_IN_SCRIPT_HTML_COMMENT_LIKE_TEXT)
      }
    }
  }

  // 获取标签位置对象
  element.loc = getSelection(context, element.loc.start)

  if (isPreBoundary) {
    context.inPre = false
  }
  if (isVPreBoundary) {
    context.inVPre = false
  }
  return element
}

可以看到,parseElement 主要做了三件事:解析开始标签,解析子节点,解析结束标签。我们结合下面的一段模板来对源码进行解析。

js 复制代码
const template = `<div>+--<p>Text1</p>+--<p>Text2</p>+</div>`

需要注意的是,在解析模板时,不能忽略空白字符 的处理。这些空白字符 包括:换行符 (\n)、回车符 (\r)、空格 (' ')、制表符 (\t) 以及换页符 (\f)。假设我们使用加号 (+) 代表换行符,用减号 (-) 代表空格字符,如上面的模板所示。

接下来,我们以这段模板来解读 parseElement 解析标签节点的过程,如下图所示:

parseTag 解析开始标签

js 复制代码
  // Start tag.
  // 1. 解析开始标签
  const wasInPre = context.inPre
  const wasInVPre = context.inVPre
  // 获取父级节点栈中的栈顶元素,即当前解析节点的父节点
  const parent = last(ancestors)
  // 调用 parseTag 解析开始标签
  const element = parseTag(context, TagType.Start, parent)

如上面的代码所示,parseTag 函数的第二个参数传入 TagType.Start ,表示作为开始标签进行处理。在解析开始标签时,会同时解析标签上的属性和指令。因此,在 parseTag 解析函数执行完毕后,会消费字符串的中的内容 <div>,处理后的模板内容将变为:

js 复制代码
const template = `+--<p>Text1</p>+--<p>Text2</p>+</div>`

递归地调用 parseChildren 函数解析子节点

js 复制代码
// Children.
// 递归地调用 parseChildren 解析子节点

// 将解析处理的标签节点压入父级节点栈
ancestors.push(element)
// 获取正确的文本模式
const mode = context.options.getTextMode(element, parent)
// 解析子节点
const children = parseChildren(context, mode, ancestors)
// 解析完当前标签节点后,需要弹出父节点栈中的栈顶元素,即与当前解析的同名的标签
ancestors.pop()

经过 parseTag 函数解析完开始标签后,会得到一个标签节点,在调用 parseChildren 解析子节点前,需要根据这个标签节点的类型切换到正确的文本模式 ,然后再递归地调用 parseChildren 解析子节点。在这个过程中,parseChildren 函数会消费字符串的内容:+--<p>Text1</p>+--<p>Text2</p>+。处理后的模板内容将变为:

js 复制代码
const template = `</div>`

parseTag 处理结束标签

经过 parseChildren 函数处理后,模板内容只剩下一个结束标签了,因此,只需要调用 parseTag 解析函数来消费它即可。代码如下所示:

js 复制代码
// End tag.
// 解析结束标签
if (startsWithEndTagOpen(context.source, element.tag)) {
  parseTag(context, TagType.End, parent)
} else {
  // 报错,缺少闭合标签
  emitError(context, ErrorCodes.X_MISSING_END_TAG, 0, element.loc.start)
  if (context.source.length === 0 && element.tag.toLowerCase() === 'script') {
    const first = children[0]
    if (first && startsWith(first.loc.source, '<!--')) {
      emitError(context, ErrorCodes.EOF_IN_SCRIPT_HTML_COMMENT_LIKE_TEXT)
    }
  }
}

可以看到,在处理结束标签时,parseTag 函数的第二个参数传入 TagType.End ,表示作为结束标签进行处理。在解析结束标签时,直接消费模板内容,不会有任何内容返回。如果解析失败,则会报错。

parseElement 函数解析标签节点的过程中,调用了 parseTag 来解析开始标签和结束标签,接下来,我们来看看 parseTag 是如何解析标签的。

parseTag 解析开始&结束标签

parseTag 函数的源码如下所示:

parseTag源码

js 复制代码
// packages/compiler-core/src/parse.ts

/**
 * 解析标签,编译器使用 parseTag 函数来解析标签。该函数接受两个参数:type 表示标签的类 * 型,可以是 StartTag 或 EndTag;options 表示解析* 选项,包括 isNative、isVoid、isCustomElement 等
 */
function parseTag(
  context: ParserContext,
  type: TagType.Start,
  parent: ElementNode | undefined
): ElementNode
function parseTag(
  context: ParserContext,
  type: TagType.End,
  parent: ElementNode | undefined
): void
function parseTag(
  context: ParserContext,
  type: TagType,
  parent: ElementNode | undefined
): ElementNode | undefined {
  __TEST__ && assert(/^</?[a-z]/i.test(context.source))
  __TEST__ &&
    assert(
      type === (startsWith(context.source, '</') ? TagType.End : TagType.Start)
    )

  // 开始标签
  const start = getCursor(context)
  // 匹配开始标签   
  const match = /^</?([a-z][^\t\r\n\f />]*)/i.exec(context.source)!
  // 正则表达式的第一个捕获组就是标签名称   
  const tag = match[1]
  const ns = context.options.getNamespace(tag, parent)

  // 消费正则表达式匹配的全部内容,例如 <div 这段内容    
  advanceBy(context, match[0].length)
  // 消费标签中无用的空白字符
  advanceSpaces(context)

  // 保存当前的状态,以便在需要重新解析属性时可以恢复到当前状态
  // 因为在解析属性时,有些属性可能会使用 v-pre 指令来跳过编译器的解析过程。如果某个属性中包含了 v-pre 指令,则需要将该属性的解析过程跳过,直接将属性值作为字符串插入到生成的代码中。在重新解析属性时,需要恢复到当前状态,并重新解析所有属性
  const cursor = getCursor(context)
  const currentSource = context.source

  // <pre> 标签用于表示预格式化文本,其中的文本会保留所有的空格和换行符。在解析 HTML 文档时,编译器需要对 <pre> 标签进行特殊处理,以保留其中的空格和换行符。
  if (context.options.isPreTag(tag)) {
    context.inPre = true
  }

  // Attributes.
  // 解析属性   
  let props = parseAttributes(context, type)

  // check v-pre
  if (
    type === TagType.Start &&
    !context.inVPre &&
    props.some(p => p.type === NodeTypes.DIRECTIVE && p.name === 'pre')
  ) {
    context.inVPre = true
    // 将当前状态和游标位置合并
    extend(context, cursor)
    context.source = currentSource
    // 重新解析元素的属性,并过滤掉 v-pre 指令本身。这是因为在 v-pre 块中,所有的指令都会被跳过,包括 v-pre 指令本身。
    props = parseAttributes(context, type).filter(p => p.name !== 'v-pre')
  }

  // Tag close.
  // 解析结束标签
  
  // 用于判断是否是自闭合标签   
  let isSelfClosing = false
  if (context.source.length === 0) {
    emitError(context, ErrorCodes.EOF_IN_TAG)
  } else {
    // 在消费匹配的内容后,如果字符串以 /> 开头,则说明这是一个自闭合标签
    isSelfClosing = startsWith(context.source, '/>')
    if (type === TagType.End && isSelfClosing) {
      emitError(context, ErrorCodes.END_TAG_WITH_TRAILING_SOLIDUS)
    }
    // 如果是自闭合标签,则消费 /> ,否则消费 >
    advanceBy(context, isSelfClosing ? 2 : 1)
  }

  // parseTag 函数解析的是结束标签,消费完结束标签后,不返回任何内容
  if (type === TagType.End) {
    return
  }

  // v2.x 版本适配
  if (
    __COMPAT__ &&
    __DEV__ &&
    isCompatEnabled(
      CompilerDeprecationTypes.COMPILER_V_IF_V_FOR_PRECEDENCE,
      context
    )
  ) {
    let hasIf = false
    let hasFor = false
    for (let i = 0; i < props.length; i++) {
      const p = props[i]
      if (p.type === NodeTypes.DIRECTIVE) {
        if (p.name === 'if') {
          hasIf = true
        } else if (p.name === 'for') {
          hasFor = true
        }
      }
      if (hasIf && hasFor) {
        warnDeprecation(
          CompilerDeprecationTypes.COMPILER_V_IF_V_FOR_PRECEDENCE,
          context,
          getSelection(context, start)
        )
        break
      }
    }
  }

  let tagType = ElementTypes.ELEMENT
  if (!context.inVPre) {
    // 标签为 插槽
    if (tag === 'slot') {
      tagType = ElementTypes.SLOT
    } else if (tag === 'template') {
      //标签为template 
      if (
        props.some(
          p =>
            p.type === NodeTypes.DIRECTIVE && isSpecialTemplateDirective(p.name)
        )
      ) {
        tagType = ElementTypes.TEMPLATE
      }
    } else if (isComponent(tag, props, context)) {
      // 标签类型为 组件 
      tagType = ElementTypes.COMPONENT
    }
  }

  // parseTag 函数解析的是开始标签,则返回标签元素
  return {
    type: NodeTypes.ELEMENT,
    ns,
    tag,
    tagType,
    props,
    isSelfClosing,
    children: [],
    loc: getSelection(context, start),
    codegenNode: undefined // to be created during transform phase
  }
}

函数类型定义

我们先来看看 parseTag 函数的两个函数类型定义,如下代码所示:

js 复制代码
// packages/compiler-core/src/parse.ts

function parseTag(
  context: ParserContext,
  type: TagType.Start,
  parent: ElementNode | undefined
): ElementNode
function parseTag(
  context: ParserContext,
  type: TagType.End,
  parent: ElementNode | undefined
): void

可以看到,源码中为 parseTag 函数提供了两个函数类型定义。在 TypeScript 语言中,为同一个函数提供多个函数类型定义来进行函数重载,当函数在调用的时候会进行正确的类型检查。第一个函数类型定义的第二个参数 type 接收的类型是 TagType.Start,返回值类型是ElementNode类型,可以看出这个函数类型定义用于parseTag 函数解析开始标签的情况。第二个函数类型定义的第二个参数 type 接收的类型是 TagType.End,返回值类型是 void,即没有返回值,可以看出这个函数类型定义用于 parseTag 函数解析结束标签的情况。

解析标签开始部分

js 复制代码
// Tag open.
const start = getCursor(context)
// 匹配开始标签   
const match = /^</?([a-z][^\t\r\n\f />]*)/i.exec(context.source)!
// 正则表达式的第一个捕获组就是标签名称   
const tag = match[1]
const ns = context.options.getNamespace(tag, parent)

// 消费正则表达式匹配的全部内容,例如 <div 这段内容    
advanceBy(context, match[0].length)
// 消费标签中无用的空白字符
advanceSpaces(context)

// 保存当前的状态,以便在需要重新解析属性时可以恢复到当前状态
const cursor = getCursor(context)
const currentSource = context.source

// check <pre> tag
if (context.options.isPreTag(tag)) {
  context.inPre = true
}

// Attributes.
// 解析属性   
let props = parseAttributes(context, type)

// check v-pre
if (
  type === TagType.Start &&
  !context.inVPre &&
  props.some(p => p.type === NodeTypes.DIRECTIVE && p.name === 'pre')
) {
  context.inVPre = true
  // 将当前状态和游标位置合并
  extend(context, cursor)
  context.source = currentSource
  // 重新解析元素的属性,并过滤掉 v-pre 指令本身。这是因为在 v-pre 块中,所有的指令都会被跳过,包括 v-pre 指令本身。
  props = parseAttributes(context, type).filter(p => p.name !== 'v-pre')
}

parseTag 在解析开始标签时,首先通过正则表达式来匹配出开始标签,源码中给出的正则表达式用于匹配开始和结束标签,我们将其进行拆分,用于匹配开始标签的正则表达式为:/^<([a-z][^\t\r\n\f />]*)/i,用于匹配结束标签的正则表达式为:/^</([a-z][^\t\r\n\f />]*)/i 。我们来看看用于匹配开始标签的正则表达式的含义。

我们通过几个例子来理解这个正则表达式:

  • 对于字符串 '<div>',会匹配出字符串 '<div',剩余 '>'
  • 对于字符串 '<div />',会匹配出字符串 '<div',剩余 '/>'
  • 对于字符串 '<div---->',其中减号(-) 代表空白符,会匹配出字符串 '<div',剩余 '---->'

在匹配出开始标签后,会调用 advanceBy 函数来消费正则表达式匹配的全部内容,如 '<div' 这段内容。由于标签中可能存在无用的空白符,例如<div---->,因此我们需要调用 advanceSpaces 函数来消费空白字符。消费完正则表达式匹配的全部内容后,还需要解析开始标签上的属性和指令,因此调用 parseAttributes 函数来解析属性和指令。

解析标签结束部分

js 复制代码
// Tag close.
// 解析结束标签

// 用于判断是否是自闭合标签   
let isSelfClosing = false
if (context.source.length === 0) {
  emitError(context, ErrorCodes.EOF_IN_TAG)
} else {
  // 在消费匹配的内容后,如果字符串以 /> 开头,则说明这是一个自闭合标签
  isSelfClosing = startsWith(context.source, '/>')
  if (type === TagType.End && isSelfClosing) {
    emitError(context, ErrorCodes.END_TAG_WITH_TRAILING_SOLIDUS)
  }
  // 如果是自闭合标签,则消费 /> ,否则消费 >
  advanceBy(context, isSelfClosing ? 2 : 1)
}

// parseTag 函数解析的是结束标签,消费完结束标签后,不返回任何内容
if (type === TagType.End) {
  return
}

在消费完由正则匹配的内容后,检查剩余模板内容是否以字符串 /> 开头,如果是,则说明当前解析的是一个自闭合标签,此时将 isSelfClosing 设置为 true。然后判断标签是否自闭合,如果是,则调用 advanceBy 函数消费内容 />,否则只需要消费内容 > 即可。

返回标签元素

js 复制代码
return {
  type: NodeTypes.ELEMENT,
  ns,
  tag,
  tagType,
  props,
  isSelfClosing,
  children: [],
  loc: getSelection(context, start),
  codegenNode: undefined // to be created during transform phase
}

parseTag 函数用于解析开始标签时,则会返回一个标签元素,如上面的代码所示。

parseAttributes 循环解析属性

parseTag 函数在解析开始标签的同时,还会解析开始标签上的属性和指令,解析属性和指令调用的是 parseAttributes 函数。其源码如下:

parseAttributes源码

js 复制代码
// packages/compiler-core/src/parse.ts

function parseAttributes(
  context: ParserContext,
  type: TagType
): (AttributeNode | DirectiveNode)[] {
  // 用来存储解析过程中产生的属性节点和指令节点 
  const props = []
  // 属性名称集合,set数据结构可去重
  const attributeNames = new Set<string>()
  // 开启 while 循环,不断地消费模板内部,直至遇到标签的 "结束部分" 为止   
  while (
    context.source.length > 0 &&
    !startsWith(context.source, '>') &&
    !startsWith(context.source, '/>')
  ) {

    // 如果遇到的字符是 / ,说明已经解析到结束标签的结束的结束部分,此时应该退出属性的解析
    if (startsWith(context.source, '/')) {
      emitError(context, ErrorCodes.UNEXPECTED_SOLIDUS_IN_TAG)
      // 消费 / 字符
      advanceBy(context, 1)
      // 消费空白字符
      advanceSpaces(context)
      continue
    }

    // 解析的是结束标签,结束标签上没有属性或指令,报错
    if (type === TagType.End) {
      emitError(context, ErrorCodes.END_TAG_WITH_ATTRIBUTES)
    }

    // 解析属性节点和指令节点
    const attr = parseAttribute(context, attributeNames)

    // Trim whitespace between class
    // https://github.com/vuejs/core/issues/4251
    // 移除 class 属性的值中的空白符
    if (
      attr.type === NodeTypes.ATTRIBUTE &&
      attr.value &&
      attr.name === 'class'
    ) {
      attr.value.content = attr.value.content.replace(/\s+/g, ' ').trim()
    }

    if (type === TagType.Start) {
      props.push(attr)
    }

    // 非空白字符、非字符 /、 非字符>
    if (/^[^\t\r\n\f />]/.test(context.source)) {
      emitError(context, ErrorCodes.MISSING_WHITESPACE_BETWEEN_ATTRIBUTES)
    }
    // 消费空白字符
    advanceSpaces(context)
  }
  return props
}

如源码中所示,在 parseChildren 函数中开启了一个 while 循环来断地消费模板内部,直至遇到标签的 "结束部分" 为止。实际上,parseAttributes 函数解析模板内容的过程,就是不断地解析属性名称、等于号、属性值的过程。如下图所示:

parseAttributes 函数会从左到右的顺序不断地消费字符串。

parseAttribute 解析属性

属性名称、等于号、属性值的解析过程在 parseAttribute 函数中,我们来看看这个函数。完整源码如下:

js 复制代码
// packages/compiler-core/src/parse.ts

function parseAttribute(
  context: ParserContext,
  nameSet: Set<string>
): AttributeNode | DirectiveNode {
  __TEST__ && assert(/^[^\t\r\n\f />]/.test(context.source))

  // Name.
  const start = getCursor(context)
  // 匹配属性名称 
  const match = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source)!
  // /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec('v-on:click="doThis"')  属性名称为 v-on:click
  // /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(':src="imageSrc"') 属性名称为 :src
  // /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec('@click="doThis"') 属性名称为 @click
  // 得到属性名称   
  const name = match[0]

  if (nameSet.has(name)) {
    emitError(context, ErrorCodes.DUPLICATE_ATTRIBUTE)
  }
  // 将属性名称添加到属性名集合中   
  nameSet.add(name)

  // 如果指令名称的第一个字符是等于号(=),说明指令名称是错误的
  if (name[0] === '=') {
    emitError(context, ErrorCodes.UNEXPECTED_EQUALS_SIGN_BEFORE_ATTRIBUTE_NAME)
  }
  {
    const pattern = /["'<]/g
    let m: RegExpExecArray | null
    while ((m = pattern.exec(name))) {
      emitError(
        context,
        ErrorCodes.UNEXPECTED_CHARACTER_IN_ATTRIBUTE_NAME,
        m.index
      )
    }
  }

  // 消费属性名   
  advanceBy(context, name.length)

  // Value
  // 用于存储属性值
  let value: AttributeValue = undefined


  // 消费属性名称与等于号之间的空白符 
  if (/^[\t\r\n\f ]*=/.test(context.source)) {
    // 消费属性名称与等号之间的空白符
    advanceSpaces(context)
    // 消费等于号
    advanceBy(context, 1)
    // 消费等号与属性值之间的空白符
    advanceSpaces(context)
    // 解析属性值,会判断属性值是否被引号(' 或 ") 引用
    value = parseAttributeValue(context)
    // 属性值不存在,报错
    if (!value) {
      emitError(context, ErrorCodes.MISSING_ATTRIBUTE_VALUE)
    }
  }
  const loc = getSelection(context, start)

  // 处理指令名称
  if (!context.inVPre && /^(v-[A-Za-z0-9-]|:|.|@|#)/.test(name)) {
    // 匹配指令名称(v-xxx)
    const match =
      /(?:^v-([a-z0-9-]+))?(?:(?::|^.|^@|^#)([[^]]+]|[^.]+))?(.+)?$/i.exec(
        name
      )!

    // 解析修饰符,如 v-model.number="age",其中通过.number 的方式为 v-model 添加修饰符
    let isPropShorthand = startsWith(name, '.')
    // 获取指令名称
    let dirName =
      match[1] ||
      // 使用 v-bind 绑定属性的情况,如 <img v-bind:src="imageSrc">,简写:<img :src="imageSrc">
      (isPropShorthand || startsWith(name, ':')
        ? 'bind'   // v-bind 指令
        : startsWith(name, '@')  // 事件绑定,有两种方式: v-on 和 缩写方式,如 v-on:click 简写为:@click
        ? 'on'  // v-on 指令
        : 'slot') // v-slot 指令
    let arg: ExpressionNode | undefined

    if (match[2]) {
      const isSlot = dirName === 'slot'
      const startOffset = name.lastIndexOf(match[2])
      const loc = getSelection(
        context,
        getNewPosition(context, start, startOffset),
        getNewPosition(
          context,
          start,
          startOffset + match[2].length + ((isSlot && match[3]) || '').length
        )
      )

      // 匹配指令的这则 match 的捕获组的第三个元素为属性名称
      let content = match[2]
      let isStatic = true

        // 绑定的是动态属性
        // 例如:
        // <!-- 动态 attribute 名缩写 (2.6.0+) -->
        // <button :[key]="value"></button>
      if (content.startsWith('[')) {
        isStatic = false

        if (!content.endsWith(']')) {
          emitError(
            context,
            ErrorCodes.X_MISSING_DYNAMIC_DIRECTIVE_ARGUMENT_END
          )
          content = content.slice(1)
        } else {
          // 解析出绑定的动态属性名称 
          content = content.slice(1, content.length - 1)
        }
      } else if (isSlot) {
        // #1241 special case for v-slot: vuetify relies extensively on slot
        // names containing dots. v-slot doesn't have any modifiers and Vue 2.x
        // supports such usage so we are keeping it consistent with 2.x.
        // 将当前插槽名称的后缀(即 v-slot 指令的参数)添加到 content 变量中
        content += match[3] || ''
      }

      arg = {
        type: NodeTypes.SIMPLE_EXPRESSION,
        content,
        isStatic,
        constType: isStatic
          ? ConstantTypes.CAN_STRINGIFY
          : ConstantTypes.NOT_CONSTANT,
        loc
      }
    }

    // 属性值被引号引用
    if (value && value.isQuoted) {
      const valueLoc = value.loc
      valueLoc.start.offset++
      valueLoc.start.column++
      valueLoc.end = advancePositionWithClone(valueLoc.start, value.content)
      valueLoc.source = valueLoc.source.slice(1, -1)
    }

    const modifiers = match[3] ? match[3].slice(1).split('.') : []
    if (isPropShorthand) modifiers.push('prop')

    // 
    // 2.x版本的适配:v-bind:foo.sync -> v-model:foo
    if (__COMPAT__ && dirName === 'bind' && arg) {
      if (
        modifiers.includes('sync') &&
        checkCompatEnabled(
          CompilerDeprecationTypes.COMPILER_V_BIND_SYNC,
          context,
          loc,
          arg.loc.source
        )
      ) {
        dirName = 'model'
        modifiers.splice(modifiers.indexOf('sync'), 1)
      }

      if (__DEV__ && modifiers.includes('prop')) {
        checkCompatEnabled(
          CompilerDeprecationTypes.COMPILER_V_BIND_PROP,
          context,
          loc
        )
      }
    }

    // 返回指令名称
    return {
      type: NodeTypes.DIRECTIVE,
      name: dirName,
      exp: value && {
        type: NodeTypes.SIMPLE_EXPRESSION,
        content: value.content,
        isStatic: false,
        // Treat as non-constant by default. This can be potentially set to
        // other values by `transformExpression` to make it eligible for hoisting.
        // 表示节点不是常量节点
        constType: ConstantTypes.NOT_CONSTANT,
        loc: value.loc
      },
      arg,
      modifiers,
      loc
    }
  }

  // 判断指令的名称是否合法
  if (!context.inVPre && startsWith(name, 'v-')) {
    emitError(context, ErrorCodes.X_MISSING_DIRECTIVE_NAME)
  }

  // 返回属性对象   
  return {
    type: NodeTypes.ATTRIBUTE,
    name, // 属性名称
    // 属性值
    value: value && {
      type: NodeTypes.TEXT,
      content: value.content,
      loc: value.loc
    },
    loc
  }
}

解析属性名称

parseAttribute 函数中,首先使用一个正则表达式来匹配属性名称,我们来看看这个正则是如何工作的。如下图的正则表达式:

如上图所示,我们将这个正则表达式分为A、B两个部分来看:

  • 部分 A 用于匹配一个位置,这个位置不能是空白字符,也不能是字符 / 或字符 > ,并且字符串要以该位置开头;
  • 部分 B 则用于匹配 0 个或多个位置,这些位置不能是空白字符,也不能是字符 /、>、= 。注意,这些位置不允许出现等于号(=)字符,这就实现了只匹配等于号之前的内容,即属性名称。

我们通过几个例子来理解这个正则表达式:

  • 对于指令 v-on:click="doThis",匹配出的属性名称为 v-on:click
  • 对于指令 :src="imageSrc",匹配出的属性名称为 :src
  • 对于指令 @click="doThis,匹配出的属性名称为 @click

消费空白符

经过正则表达式解析出属性名称并消费属性名称后,由于属性名称后面可能存在空白字符,因此,还需要消费属性名称后面可能存在空白字符。如下面这段模板中,属性名称和等于号之间存在空白符:

js 复制代码
id  =  "foo" v-show="display"

消费属性名称后面的空白字符的源码如下面所示:

js 复制代码
// 消费属性名称与等于号之间的空白符 
if (/^[\t\r\n\f ]*=/.test(context.source)) {
  // 消费属性名称与等号之间的空白符
  advanceSpaces(context)
  // 消费等于号
  advanceBy(context, 1)
  // 消费等号与属性值之间的空白符
  advanceSpaces(context)
  // 解析属性值,会判断属性值是否被引号(' 或 ") 引用
  value = parseAttributeValue(context)
  // 属性值不存在,报错
  if (!value) {
    emitError(context, ErrorCodes.MISSING_ATTRIBUTE_VALUE)
  }
}

可以看到,源码中通过一个正则匹配出属性名称后面可能存在空白字符,然后调用 advanceSpaces 函数消费属性名称与等号之间的空白符,接着调用 advanceBy 函数消费等于号。由于等于号与属性值之间也可能存空白字符,因此还需要调用 advanceSpaces 函数将空白字符消费掉。消费完这些空白字符后,接下来调用了 parseAttributeValue 函数来解析属性值。

解析指令名称

如果解析处理的属性是指令,则提取出正确的指令名称,如指令 v-bind 提取出的指令名称为 bind,然后将指令名称返回。源码如下面所示:

js 复制代码
// 处理指令名称
if (!context.inVPre && /^(v-[A-Za-z0-9-]|:|.|@|#)/.test(name)) {
  // 匹配指令名称
  const match =
    /(?:^v-([a-z0-9-]+))?(?:(?::|^.|^@|^#)([[^]]+]|[^.]+))?(.+)?$/i.exec(
      name
    )!

  // 解析修饰符,如 v-model.number="age",其中通过.number 的方式为 v-model 添加修饰符
  let isPropShorthand = startsWith(name, '.')
  // 获取指令名称
  let dirName =
    match[1] ||
    // 使用 v-vind 绑定属性的情况,如 <img v-bind:src="imageSrc">,简写:<img :src="imageSrc">
    (isPropShorthand || startsWith(name, ':')
      ? 'bind'   // v-bind 指令
      : startsWith(name, '@')  // 事件绑定,有两种方式: v-on 和 缩写方式,如 v-on:click 简写为:@click
      ? 'on'  // v-on 指令
      : 'slot') // v-slot 指令
  let arg: ExpressionNode | undefined

  if (match[2]) {
    const isSlot = dirName === 'slot'
    const startOffset = name.lastIndexOf(match[2])
    const loc = getSelection(
      context,
      getNewPosition(context, start, startOffset),
      getNewPosition(
        context,
        start,
        startOffset + match[2].length + ((isSlot && match[3]) || '').length
      )
    )

    // 匹配指令的这则 match 的捕获组的第三个元素为属性名称
    let content = match[2]
    let isStatic = true

      // 绑定的是动态属性
      // 例如:
      // <!-- 动态 attribute 名缩写 (2.6.0+) -->
      // <button :[key]="value"></button>
    if (content.startsWith('[')) {
      isStatic = false

      if (!content.endsWith(']')) {
        emitError(
          context,
          ErrorCodes.X_MISSING_DYNAMIC_DIRECTIVE_ARGUMENT_END
        )
        content = content.slice(1)
      } else {
        // 解析出绑定的动态属性名称 
        content = content.slice(1, content.length - 1)
      }
    } else if (isSlot) {
      // 判断当前插槽名称是否包含了 . 字符。如果包含了 . 字符,则说明当前插槽名称是一个包含命名空间的名称,需要将 . 字符保留下来。这是因为在一些 UI 库中,插槽名称会包含 . 字符,例如 Vuetify 库就会使用 . 字符来分隔插槽名称的命名空间。
      content += match[3] || ''
    }

    arg = {
      type: NodeTypes.SIMPLE_EXPRESSION,
      content,
      isStatic,
      constType: isStatic
        ? ConstantTypes.CAN_STRINGIFY
        : ConstantTypes.NOT_CONSTANT,
      loc
    }
  }

  // 属性值被引号引用
  if (value && value.isQuoted) {
    const valueLoc = value.loc
    valueLoc.start.offset++
    valueLoc.start.column++
    valueLoc.end = advancePositionWithClone(valueLoc.start, value.content)
    valueLoc.source = valueLoc.source.slice(1, -1)
  }

  const modifiers = match[3] ? match[3].slice(1).split('.') : []
  if (isPropShorthand) modifiers.push('prop')

  // 2.x版本的适配:v-bind:foo.sync -> v-model:foo
  if (__COMPAT__ && dirName === 'bind' && arg) {
    if (
      modifiers.includes('sync') &&
      checkCompatEnabled(
        CompilerDeprecationTypes.COMPILER_V_BIND_SYNC,
        context,
        loc,
        arg.loc.source
      )
    ) {
      dirName = 'model'
      modifiers.splice(modifiers.indexOf('sync'), 1)
    }

    if (__DEV__ && modifiers.includes('prop')) {
      checkCompatEnabled(
        CompilerDeprecationTypes.COMPILER_V_BIND_PROP,
        context,
        loc
      )
    }
  }

  // 返回指令名称
  return {
    type: NodeTypes.DIRECTIVE,
    name: dirName,
    exp: value && {
      type: NodeTypes.SIMPLE_EXPRESSION,
      content: value.content,
      isStatic: false,
      // Treat as non-constant by default. This can be potentially set to
      // other values by `transformExpression` to make it eligible for hoisting.
      constType: ConstantTypes.NOT_CONSTANT,
      loc: value.loc
    },
    arg,
    modifiers,
    loc
  }
}

返回属性对象

如果解析出来的属性不是指令,那么返回该属性和属性值。源码如下面所示:

js 复制代码
// 返回属性对象   
return {
  type: NodeTypes.ATTRIBUTE,
  name, // 属性名称
  // 属性值
  value: value && {
    type: NodeTypes.TEXT,
    content: value.content,
    loc: value.loc
  },
  loc
}

parseAttributeValue 解析属性值

在上面我们介绍到,在消费完属性名称与等于号之间的空白符、等于号以及等于号与属性值之间的空白字符后,会调用 parseAttributeValue 函数来解析属性值。接下来,我们来看看 parseAttributeValue 函数,其源码如下面所示:

js 复制代码
function parseAttributeValue(context: ParserContext): AttributeValue {
  const start = getCursor(context)
  let content: string

  // 获取当前模板内容的第一个字符
  const quote = context.source[0]
  // 判断属性值是否被引号引用
  const isQuoted = quote === `"` || quote === `'`
  if (isQuoted) {
    // Quoted value.
    // 属性值被引号引用,消费引号
    advanceBy(context, 1)
    // 获取下一个引号的索引
    const endIndex = context.source.indexOf(quote)
    // 没有下一个引号,模板内容作为文本解析
    if (endIndex === -1) {
      content = parseTextData(
        context,
        context.source.length,
        TextModes.ATTRIBUTE_VALUE
      )
    } else {
      // 解析属性值
      content = parseTextData(context, endIndex, TextModes.ATTRIBUTE_VALUE)
      // 消费引号
      advanceBy(context, 1)
    }
  } else {

    // 代码运行到这里,说明属性值没有被引号引用 
    // Unquoted

    // 下一个空白符之前的内容全部作为属性值
    const match = /^[^\t\r\n\f >]+/.exec(context.source)
    if (!match) {
      return undefined
    }
    const unexpectedChars = /["'<=`]/g
    let m: RegExpExecArray | null
    while ((m = unexpectedChars.exec(match[0]))) {
      emitError(
        context,
        ErrorCodes.UNEXPECTED_CHARACTER_IN_UNQUOTED_ATTRIBUTE_VALUE,
        m.index
      )
    }
    // 解析属性值
    // 通过 match[0] 获取属性值
    content = parseTextData(context, match[0].length, TextModes.ATTRIBUTE_VALUE)
  }

  // 返回解析后属性值
  return { content, isQuoted, loc: getSelection(context, start) }
}

对于属性值的解析分为两部分,第一部分是被引号包裹的属性值的解析,第二部分是没有被引号包裹的属性值的解析。

解析被引号包裹的属性值

js 复制代码
if (isQuoted) {
  // Quoted value.
  // 属性值被引号引用,消费引号
  advanceBy(context, 1)
  // 获取下一个引号的索引
  const endIndex = context.source.indexOf(quote)
  // 没有下一个引号,模板内容作为文本解析
  if (endIndex === -1) {
    content = parseTextData(
      context,
      context.source.length,
      TextModes.ATTRIBUTE_VALUE
    )
  } else {
    // 解析属性值
    content = parseTextData(context, endIndex, TextModes.ATTRIBUTE_VALUE)
    // 消费引号
    advanceBy(context, 1)
  }
}

被引号包裹的属性值有两种情况,一种是被双引号包裹 ,另一种是被单引号包裹 。如果属性值被引号包裹,消费掉第一个引号后,下一个引号之前的内容将被解析为属性值 ,此时调用 parseTextData 函数来解析属性值。

解析没有被引号包裹的属性值

js 复制代码
else {

  // 代码运行到这里,说明属性值没有被引号引用 
  // Unquoted

  // 下一个空白符之前的内容全部作为属性值
  const match = /^[^\t\r\n\f >]+/.exec(context.source)
  if (!match) {
    return undefined
  }
  const unexpectedChars = /["'<=`]/g
  let m: RegExpExecArray | null
  while ((m = unexpectedChars.exec(match[0]))) {
    emitError(
      context,
      ErrorCodes.UNEXPECTED_CHARACTER_IN_UNQUOTED_ATTRIBUTE_VALUE,
      m.index
    )
  }
  // 解析属性值
  // 通过 match[0] 获取属性值
  content = parseTextData(context, match[0].length, TextModes.ATTRIBUTE_VALUE)
}

如果属性值没有被引号包裹,则通过一个正则来提取属性值。用于匹配属性值的正则表达式如下图所示:

该正则表达式从字符串的开始位置进行匹配,并且会匹配一个或多个非空白字符、非字符 > 。换句话说,该正则表达式会一直对字符串进行匹配,直到遇到空白字符或字符 > 为止,这就实现了属性值的提取。提取出属性值后,同样调用 parseTextData 函数来解析属性值。

parseTextData 解析文本内容

parseTextData源码

js 复制代码
// packages/compiler-core/src/parse.ts

/**
 * 在解析文本时,编译器需要将其中的 HTML 实体转换为对应的字符,以便在生成的代码中正确地表示文本内容。
 */
function parseTextData(
  context: ParserContext,
  length: number,
  mode: TextModes
): string {
  // 获取文本内容
  const rawText = context.source.slice(0, length)
  // 消费文本内容
  advanceBy(context, length)
  // 如果文本模式为 RAWTEXT 或 CDATA 或文本内容中不包含 &,则直接返回属性值
  if (
    mode === TextModes.RAWTEXT ||
    mode === TextModes.CDATA ||
    !rawText.includes('&')
  ) {
    return rawText
  } else {
    // DATA or RCDATA containing "&"". Entity decoding required.
    // 对文本内容中的HTML实体进行解码
    return context.options.decodeEntities(
      rawText,
      mode === TextModes.ATTRIBUTE_VALUE
    )
  }
}

可以看到,parseTextData 函数的实现十分简单。首先获取需要解析的文本内容,如果当前的文本模式为 RAWTEXT 或 CDATA 或文本内容中不包含 &,则文本内容不需要做额外处理,将其直接返回。如果当前文本模式为 DATARCDATA ,并且文本内容包含 & 字符,则需要对文本内容中的HTML实体 进行解码。如上面所示的代码中,调用了转换上下文选项中的 decodeEntities 方法来解码HTML实体

decodeHtml 解析HTML实体

调用转换上下文选项中的 decodeEntities 方法,实际上调用的是 decodeHtml 方法,如下代码所示:

js 复制代码
export const parserOptions: ParserOptions = {
  // 省略部分代码
  decodeEntities: __BROWSER__ ? decodeHtmlBrowser : decodeHtml,
  // 省略部分代码
}

在进入 decodeHtml 源码的解读之前,我们先来了解一下什么是HTML实体

HTML 实体

HTML 实体是一段以字符 & 开始的文本内容。实体用来描述 HTML 中的保留字符和一些难以通过普通键盘输入的字符,以及一些不可见的字符。例如,在 HTML 中,字符 < 具有特殊含义,如果希望以普通文本的方式来显示字符 > ,需要通过实体来表达,如下代码所示:

html 复制代码
<div>A&lt;B</div>

其中字符串 &lt;就是一个 HTML 实体,用来表示字符 < 。

HTML 实体总是以字符 & 开头,以字符 ; 结尾。WHATWC 规范中明确规定,如果不为实体加分号,将会产生解析错误。但考虑到历史原因 (互联网上存在大量省略分号的情况),现代浏览器都能够解析早期规范中定义的那些可以省略分号的 HTML 实体。

HTML 实体有两类,一类叫作 命名字符引用 ,也叫命名实体,这类实体具有特定的名称,例如 &lt; 。还有一类字符引用没有特定的名称,只能用数字表示,这类实体叫做数字字符引用

数字字符引用以字符串 &# 开头,例如&#60;。实际上,&#60; 对应的也是 < 。数字字符引用既可以用十进制来表示,也可以用十六进制来表示。当使用十六进制表示实体时,需要以字符串 &#x 开头。

decodeHtml 源码

了解完了HTML实体 ,我们来看看 decodeHtml 的源码实现。代码如下所示:

js 复制代码
// packages/compiler-dom/src/decodeHtml.ts

export const decodeHtml: ParserOptions['decodeEntities'] = (
  rawText, // 要被解码的文本内容
  asAttr // 布尔值,代表文本内容是否作为属性值
) => {
  let offset = 0
  const end = rawText.length
  // 经过解码后的的文本内容将作为返回值被返回   
  let decodedText = ''

  //advance 函数用于消费指定长度的文本
  function advance(length: number) {
    offset += length
    rawText = rawText.slice(length)
  }

  // 消费字符串,直到处理完毕为止
  while (offset < end) {
    // 用于匹配字符引用的开始部分,如果匹配成功,那么 head[0] 的值将有三种可能:
    // 1. head[0] === '&',这说明该字符引用是命名字符引用
    // 2. head[0] === '&#',这说明该字符引用是用十进制表示的数字字符引用
    // 3. head[0] === '&#x',这说明该字符串应用是用十六进制表示的数字字符引用
    const head = /&(?:#x?)?/i.exec(rawText)
    // 如果没有匹配,说明已经没有需要解码的内容了
    if (!head || offset + head.index >= end) {
      // 计算剩余内容的长度
      const remaining = end - offset
      // 将剩余内容加到 decodedText 上
      decodedText += rawText.slice(0, remaining)
      // 消费剩余内容
      advance(remaining)
      break
    }

    // Advance to the "&".
    // head.index 为匹配的字符 & 在 rawText 中的位置索引
    decodedText += rawText.slice(0, head.index)
    // 消费字符 & 之前的内容 
    advance(head.index)

    // 如果满足条件,则说明是命名字符引用,否则为数字字符引用
    if (head[0] === '&') {
      // Named character reference.
      // 命名字符引用


      let name = ''
      let value: string | undefined = undefined
      // 数字 & 的下一个字符必须是 ASCII 字母或数字,这样才是合法的命名字符引用
      if (/[0-9a-z]/i.test(rawText[1])) {
        // 根据引用表计算实体名称的最大长度
        if (!maxCRNameLength) {
          maxCRNameLength = Object.keys(namedCharacterReferences).reduce(
            (max, name) => Math.max(max, name.length),
            0
          )
        }
        // 从最大长度开始对文本内容进行截取,并试图去引用表中找到对应的项
        for (let length = maxCRNameLength; !value && length > 0; --length) {
          // 截取字符 & 到最大长度之间的字符作为实体名称
          name = rawText.slice(1, 1 + length)
          // 使用实体名称去索引表中查找对应项的值
          value = (namedCharacterReferences as Record<string, string>)[name]
        }
        // 如果找到了对应项的值,说明解码成功
        if (value) {
          // 检查实体名称的最后一个匹配字符是否是分号
          const semi = name.endsWith(';')
          // 如果解码的文本作为属性值,最后一个匹配的字符不是分号,
          // 并且最后一个匹配字符的下一个字符是等于号 (=)、ASCII 字母 或 数字,
          // 由于历史原因,将字符 & 和 实体名称 name 作为普通文本
          if (
            asAttr &&
            !semi &&
            /[=a-z0-9]/i.test(rawText[name.length + 1] || '') // 
          ) {
            decodedText += '&' + name
            advance(1 + name.length)
          } else {
            // 其它情况下,正常使用解码后的内容拼接到 decodedText 上
            decodedText += value
            advance(1 + name.length)
          }
        } else {
          // 如果没有找到对应的值,说明解码失败
          decodedText += '&' + name
          advance(1 + name.length)
        }
      } else {
        // 如果字符 & 的下一个字符不是 ASCII 字母或数字,则将字符 & 作为普通文本
        decodedText += '&'
        advance(1)
      }
    } else {
      // Numeric character reference.
      // 数组字符引用

      // 判断是以十进制表示还是以十六进制表示
      const hex = head[0] === '&#x'
      // 根据不同进制表示法,选用不同正则
      const pattern = hex ? /^&#x([0-9a-f]+);?/i : /^&#([0-9]+);?/
      // 最终,body[1] 的值就是 Unicode 码点
      const body = pattern.exec(rawText)
      if (!body) {
        // 如果没有匹配,则不进行解码操作,只把 head[0] 追加到 decodedText 上并消费
        decodedText += head[0]
        advance(head[0].length)
      } else {
        // https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state
        // 根据对应的进制,将码点字符串转换为数字
        let cp = Number.parseInt(body[1], hex ? 16 : 10)
        // 检查码点的合法性
        if (cp === 0) {
          // 如果码点值为 0x00,替换为 0xfffd
          cp = 0xfffd
        } else if (cp > 0x10ffff) {
          // 如果码点值超过 Unicode 的最大值,替换为 0xfffd
          cp = 0xfffd
        } else if (cp >= 0xd800 && cp <= 0xdfff) {
          // 如果码点值处于 surrogate  pair 范围内,替换为 0xfffd
          cp = 0xfffd
        } else if ((cp >= 0xfdd0 && cp <= 0xfdef) || (cp & 0xfffe) === 0xfffe) {
          // 如果码点值处于 noncharacter 范围内,则什么都不做,交给平台处理
          // noop
        } else if (
          // 控制字符集的范围是: [0x01, 0x1f] 加上 [0x7f, 0x9f]
          // 去掉 ASCII 空白符: 0x09(TAB)、0x0A(LF)、0x0C(FF)
          // 0x0D(CR) 虽然也是 ASCII 空白符,但需要包含
          (cp >= 0x01 && cp <= 0x08) ||
          cp === 0x0b ||
          (cp >= 0x0d && cp <= 0x1f) ||
          (cp >= 0x7f && cp <= 0x9f)
        ) {
          // 在 CCR_REPLACEMENTS 表中查找替换码点,如果找不到,则使用原码点
          cp = CCR_REPLACEMENTS[cp] || cp
        }
        // 解码后追加到 decodedText 上
        decodedText += String.fromCodePoint(cp)
        // 消费整个数字字符引用的内容
        advance(body[0].length)
      }
    }
  }
  return decodedText
}

可以看到,解码HTML实体 的过程,就是不断消费字符串的过程。由于HTML实体 分为命名字符引用和数字字符引用,因此 decodeHtml 函数分别对这两种实体的解码做了不同的处理。

解码命名字符引用

js 复制代码
// 如果满足条件,则说明是命名字符引用,否则为数字字符引用
if (head[0] === '&') {
  // Named character reference.
  // 命名字符引用
  
  
  let name = ''
  let value: string | undefined = undefined
  // 数字 & 的下一个字符必须是 ASCII 字母或数字,这样才是合法的命名字符引用
  if (/[0-9a-z]/i.test(rawText[1])) {
    // 根据引用表计算实体名称的最大长度
    if (!maxCRNameLength) {
      maxCRNameLength = Object.keys(namedCharacterReferences).reduce(
        (max, name) => Math.max(max, name.length),
        0
      )
    }
    // 从最大长度开始对文本内容进行截取,并试图去引用表中找到对应的项
    for (let length = maxCRNameLength; !value && length > 0; --length) {
      // 截取字符 & 到最大长度之间的字符作为实体名称
      name = rawText.slice(1, 1 + length)
      // 使用实体名称去索引表中查找对应项的值
      value = (namedCharacterReferences as Record<string, string>)[name]
    }
    // 如果找到了对应项的值,说明解码成功
    if (value) {
      // 检查实体名称的最后一个匹配字符是否是分号
      const semi = name.endsWith(';')
      // 如果解码的文本作为属性值,最后一个匹配的字符不是分号,
      // 并且最后一个匹配字符的下一个字符是等于号 (=)、ASCII 字母 或 数字,
      // 由于历史原因,将字符 & 和 实体名称 name 作为普通文本
      if (
        asAttr &&
        !semi &&
        /[=a-z0-9]/i.test(rawText[name.length + 1] || '') // 
      ) {
        decodedText += '&' + name
        advance(1 + name.length)
      } else {
        // 其它情况下,正常使用解码后的内容拼接到 decodedText 上
        decodedText += value
        advance(1 + name.length)
      }
    } else {
      // 如果没有找到对应的值,说明解码失败
      decodedText += '&' + name
      advance(1 + name.length)
    }
  } else {
    // 如果字符 & 的下一个字符不是 ASCII 字母或数字,则将字符 & 作为普通文本
    decodedText += '&'
    advance(1)
  }
}

在解码命名字符引用时,解析出来的实体名称后,如果最后一个匹配的字符不是分号,并且最后一个匹配字符的下一个字符是等于号 (=)、ASCII 字母 或 数字,由于历史原因,将字符 & 和 实体名称 name 作为普通文本。例如如下的HTML文本:

html 复制代码
<a href="foo.com?a=1&lt=2">foo.com?a=1&lt=2</a>

否则对解析出来的实体名称进行解码,即从实体名称去命名字符索引表中查找对应项的值,然后将解码后的内容拼接到 decodedText 上。在解码时对于字符引用中非分号,处理规则如下:

  • 当存在分号时:执行完整匹配
  • 当省略分号时,执行最短匹配

为了实现上面的处理规则,Vue.js 3 中精心设计了命名字符索引表。如下面的代码所示 (只取了其中一部分的内容):

js 复制代码
const namedCharacterReferences = {
  "gt": ">",
  "gt;": ">",
  "lt": "<",
  "lt;": "<",
  "ltcc;": "⪦",
  "amp": "&",
}

如上面的 namedCharacterReferences 对象所示,相同字符对应的实体会有多个,即带分号的版本和不带分号的版本,例如 "gt""gt;"。另外一些实体则只有带分号的版本,因为这些实体不允许省略分号。

解码数字字符引用

数字字符引用的格式是:前缀 + Unicode 码点。由于数字字符引用的前缀可以是以十进制表示 (&#) ,也可以是以十六进制表示 (&#x) ,因此源码使用下面的代码来完成码点的提取:

js 复制代码
// 判断是以十进制表示还是以十六进制表示
const hex = head[0] === '&#x'
// 根据不同进制表示法,选用不同正则
const pattern = hex ? /^&#x([0-9a-f]+);?/i : /^&#([0-9]+);?/
// 最终,body[1] 的值就是 Unicode 码点
const body = pattern.exec(rawText)

提取出码点 后,需要对码点的值进行合法性检查,如下代码所示:

js 复制代码
// 根据对应的进制,将码点字符串转换为数字
let cp = Number.parseInt(body[1], hex ? 16 : 10)
// 检查码点的合法性
if (cp === 0) {
  // 如果码点值为 0x00,替换为 0xfffd
  cp = 0xfffd
} else if (cp > 0x10ffff) {
  // 如果码点值超过 Unicode 的最大值,替换为 0xfffd
  cp = 0xfffd
} else if (cp >= 0xd800 && cp <= 0xdfff) {
  // 如果码点值处于 surrogate  pair 范围内,替换为 0xfffd
  cp = 0xfffd
} else if ((cp >= 0xfdd0 && cp <= 0xfdef) || (cp & 0xfffe) === 0xfffe) {
  // 如果码点值处于 noncharacter 范围内,则什么都不做,交给平台处理
  // noop
} else if (
  // 控制字符集的范围是: [0x01, 0x1f] 加上 [0x7f, 0x9f]
  // 去掉 ASCII 空白符: 0x09(TAB)、0x0A(LF)、0x0C(FF)
  // 0x0D(CR) 虽然也是 ASCII 空白符,但需要包含
  (cp >= 0x01 && cp <= 0x08) ||
  cp === 0x0b ||
  (cp >= 0x0d && cp <= 0x1f) ||
  (cp >= 0x7f && cp <= 0x9f)
) {
  // 在 CCR_REPLACEMENTS 表中查找替换码点,如果找不到,则使用原码点
  cp = CCR_REPLACEMENTS[cp] || cp
}

最后,调用 String.fromCodePoint函数将码点 解码为对应的字符,然后将解码后的字符添加到 decodedText,完成数字字符引用的解码。如下代码所示:

js 复制代码
// 解码后追加到 decodedText 上
decodedText += String.fromCodePoint(cp)
// 消费整个数字字符引用的内容
advance(body[0].length)

parseText 解析文本节点

我们知道,解析器的本质是一个状态机。当状态机处于 "状态 1" 时,如果读取模板的第一个字符既不是字符 < ,也不是插值定界符 {{ ,因此状态机会进入 "状态 6" ,即调用 parseText 函数来处理文本内容。此时解析器会在模板中寻找下一个 < 字符或插值定界符 {{ 的位置索引,即为索引 I 。然后,解析器会从模板的头部到索引 I 的位置截取内容,这段截取出来的字符串将作为文本节点的内容。解析来,我们来看看 parseText 函数。

parseText源码

js 复制代码
// packages/compiler-core/src/parse.ts

function parseText(context: ParserContext, mode: TextModes): TextNode {
  __TEST__ && assert(context.source.length > 0)

  // 文本模式为 CDATA,下一个定界符是 ]]>
  // 文本模式为 DATA、RAWTEXT 等模式时,下一个字符是 < 或者是插值定界符 {{
  const endTokens =
    mode === TextModes.CDATA ? [']]>'] : ['<', context.options.delimiters[0]]

  // endIndex 为文本内容的结尾索引,默认将整个模板剩余内容都作为文本内容
  let endIndex = context.source.length
  for (let i = 0; i < endTokens.length; i++) {
    // 寻找字符 < 、]]> 与定界符{{ 的索引位置
    const index = context.source.indexOf(endTokens[i], 1)
    // 取 index 和当前 endIndex 中较小的一个作为新的结尾索引
    if (index !== -1 && endIndex > index) {
      endIndex = index
    }
  }

  __TEST__ && assert(endIndex > 0)

  const start = getCursor(context)
  // 从模板的头部到索引 endIndex 的位置截取内容,然后解析这段截取出来的内容
  const content = parseTextData(context, endIndex, mode)

  // 返回解析后的文本节点
  return {
    type: NodeTypes.TEXT,
    content,
    loc: getSelection(context, start)
  }
}

如上面的代码所示。当文本模式为 CDATA 时,结束符为 ]]> 。当文本模式为非 CDATA 模式时,下一个字符是 < 或者插值定界符是 {{ 。由于结束符 ]]>、 字符 < 与定界符 {{ 的出现顺序是未知的,所以我们需要取三者中较小的一个作为文本截取的终点。有了截取终点后,解析器会从模板的头部到索引 endIndex 的位置截取内容,然后调用 parseTextData 函数来解析这段截取出来的内容。parseText 函数在上文已经详细介绍过,请查看 parseTextData 解析文本内容 小节。

parseInterpolation 解析插值节点

默认情况下,插值以字符串 {{ 开头,并以字符串 }} 结尾。通常会将这两个特殊的字符串称为定界符。定界符中间的内容可以是任意合法的 JavaScript 表达式。

解析器在遇到文本插值的起始定界符 {{ 时,会进入 "插值状态6" ,并调用 parseInterpolation 函数来解析插值内容,如下图所示:

解析器在解析插值时,只需要将文本插值的开始定界符与结束定界符之间的内容提取出来,作为 JavaScript 表达式即可。parseInterpolation 函数源码如下:

parseInterpolation源码

js 复制代码
// packages/compiler-core/src/parse.ts

function parseInterpolation(
  context: ParserContext,
  mode: TextModes
): InterpolationNode | undefined {
  // 插值节点的定界符 {{  和  }}
  // 开始定界符 {{
  // 结束定界符 }}
  const [open, close] = context.options.delimiters
  __TEST__ && assert(startsWith(context.source, open))

  // 找到结束定界符的位置索引
  const closeIndex = context.source.indexOf(close, open.length)

  // 没有找到结束定界符,说明 插值缺少结束定界符,报错
  if (closeIndex === -1) {
    emitError(context, ErrorCodes.X_MISSING_INTERPOLATION_END)
    return undefined
  }

  const start = getCursor(context)
  // 消费开始定界符
  advanceBy(context, open.length)
  const innerStart = getCursor(context)
  const innerEnd = getCursor(context)
  // 插值表达式内容的长度   
  const rawContentLength = closeIndex - open.length

  // 截取开始定界符与结束定界符之间的内容作为插值表达式
  const rawContent = context.source.slice(0, rawContentLength)

  // 调用 parseTextData 对插值表达式进行解析
  const preTrimContent = parseTextData(context, rawContentLength, mode)

  // 去除表达式内容两端的空格
  const content = preTrimContent.trim()
  const startOffset = preTrimContent.indexOf(content)
  if (startOffset > 0) {
    advancePositionWithMutation(innerStart, rawContent, startOffset)
  }
  const endOffset =
    rawContentLength - (preTrimContent.length - content.length - startOffset)
  // 
  advancePositionWithMutation(innerEnd, rawContent, endOffset)
  // 消费表达式的内容
  advanceBy(context, close.length)

  // 返回类型为 Interpolation 的节点,代表插值节点
  return {
    type: NodeTypes.INTERPOLATION,
    // 插值节点的content是一个类型为 Expression 的表达式节点
    content: {
      type: NodeTypes.SIMPLE_EXPRESSION,
      isStatic: false,
      // Set `isConstant` to false by default and will decide in transformExpression
      constType: ConstantTypes.NOT_CONSTANT,
      content,
      loc: getSelection(context, innerStart, innerEnd)
    },
    loc: getSelection(context, start)
  }
}

可以看到,将插值表达式的内容提取出来后,调用 parseTextData 函数对插值表达式里可能存在的HTML实体进行解码。最后返回一个类型为 Interpolation 的节点,代表插值节点,其中插值节点的content是一个类型为 Expression 的表达式节点。

parseComment 解析注释节点

注释以字符串 <!-- 开头,并以字符串 --> 结尾。我们同样将这两个特殊的字符串称为定界符定界符中间的内容就是注释内容。

解析器在遇到注释的起始定界符 <!-- 时,会进入 "注释状态4" ,并调用 parseComment 函数来解析注释,如下图所示:

解析器在解析注释时,只需要将注释的开始定界符与结束定界符之间的内容提取出来,作为 注释内容即可。parseComment 函数源码如下:

js 复制代码
// packages/compiler-core/src/parse.ts

function parseComment(context: ParserContext): CommentNode {
  __TEST__ && assert(startsWith(context.source, '<!--'))

  const start = getCursor(context)
  let content: string

  // Regular comment.
  // 注释结束部分的匹配正则
  const match = /--(!)?>/.exec(context.source)
  if (!match) {
    // 默认截取模板内容中 <!-- 字符后面的所有内容作为注释内容
    content = context.source.slice(4)
    advanceBy(context, context.source.length)
    emitError(context, ErrorCodes.EOF_IN_COMMENT)
  } else {
    if (match.index <= 3) {
      emitError(context, ErrorCodes.ABRUPT_CLOSING_OF_EMPTY_COMMENT)
    }
    if (match[1]) {
      emitError(context, ErrorCodes.INCORRECTLY_CLOSED_COMMENT)
    }

    // 获取注释的内容
    content = context.source.slice(4, match.index)

    // Advancing with reporting nested comments.
    const s = context.source.slice(0, match.index)
    let prevIndex = 1,
      nestedIndex = 0
    while ((nestedIndex = s.indexOf('<!--', prevIndex)) !== -1) {
      advanceBy(context, nestedIndex - prevIndex + 1)
      if (nestedIndex + 4 < s.length) {
        emitError(context, ErrorCodes.NESTED_COMMENT)
      }
      prevIndex = nestedIndex + 1
    }
    advanceBy(context, match.index + match[0].length - prevIndex + 1)
  }

  // 返回类型为 Comment 的节点 
  return {
    type: NodeTypes.COMMENT,
    content,
    loc: getSelection(context, start)
  }
}

可以看到,将注释内容提取出来后,直接将其作为 Comment 类型的节点。

总结

本文详细介绍了解析器在解析模板过程中对标签节点注释节点插值节点文本节点等节点的解析过程。

在调用 parseElement 函数解析标签节点 时,会调用 parseTag 函数来解析开始标签和结束标签 ,对于开始标签和结束标签之间的内容 ,则递归调用 parseChildren 函数来解析子节点

在调用 parseTag 解析标签时,除了将标签名称解析出来,还会调用 parseAttributes 函数来不断地解析标签上的属性、指令等。

在调用 parseInterpolation 函数解析插值时,只需要将文本插值的开始定界符结束定界符 之间的内容提取出来,作为 JavaScript 表达式即可。在调用 parseComment 函数解析注释时,也是相似的思路。

所以说,无论是解析文本节点插值节点 还是解析标签的属性值 ,最终都会调用 decodeHtml 函数来解码文本内容中可能存在的HTML实体

相关推荐
吕彬-前端16 分钟前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱19 分钟前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
guai_guai_guai28 分钟前
uniapp
前端·javascript·vue.js·uni-app
bysking1 小时前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
王哲晓2 小时前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js
fg_4112 小时前
无网络安装ionic和运行
前端·npm
理想不理想v2 小时前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试
酷酷的阿云2 小时前
不用ECharts!从0到1徒手撸一个Vue3柱状图
前端·javascript·vue.js
微信:137971205872 小时前
web端手机录音
前端
齐 飞2 小时前
MongoDB笔记01-概念与安装
前端·数据库·笔记·后端·mongodb