likes
comments
collection
share

Vue3 编译原理直通车💥——parser 篇

作者站长头像
站长
· 阅读数 13

啥?Vue 都到 3.3 版本了,你说你还不晓得 Vue 的编译器原理?

本着对学习技术 (跳槽涨薪) 的追求,今天和大家伙唠唠 Vue3 的编译原理!

什么是编译器

编译器其实就是一种将源代码转换为目标代码的程序,用一句话概括就是:我们将源代码输入到程序之中,程序经过处理后会输出目标代码

Vue3 编译原理直通车💥——parser 篇

完整编译过程

一个完整的编译过程通常会包括以下几个部分:

词法分析(Lexical Analysis)

将源代码分解为词法单元(Token),并确定每个词法单元的类型和值。

语法分析(Syntax Analysis)

将词法单元序列转换为抽象语法树(Abstract Syntax Tree,AST),并检查语法是否正确。

语义分析(Semantic Analysis)

对抽象语法树进行语义分析,包括类型检查、作用域分析、符号表管理等。

中间代码生成(Intermediate Code Generation)

将抽象语法树转换为中间代码(Intermediate Code),并进行优化。

目标代码生成(Code Generation)

将中间代码转换为目标代码(Target Code),并进行优化。

Vue3 编译原理直通车💥——parser 篇

简单介绍了一下编译器是什么,下面我们来看看 Vue 的编译器都做了什么。

Vue 编译器

Vue 中,我们使用模板语法来描述 UI:

<template>
  <div :id="dyncId">hello world</div>
</template>

但是当项目跑起来的时候时,是在浏览器、服务端等环境中运行的,这些环境下并不能识别 Vue 的这种写法;这时候就需要 Vue 的编译器发挥作用了。

Vue 的编译器,会把上面的模板语法"翻译"成浏览器能够识别的语法;以浏览器端为例就是下面这段代码:

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("template", null, [
    _createElementVNode("div", { id: _ctx.dyncId }, "hello world", 8 /* PROPS */, ["id"])
  ]))
}

可以看到,这是一段 JS 代码,这里的 render 是不是十分眼熟?

没错, Vue 的编译器的输入源代码是模板语法,输出的目标代码其实就是渲染函数

而要弄明白从输入模板语法到生成渲染函数之间的具体运作过程,就不得不介绍一下下面的三兄弟:

解析器(parser)

负责将模板通过 parse 函数进行词法分析、语法分析,然后转化为模板 AST

转换器(transformer)

转换器通过 transform 函数接收模板 AST,并将 模板 AST 转换为 JavaScript AST

生成器(generator)

生成器则通过 generate 函数,将 JavaScript AST 转化为我们最终的渲染函数。

用一张图表示它们之间的关系,就是下面这样的:

Vue3 编译原理直通车💥——parser 篇

本文会先从解析器 parser 开始,系统地介绍 Vue 编译器相关原理

至于剩余的 转换器(transformer)生成器(generator)原理会在后续更新,容我先埋个坑~

解析器(parser)

我们之前说道,解析器(parser) 就是通过 parse 函数,把输入的模板转化为 模板 AST

这里先简单介绍一下模板 AST 是什么:

模板 AST

所谓的 模板 AST(Abstract Syntax Tree) 其实就是用 JS 语法对 Vue 的模板做了一层抽象。

举个例子,我们有如下模板:

<template>
  <div>
    <h1>
      {{ show }} value
    </h1>
    <div>
      <!-- 我是注释 -->
    </div>
    <span v-show="show">
      hello world
    </span>
  </div>
</template>

经过 parse 函数解析后,对应的 模板 AST 长这样:

