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

2,707 阅读6分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情 >>

版本:3.2.31

模板AST 解析器 parser 在编译器的编译过程中负责将 模板字符串解析为模板AST,如下图所示:

模板字符串解析是编译器的第一步,如下面的源码所示:

// 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
    })
  )
}

下面,我们从模板解析器的入口函数 baseParse 入手,来探究解析器的工作方式。在解读解析器的源码之前,我们先来简单了解下状态机这个概念。

解析器的实现原理与状态机

状态机是有限状态自动机的简称,是现实事物运行规则抽象而成的一个数学模型。所谓 “有限状态”,就是指有限个状态,而 “自动机” 则意味着伴随着字符的输入,解析器会自动地在不同状态间迁移。而解析器的本质就是状态机,它会逐个读取字符串,在不同状态之间迁移。

文本模式及其对解析器的影响

文本模式指的是 解析器 在工作时所进入的一些特殊状态,在不同的特殊状态下,解析器对文本的解析行为会有所不同。具体来说,当解析器遇到一些特殊标签时,会切换模式,从而影响其对文本的解析行文。这些特殊的标签是:

  • 标签、<textarea> 标签,当解析器遇到这两个标签时,会切换到 RCDATA 模式;
  • 、<xmp>、<iframe>、<noembed>、<noframes>、<noscripts> 等标签,当解析器遇到这些标签时,会切换到 RAWTEXT 模式;
  • 当解析器遇到 <![CDATA[ 字符串时,会进入 CDATA 模式

解析器的初始模式是 DATA 模式。对于 Vue.js的模板 DSL 来说,模板中不允许出现

《Vue.js 设计与实现》一书对于解析器在不同模式下对文本的解析行为作了详细的介绍,在 p409~p412。

baseParse 函数

baseParse 函数是解析器的入口函数,它会将模板字符串解析为模板AST并将其返回。我们来看看 baseParse 函数做了什么事情。

// packages/compiler-core/src/parse.ts

export function baseParse(
  content: string, // 模板内容
  options: ParserOptions = {} // 接下选项
): RootNode {
  // 创建解析器上下文对象
  const context = createParserContext(content, options)
  // 获取解析过程的 column/line/offset 等游标信息
  const start = getCursor(context)
  // 创建 模板AST 的根节点
  return createRoot(
    // 解析子节点,作为 root 根节点的 children 属性
    parseChildren(context, TextModes.DATA, []),
    // 获取模板解析的内容区域,类似于用户选择的文本的区域
    getSelection(context, start)
  )
}

在 baseParse 函数中:

首先调用了 createParserContext 函数来创建解析器上下文,用来维护模板解析过程中程序的各种状态。

接着根据上下文获取解析过程的游标信息,由于还未进行解析,所以游标中的 column、line、offset 属性对应的是 template 的起始值。即 column 的初始值为1,line 的初始值为1,offset 的初始值为 0。

最后是调用 createRoot 函数创建模板AST 的根节点并返回根节点,至此模板AST 生成,模板字符串解析完成。

创建模板AST根节点

// packages/compiler-core/src/ast.ts

export function createRoot(
  children: TemplateChildNode[],
  loc = locStub
): RootNode {
  return {
    type: NodeTypes.ROOT,
    children,
    helpers: [],
    components: [],
    directives: [],
    hoists: [],
    imports: [],
    cached: 0,
    temps: 0,
    codegenNode: undefined,
    loc
  }
}

由上面的源码可以看到,createRoot 函数返回了一个 ROOT 类型的根节点对象,并将经过 parseChildren 解析后得到的子节点作为根节点对象的 children 属性,从而构建出一棵 模板AST 抽象语法树。

parseChildren 的状态迁移过程

parseChildren 函数本质上是一个状态机,该状态机有多少种状态取决于子节点的类型数量。在模板中,元素的子节点可以是以下几种:

  • 标签节点,例如

  • 文本插值节点,例如 {{ val }}

  • 普通文本节点, 例如:text

  • 注释节点,例如 <!---->

  • CDATA 节点,例如 <![CDATA[ xxx ]]>

下面,我们通过一个图来理解 parseChildren 函数在解析模板过程中的状态迁移过程:

我们把上图所展示的状态迁移过程总结如下:

  • 当遇到字符 < 时,进入临时状态:

  • 如果下一个字符匹配正则 /a-z/i,则认为这是一个标签节点,于是调用 parseElement 函数完成标签的解析。

  • 如果字符串以 <!-- 开头,则认为这是一个注释节点,于是调用 parseComment 函数完成注释节点的解析。

  • 如果字符串以 <![DATA[ 开头,则认为这是一个CDATA 节点,于是调用 parseCDATA 函数完成 CDATA 节点的解析。

  • 如果字符串以 {{ 开头,则认为这是一个插值节点,于是调用 parseInterpolation 函数完成插值节点的解析。

  • 其它情况,都作为普通文本,调用 parseText 函数完成文本节点的解析。

理解了parseChildren 函数的状态迁移过程,我们开始深入分析parseChildren是如何解析子节点的。

parseChildren 解析子节点

为了便于理解 parseChildren 函数的主要做的事情,我们对函数代码进行精简,只保留主要逻辑,如下代码所示:

// packages/compiler-core/src/parse.ts

function parseChildren(
  context: ParserContext,
  mode: TextModes,
  ancestors: ElementNode[]
): TemplateChildNode[] {
  // 获取当前节点的父节点 
  const parent = last(ancestors)
  const ns = parent ? parent.ns : Namespaces.HTML
  // 存储解析后的节点   
  const nodes: TemplateChildNode[] = []

  // parseChildren 本质上是一个状态机,因此这里开启一个 while 循环使得状态机自动运行
  // 当标签未闭合时,解析对应阶段    
  while (!isEnd(context, mode, ancestors)) {
    // 省略处理逻辑    
  }

  // Whitespace handling strategy like v2
  // 处理空白字符,提高输出效率
  let removedWhitespace = false
  if (mode !== TextModes.RAWTEXT && mode !== TextModes.RCDATA) {
    // 省略处理逻辑  
  }

  // 移除空白字符,返回解析后的节点数组
  return removedWhitespace ? nodes.filter(Boolean) : nodes
}

从上面的代码中可以看到,parseChildren 函数接收三个参数,它们分别是:

  • context:解析器上下文,用来维护模板解析过程中程序的各种状态;
  • mode:文本模式,如 DATA、RCDATA、RAWTEXT、CDATA 等;
  • ancestors:祖先节点数组。ancestors 参数对于判断parseChildren函数内的while循环十分重要,它通过模拟一个栈结构,存储解析器在解析过程中的父级节点。

parseChildren 主要做了以下事情:

  1. 首先会从当前节点的父节点,并确定html的命名空间,该命名空间将在模板解析过程中判断解析器是否处于 RCDATA 状态。同时定义了一个 nodes 数组,用来存储解析后的节点。
  2. 由于 parseChildren 本质上是一个状态机,因此在 parseChildren 里开启了一个while循环是的状态机自动运行,即逐个读取字符串,在不同状态之间迁移,对模板进行解析。
  3. 接着是对模板中的空白字符进行处理。
  4. 最后是将解析完成的节点返回。

解析过程

模板解析的核心逻辑在while循环体内,我们接下来重点分析这部分的逻辑。下面是 while 循环的源码:

  // parseChildren 本质上是一个状态机,因此这里开启一个 while 循环使得状态机自动运行
  // 当标签未闭合时,解析对应阶段    
  while (!isEnd(context, mode, ancestors)) {
    __TEST__ && assert(context.source.length > 0)
    const s = context.source
    let node: TemplateChildNode | TemplateChildNode[] | undefined = undefined

    // 只有 DATA 模式和 RCDATA 模式才支持插值节点的解析
    if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
      if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
        // 插值节点的解析

        // '{{'
        node = parseInterpolation(context, mode)
      } else if (mode === TextModes.DATA && s[0] === '<') {
         // 这里进入开始标签的解析 
         // 只有 DATA 模式才支持标签节点的解析

        // https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state
        if (s.length === 1) {
          emitError(context, ErrorCodes.EOF_BEFORE_TAG_NAME, 1)
        } else if (s[1] === '!') {
          // 注释节点或 CDATA 节点的解析
          // https://html.spec.whatwg.org/multipage/parsing.html#markup-declaration-open-state
          if (startsWith(s, '<!--')) {
            // 以 <!-- 开头,说明是注释节点,解析注释节点

            node = parseComment(context)
          } else if (startsWith(s, '<!DOCTYPE')) {
            // 如果以 '<!DOCTYPE' 开头,忽略 DOCTYPE,当做伪注释解析

            // Ignore DOCTYPE by a limitation.
            node = parseBogusComment(context)
          } else if (startsWith(s, '<![CDATA[')) {
            // 如果以 '<![CDATA[' 开头,又在 HTML 环境中,解析 CDATA

            if (ns !== Namespaces.HTML) {
              node = parseCDATA(context, ancestors)
            } else {
              emitError(context, ErrorCodes.CDATA_IN_HTML_CONTENT)
              node = parseBogusComment(context)
            }
          } else {
            emitError(context, ErrorCodes.INCORRECTLY_OPENED_COMMENT)
            node = parseBogusComment(context)
          }
        } else if (s[1] === '/') {
          // 进入结束标签的解析 
          // https://html.spec.whatwg.org/multipage/parsing.html#end-tag-open-state
          if (s.length === 2) {
            // 标签名错误,报错
            emitError(context, ErrorCodes.EOF_BEFORE_TAG_NAME, 2)
          } else if (s[2] === '>') {
            // 如果源模板字符串的第三个字符位置是 '>',那么就是自闭合标签,前进三个字符的扫描位置
            emitError(context, ErrorCodes.MISSING_END_TAG_NAME, 2)
            // 消费字符串
            advanceBy(context, 3)
            continue
          } else if (/[a-z]/i.test(s[2])) {
            // 无效的结束标签
            emitError(context, ErrorCodes.X_INVALID_END_TAG)
            // 解析标签
            parseTag(context, TagType.End, parent)
            continue
          } else {
            emitError(
              context,
              ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME,
              2
            )
            node = parseBogusComment(context)
          }
        } else if (/[a-z]/i.test(s[1])) {
          // 标签节点的解析
          node = parseElement(context, ancestors)

          // 2.x <template> with no directive compat
          if (
            __COMPAT__ &&
            isCompatEnabled(
              CompilerDeprecationTypes.COMPILER_NATIVE_TEMPLATE,
              context
            ) &&
            node &&
            node.tag === 'template' &&
            !node.props.some(
              p =>
                p.type === NodeTypes.DIRECTIVE &&
                isSpecialTemplateDirective(p.name)
            )
          ) {
            __DEV__ &&
              warnDeprecation(
                CompilerDeprecationTypes.COMPILER_NATIVE_TEMPLATE,
                context,
                node.loc
              )
            node = node.children
          }
        } else if (s[1] === '?') {
            // 如果第二个字符是 ? , 则当做为注释解析
          emitError(
            context,
            ErrorCodes.UNEXPECTED_QUESTION_MARK_INSTEAD_OF_TAG_NAME,
            1
          )
          node = parseBogusComment(context)
        } else {
            // 都不是以上这些情况,则报出第一个字符不是合法标签字符的错误。
          emitError(context, ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME, 1)
        }
      }
    }

    // 如果上面的情况都解析完毕后,没有创建对应的节点,则当作文本来解析
    if (!node) {
      node = parseText(context, mode)
    }

    // 如果解析出来的节点是数组,则遍历将其添加进 node 数组中
    if (isArray(node)) {
      for (let i = 0; i < node.length; i++) {
        pushNode(nodes, node[i])
      }
    } else {
      pushNode(nodes, node)
    }
  }

首先会判断解析器所处的文本模式,只有当文本模式为 DATA 模式或 CDATA 模式时才会对模板进行解析。如下代码:

if (mode === TextModes.DATA || mode === TextModes.RCDATA) { // 省略解析逻辑 }

第一种情况是对插值节点的处理。

如果当前节点没有使用 v-pre 指令来跳过插值节点的解析,并且当前解析的字符串以 {{ 开头,则认为这是一个插值节点,于是调用 parseInterpolation 函数对插值节点进行解析。如下面的代码:

if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
  // 插值节点的解析

  // '{{'
  node = parseInterpolation(context, mode)
} 

从上面的代码中我们也可以发现,如果我们不希望使用双大号作为表达式插值,那么我们可以修改编译器的delimiters 选项即可,例如我们使用 ES6 模板字符串作为表达式插值,用法如下:

// 将分隔符设置为 ES6 模板字符串风格
app.config.compilerOptions.delimiters = ['${', '}']    

接下来判断第一个字符是否是 "<",如果是,并且第二个字符是 '!',会尝试去解析下面三种节点:

  • 注释节点

  • DOCTYPE节点

  • CDATA节点

解析注释节点

如果字符串以 <!-- 开头,说明是注释节点,则调用 parseComment 函数解析注释节点,如下代码所示:

if (startsWith(s, '<!--')) {
  // 以 <!-- 开头,说明是注释节点,解析注释节点
  node = parseComment(context)
} 

解析 DOCTYPE节点

如果字符串以 '<!DOCTYPE' 开头,那么忽略 DOCTYPE,将字符串当做伪注释解析,调用 parseBogusComment 函数完成解析。如下代码所示:

else if (startsWith(s, '<!DOCTYPE')) {
  // 如果以 '<!DOCTYPE' 开头,忽略 DOCTYPE,当做伪注释解析
  // Ignore DOCTYPE by a limitation.
  node = parseBogusComment(context)
} 

解析 CDATA节点

如果字符串以 '<![CDATA[' 开头,并且不在 HTML 环境中,则调用 parseCDATA 函数解析 CDATA 节点。否则当作为注释进行解析。如下代码所示:

else if (startsWith(s, '<![CDATA[')) {
  // 如果以 '<![CDATA[' 开头,又在 HTML 环境中,解析 CDATA
  if (ns !== Namespaces.HTML) {
    node = parseCDATA(context, ancestors)
  } else {
    emitError(context, ErrorCodes.CDATA_IN_HTML_CONTENT)
    node = parseBogusComment(context)
  }
}

如果第一个字符是 "<",并且第二个字符是 '/',则会尝试结束标签的解析。

如果只有两个字符串,说明结束标签错误,则会报错。如下代码所示:

if (s.length === 2) {
  // 标签名错误,报错
  emitError(context, ErrorCodes.EOF_BEFORE_TAG_NAME, 2)
}

如果源模板字符串的第三个字符位置是 '>',那么就是自闭合标签,让解析器前进三个字符的扫描位置,跳过"</>",如下代码所示:

else if (s[2] === '>') {
  // 如果源模板字符串的第三个字符位置是 '>',那么就是自闭合标签,前进三个字符的扫描位置
  emitError(context, ErrorCodes.MISSING_END_TAG_NAME, 2)
  // 消费字符串
  advanceBy(context, 3)
  continue
}

如果第一个字符是 '<',且第二个字符是 '/',并且第三个字符是小写英文字符,此时解析结束标签,如下代码所示:

else if (/[a-z]/i.test(s[2])) {
  // 无效的结束标签
  emitError(context, ErrorCodes.X_INVALID_END_TAG)
  // 解析结束标签
  parseTag(context, TagType.End, parent)
  continue
}

如果第一个字符是 "<",并且第二个字符是 小写英文字符,则认为这是一个标签节点,于是调用 parseElement 完成标签的解析。如下代码所示:

else if (/[a-z]/i.test(s[1])) {
  // 标签节点的解析
  node = parseElement(context, ancestors)

  // 省略部分代码
} 

如果第一个字符是 "<",并且第二个字符是 "?",将字符串当做伪注释解析,调用 parseBogusComment 函数完成解析。如下代码所示:

else if (s[1] === '?') {
  // 如果第二个字符是 ? , 则当做为注释解析
  emitError(
    context,
    ErrorCodes.UNEXPECTED_QUESTION_MARK_INSTEAD_OF_TAG_NAME,
    1
  )
  node = parseBogusComment(context)
}

当尝试在 DATA 模式和 CDATA 模式下没有解析出任何node节点,这时一切内容都将作为文本处理,如下代码所示:

// node 不存在,说明处于其它模式,即非 DATA 模式且非RCDATA模式
// 这是一切内容都作为文本处理
if (!node) {
  // 解析文本节点
  node = parseText(context, mode)
}

最后如果解析处理的节点是数组,遍历将其添加进 node 数组中,如下代码所示:

// 如果解析出来的节点是数组,则遍历将其添加进 node 数组中
if (isArray(node)) {
  for (let i = 0; i < node.length; i++) {
    pushNode(nodes, node[i])
  }
} else {
  pushNode(nodes, node)
}

上面就是 while 循环体内解析模板字符串的一个过程。

while 循环何时停止

我们知道,parseChildren 函数本质上是一个状态机,它会开启一个 while 循环使得状态机自动运行,如下面的代码所示:

// packages/compiler-core/src/parse.ts

function parseChildren(
  context: ParserContext,
  mode: TextModes,
  ancestors: ElementNode[]
): TemplateChildNode[] {
  
  // 省略部分代码

  // parseChildren 本质上是一个状态机,因此这里开启一个 while 循环使得状态机自动运行
  // 当标签未闭合时,解析对应阶段    
  while (!isEnd(context, mode, ancestors)) {
    // 省略处理逻辑    
  }

  // 省略部分代码
}

那么,状态机何时停止呢?换句话说,while 循环应该何时停止运行呢?这涉及到 isEnd 函数的判断逻辑。我们来看看 isEnd 函数的源码:

function isEnd(
  context: ParserContext,
  mode: TextModes,
  ancestors: ElementNode[] // ancestors 参数模拟栈结构,存储解析过程中的父级节点
): boolean {
  const s = context.source

  switch (mode) {
    // 父级节点栈中存在与当前解析到的结束标签同名的节点,就停止状态机,即退出 while 循环
    case TextModes.DATA:
      if (startsWith(s, '</')) {
        // TODO: probably bad performance
        for (let i = ancestors.length - 1; i >= 0; --i) {
          if (startsWithEndTagOpen(s, ancestors[i].tag)) {
            return true
          }
        }
      }
      break

    // 父级节点栈中存在与当前解析到的结束标签同名的节点,就停止状态机,即退出 while 循环
    case TextModes.RCDATA:
    case TextModes.RAWTEXT: {
      const parent = last(ancestors)
      if (parent && startsWithEndTagOpen(s, parent.tag)) {
        return true
      }
      break
    }

    // 文本模式 为 CDATA 模式时,字符串以 ]]> 开头,返回 true,停止状态机,即退出 while 循环
    case TextModes.CDATA:
      if (startsWith(s, ']]>')) {
        return true
      }
      break
  }

  return !s
}

isEnd 函数的第三个参数 ancestors 模拟栈结构,存储解析过程中的父级节点。当父级节点栈中存在与当前解析到的结束标签同名的节点时,isEnd 函会返回true。即意味着此时停止状态机,也就是退出while循环,结束对节点的解析。

总结

本文首先对解析器的实现原理作了简单的介绍。解析器本质上就是一个状态机。接着分析了解析器的核心函数 parseChildren 的实现原理以及实现过程。