直击 Vue 3.0 编译背后的过程和思想(二)

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


Vue3 为了运行时的性能优化,在编译阶段也是下了不少功夫,在接下来的系列文章中,我们一起去了解 Vue 3 编译过程以及背后的优化思想。由于编译过程平时开发中很难接触到,所以不需要你对每一个细节都了解,你只要对整体有一个理解和掌握即可。

前言

「Vue3 的编译优化」是通过编译阶段对静态模板的分析,编译生成了 Block treeBlock tree 是一个将模版基于动态节点指令切割的嵌套区块,每个区块内部的节点结构是固定的,而且每个区块只需要以一个 Array 来追踪自身包含的动态节点。

上一篇文章中,Vue3 的编译有了初步的了解。

直击 Vue 3.0 编译背后的过程和思想(一)

也大概知道了 Vue3 整个编译在做三件事情:

  • 模板字符串 template 的解析,将 template 解析成 AST
  • AST 转换
  • 代码生成

那么这篇文章的主题就是带你理解 Vue 3是如何将开发者写的 template 转换为抽象语法树。

如果有时间可以先了解一下 Vue 2是如何将开发者写的 template 转换为抽象语法树。Vue 编译三部曲:如何将 template 编译成 AST ? 并将 Vue3 和 Vue2 的 parse 阶段对比分析,加深自己的理解。

接下来就正式开始今天的探索。

parseChildren 函数的实现

Vue3 中解析 template 的重点就是parseChildren 函数,这个函数输入的是template输出的即为AST

parseChildren 函数 会将 template 字符串进行逐一解析,将解析出来的 node放入到一个数组nodes中。然后会遍历生成的nodes中每一个节点的信息对空白符进行处理。处理空白符的一个目的是提升编译效率。

AST节点的生成