const templateAst = {
  // 每个 template ast 都会有一个虚拟的 root 根节点
  type: 'Root',
  // 这里的 children 就是模板解析出的真正内容
  children: [
    {
      type: 'Element', // 这是一个元素节点
      tag: 'div', // 节点标签为 div
      children: [
        {
          type: 'Element',
          tag: 'h1',
          children: [
            { 
              type: 'Interpolation', // 表示这是一个插值节点
              content: {
                type: 'Expression', // 节点内容类型是个表达式
                content: 'show' // 表达式内容
              }
            },
            { 
              type: 'Text', // 这是一个文本节点
              content: 'value' // 文本内容
            }
          ]
        },
        {
          type: 'Element',
          tag: 'div',
          children: [
            { 
              type: 'Comment', // <!-- 我是注释 --> 这是注释节点
              content: '我是注释' // 注释的内容
            }
          ]
        },
        {
          type: 'Element',
          tag: 'span',
          props: [ // span 标签的相关 props
            { 
              type: 'Attribute', // 这是一个属性
              name: 'v-show',  // 属性名称
              value: 'show'  // 属性值
            }
          ],
          children: [
            { 
              type: 'Text', // 这是一个文本节点
              content: 'value' // 文本内容
            }
          ]
        }
      ]
    }
  ]
}

从解析后的 AST 代码中我们可以看出,每个 templateAst 都会有一个虚拟的 root 根节点,而 root 同级别的 children 就是模板解析出的真正内容

并且标签、文本、注释以及插值表达式等等这些,都会被用不同的 type 来标识;不同的 type 又会有自己的一系列 "附加属性",来对这种 type 进行进一步补充说明。

实际上,除了代码中列举的这些,Vue 的 模板 AST 还定义了许多其它类型的节点 type,这里就不展开讲啦。 有兴趣的小伙伴可以直接查看源码,相关源码在:packages\compiler-core\src\ast.ts 路径下。

文本模式

在正式开始讲解解析器原理时,还需要介绍一下文本模式

文本模式指的是解析器在解析模板时的一些特殊状态,文本模式不同,解析器对模板的解析行为会有所不同

大家看到这里可能会疑惑,文本模式的作用是什么?我知道你很急,但是请你先别急🤡,我们先来看看下面这两个模板的编译结果:

模板一:

<textarea>
  <p>hello</p>
</textarea>

模板二:

<div>
  <p>hello</p>
</div>

大家可以动手试试,会发现模板一也就是 textarea 标签中的 <p>hello</p>当做一段文本解析了,而不是作为标签被处理

Vue3 编译原理直通车💥——parser 篇

并且 <textarea>&lt;</textarea> 中的 &lt; 也就是 HTML 字符实体部分,会被解析成 '>',而 <iframe>&lt;</iframe> 中的 &lt; 则会被纯文本 '&lt;' 解析。

这说明标签不同,会导致对标签嵌套的子节点内容的解析规则不同,在解析模板时也要考虑这一点。

