Vue 3源码深度解析之:`Vue`的`template`解析器:它如何处理表达式和`script`标签。

各位靓仔靓女们,晚上好!我是你们的老朋友,今天咱们来聊聊Vue 3源码里那个神秘的“template解析器”。别害怕,咱不搞玄学,保证给你扒得明明白白,连它裤衩啥颜色都给你看清楚!

咱们今天的主题是:Vue 3 源码深度解析之:Vuetemplate 解析器:它如何处理表达式和 script 标签。

准备好了吗?系好安全带,发车!

一、 Template 解析器的概览:庖丁解牛的开始

首先,我们要明确一个概念:template 解析器的作用是啥? 简单来说,就是把我们写的 HTML 模板,转换成 Vue 内部能理解的抽象语法树 (Abstract Syntax Tree, AST)。这个 AST 就像是代码的骨架,Vue 后面会根据这个骨架生成渲染函数,最终把数据变成用户看到的界面。

你可以把 template 解析器想象成一个“庖丁”,它负责把 HTML 这头“牛”分解成一块块“肉”,每一块“肉”都代表着 HTML 中的一个元素、属性、文本等等。

那么,Vue 3 的 template 解析器在哪里呢?它主要位于 @vue/compiler-core 这个包里面。 它的核心入口函数是parse()

二、 parse() 函数:总指挥官的登场

parse() 函数是解析器的总指挥官,它接收一个 HTML 字符串作为输入,然后开始“大卸八块”的过程。

function parse(
  input: string,
  options: ParserOptions = {}
): RootNode {
  const context = createParserContext(input, options)
  const start = getCursor(context)
  return createRoot(
    parseChildren(context, TextModes.DATA, []), // 从这里开始解析子节点
    getSelection(context, start)
  )
}

这个函数做了几件事:

  1. 创建解析器上下文 (createParserContext):这个上下文包含了解析过程中的各种信息,比如当前解析的位置、解析选项等等。你可以把它想象成“庖丁”的工具箱,里面装着各种刀具和调料。
  2. 创建根节点 (createRoot):AST 肯定要有个根节点,这个函数就是创建这个根节点的。
  3. 解析子节点 (parseChildren):这是最核心的部分,它会递归地解析 HTML 中的各个节点。
  4. 返回 AST 根节点:最终,parse() 函数会返回一个 AST 根节点,这个根节点包含了整个 HTML 模板的结构。

三、 parseChildren() 函数:解析子节点的利器

parseChildren() 函数是解析 HTML 结构的关键。它会循环遍历 HTML 字符串,根据不同的标签类型,调用不同的解析函数。

function parseChildren(
  context: ParserContext,
  mode: TextModes,
  ancestors: ElementNode[]
): TemplateChildNode[] {
  const nodes: TemplateChildNode[] = []

  while (!isEnd(context, mode, ancestors)) {
    const s = context.source
    let node: TemplateChildNode | TemplateChildNode[] | undefined = undefined

    if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
      if (!context.options.decodeEntities || !s.startsWith('&')) {
        if (s[0] === '<') {
          if (
            s.length > 1 &&
            s[1] === '!' &&
            s.startsWith('<!--')
          ) {
            // 解析注释节点
            node = parseComment(context)
          } else if (s.length > 1 && s[1] === '[') {
            // 解析 CDATA 节点
            node = parseCDATA(context, ancestors)
          } else if (s.length > 1 && /[a-z]/i.test(s[1])) {
            // 解析元素节点
            node = parseElement(context, ancestors)
          } else if (s.startsWith('</')) {
            // 结束标签错误
            emitError(context, ErrorCodes.X_END_TAG_WITHOUT_MATCHING_OPEN_TAG)
            parseTag(context, TagType.End, ancestors)
            continue
          }
        } else if (s.startsWith('{{')) {
          // 解析插值表达式
          node = parseInterpolation(context, mode)
        }
      }
      if (!node) {
        // 解析文本节点
        node = parseText(context, mode)
      }
    } else if (mode === TextModes.RAWTEXT || mode === TextModes.SCRIPT) {
      // 解析文本节点 (RAWTEXT/SCRIPT 模式)
      node = parseText(context, mode)
    }

    if (isArray(node)) {
      for (let i = 0; i < node.length; i++) {
        pushNode(nodes, node[i])
      }
    } else {
      pushNode(nodes, node)
    }

    if (node) {
      if (mode === TextModes.SCRIPT) {
        // 在 script 标签中,跳过空白字符
        advanceSpaces(context)
      }
      continue
    }
  }

  return nodes
}