先放一段长长的代码,虽然这段代码看就让人没有心情再往下了,但是这段代码就是 AST 节点的生成核心。

    function parseChildren(context, mode, ancestors) {
        ...
        const nodes = [];
        while (!isEnd(context, mode, ancestors)) {
            const s = context.source;
            let node = undefined;
            if (mode === 0 /* DATA */ || mode === 1 /* RCDATA */) {
                if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
                    // '{{'
                    node = parseInterpolation(context, mode);
                }
                // 处理 < 开头的代码
                else if (mode === 0 /* DATA */ && s[0] === '<') {
                    // https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state
                    // 节点长度为1,说明代码结尾是 < ,报错
                    if (s.length === 1) {
                        emitError(context, 5 /* EOF_BEFORE_TAG_NAME */, 1);
                    }
                    // 处理 <! 开头的代码
                    else if (s[1] === '!') {
                        // https://html.spec.whatwg.org/multipage/parsing.html#markup-declaration-open-state
                        // <!-- :注释节点,
                        if (startsWith(s, '<!--')) {
                            node = parseComment(context);
                        }
                        // <!DOCTYPE: <!DOCTYPE 节点
                        else if (startsWith(s, '<!DOCTYPE')) {
                            // Ignore DOCTYPE by a limitation.
                            node = parseBogusComment(context);
                        }
                        // <![CDATA[:<![CDATA[ 节点
                        else if (startsWith(s, '<![CDATA[')) {
                            if (ns !== 0 /* HTML */) {
                                node = parseCDATA(context, ancestors);
                            }
                            else {
                                emitError(context, 1 /* CDATA_IN_HTML_CONTENT */);
                                node = parseBogusComment(context);
                            }
                        }
                        else {
                            emitError(context, 11 /* INCORRECTLY_OPENED_COMMENT */);
                            node = parseBogusComment(context);
                        }
                    }
                    // 处理</ 结束标签
                    else if (s[1] === '/') {
                        // https://html.spec.whatwg.org/multipage/parsing.html#end-tag-open-state
                        // 长度为2,说明只有 </ 解说标签,报错
                        if (s.length === 2) {
                            emitError(context, 5 /* EOF_BEFORE_TAG_NAME */, 2);
                        }
                        // </> 缺少元素标记的结束标签,报错
                        else if (s[2] === '>') {
                            emitError(context, 14 /* MISSING_END_TAG_NAME */, 2);
                            advanceBy(context, 3);
                            continue;
                        }
                        // 
                        else if (/[a-z]/i.test(s[2])) {
                            emitError(context, 23 /* X_INVALID_END_TAG */);
                            parseTag(context, 1 /* End */, parent);
                            continue;
                        }
                        else {
                            emitError(context, 12 /* INVALID_FIRST_CHARACTER_OF_TAG_NAME */, 2);
                            node = parseBogusComment(context);
                        }
                    }
                    // 解析标签元素节点
                    else if (/[a-z]/i.test(s[1])) {
                        node = parseElement(context, ancestors);
                    }
                    else if (s[1] === '?') {
                        emitError(context, 21 /* UNEXPECTED_QUESTION_MARK_INSTEAD_OF_TAG_NAME */, 1);
                        node = parseBogusComment(context);
                    }
                    else {
                        emitError(context, 12 /* 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);
            }
        }
        ...
    }

整个解析的代码看起来非常的复杂,但是不同怕,思路非常简单, 将 template 重头开始遍历,根据不同的情况去处理不同类型的节点,然后把生成的 node 添加到 AST nodes 数组中。

在解析的过程中,解析上下文 context 的状态也是在不断发生变化的,我们可以通过 context.source 拿到当前解析剩余的代码,然后根据不同的情况走不同的分支处理逻辑。在解析的过程中,可能会遇到各种错误,都会通过 emitError 方法报错。

这里正式了解解析流程之前,有几个知识点先熟悉一下。

mode 是什么?

在进行 parseChildren解析时,会时常用到一个变量 mode。这个mode是什么了?

export const enum TextModes {
  //          | Elements | Entities | End sign              | Inside of
  DATA, //    | ✔        | ✔        | End tags of ancestors |
  RCDATA, //  | ✘        | ✔        | End tag of the parent | <textarea>
  RAWTEXT, // | ✘        | ✘        | End tag of the parent | <style>,<script>
  CDATA,
  ATTRIBUTE_VALUE
}

mode是当前解析的模式,在不同模式下,解析可能会存在少量的补丁操作,但是不会影响整体的流程,可以简单了解一下有那些 mode

  • DATA(mode = 0 ):类型即为元素(包括组件);
  • RCDATA(mode = 1 ):是在标签中的文本;
  • RAWTEXT(mode = 2 ):类型为script、noscript、iframe、style中的代码;
  • CDATA(mode = 3 ):前端比较少接触的<![CDATA[cdata]]>代码,这是使用于XML与XHTML中的注释,在该注释中的 cdata 代码将不会被解析器解析,而会当做普通文本处理;
  • ATTRIBUTE_VALUE(mode = 4 ):顾名思义,即是各个标签的属性;

ancestors 的作用是什么?

ancestors 是一个数组,用于存储祖先节点数组。 例如我有一个这样的简单模板:

<div>
  <ul>
    <li>1</li>
  </ul>
</div>

根据节点栈(ancestors)中的最后一个入栈节点和匹配到的结束标签做比较,如果判断为同一标签,表明节点是合法闭合的。和 Vue 2.x 中思想一样。利用栈来判断节点开闭合法性。

其实 HTML 的嵌套结构的解析过程,就是一个递归解析元素节点的过程,为了维护父子关系,当需要解析子节点时,我们就把当前节点入栈,子节点解析完毕后,我们就把当前节点出栈,因此 ancestors 的设计就是一个栈的数据结构,整个过程是一个不断入栈和出栈的过程。

advanceBy 是做什么的?

advanceBy 的主要作用就是更新解析上下文 context 中的 source 来前进代码,同时更新 offset、line、column 等和代码位置相关的属性。

例如有这样一段模板:

        <div>
          测试解析移动
        </div>

注意模板中的换行和空格都会被计算到,上面的模板中在写 template 时,可能存在换行和空格。

在解析时,就会被解析成如下这样的模板字符串。

"\n      <div>\n        测试解析移动\n      </div>\n    "

createParserContext,创建解析上下文时,默认 column: 1, line: 1, offset: 0,所以后续的相关信息都是这这个基础之上进行改变。具体的信息变化如下:

你也可以去用一段模板,在源码中 debugger,查看偏移新的变化,加深理解。

function advanceBy(context, numberOfCharacters) {
  const { source } = context;
  advancePositionWithMutation(context, source, numberOfCharacters);
  context.source = source.slice(numberOfCharacters);
}

并且在进行模版字符串向前推进时,由于在解析的过程中会频繁调用advanceBy函数,考虑到拷贝新的位置信息耗费性能,因此在进行advanceBy函数调用时,直接修改源位置信息以节省开销。

template 解析什么时候结束?

整个 template 的解析过程就是从头到尾的解析,循环处理 source,循环截止条件是 isEnd 方法返回 true,即是处理完成了,结束有两个条件:

  • context.source 为空,即整个模板都处理完成
  • 碰到截止节点标签,且能在 `未匹配的起始标签 ancestors 里面找到对对应的 tag。这个对应 parseChildren 的子节点处理完成。

节点类型有多少种类型?

在之前的编译三部曲第一步中我们也介绍了,Vue 2.x 版本template生成 AST时,会把元素、文字、注释都创建成节点描述对象。所以一共有三种节点类型:

  • type = 1基础元素节点
  • type = 2含有expressiontokens文本节点
  • type = 3纯文本节点或者是注释节点

而在 Vue 3.x 中,节点类型变多:

  • type = 0根节点
  • type = 1元素节点
  • type = 2文本节点
  • type = 3注释节点
  • type = 4表达式
  • type = 5插值,如双花插值 {{ }}
  • type = 6属性
  • type = 7指令

while 进行遍历解析

while 的遍历解析是整个解析过程中的重点,但是我们没有必要去了解所有代码判断逻辑,我们只需要了解大致的思路就 ok 了。下面我会将一些重要的解析列举分析。

插值解析

对插值的解析,它会解析模板中的插值比如 {{ msg }} 。如果当前模板(注意是当前模板)是以{{开头的字符串,且不在v-pre 指令的环境下(v-pre 会跳过插值的解析),则会走到插值的解析处理逻辑 parseInterpolation 函数。这里有一个好玩的点,正常情况下插值我们使用的是 {{ xxx }}包裹,但是这不是绝对的。

例如:

// meg: '小白'
<div>
  [[msg]]
</div>

我们也可以自定义插值符号,这也是能正常解析的。但是你必须手动设置配置项 delimiters: [[[,`]]

往下,我们来看parseInterpolation函数的实现:

function parseInterpolation(context, mode) {
  // ①
  const [open, close] = context.options.delimiters;
  // ②
  const closeIndex = context.source.indexOf(close, open.length);
  if (closeIndex === -1) {
    emitError(context, 25 /* 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);
  // ⑤
  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);
  // ⑥
  return {
    type: 5 /* INTERPOLATION */,
    content: {
      type: 4 /* SIMPLE_EXPRESSION */,
      isStatic: false,
      // Set `isConstant` to false by default and will decide in transformExpression
      constType: 0 /* NOT_CONSTANT */,
      content,
      loc: getSelection(context, innerStart, innerEnd)
    },
    loc: getSelection(context, start)
  };
}

这里我们还是以上面的模板为例:

// meg: '小白'
<div>
  [[msg]]
</div>

①,解析当前配置中插值的开始标记和结束标记:open = [[,close = ]]

②,找到插值的结束分隔符的位置,如果没有找到,就报错。

③,获取当前插值的位置信息,例如例子模板中的位置信息为:column: 9,line: 3, offset: 21。并将代码移动开始分隔符之后。

④,获取内部插值的开始位置、结束位置、插值元素内容的长度、插值的原始内容。

⑤,然后通过 parseTextData 获取插值中间的内容并前进代码到插值内容。除了普通字符串,parseTextData 内部会处理一些 HTML 实体符号比如 &nbsp 。由于开发者在写插值的内容时,可能会为了美观,写入前后空白字符,所以最终返回的 content 需要执行一下 trim 函数。并且还会记录偏移量,做代码前进的操作。

⑥,最终返回的值就是一个描述插值节点的对象:

  • type(5) 表示它是一个插值节点
  • loc 表示插值的代码开头和结束的位置信息
  • content 又是一个描述表达式节点的对象:
    • type(4) 表示它是一个表达式节点
    • loc 表示内容的代码开头和结束的位置信息
    • content 表示插值的内容

注释解析

对注释的解析,它会解析模板中的注释节点,比如 <!-- 这是一段注释 -->, 即当前代码是以 <!-- 开头的字符串,则走到注释节点的解析处理逻辑。

function parseComment(context) {
  ...
  // ①
  const match = /--(!)?>/.exec(context.source);
  if (!match) {
    ...
  }
  else {
    if (match.index <= 3) {
      emitError(context, 0 /* ABRUPT_CLOSING_OF_EMPTY_COMMENT */);
    }
    if (match[1]) {
      emitError(context, 10 /* INCORRECTLY_CLOSED_COMMENT */);
    }
    // ②
    content = context.source.slice(4, match.index);
    const s = context.source.slice(0, match.index);
    let prevIndex = 1, nestedIndex = 0;
    // ③
    while ((nestedIndex = s.indexOf('<!--', prevIndex)) !== -1) {
      ...
    }
    advanceBy(context, match.index + match[0].length - prevIndex + 1);
  }
  // ④
  return {
    type: 3 /* COMMENT */,
    content,
    loc: getSelection(context, start)
  };
}

其实,parseComment 的实现很简单,用一段模板举例:

<!-- 这是一段注释 -->

①,利用注释结束符的正则表达式去匹配代码,找出注释结束符。如果没有匹配到或者注释结束符不合法,会报错。

②,如果找到合法的注释结束符,则获取它中间的注释内容 content,然后截取注释开头到结尾之间的代码。

③,判断第二步截取到代码是否有嵌套注释,如果有嵌套注释也会报错。

④,最终返回的值就是一个描述插值节点的对象:

  • type(3) 表示它是一个注释节点
  • loc 表示注释的代码开头和结束的位置信息
  • content 表示当前解析到的注释内容

<!DOCTYPE 节点解析

对虚假注释的解析,它会解析模板中的 <!DOCTYPE 节点解析 ,比如 <!DOCTYPE html>, 即当前代码是以 <!DOCTYPE 开头的字符串,则走到虚假注释节点的解析处理逻辑。

function parseBogusComment(context) {
  ...
  return {
    type: 3 /* COMMENT */,
    content,
    loc: getSelection(context, start)
  };
}

<!DOCTYPE 节点解析 先获取到位置信息,在获取 <!DOCTYPE 节点 的内容,移动代码,最后返回节点信息。

例如有这样一段模板:

<!DOCTYPE html>

最后返回的节点对象就是这样:

<![CDATA[ 节点解析

这里是解析 <![CDATA[cdata]]>代码,但是一般情况下,不会走到这一环,原因在于这是XML与XHTML中的注释。如果真遇到这样的代码,在 HTML 中,会被当做注释处理,并报错提醒。

if (startsWith(s, '<![CDATA[')) {
  if (ns !== 0 /* HTML */) {
    node = parseCDATA(context, ancestors);
  }
  else {
    emitError(context, 1 /* CDATA_IN_HTML_CONTENT */);
    node = parseBogusComment(context);
  }
}

如果是 XML 或者 XHTML中,解析也非常的简单。移动代码位置,调用 parseChildren 函数递归解析。

function parseCDATA(context, ancestors) {
  advanceBy(context, 9);
  const nodes = parseChildren(context, 3 /* CDATA */, ancestors);
  if (context.source.length === 0) {
    emitError(context, 6 /* EOF_IN_CDATA */);
  }
  else {
    advanceBy(context, 3);
  }
  return nodes;
}

文本节点的解析

对文本节点的解析,它会解析模板中的普通文本,比如 「这是一段文本」,即当前代码既不是以 插值分隔符开头的字符串,也不是以 < 开头的字符串,则走到普通文本的解析处理逻辑。但是有特殊情况,如果文本中包含了插值分隔符开头或者<会怎么处理了?

这是<一段文本
        
// 假设插值分隔符是 {{
这是{{一段文本

我们来看 parseText 的实现:

function parseText(context, mode) {
  const endTokens = mode === 3 /* CDATA */ ? [']]>'] : ['<', context.options.delimiters[0]];
  let endIndex = context.source.length;
  for (let i = 0; i < endTokens.length; i++) {
    const index = context.source.indexOf(endTokens[i], 1);
    if (index !== -1 && endIndex > index) {
      endIndex = index;
    }
  }
  const start = getCursor(context);
  const content = parseTextData(context, endIndex, mode);
  return {
    type: 2 /* TEXT */,
    content,
    loc: getSelection(context, start)
  };
}

对于一段文本来说,如果在遇到 插值分隔符开头 和 <的字符串,会进行分段处理文本

我们以下面的模板为例:

这是{{一段文本

parseText 函数进行解析,发现存在插值分隔符的开头字符,则会标记插值分割符的开头字符之前的位置。

例如:例子中的模板的插值分隔符的开头字符之前的位置是 2,所以执行 parseTextData 获取文本的内容后,只会解析出这是 字符串,并当做一个节点返回,这样就会返回第一个节点描述。

这时代码还剩 {{一段文本,由于 while 循环没有结束,会再走一次 while 循环逻辑,发现存在插值分隔符开头的字符,所以走到插值的解析逻辑中去。

while (...) {
  const s = context.source;
  let node = undefined;
  if (...) {
    if (startsWith(s, context.options.delimiters[0])) {
      node = parseInterpolation(context, mode);
    }
    ...
  }
  if (!node) {
    node = parseText(context, mode);
  }
  ...
}

  function parseInterpolation(context, mode) {
    const [open, close] = context.options.delimiters;
    const closeIndex = context.source.indexOf(close, open.length);
    if (closeIndex === -1) {
       ...
      return undefined;
    }
    ...
  }

但是文本中只有插值的开头分隔符没有结束分隔符,所以再次走到了 parseText 函数进行解析。将 {{一段文本解析返回为第二个节点描述。

但是这还不是全部,因为最后这段包含插槽分割开头字符串的字符最终只会返回一个节点描述,而不是上面描述的是分开的两个。

原因在于在解析过程中,发现前后两个节点都是文本节点,那么就会进行节点合并。

Merge if both this and the previous node are text and those are consecutive. This happens for cases like "a < b".

function pushNode(nodes, node) {
  if (node.type === 2 /* TEXT */) {
    const prev = last(nodes);
    // Merge if both this and the previous node are text and those are
    // consecutive. This happens for cases like "a < b".
    if (prev &&
        prev.type === 2 /* TEXT */ &&
        prev.loc.end.offset === node.loc.start.offset) {
        prev.content += node.content;
        prev.loc.end = node.loc.end;
        prev.loc.source += node.loc.source;
      return;
    }
  }
  nodes.push(node);
}

元素节点的解析

最后重点来了,相对于前面五种类型的解析过程,元素节点的解析过程应该是最复杂的了,即当前代码是以 < 开头,并且后面跟着字母,说明它是一个标签的开头,则走到元素节点的解析处理逻辑。

if (s[0] === '<') {
  if (/[a-z]/i.test(s[1])) {
    node = parseElement(context, ancestors);
  }
}

parseElement整个过程主要做三件事情:

接下来我们分别简单了解下这三件事具体做了什么?

解析开始标签
// ①
const wasInPre = context.inPre;
// ②
const wasInVPre = context.inVPre;
// ③
const parent = last(ancestors);
// ④
const element = parseTag(context, 0 /* Start */, parent);
// ⑤
...
if (element.isSelfClosing || context.options.isVoidTag(element.tag)) {
  ...
  return element;
}

①,标记是否在 pre 标签内。例如如下模板,当解析到元素 span时,wasInPre 为真。

<pre>
  <span>
  [[ msg ]]
  </span>
</pre>

②,标记是否在 v-pre 指令内,例如如下模板,当解析到元素 span时,wasInVPre 为真。

<div v-pre>
  <span>
    [[ msg ]]
  </span>
</div>

③,获取记录栈最后一个元素,即当前解析元素的父元素。

④,解析开始标签,生成一个标签节点,并前进代码到开始标签后。标签节点的解析详情请见,下一小结!

⑤,如果是自闭和标签或者void tag,直接返回标签节点。其实自闭和标签和 void tag本质是一个意思,就是不一定需要开闭标记一起出现的标签。

例如:这些标签,他们就是自闭和标签。

<area />
<base />
<br />
<col />
<embed />
<hr />
<img />
<input />
<link />
<meta />
<param />
<source />
<track />
<wbr />

这些标签都是可以被正常解析的。

解析子节点
ancestors.push(element);
...
const children = parseChildren(context, mode, ancestors);
ancestors.pop();
element.children = children;

接下来第二步就是解析子节点,

  1. 把解析好的 element 节点添加到 ancestors 数组中,然后执行 parseChildren 去解析子节点,并传入 ancestors。
  2. 如果有嵌套的标签,那么就会递归执行 parseElement,可以看到,在 parseElement 的一开始,我们能获取 ancestors 数组的最后一个值拿到父元素的标签节点,这个就是我们在执行 parseChildren 前添加到数组尾部的。
  3. 解析完子节点后,我们再把 element 从 ancestors 中弹出,然后把 children 数组添加到 element.children 中,同时也把代码前进到子节点的末尾。
解析结束标签

最后,解析结束标签,前进代码到结束标签,然后更新标签节点的代码位置。最终返回的值就是一个标签节点 element。

小结

元素的解析结果本质上是一个递归的过程,原因在于开发者书写的模板是一个嵌套的结构。递归解析才能将嵌套关系全部解析完成。通过不断地递归解析,就可以完整地解析整个模板,并且标签类型的 AST 节点会保持对子节点数组的引用,这样就构成了一个树形的数据结构,所以整个解析过程构造出的 AST 节点数组就能很好地映射整个模板的 DOM 结构。

标签节点解析

在元素的解析过程中,会解析开始标签和结束标签,在这个解析过程中,都会用到标签节点的解析。

标签节点解析主要做一下几件事情:

匹配标签文本结束

首先看看匹配标签文本结束具体在做什么?

// ①
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);
// ③
advanceBy(context, match[0].length);
advanceSpaces(context);
// ④
const cursor = getCursor(context);
const currentSource = context.source;

①,获取节点位置信息。

②,匹配标签文本结束的位置

③,前进代码

④,保存当前的位置状态和源码状态,目的是为了防止使用到 v-pre 时可以重新获取信息

节点属性解析

接着是解析标签中的属性,比如 class、style 和指令等,属性解析详情看下面 「节点属性解析小节」。

let props = parseAttributes(context, type);
标签闭合

最后判断是不是一个自闭和标签,并前进代码到闭合标签后;最后判断标签类型,是组件、插槽还是模板。

小结

标签节点整体的解析流程比较清晰:匹配结束标签,解析属性,关闭标签。最终返回的值就是一个描述标签节点的对象,其:

  • type 表示它是一个标签节点
  • tag 表示标签名
  • tagType 表示标签的类型
  • content 表示文本的内容
  • isSelfClosing 表示是否是一个闭合标签
  • loc 表示文本的代码开头和结束的位置信息
  • children 是标签的子节点数组,会先初始化为空。

元素节点type 都为 0,请注意返回的节点描述对象虽然type 都是 0,但是在 type = 0 下会细分 tagType

tagType 的不同也就表示了不同的元素。

节点属性解析

parseTag在解析时首先匹配标签文本结束的位置,并前进代码到标签文本后面的空白字符后,然后会解析标签中的属性,比如 class、style 和指令等。但是我们知道在 Vue 模板中元素,我们不仅可以写属性还可以写 Vue 的指令。

在正式解析之前我们先看一下含有属性和指令的节点最后会解析成什么样子?

假设有一段这样的模板如下:

<div 
     ref="test-ref"
     id="testId"
     key="test-key"
     class="text-clasee" 
     style="color: red" 
     data-a="test-a"
     data-b="test-b"
     :class="{ active: isActive }" 
     :style="{ color: activeColor, fontSize: fontSize + 'px' }"
     v-has:a:b:c={isShow}
     @click="clickItem(item)"
>
  1
</div>

最后所有的属性都会被解析到一个数组中,挂在节点的 props属性上。每一个 prop就包含了一个属性的所有信息。

我们有了一个大致理解。接下来我们来看看究竟是如何对节点属性进行解析的。

属性名的解析

一个完整的属性包含三部分:属性名称= 属性值。在 Vue 模板中可能是属性名称如:class 、id 这样的属性,也可能是 Vue 的指令如:on-xx、:xxx ,所以解析时会将 Vue 指令也进行单独处理。

在上面的例子中也能发现,对于 Vue 指令和属性,返回的节点类型也是不一样的。

  • type = 6: 属性
  • type = 7: 指令

并且在属性节点描述上也存在很大不一样。

  • 属性:包含了一个 value描述对象type 为 2(文本)
  • 指令:包含了 exp描述对象用于记录指令的表达式信息type 为4(表达式)。如果指令存在参数,还会arg描述对象用于记录指令中的参数type 为4(表达式)

我们来看看源码实现:

function parseAttribute(context, nameSet) {
  ...
  // ①
  const match = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source);
  const name = match[0];
  // ②
  if (nameSet.has(name)) {
    emitError(context, 2 /* DUPLICATE_ATTRIBUTE */);
  }
  ...
  if (name[0] === '=') {
    emitError(context, 19 /* UNEXPECTED_EQUALS_SIGN_BEFORE_ATTRIBUTE_NAME */);
  }
  {
    const pattern = /["'<]/g;
    let m;
    while ((m = pattern.exec(name))) {
      emitError(context, 17 /* UNEXPECTED_CHARACTER_IN_ATTRIBUTE_NAME */, m.index);
    }
  }
  // ③
  if (!context.inVPre && /^(v-[A-Za-z0-9-]|:|.|@|#)/.test(name)) {    
    ...
    return {
      type: 7 /* DIRECTIVE */,
      name: dirName,
      exp: value && {
        type: 4 /* SIMPLE_EXPRESSION */,
        content: value.content,
        isStatic: false,
        constType: 0 /* NOT_CONSTANT */,
        loc: value.loc
      },
      arg,
      modifiers,
      loc
    };
  }
  ...
  // ④
  return {
    type: 6 /* ATTRIBUTE */,
    name,
    value: value && {
      type: 2 /* TEXT */,
      content: value.content,
      loc: value.loc
    },
    loc
  };
    }

①,正则匹配到属性名:/^[^\t\r\n\f />][^\t\r\n\f />=]*/,例如:有如下模板,按照正则匹配规则就会匹配到属性名。

<div key="test-key">1</div>

0: "key"
groups: undefined
index: 0
input: "key="test-key">1</div>"

②,属性名的相关校验,目的是保证属性名是合法的。不合法会在渲染阶段因为不能正常的添加属性而报错。

当同一个节点出现相同属性时,会警告。

<div key="test-key" key="test-key">1</div>

当节点属性 key 以 = 开头 时,会警告和报错。

<div =key="test-key">1</div>

当节点属性的名称中包含 「"」、「'」、「<」 时会警告和报错。

<div 'key'="test-key">1</div>
<div "key"="test-key">1</div>
<div <key="test-key">1</div>

③,匹配代码开头是否是 v-@:.#开头的字符串,对指令属性进行处理返回指令的描述对象。v-开头的属性统统都任务是指令。@字符是 v-on 的缩写。: 是 v-bind 的缩写,.也是 v-bind 的缩写(当使用 .prop 修饰符时)。 #是 v-slot 的缩写。

④,不是指令,返回属性的描述对象。

属性值的解析

属性值的解析方法比较简单:

  • 如果 value值``有引号(「"」or 「'」)开始,那么就找到下一个引号为value值结束
  • 如果value值``没有引号,那么就找到下一个空格为value值结束

总结

通过这篇文章,希望能让你了解 Vue3 编译过程的第一步,即把 template 解析生成 AST 对象,整个解析过程是一个逐步解析的过程,从 template 第一个字符开始不断的解析分析,不同节点类型找到对应的解析处理逻辑,创建 AST 节点,处理的过程中也在不断前进代码,更新解析上下文,最终根据生成的 AST 节点数组创建 AST 根节点。


由于编译过程平时开发中很难接触到,所以不需要你对每一个细节都了解,你只要对整体有一个理解和掌握即可。好的,到这里,本篇文章结束。

参考

猜你喜欢

转载自juejin.im/post/7126712173014810661