Vue 编译器中,使用到的文本模式有以下几种:

  • DATA 模式:解析器初始文本模式;
    • 这个模式下,解析器可以识别标签元素
    • 这个模式下,解析器可以识别 HTML 字符实体,如: &lt;、&copy;
  • RCDATA 模式:当遇到标签 <title><textarea> 时会切换到这个模式;
    • 这个模式下,解析器不能识别标签元素
    • 这个模式下,解析器可以识别 HTML 字符实体,如: &lt;、&copy;
  • RAWTEXT 模式:当遇到标签 <style><xmp><iframe><noembed><noframes><noscript><script> 时会切换到这个模式;
    • 这个模式下,解析器不能识别标签元素
    • 这个模式下,解析器不能识别 HTML 字符实体
  • CDATA 模式:当遇到 <![CDATA[ 时,会切换到这个模式;
    • 这个模式下,解析器不能识别标签元素
    • 这个模式下,解析器不能识别 HTML 字符实体
  • ATTRIBUTE_VALUE 模式:解析标签上的属性值时,会切换到这个模式;如: <div id="foo"> 中的 foo

parser 原理

介绍完了风景,接下来换介绍我自己 啊不是,介绍完了什么是模板 AST文本模式,接下来说说 Vue parser 解析器的原理

在上文中,我们已经给出了对应的输入(模板字符串)和输出(模板 AST),大家不妨试着思考一下,如果让你来实现这个 parser,你会怎么操作呢?

你可能很容易就想到:遍历模板内容逐个读取字符,通过编写正则表达式,根据规则去匹配并消费字符串,同时设置对应的 type

Bingo!🎉思路完全正确。

按照这个思路,我们先定义一个消费模板字符串的方法 advanceBy,它的作用就是根据传入的数字 num,截取 num 后模板的内容,达到消费字符串的目的

function advanceBy(context, num) {
  context.source = context.source.slice(num)
}

接下来,还记得我们前面说的不同文本模式对解析器解析结果的影响吗?我们将所有的文本模式列举出来,方便后续使用:

// 文本模式
const TextModes = {
  DATA,
  RCDATA,
  RAWTEXT,
  CDATA,
  ATTRIBUTE_VALUE
}
// 消费字符串方法
function advanceBy(context, num) {
  context.source = context.source.slice(num)
}

而在后续的模板解析的过程中,模板字符串会被传递到各个方法中做处理,所以我们可以先定义一个 baseParse 函数,并使用一个 context 对象进行统一管理

// 文本模式
const TextModes = {
  DATA,
  RCDATA,
  RAWTEXT,
  CDATA,
  ATTRIBUTE_VALUE
}
// 消费字符串方法
function advanceBy(context, num) {
  context.source = context.source.slice(num)
}
// 解析器入口
function baseParse(str) {
  const context = {
    // 这里的 str 就是我们的模板字符串
    source: str
  }
}

准备工作就绪,下面正式开始解析我们的模板!

parseChildren

在前面介绍 模板AST 的时候提到过,每个 模板 AST 都会有一个虚拟的 root 节点,真正解析后的内容是挂在 children 属性下

所以我们在这里定义一个 parseChildren 函数,作为分发处理模板解析逻辑的入口

function baseParse(str) {
  const context = {
    source: str
  }
  // parseChildren 是开启处理模板的真正入口
  // 第一个参数是模板字符串
  // 第二个参数是文本模式
  // 第三个参数是个栈,作用在后文会介绍
  const nodes = parseChildren(context, TextModes.DATA, [])
  // 每个模板 AST 都有一个逻辑根节点 root
  return {
    type: 'Root',
    children: nodes
  }
}

那么 parseChildren 函数的具体逻辑又是什么呢?

要弄明白这一点,我们需要先通过下面的 gif 看看正常情况下模板的解析过程:

Vue3 编译原理直通车💥——parser 篇

用文字来描述就是:

  1. 当遇到开始标签 <div> 时,将 <div> 压入标签栈中;同时 parseChildren 会被调用(这里记做 parseChildren1 ),
  2. 当解析到标签 <div> 内嵌套的子标签 <p> 时:
    • <p> 压入标签栈中;
    • parseChildren 会被重复的调用(这里记做 parseChildren2 );此时 parseChildren1 还没执行结束;
  3. 当解析到标签 </p> 时,这是一个结束标签,意味着对于 p 标签 的解析已经结束了:
    • 我们在标签栈中寻找有没有对应的开始标签 <p>,若是找到了,将其推出标签栈;
    • 并且停止 parseChildren2 的执行
  4. 接下来,解析到标签 <div> 内嵌套的子标签 <span> 时:
    • <span> 压入标签栈中;
    • 重复调用 parseChildren (这里记做 parseChildren3 );注意,此时 parseChildren1 还没执行结束;
  5. 当解析到标签 </span> 时,重复步骤 3 的过程;
  6. 最后,解析到标签 </div> 时:
    • 在标签栈中寻找有没有对应的开始标签 <div>,若是找到了,将其推出标签栈;
    • 停止 parseChildren1 的执行。

上述的过程其实传达了一个重要信息:parseChildren 函数会随着时间的推移,不停的消费模板字符串;直到遇到结束标签,且标签栈中有对应的开始标签时,才会停止当前 parseChildren 的执行

此外,除了遇到相应的结束标签要停止循环之外,当模板被解析完毕时也要停止 parseChildren 的执行。

通过这个信息,我们可以写出如下代码:

function parseChildren(source, mode, ancestors) {
  // 使用 while 开启循环,当 isEnd 为 true 时停止循环
  while(!isEnd(context, ancestors)) {
    // TODO 解析模板
  }
}

function isEnd(context, ancestors) {
  const s = context.source
  // 当模板节点被解析完毕时,需要停止循环
  if (!s) return true
  // 如果当前模板是以 '</' 开头
  if (s.startsWith('</')) {
    // 遍历节点栈,节点栈
    for (let i = ancestors.length - 1; i >= 0; --i) {
      // ancestors[i].tag 是标签的名称,如 div、p
      // 只要节点栈中存在同名的节点,就返回 true 停止循环
      if (s.startsWith(`</${ancestors[i].tag}`)) {
        return true
      }
    }
  }
}

现在,我们的 parseChildren 已经能够随着时间推移不断解析模板字符串,并在恰当的时候停止解析了

但是具体是如何解析, 而 parseChildren 是在什么时候被重复调用形成递归,以及 ancestors 中的节点又是在什么时候进出栈的呢

我们接着往下看:

function parseChildren(source, mode) {
  // 用一个数组来储存我们解析后的节点
  let nodes = []
  const s = context.source
  let node
  while (!isEnd(context, mode, ancestors)) {
    // 只有 DATA 模式才支持解析标签
    if (mode === TextModes.DATA && /[a-z]/i.test(s[1])) {
      // 如果以 '<' 开头,并且后头紧跟着 a-z 之间的字母,则作为标签处理
      node = parseElement(context)
    }
  }
  // 将节点 push 进入 nodes
  nodes.push(node)
  // 返回节点
  return node
}

在前面的文本模式中,我们介绍过只有 DATA 模式下解析器才支持解析标签节点;在上面的代码中,我们增加了这部分的判断逻辑,并且把解析标签的逻辑交给 parseElement 函数处理。

我们来看看 parseElement 函数做了什么。

parseElement

parseElement 函数逻辑如下:

function parseElement(context, ancestors) {
  const s = context.source
  // 解析开始标签
  const element = parseTag(context, 'start')
  // 将标签推入节点栈
  ancestors.push(element)
  // 调用 parseChildren 函数逻辑,形成递归,并将结果挂载在 element 的 children 属性下
  element.children = parseChildren(context)
  // 当代码执行到这里,说明当前节点 element 的子节点已经全部解析完毕了
  // 将栈顶的元素推出栈
  ancestors.pop()
  // 解析结束标签
  parseTag('end')
  // 返回对应的 element
  return element
}

从上面的代码中可以看到,parseElement 函数其实很简单,它主要做了几件事:

  1. 调用 parseTag 函数解析开始标签(这个函数的作用在后文会介绍);
  2. 将当前 解析完毕的 element 并将其推入节点栈
  3. 紧接着调用 parseChildren 函数形成递归,递归结束后的返回结果被挂载在 elementchildren 属性下;
  4. ancestors 栈顶的元素 pop 出栈;
  5. 最后解析结束标签,返回对应的 element

parseElement 函数中,我们就完成了 parseChildren 的递归调用,以及 ancestors 中节点进出栈的逻辑

那么 parseTag 函数又做了什么呢?接着往下看:

parseTag

标签又分为开始标签和结束标签,两者的处理规则是不同的,对于开始标签,标签上会有额外的属性需要处理,如: <div id="id" v-show="show">,而结束标签则只需要处理标签名称就可以

因此 parseTag 函数中用一个参数 type,来区分开始标签和结束标签。

最终代码如下:

// 解析 element tag
function parseTag(context, type = 'start') {
 // 通过正则匹配到标签名称,会匹配到 '>',
 // 以 <div> 为例,match[0] 为 '<div',match[1] 为 'div'
  const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source)
  // 这里的 tag 就是 '<div>' 中的 'div'
  const tag = match[1]
  // 消费被匹配到的字符串长度,也就是 '<div' 的长度
  advanceBy(context, match[0].length)
  // 调用 parseAttributes 函数来处理属性
  const props = parseAttributes(context, type)
  // 判断是否是自闭合标签
  const isSelfClosing = context.source.startsWith('/>')
  // 如果是,消费 '/>',否则消费 '>'
  advanceBy(context, isSelfClosing ? 2 : 1)
  // vue 模板中还有些内置的标签 <slot> 等,通过 tagType 来区分
  let tagType = 0
  if (tag === 'slot') {
    tagType = 'Slot'
  } else if (tag === 'template') {
    tagType = 'Template'
  } else if (tag === 'component') {
    tagType = 'Component'
  }
  // 返回节点对象信息
  return {
    type: 'Element',
    tag,
    tagType,
    props,
    isSelfClosing,
    // 子节点暂时留空
    children: []
  }
}

parseTag 函数做了以下几件事:

  1. 先通过正则匹配出对应的标签名称,并消费匹配出的字符串长度;
  2. 然后调用 parseAttributes 函数来进一步处理标签上的属性
  3. 判断是否是自闭合标签,来决定是消费 '/>' 还是 >
  4. 再根据 tag 来确定 tagType
  5. 最后返回对应的节点信息。

接下来我们来看看 parseAttributes 函数的逻辑:

parseAttributes

当标签的名称部分被消费后,传递给 parseAttributes 函数的部分就是对应的属性和字符 '>'

比如:模板 <div id="id" v-show="show"> 传递给 parseAttributes 函数的部分就是 id="id" v-show="show">

不难看出,在字符 '>' 之前的部分都是我们需要解析的属性内容

我们可以用一个 while 语句配合 advanceBy 函数来迭代这部分的内容:

function parseAttributes (context, type) {
  // 属性值有多个,用一个数组来保存
  const props = []
  // 结束标签上是没有属性的 直接退出
  if (type === 'end') return
  // 当遍历到 '>' 或者 '/>' 时停止
  while(
    !context.source.startsWith('>') &&
    !context.source.startsWith('/>')
  ) {
    // 正则匹配属性名称,也就是 '=' 前的部分
    // 如:id="foo" 匹配出 id
    const match = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source)!
    // match[0] 在 id="foo" 中就是 id
    const name = match[0]
    // 消费属性名称
    advanceBy(context, name.length)
    // 属性值
    let value = undefined
    // 正则判断剩下的部分是否以 '=' 号开头,是则消费 '=' 号
    if (/^[\t\r\n\f ]*=/.test(context.source)) {
      advanceBy(context, 1)
      // 解析属性值
      value = parseAttributeValue(context)
    }
    // 创建一个属性节点,并添加到 props 数组中
    props.push({
      type: 'Attribute',
      name, // 属性名称
      value // 属性值
    })
  }
  // 最后返回 props
  return props
} 