这个函数的核心逻辑是:

  1. 判断是否结束 (isEnd):判断是否已经到达 HTML 字符串的末尾,或者遇到了闭合标签。
  2. 判断节点类型:根据 HTML 字符串的开头字符,判断当前节点的类型,比如元素节点、注释节点、文本节点等等。
  3. 调用相应的解析函数:根据节点类型,调用相应的解析函数,比如 parseElement()parseComment()parseText() 等等。
  4. 将解析结果添加到节点列表 (nodes):将解析得到的节点添加到 nodes 列表中。
  5. 循环遍历:重复以上步骤,直到解析完整个 HTML 字符串。

四、 表达式解析:parseInterpolation() 的妙手

Vue 的一个重要特性就是数据绑定,而数据绑定通常使用表达式来实现。parseInterpolation() 函数就是负责解析插值表达式的,比如 {{ message }}

function parseInterpolation(
  context: ParserContext,
  mode: TextModes
): InterpolationNode | undefined {
  const [open, close] = context.options.delimiters

  if (!startsWith(context.source, open)) {
    return
  }

  const closeIndex = context.source.indexOf(close, open.length)
  if (closeIndex === -1) {
    return
  }

  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)
  const preTrimContent = rawContent.trim()
  const content = preTrimContent

  advanceBy(context, closeIndex - open.length + close.length)

  return {
    type: NodeTypes.INTERPOLATION,
    content: {
      type: NodeTypes.SIMPLE_EXPRESSION,
      content,
      isStatic: false,
      loc: getSelection(context, innerStart, innerEnd)
    },
    loc: getSelection(context, start)
  }
}

这个函数做了几件事:

  1. 判断是否以分隔符开始 (startsWith):判断 HTML 字符串是否以插值表达式的开始分隔符 (默认是 {{) 开始。
  2. 查找结束分隔符 (indexOf):查找插值表达式的结束分隔符 (默认是 }})。
  3. 提取表达式内容 (slice):提取插值表达式的内容,比如 message
  4. 创建插值节点 (InterpolationNode):创建一个 InterpolationNode 对象,这个对象包含了表达式的内容和位置信息。
  5. 返回插值节点:返回创建的 InterpolationNode 对象。

五、 script 标签的处理:parseElement() 的特殊关照

script 标签在 HTML 中具有特殊地位,它包含了 JavaScript 代码,Vue 需要特殊处理。parseElement() 函数负责解析元素节点,包括 script 标签。

function parseElement(
  context: ParserContext,
  ancestors: ElementNode[]
): ElementNode | undefined {
  const parent = last(ancestors)
  const element = parseTag(context, TagType.Start, ancestors)
  const isSelfClosing = element.isSelfClosing

  if (element.tagType === TagType.Start) {
    ancestors.push(element)

    // 设置 textMode
    let mode = context.options.getTextMode(element)
    const children = parseChildren(context, mode, ancestors)
    ancestors.pop()

    element.children = children
  }

  if (startsWith(context.source, `</${element.tag}`)) {
    parseTag(context, TagType.End, ancestors)
  } else {
    // 缺少闭合标签
    emitError(
      context,
      ErrorCodes.X_MISSING_END_TAG,
      element.loc.start
    )

    if (element.tag === 'script') {
      // 特殊处理:如果 script 标签缺少闭合标签,尝试自动闭合
      advanceBy(context, context.source.length);
    }
  }

  return element
}

