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

版本:v3.3.4

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

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

baseCompile源码

js 复制代码
// packages/compiler-core/src/compile.ts
export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {

  // 省略部分代码

  // 1. 将模板字符串解析为成模板AST
  const ast = isString(template) ? baseParse(template, options) : template
  
  // 省略部分代码

  // 2. 将 模板AST 转换成 JavaScript AST
  transform(
    ast,
    extend({}, options, {
      prefixIdentifiers,
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []) // user transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {} // user transforms
      )
    })
  )

  // 3. 将JavaScript AST 转换成渲染函数,generate函数会将渲染函数的代码以字符串的形式返回。
  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}

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

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

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

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

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

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

什么是 RCDATA

在 HTML 中,RCDATARaw Character Data)是指一些特殊的标记,用于包含纯文本 数据,例如 <textarea><title> 元素中的文本内容。在浏览器渲染这些元素时,会将其中的文本内容视为纯文本,而不会进行 HTML 标记的解析。

解析器的初始模式是 DATA 模式。对于 Vue.js 的模板 DSL 来说,模板中不允许出现 <script> 标签,因此 Vue.js 模板解析器在遇到 <script> 标签时也会切换到 RAWTEXT 模式。

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

baseParse 函数

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

baseParse源码

js 复制代码
// 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 函数中:

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

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

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

创建模板AST根节点

createRoot源码

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

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

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

parseChildren 的状态迁移过程

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

  • 标签节点,例如 <div>
  • 文本插值节点,例如 {{ val }}
  • 普通文本节点, 例如:text
  • 注释节点,例如 <!---->
  • CDATA 节点,例如 <![CDATA[ xxx ]]>

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

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

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

    • 如果下一个字符匹配正则 /a-z/i,则认为这是一个标签节点 ,于是调用 parseElement 函数完成标签的解析。
    • 如果字符串以 <!-- 开头,则认为这是一个注释节点 ,于是调用 parseComment 函数完成注释节点的解析。
    • 如果字符串以 <![DATA[ 开头,则认为这是一个 CDATA 节点,于是调用 parseCDATA 函数完成 CDATA 节点的解析。
  • 如果字符串以 {{ 开头,则认为这是一个插值节点,于是调用 parseInterpolation 函数完成插值节点的解析。

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

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

parseChildren 解析子节点

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

parseChildren源码

js 复制代码
// 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 循环的源码:

js 复制代码
  // 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,当做伪注释解析

            // 如果当前节点是 DOCTYPE 节点,则需要忽略该节点,因为 Vue.js 3 的编译器不支持解析 DOCTYPE 节点。
            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
          // 处理不带指令的 <template> 元素
          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 模式时才会对模板进行解析。如下代码:

js 复制代码
if (mode === TextModes.DATA || mode === TextModes.RCDATA) { // 省略解析逻辑 }

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

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

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

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

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

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

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

  • 注释节点
  • DOCTYPE节点
  • CDATA节点

解析注释节点

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

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

解析 DOCTYPE节点

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

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

解析 CDATA节点

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

js 复制代码
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)
  }
}

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

如果只有两个字符串(<>、</之类的),说明结束标签错误,则会报错。如下代码所示:

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

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

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

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

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

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

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

  // 省略部分代码
} 

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

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

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

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

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

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

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

while 循环何时停止

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

parseChildren源码

js 复制代码
// 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 函数的源码:

isEnd源码

js 复制代码
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 模拟栈结构,存储解析过程中的父级节点。以下两种情况会退出 while 循环👇:

  • 父级节点栈 中存在与当前解析到的结束标签同名的节点 时,isEnd 函会返回true。即意味着此时停止状态机 ,也就是退出while循环,结束对节点的解析。
    • 为什么?因为前面提过,编译器在解析 HTML 文档时,使用状态机的方式进行解析。状态机会根据当前的状态和输入的字符,决定下一步要执行的操作。在解析结束标签时,状态机需要判断当前结束标签的名称是否与父级节点栈中存在的节点名称相同。如果相同,则说明当前结束标签与某个父级节点对应,需要结束对该节点的解析
  • 如果当前节点的类型为 CDATA,则需要判断字符串是否以 ]]> 开头。如果是,则说明当前节点的内容已经结束,需要停止状态机,即退出 while 循环。
    • 为什么?因为在 HTML 中,]]> 是一个特殊的字符序列,用于表示 CDATA 节点的结束标记。如果在 CDATA 节点中出现了 ]]> 字符序列,则会被解析器误认为是 CDATA 节点的结束标记,从而导致解析错误。因此,在解析 CDATA 节点时,需要特殊处理 ]]> 字符序列,以避免解析错误的发生

总结

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

相关推荐
Fighting_p2 分钟前
【记录】列表自动滚动轮播功能实现
前端·javascript·vue.js
前端Hardy3 分钟前
HTML&CSS:超炫丝滑的卡片水波纹效果
前端·javascript·css·3d·html
技术思考者7 分钟前
HTML速查
前端·css·html
缺少动力的火车8 分钟前
Java前端基础—HTML
java·前端·html
Domain-zhuo21 分钟前
Git和SVN有什么区别?
前端·javascript·vue.js·git·svn·webpack·node.js
雪球不会消失了25 分钟前
SpringMVC中的拦截器
java·开发语言·前端
李云龙I36 分钟前
解锁高效布局:Tab组件最佳实践指南
前端
m0_7482370541 分钟前
Monorepo pnpm 模式管理多个 web 项目
大数据·前端·elasticsearch
JinSoooo43 分钟前
pnpm monorepo 联调方案
前端·pnpm·monorepo
m0_748244961 小时前
【AI系统】LLVM 前端和优化层
前端·状态模式