// 解析属性值
function parseAttributeValue(context) {}

parseAttributes 函数中,我们将 属性名称'=' 符号的部分已经消费掉了,接下来通过 parseAttributeValue 函数来消费属性值

对于属性值,通有几种情况:

  • 被双引号包裹:id="id"
  • 被单引号包裹:id='id'
  • 没有引号包裹:id=id

如果属性值被引号包裹,那么值就是两个引号之间的部分,我们在消费一个引号之后,在下一个引号之前的部分都是属性值中的内容;

如果属性值没有被引号包裹,那么属性值就是在下一个空白字符之前的所有字符

具体代码如下:

function parseAttributeValue (context) {
  let content
  // 判断是否以引号开头
  const quote = context.source[0]
  const isQuoted = quote === `"` || quote === `'`
  // 如果属性值被引号引用
  if (isQuoted) {
    // 消费属性值前面的引号
    advanceBy(context, 1)
    // 找到属性值后面的引号的 index
    const endIndex = context.source.indexOf(quote)
    if (endIndex === -1) {
      // 缺少引号错误
      console.error('缺少引号')
    } else {
      // 获取下一个引号之前的内容作为属性值
      content = context.source.slice(0, endIndex)
      // 消费属性值
      advanceBy(context, content.length)
      // 消费属性值后的引号
      advanceBy(context, 1)
    }
  } else {
    // 如果属性值没有被引号引用
    // 通过正则匹配,下一个空白字符之前的内容作为属性值
    const match = /^[^\t\r\n\f >]+/.exec(context.source)
    content = match[0]
    // 消费匹配到的属性值
    advanceBy(context, content.length)
  }
  // 返回属性值
  return content
}