这个函数的核心逻辑是:

  1. 解析开始标签 (parseTag):解析元素节点的开始标签,比如 <script>
  2. 递归解析子节点 (parseChildren):递归地解析元素节点的子节点。
  3. 解析结束标签 (parseTag):解析元素节点的结束标签,比如 </script>
  4. 处理缺少闭合标签的情况:如果缺少闭合标签,则报错,并尝试自动闭合 script 标签。

特别注意,parseElement 中会调用 context.options.getTextMode(element) 来获取当前元素的 textMode。 对于 script 标签,textMode 会被设置为 TextModes.SCRIPTTextModes.SCRIPT 会影响 parseChildren 的行为,如上面代码所示,在 script 标签中,parseChildren 会跳过空白字符。

六、 例子:解析一个简单的 Vue 模板

为了更好地理解 template 解析器的工作原理,我们来看一个简单的例子:

<div>
  <h1>{{ message }}</h1>
  <script>
    console.log('Hello, world!');
  </script>
</div>

这个 HTML 模板会被解析成如下的 AST:

{
  "type": 0, // NodeTypes.ROOT
  "children": [
    {
      "type": 1, // NodeTypes.ELEMENT
      "tag": "div",
      "children": [
        {
          "type": 1, // NodeTypes.ELEMENT
          "tag": "h1",
          "children": [
            {
              "type": 5, // NodeTypes.INTERPOLATION
              "content": {
                "type": 4, // NodeTypes.SIMPLE_EXPRESSION
                "content": "message",
                "isStatic": false,
                "loc": {
                  "start": {
                    "line": 2,
                    "column": 9,
                    "offset": 16
                  },
                  "end": {
                    "line": 2,
                    "column": 16,
                    "offset": 23
                  }
                }
              },
              "loc": {
                "start": {
                  "line": 2,
                  "column": 5,
                  "offset": 12
                },
                "end": {
                  "line": 2,
                  "column": 19,
                  "offset": 26
                }
              }
            }
          ],
          "loc": {
            "start": {
              "line": 2,
              "column": 1,
              "offset": 8
            },
            "end": {
              "line": 2,
              "column": 24,
              "offset": 31
            }
          }
        },
        {
          "type": 1, // NodeTypes.ELEMENT
          "tag": "script",
          "children": [
            {
              "type": 2, // NodeTypes.TEXT
              "content": "n    console.log('Hello, world!');n  ",
              "loc": {
                "start": {
                  "line": 3,
                  "column": 9,
                  "offset": 40
                },
                "end": {
                  "line": 5,
                  "column": 3,
                  "offset": 73
                }
              }
            }
          ],
          "loc": {
            "start": {
              "line": 3,
              "column": 1,
              "offset": 32
            },
            "end": {
              "line": 5,
              "column": 10,
              "offset": 80
            }
          }
        }
      ],
      "loc": {
        "start": {
          "line": 1,
          "column": 1,
          "offset": 0
        },
        "end": {
          "line": 6,
          "column": 7,
          "offset": 86
        }
      }
    }
  ],
  "loc": {
    "start": {
      "line": 1,
      "column": 1,
      "offset": 0
    },
    "end": {
      "line": 6,
      "column": 7,
      "offset": 86
    }
  }
}

可以看到,AST 清晰地描述了 HTML 模板的结构,包括 div 元素、h1 元素、插值表达式和 script 元素。

七、总结:Template 解析器的价值

Template 解析器是 Vue 编译器的重要组成部分,它负责将 HTML 模板转换成 AST,为后续的优化和代码生成奠定了基础。理解 Template 解析器的工作原理,可以帮助我们更好地理解 Vue 的内部机制,从而更好地使用 Vue。

表格总结

函数名 作用
parse() 解析器的入口函数,接收 HTML 字符串作为输入,返回 AST 根节点。
parseChildren() 递归地解析 HTML 中的各个节点,根据节点类型调用不同的解析函数。
parseElement() 解析元素节点,包括 script 标签。
parseComment() 解析注释节点。
parseText() 解析文本节点。
parseInterpolation() 解析插值表达式,比如 {{ message }}

好了,今天的分享就到这里。希望大家有所收获!如果有什么问题,欢迎在评论区留言,我们一起探讨! 下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注