实际上,parseAttributes 函数中对于 v-show@#: 等等这些自定义指令以及具有特殊意义的符号通过正则匹配做了其它的处理,并且它们的 type 也不是 Attribute,而是 Directive;这里就不展开讲了,有兴趣的小伙伴可以自行查询源码,相关代码在: packages/compiler-core/src/parse.ts 路径下。

针对 Element 类型元素标签的解析逻辑到这里就结束啦。

当然,除了我们介绍的 DATA 模式下对 Element 类型节点的解析逻辑之外,在 Vue 的解析器中,针对插值表达式、注释、<!DOCTYPE> 标签、<![CDATA[ 标签以及文本都有不同的解析方法

完整的 parseChildren 函数如下:

function parseChildren(source, mode) {
  // 用一个数组来储存我们解析后的节点
  let nodes = []
  const s = context.source
  let node
  while (!isEnd(context, mode, ancestors)) {
    // 只有 DATA 模式和 RCDATA 模式才支持解析插值表达式和标签
    if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
      // 以 '{{' 开头
      if (s.startsWith('{{')) {
        // 解析 '{{}}' 插值表达式
        node = parseInterpolation(context, mode)
      } else if (mode === TextModes.DATA && s[0] === '<') {
        // DATA 模式下才支持相关标签节点的解析
        if (s[1] === '!') {
          if (startsWith(s, '<!--')) { // 模板以 '<!--' 开头
            // 解析注释
            node = parseComment(context)
          } else if (s.startsWith('<!DOCTYPE')) { // 模板以 '<!DOCTYPE' 开头
            // 解析 DOCTYPE
            node = parseBogusComment(context)
          } else if (s.startsWith('<![CDATA[')) { // 模板以 '<![CDATA[' 开头
            // 解析 CDATA
            node = parseCDATA(context, ancestors)
          }
        } else if (/[a-z]/i.test(s[1])) {
          // 如果以 '<' 开头,并且后头紧跟着 a-z 之间的字母,则作为标签处理
          node = parseElement(context)
        }
      }
    }
    // 如果 node 不存在,这种情况下将其作为文本处理
    if (!node) {
      node = parseText(context, mode)
    }
  }
  nodes.push(node)
  // 返回节点
  return node
}

parseInterpolation

插值表达式就是以字符串 {{ 开头,以字符串 }} 结尾;

parseInterpolation 函数做的就是将 {{}} 之间的内容提取出来,作为 JavaScript 表达式返回:

function parseInterpolation (context) {
  const s = context.source
  // 消费 '{{'
  advanceBy(context, '{{'.length)
  // 找到 '}}' 的位置索引
  const closeIndex = s.indexOf('}}')
  // 截取 '{{' 和 '}}' 之间的内容作为 JS 表达式
  const content = s.slice(0, closeIndex)
  // 消费 '}}'
  advanceBy(context, '}}'.length)
  return {
    type: 'Interpolation', // 类型 Interpolation 代表插值节点
    content: { // 插值节点的内容
      type: 'Expression', // 类型 Expression 表示是个表达式
      content
    }
  }
}

parseCDATA 函数:

function parseCDATA(context, ancestors) {
  // 消费 '<![CDATA[' 共9个字符
  advanceBy(context, '<![CDATA['.length)
  // 递归调用 parseChildren 进行解析
  const nodes = parseChildren(context, TextModes.CDATA, ancestors)
  // 消费剩余 ']]>' 共3个字符
  advanceBy(context, ']]>'.length)
  // 返回 nodes
  return nodes
}

其余解析函数

其实这些解析函数的逻辑都大同小异,就是:通过正则匹配出对应的模板字符串,然后再调用 advanceBy 方法消费字符串,再去做一些边界情况的处理

剩余的解析函数在这里就不细说啦,具体做了哪些事就留给大家自己探索吧🙊!

相关代码在 packages/compiler-core/src/parse.ts 路径下。

总结

文章的最后我们来一起做个小小的回顾:

首先是 baseParse 函数,它是我们模板解析的入口,接收模板字符串作为参数,在其中我们维护了解析过程中共享的全局上下文信息

然后是 parseChildren 函数,它会使用 while 开启一个循环,根据解析器当前所处的文本模式以及当前解析到的字符串,来调用对应的解析方法,直到在节点栈中遇对应的结束标签时停止当前循环

如果解析出来的标签类型的节点,会调用 parseElement 方法;在 parseElement 中会将该标签压入节点栈,然后调用 parseChildren 开启一轮新的节点解析从而形成递归;直到 parseChildren 函数执行完毕,再将栈顶的元素推出栈