Vue3架构分析(二)—— Vue3编译器的设计与实现

大家好,我是原心。

提示: 本系列文章都是基于vue 3.4.21源码进行解读梳理,文章中关键的地方会链接到github源码文件对应位置

上一篇文章,我分享了一下从Vue单文件组件到浏览器中的DOM的主流程,不过,其中忽略了诸多细节。

接下来,我想详细地分析一下Vue3的单文件组件的编译过程(即,其从.vue文件变成JS和CSS的过程),并试着分析其编译器的设计与实现。

提示: 编译器的职责是将源码转换成目标代码,而Vue编译器的职责就是将vue源码(template,script,style)转换成浏览器能够执行的js和css代码。

一、整体设计

为了使主流程的清晰易懂,我们先梳理了Vue编译器的整体流程图,如下所示:

Vue编译器的工作流程如下:

  1. 词法分析: 将Vue源文件字符串输入词法分析器,经过处理之后得到许多token(标记),单个token对应源码中的一段连续的字符串,表示程序语言中有具体含义的词语(类似将文章分成许多词语)。

  2. 语法分析: 按顺序输入第一步产生的token,经过语法分析,根据不同token的上下文和含义,生成Vue AST,它是Vue程序文本的结构化描述

  3. 语法转换: 处理第二步生成的Vue AST树中的所有节点,将其转换成JS AST。这个过程中,我们的转换器需要对vue中的指令插值表达式组件元素等等 进行一系列处理,最终产出可以用于生成js代码的JS AST

  4. 代码生成器: 处理步骤三产生的JS AST树中的所有节点,生成最终的JS代码

下面摘录表达这一过程的源码如下(您也可以通过源码链接查看):

ts 复制代码
export function baseCompile(
  source: string | RootNode,
  options: CompilerOptions = {},
): CodegenResult {
  ... 
  // 传入的是字符串(源代码,则执行baseParse函数进行编译)
  const ast = isString(source) ? baseParse(source, resolvedOptions) : source
  ... 
  // 执行转换,将编译后的vue ast转换成js ast
  transform(
    ast,
    extend({}, resolvedOptions, {
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []), // user transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {}, // user transforms
      ),
    }),
  )

  // 根据转换后的ast和编译配置,生成目标代码
  return generate(ast, resolvedOptions)
}

现在,我们对Vue3的编译器的工作流程已经有了一个宏观的认识,接着我们先来看看Vue3编译器各部分的设计实现

二、词法分析器和语法分析器的设计与实现

Vue3的词法分析器和语法分析器设计得简单而巧妙,下面是我根据源码梳理得出的UML类图,应该可以表达出其设计思路:

根据上面的描述,按理词法分析器和语法分析器应该是完全解耦的,唯一的关联只是语法分析器依赖词法分析器输出的token,那上面这个图怎么耦合起来了呢?

理想情况下词法分析器和语法分析器确实不该耦合,但是为了让分词和语法分析同步进行,不得不这样做。

我想,之所以要让分词和语法分析同步进行的主要原因有以下两个:

  1. 源码的语法可能存在错误或不合理的情况,一边分词一边做语法分析能更快发现存在的问题
  2. 基于性能的考虑,一边分词一边做语法分析能够节省一些不必要的额外步骤

下面,我们简单解读一下上面的类图:

  1. 首先词法分析器(Tokenizer) 依赖一个抽象的取词器 (Callbacks接口),取词器 提供了一些方法用于接收词法分析器分析后输出的token,对于不同类型的token,将传递给不同的接收方法。
  2. 语法分析器(Parser)实现了取词器 (Callbacks接口),并且将这个实现作为词法分析器的依赖传递给他,然后语法分析器就可以静静地等待词法分析器回传token了。
  3. 语法分析器 中的取词器 收到词法分析器 回传的token后,会对不同类型的token进行不同的处理,生成AST节点并将AST节点添加到Vue AST树中。

这样的设计让词法分析器语法分析器 解耦了,无论是词法分析器 还是语法分析器 的实现都是可替换的,只要他们都遵循取词器的接口进行通信就好。下面摘录了能表达上述设计的代码:

ts 复制代码
interface Callbacks {
  ontext(start: number, endIndex: number): void
  ontextentity(char: string, start: number, endIndex: number): void

  oninterpolation(start: number, endIndex: number): void

  onopentagname(start: number, endIndex: number): void
  onopentagend(endIndex: number): void
  onselfclosingtag(endIndex: number): void
  onclosetag(start: number, endIndex: number): void

  onattribdata(start: number, endIndex: number): void
  onattribentity(char: string, start: number, end: number): void
  onattribend(quote: QuoteType, endIndex: number): void
  onattribname(start: number, endIndex: number): void
  onattribnameend(endIndex: number): void

  ondirname(start: number, endIndex: number): void
  ondirarg(start: number, endIndex: number): void
  ondirmodifier(start: number, endIndex: number): void

  oncomment(start: number, endIndex: number): void
  oncdata(start: number, endIndex: number): void

  onprocessinginstruction(start: number, endIndex: number): void
  // ondeclaration(start: number, endIndex: number): void
  onend(): void
  onerr(code: ErrorCodes, index: number): void
}
ts 复制代码
class Tokenizer {
  ...
  constructor(
    private readonly stack: ElementNode[],
    private readonly cbs: Callbacks,
  )

  ...
}
ts 复制代码
// 这里的传入的第二个对象参数就是对Callbacks接口的实现
const tokenizer = new Tokenizer(stack, {
  onerr: emitError,
  // 当词法分析器分析出一串完整的文本时,就会调用该函数
  ontext(start, end) {
    onText(getSlice(start, end), start, end)
  },
  ...
})

export function baseParse(input: string, options?: ParserOptions): RootNode {
  ...
  currentInput = input
  ...
  const root = (currentRoot = createRoot([], input))
  // 调用词法分析器进行分词
  tokenizer.parse(currentInput)
  ...
  return root
}

现在,我们已经在宏观上的了解了Vue3词法分析器和语法分析器的设计模型,接下来我们通过源码来学习一下各部分的具体实现。

提示:本节后续内容可能会比较烧脑和过于细节,如果不感兴趣可以直接跳转到转换器的设计与实现

(一)词法分析器的实现分析

词法分析器的核心本质实际上是做字符串处理,将源码字符串处理成许多有特殊意义的token。

要读懂词法分析器 的源码,我们要先理解有限状态自动机 (又叫有限自动机)模型,因为我们的词法分析器的运转就是基于这个模型来运转的。

有限自动机的几个要素:

  1. 具有有限个明确的状态
  2. 各状态之间的流转途径有明确的定义
  3. 状态机初始化时具有一个确定的初始状态
  4. 分析结束时,可以处于的正确的状态定义

为了便于理解词法分析器状态机的工作流程,我们先来举一个非常简单的例子(复杂了会很烧脑):

  • 我们基于下面的vue sfc组件(①),来设置分析一下tokenizer在执行过程中的状态流转
vue 复制代码
<template>
  <div>{{ a }}</div>
</template>
<script setup>
import { ref } from 'vue'

const a = ref('a')
</script>
  • 为了更好地理解后续内容,我们先放出基于上面的代码,词法分析器和语法分析器内部的状态流转图:

如图所示,为了完成左侧vue sfc源码到Vue AST的转换,词法分析器的状态机一共做了25次状态流转,而这个过程中语法分析器的辅助栈也进行了六次变化,当然整个过程中,Vue AST中的节点内部做了更多次变化。

在进行源码实现分析之前,我们先看看为了实现词法分析器功能,做的一些类型定义及内容。

  • Vue的词法分析器的所有状态定义(共34种状态)源码链接
ts 复制代码
/** All the states the tokenizer can be in. */
export enum State {
  Text = 1,

  // interpolation (插值表达式相关的状态)
  InterpolationOpen,
  Interpolation,
  InterpolationClose,

  // Tags(标签相关的状态)
  BeforeTagName, // After <
  InTagName,
  InSelfClosingTag,
  BeforeClosingTagName,
  InClosingTagName,
  AfterClosingTagName,

  // Attrs (属性相关的状态)
  BeforeAttrName,
  InAttrName,
  InDirName,
  InDirArg,
  InDirDynamicArg,
  InDirModifier,
  AfterAttrName,
  BeforeAttrValue,
  InAttrValueDq, // "
  InAttrValueSq, // '
  InAttrValueNq,

  // Declarations
  BeforeDeclaration, // !
  InDeclaration,

  // Processing instructions
  InProcessingInstruction, // ?

  // Comments & CDATA
  BeforeComment,
  CDATASequence,
  InSpecialComment,
  InCommentLike,

  // Special tags
  BeforeSpecialS, // Decide if we deal with `<script` or `<style`
  BeforeSpecialT, // Decide if we deal with `<title` or `<textarea`
  SpecialStartSequence,
  InRCDATA,

  InEntity,

  InSFCRootTagName,
}
  • 特殊字符的定义,用于词法分析过程中辅助判断 查看源码
ts 复制代码
export enum CharCodes {
  Tab = 0x9, // "\t"
  NewLine = 0xa, // "\n"
  FormFeed = 0xc, // "\f"
  CarriageReturn = 0xd, // "\r"
  Space = 0x20, // " "
  ExclamationMark = 0x21, // "!"
  Number = 0x23, // "#"
  Amp = 0x26, // "&"
  SingleQuote = 0x27, // "'"
  DoubleQuote = 0x22, // '"'
  GraveAccent = 96, // "`"
  Dash = 0x2d, // "-"
  Slash = 0x2f, // "/"
  Zero = 0x30, // "0"
  Nine = 0x39, // "9"
  Semi = 0x3b, // ";"
  Lt = 0x3c, // "<"
  Eq = 0x3d, // "="
  Gt = 0x3e, // ">"
  Questionmark = 0x3f, // "?"
  UpperA = 0x41, // "A"
  LowerA = 0x61, // "a"
  UpperF = 0x46, // "F"
  LowerF = 0x66, // "f"
  UpperZ = 0x5a, // "Z"
  LowerZ = 0x7a, // "z"
  LowerX = 0x78, // "x"
  LowerV = 0x76, // "v"
  Dot = 0x2e, // "."
  Colon = 0x3a, // ":"
  At = 0x40, // "@"
  LeftSquare = 91, // "["
  RightSquare = 93, // "]"
}
  • 有特殊含义的字符串定义,用于辅助判断有特定含义的源码串 查看源码
ts 复制代码
export const Sequences = {
  Cdata: new Uint8Array([0x43, 0x44, 0x41, 0x54, 0x41, 0x5b]), // CDATA[
  CdataEnd: new Uint8Array([0x5d, 0x5d, 0x3e]), // ]]>
  CommentEnd: new Uint8Array([0x2d, 0x2d, 0x3e]), // `-->`
  ScriptEnd: new Uint8Array([0x3c, 0x2f, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74]), // `</script`
  StyleEnd: new Uint8Array([0x3c, 0x2f, 0x73, 0x74, 0x79, 0x6c, 0x65]), // `</style`
  TitleEnd: new Uint8Array([0x3c, 0x2f, 0x74, 0x69, 0x74, 0x6c, 0x65]), // `</title`
  TextareaEnd: new Uint8Array([
    0x3c, 0x2f, 116, 101, 120, 116, 97, 114, 101, 97,
  ]), // `</textarea
}

源码中,还定义了很多辅助判断的函数,这里就不一一列举了,因为最重要的还是对状态的定义。从状态的数量(34种)就能看出来,要实现词法分析和语法分析工作量是非常大的。

不过还有两个比较关键的前置源码,一个就是状态机的初始状态,另一个是我们的词法分析器的状态流转入口,下面我们也把相关代码摘录出来了。

ts 复制代码
export default class Tokenizer {
  /** The current state the tokenizer is in. */
  public state = State.Text
  ...
}
  • 状态机的每次接收输入时,状态的流转的入口在tokenizer.parse方法中,这里包含了所有这34种状态下,状态机的逻辑分支入口 查看源码
ts 复制代码
public parse(input: string) {
    this.buffer = input
    while (this.index < this.buffer.length) {
      const c = this.buffer.charCodeAt(this.index)
      if (c === CharCodes.NewLine) {
        this.newlines.push(this.index)
      }
      switch (this.state) {
        case State.Text: {
          this.stateText(c)
          break
        }
        case State.InterpolationOpen: {
          this.stateInterpolationOpen(c)
          break
        }
        case State.Interpolation: {
          this.stateInterpolation(c)
          break
        }
        case State.InterpolationClose: {
          this.stateInterpolationClose(c)
          break
        }
        case State.SpecialStartSequence: {
          this.stateSpecialStartSequence(c)
          break
        }
        case State.InRCDATA: {
          this.stateInRCDATA(c)
          break
        }
        case State.CDATASequence: {
          this.stateCDATASequence(c)
          break
        }
        case State.InAttrValueDq: {
          this.stateInAttrValueDoubleQuotes(c)
          break
        }
        case State.InAttrName: {
          this.stateInAttrName(c)
          break
        }
        case State.InDirName: {
          this.stateInDirName(c)
          break
        }
        case State.InDirArg: {
          this.stateInDirArg(c)
          break
        }
        case State.InDirDynamicArg: {
          this.stateInDynamicDirArg(c)
          break
        }
        case State.InDirModifier: {
          this.stateInDirModifier(c)
          break
        }
        case State.InCommentLike: {
          this.stateInCommentLike(c)
          break
        }
        case State.InSpecialComment: {
          this.stateInSpecialComment(c)
          break
        }
        case State.BeforeAttrName: {
          this.stateBeforeAttrName(c)
          break
        }
        case State.InTagName: {
          this.stateInTagName(c)
          break
        }
        case State.InSFCRootTagName: {
          this.stateInSFCRootTagName(c)
          break
        }
        case State.InClosingTagName: {
          this.stateInClosingTagName(c)
          break
        }
        case State.BeforeTagName: {
          this.stateBeforeTagName(c)
          break
        }
        case State.AfterAttrName: {
          this.stateAfterAttrName(c)
          break
        }
        case State.InAttrValueSq: {
          this.stateInAttrValueSingleQuotes(c)
          break
        }
        case State.BeforeAttrValue: {
          this.stateBeforeAttrValue(c)
          break
        }
        case State.BeforeClosingTagName: {
          this.stateBeforeClosingTagName(c)
          break
        }
        case State.AfterClosingTagName: {
          this.stateAfterClosingTagName(c)
          break
        }
        case State.BeforeSpecialS: {
          this.stateBeforeSpecialS(c)
          break
        }
        case State.BeforeSpecialT: {
          this.stateBeforeSpecialT(c)
          break
        }
        case State.InAttrValueNq: {
          this.stateInAttrValueNoQuotes(c)
          break
        }
        case State.InSelfClosingTag: {
          this.stateInSelfClosingTag(c)
          break
        }
        case State.InDeclaration: {
          this.stateInDeclaration(c)
          break
        }
        case State.BeforeDeclaration: {
          this.stateBeforeDeclaration(c)
          break
        }
        case State.BeforeComment: {
          this.stateBeforeComment(c)
          break
        }
        case State.InProcessingInstruction: {
          this.stateInProcessingInstruction(c)
          break
        }
        case State.InEntity: {
          this.stateInEntity()
          break
        }
      }
      this.index++
    }
    this.cleanup()
    this.finish()
  }

下面,我们看看当执行tokenizer.parse(①, { parseMode: 'sfc' })方法传入上面的vue sfc组件源码后,并且指定为sfc模式,会经过怎样的状态流转呢?

解析出第一个token: 因为状态机的初始状态是State.Text,因此执行this.stateText(c),对应parse方法的第10

  1. 由于收到的第一个字符是<符号,因此经过处理之后,状态流转到State.BeforeTagName,下面代码的第6查看源码
ts 复制代码
private stateText(c: number): void {
  if (c === CharCodes.Lt) {
    if (this.index > this.sectionStart) {
      this.cbs.ontext(this.sectionStart, this.index)
    }
    this.state = State.BeforeTagName
    this.sectionStart = this.index
  } else if (!__BROWSER__ && c === CharCodes.Amp) {
    this.startEntity()
  } else if (!this.inVPre && c === this.delimiterOpen[0]) {
    this.state = State.InterpolationOpen
    this.delimiterIndex = 0
    this.stateInterpolationOpen(c)
  }
}
  1. this.index自增,并继续while循环,由于当前状态处于State.BeforeTagName,因此执行this.stateBeforeTagName(c)方法,进入之后,因为如此是字符t因此,会进入isTagStartChar分支,状态会切到State.InSFCRootTagName,下方第11行代码 查看源码
ts 复制代码
private stateBeforeTagName(c: number): void {
  if (c === CharCodes.ExclamationMark) {
    ...
  } else if (c === CharCodes.Questionmark) {
    ...
  } else if (isTagStartChar(c)) {
    this.sectionStart = this.index
    if (this.mode === ParseMode.BASE) {
      ...
    } else if (this.inSFCRoot) {
      this.state = State.InSFCRootTagName
    } else if (!this.inXML) {
      ...
    } else {
      ...
    }
  } else if (c === CharCodes.Slash) {
    this.state = State.BeforeClosingTagName
  } else {
    this.state = State.Text
    this.stateText(c)
  }
}
  1. this.index自增,并继续while循环,由于当前状态处于State.InSFCRootTagName,因此执行this.stateInSFCRootTagName(c),从下面的代码可以看到,如果没有收到/空格>三种字符的话,tokenizer的状态就会一直处于State.InSFCRootTagName状态,直到接收到/空格>的时候,就表示当前的标签名解析结束,执行this.handleTagName(c)通过this.cbs.onopentagname通知取词器将当前打开的标签传递给parser进行语法分析。
ts 复制代码
function isEndOfTagSection(c: number): boolean {
  return c === CharCodes.Slash || c === CharCodes.Gt || isWhitespace(c)
}
...
private stateInSFCRootTagName(c: number): void {
  if (isEndOfTagSection(c)) {
    const tag = this.buffer.slice(this.sectionStart, this.index)
    if (tag !== 'template') {
      this.enterRCDATA(toCharCodes(`</` + tag), 0)
    }
    this.handleTagName(c)
  }
}

private handleTagName(c: number) {
    this.cbs.onopentagname(this.sectionStart, this.index)
    this.sectionStart = -1
    this.state = State.BeforeAttrName
    this.stateBeforeAttrName(c)
  }
  1. 然后结束当前分析,将sectionStart重置,并尝试开始分析属性(设置状态为State.BeforeAttrName,并立即执行this.stateBeforeAttrName(c)),由于传入的字符是<template>串的>符号,因此执行会将当前状态转换到State.Text,下面的第7行代码。
ts 复制代码
private stateBeforeAttrName(c: number): void {
    if (c === CharCodes.Gt) {
      this.cbs.onopentagend(this.index)
      if (this.inRCDATA) {
        this.state = State.InRCDATA
      } else {
        this.state = State.Text
      }
      this.sectionStart = this.index + 1
    } else if (c === CharCodes.Slash) {
      this.state = State.InSelfClosingTag
      if ((__DEV__ || !__BROWSER__) && this.peek() !== CharCodes.Gt) {
        this.cbs.onerr(ErrorCodes.UNEXPECTED_SOLIDUS_IN_TAG, this.index)
      }
    } else if (c === CharCodes.Lt && this.peek() === CharCodes.Slash) {
      // special handling for </ appearing in open tag state
      // this is different from standard HTML parsing but makes practical sense
      // especially for parsing intermediate input state in IDEs.
      this.cbs.onopentagend(this.index)
      this.state = State.BeforeTagName
      this.sectionStart = this.index
    } else if (!isWhitespace(c)) {
      if ((__DEV__ || !__BROWSER__) && c === CharCodes.Eq) {
        this.cbs.onerr(
          ErrorCodes.UNEXPECTED_EQUALS_SIGN_BEFORE_ATTRIBUTE_NAME,
          this.index,
        )
      }
      this.handleAttrStart(c)
    }
  }

后续的解析说明: 通过上面的分析,大家应该直观地了解了tokenizer就是通过逐个扫描输入源码字符串,通过设置状态来区分当前分析的token类别。然后通过特定的条件控制状态流转,直到分析出源码中所有的token为止。

上面我们介绍了tokenizer通过this.cbs.onopentagname(this.sectionStart, this.index)调用来将解析出的token(打开是template标签)交给parser处理。

this.stateBeforeAttrName(c)中,因为接收到>符号,因此会调用this.cbs.onopentagend(this.index)方法来结束打开节点标签的解析。

接下来,我们试着以此为起点来分析一下语法分析器 是如何在收到一个token的时候,进行语法分析并将对应的节点添加到Vue AST树上的。

(二)语法分析器的实现分析

在接着分析之前,我们先看看语法分析器在初始化时,设置的两个个关键变量和一个工具函数。

  • stack: 用于在语法分析时,存储当前token所在语法树的父节点和祖先节点 查看源码
ts 复制代码
const stack: ElementNode[] = []
ts 复制代码
const root = (currentRoot = createRoot([], input))
  • addNode: 用于将一个Vue AST节点添加到Vue AST树中的工具函数 查看源码
ts 复制代码
function addNode(node: TemplateChildNode) {
  ;(stack[0] || currentRoot).children.push(node)
}
  • 接下来,我们来看看parser实现的onopentagnameonopentagend方法 查看源码
ts 复制代码
...
  onopentagname(start, end) {
    const name = getSlice(start, end)
    currentOpenTag = {
      type: NodeTypes.ELEMENT,
      tag: name,
      ns: currentOptions.getNamespace(name, stack[0], currentOptions.ns),
      tagType: ElementTypes.ELEMENT, // will be refined on tag close
      props: [],
      children: [],
      loc: getLoc(start - 1, end),
      codegenNode: undefined,
    }
  },

  onopentagend(end) {
    endOpenTag(end)
  },
...
function endOpenTag(end: number) {
  if (tokenizer.inSFCRoot) {
    // in SFC mode, generate locations for root-level tags' inner content.
    currentOpenTag!.innerLoc = getLoc(end + 1, end + 1)
  }
  addNode(currentOpenTag!)
  const { tag, ns } = currentOpenTag!
  if (ns === Namespaces.HTML && currentOptions.isPreTag(tag)) {
    inPre++
  }
  if (currentOptions.isVoidTag(tag)) {
    onCloseTag(currentOpenTag!, end)
  } else {
    stack.unshift(currentOpenTag!)
    if (ns === Namespaces.SVG || ns === Namespaces.MATH_ML) {
      tokenizer.inXML = true
    }
  }
  currentOpenTag = null
}

通过上面的代码,我们看到,当我们收到一个打开的标签时,会新建一个标签节点对应的Vue AST Node对象,并将其保存在currentOpenTag变量中,直到接收到onopentagend回调的时候,才通过addNode方法将当前打开的节点添加到Vue AST树中,然后再通过stack.unshift(currentOpenTag!)将当前节点添加到栈顶,接着处理当前打开节点内部的情况。

  • 我们上面例子中的代码,最终转换出来的最终生成的Vue AST结构如下
js 复制代码
{
  "type": 0,
  "source": "<template>\n<div>{{ a }}</div>\n</template>\n<script setup>\nimport { ref } from 'vue'\n\nconst a = ref('a')\n</script>",
  "children": [
    {
      "type": 1,
      "tag": "template",
      "ns": 0,
      "tagType": 0,
      "props": [],
      "children": [
        {
          "type": 1,
          "tag": "div",
          "ns": 0,
          "tagType": 0,
          "props": [],
          "children": [
            {
              "type": 5,
              "content": {
                "type": 4,
                "loc": {
                  "start": {
                    "column": 9,
                    "line": 2,
                    "offset": 19
                  },
                  "end": {
                    "column": 10,
                    "line": 2,
                    "offset": 20
                  },
                  "source": "a"
                },
                "content": "a",
                "isStatic": false,
                "constType": 0
              },
              "loc": {
                "start": {
                  "column": 6,
                  "line": 2,
                  "offset": 16
                },
                "end": {
                  "column": 13,
                  "line": 2,
                  "offset": 23
                },
                "source": "{{ a }}"
              }
            }
          ],
          "loc": {
            "start": {
              "column": 1,
              "line": 2,
              "offset": 11
            },
            "end": {
              "column": 19,
              "line": 2,
              "offset": 29
            },
            "source": "<div>{{ a }}</div>"
          }
        }
      ],
      "loc": {
        "start": {
          "column": 1,
          "line": 1,
          "offset": 0
        },
        "end": {
          "column": 12,
          "line": 3,
          "offset": 41
        },
        "source": "<template>\n<div>{{ a }}</div>\n</template>"
      },
      "innerLoc": {
        "start": {
          "column": 11,
          "line": 1,
          "offset": 10
        },
        "end": {
          "column": 1,
          "line": 3,
          "offset": 30
        },
        "source": "\n<div>{{ a }}</div>\n"
      }
    },
    {
      "type": 1,
      "tag": "script",
      "ns": 0,
      "tagType": 0,
      "props": [
        {
          "type": 6,
          "name": "setup",
          "nameLoc": {
            "start": {
              "column": 9,
              "line": 4,
              "offset": 50
            },
            "end": {
              "column": 14,
              "line": 4,
              "offset": 55
            },
            "source": "setup"
          },
          "loc": {
            "start": {
              "column": 9,
              "line": 4,
              "offset": 50
            },
            "end": {
              "column": 14,
              "line": 4,
              "offset": 55
            },
            "source": "setup"
          }
        }
      ],
      "children": [
        {
          "type": 2,
          "content": "\nimport { ref } from 'vue'\n\nconst a = ref('a')\n",
          "loc": {
            "start": {
              "column": 15,
              "line": 4,
              "offset": 56
            },
            "end": {
              "column": 1,
              "line": 8,
              "offset": 103
            },
            "source": "\nimport { ref } from 'vue'\n\nconst a = ref('a')\n"
          }
        }
      ],
      "loc": {
        "start": {
          "column": 1,
          "line": 4,
          "offset": 42
        },
        "end": {
          "column": 10,
          "line": 8,
          "offset": 112
        },
        "source": "<script setup>\nimport { ref } from 'vue'\n\nconst a = ref('a')\n</script>"
      },
      "innerLoc": {
        "start": {
          "column": 15,
          "line": 4,
          "offset": 56
        },
        "end": {
          "column": 1,
          "line": 8,
          "offset": 103
        },
        "source": "\nimport { ref } from 'vue'\n\nconst a = ref('a')\n"
      }
    }
  ],
  "helpers": {},
  "components": [],
  "directives": [],
  "hoists": [],
  "imports": [],
  "cached": 0,
  "temps": 0,
  "loc": {
    "start": {
      "column": 1,
      "line": 1,
      "offset": 0
    },
    "end": {
      "column": 10,
      "line": 8,
      "offset": 112
    },
    "source": "<template>\n<div>{{ a }}</div>\n</template>\n<script setup>\nimport { ref } from 'vue'\n\nconst a = ref('a')\n</script>"
  }
}

三、转换器的设计与实现

我们已经知道,转换器的功能是将Vue AST转换成JS AST,整个转换过程是比较复杂的,因为vue支持了的很多模板语法都需要经过合适的转换,再配合运行时才能正确地工作。

毕竟Vue的转换器除了要满足现有的转换需求,还需要考虑可能的更多语法支持,因此转换器一定要具有较好的可扩展性,并且能够灵活地让多种转换操作按照需求相互配合。

为了满足上面的需求,Vue的转换器使用了责任链模式 (或者说是类似洋葱模型,本质就是过滤器),整个转换的过程被分解成多个具有单一职责的转换函数,每个转换函数负责转换的一个环节。

Vue3为转换函数定义了三个接口,分别用于节点转换指令转换和在转换过程中存储上下文信息的接口:

ts 复制代码
export type NodeTransform = (
  node: RootNode | TemplateChildNode,
  context: TransformContext,
) => void | (() => void) | (() => void)[]
ts 复制代码
export type DirectiveTransform = (
  dir: DirectiveNode,
  node: ElementNode,
  context: TransformContext,
  // a platform specific compiler can import the base transform and augment
  // it by passing in this optional argument.
  augmentor?: (ret: DirectiveTransformResult) => DirectiveTransformResult,
) => DirectiveTransformResult
ts 复制代码
export interface TransformContext
  extends Required<Omit<TransformOptions, keyof CompilerCompatOptions>>,
    CompilerCompatOptions {
  selfName: string | null
  root: RootNode
  helpers: Map<symbol, number>
  components: Set<string>
  directives: Set<string>
  hoists: (JSChildNode | null)[]
  imports: ImportItem[]
  temps: number
  cached: number
  identifiers: { [name: string]: number | undefined }
  scopes: {
    vFor: number
    vSlot: number
    vPre: number
    vOnce: number
  }
  parent: ParentNode | null
  childIndex: number
  currentNode: RootNode | TemplateChildNode | null
  inVOnce: boolean
  helper<T extends symbol>(name: T): T
  removeHelper<T extends symbol>(name: T): void
  helperString(name: symbol): string
  replaceNode(node: TemplateChildNode): void
  removeNode(node?: TemplateChildNode): void
  onNodeRemoved(): void
  addIdentifiers(exp: ExpressionNode | string): void
  removeIdentifiers(exp: ExpressionNode | string): void
  hoist(exp: string | JSChildNode | ArrayExpression): SimpleExpressionNode
  cache<T extends JSChildNode>(exp: T, isVNode?: boolean): CacheExpression | T
  constantCache: WeakMap<TemplateChildNode, ConstantTypes>

  // 2.x Compat only
  filters?: Set<string>
}
  • Vue Transform运行过程如下图所示:

整个过程就是,首先将转换Vue ASTTransformContext传入Transform链Transform链中每个节点分两个阶段(图中的transformFnexitFn),乍看起来好像没对,不是说了有两种转换器吗?上图中却只有NodeTransformDirectiveTransform在哪里呢?

其实,这个DirectiveTransform的解析是在transformElementexitFn中执行的,下面我们摘取关键源码进行说明。

  • 下面是transform调用入口的代码,我们可以看到我们传入了nodeTransformsdirectiveTransforms的具体参数,由于代码非常清晰这里就不赘述了
ts 复制代码
export function getBaseTransformPreset(
  prefixIdentifiers?: boolean,
): TransformPreset {
  return [
    [
      // 带有v-once指令的节点转换
      transformOnce,
      // 带有v-if指令的节点转换
      transformIf,
      transformMemo,
      transformFor,
      ...(__COMPAT__ ? [transformFilter] : []),
      ...(!__BROWSER__ && prefixIdentifiers
        ? [
            // order is important
            trackVForSlotScopes,
            transformExpression,
          ]
        : __BROWSER__ && __DEV__
          ? [transformExpression]
          : []),
      transformSlotOutlet,
      // 节点转换
      transformElement,
      trackSlotScopes,
      transformText,
    ],
    {
      on: transformOn,
      bind: transformBind,
      model: transformModel,
    },
  ]
}
...
const [nodeTransforms, directiveTransforms] =
    getBaseTransformPreset(prefixIdentifiers)
  ...
  transform(
    ast,
    extend({}, resolvedOptions, {
      // 设置 nodeTransforms
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []), // user transforms
      ],
      // 设置 directiveTransforms
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {}, // user transforms
      ),
    }),
  )
  ...
  • 接下来,我们再看看transform函数的代码实现 查看源码

通过下面的代码,可以看出整体流程为:

  1. 创建转换上下文
  2. 遍历所有节点进行转换
  3. 创建根节点的代码生成节点,因为这里需要用到内部节点已经转换的结果,所以放到第三步
  4. 转换完成之后再进行静态提升和将元信息(这些元信息,在代码生成阶段会用到)设为终态,并将transformed设成true
ts 复制代码
export function transform(root: RootNode, options: TransformOptions) {
  // 创建上下文
  const context = createTransformContext(root, options)

  // 遍历Vue AST所有节点,进行相应的转换
  traverseNode(root, context)

  // 静态提升
  if (options.hoistStatic) {
    hoistStatic(root, context)
  }

  // 创建根节点的代码生成节点
  if (!options.ssr) {
    createRootCodegen(root, context)
  }

  // finalize meta information
  root.helpers = new Set([...context.helpers.keys()])
  root.components = [...context.components]
  root.directives = [...context.directives]
  root.imports = context.imports
  root.hoists = context.hoists
  root.temps = context.temps
  root.cached = context.cached
  root.transformed = true

  // 兼容vue2
  if (__COMPAT__) {
    root.filters = [...context.filters!]
  }
}

接下来,我们重点看一下transformElement这个比较"特殊"的转换器,说它特殊,是因为它是将Vue AST转换成JS AST的关键转换器。

这里有两个特殊的地方:

  1. 它的transformFn阶段什么都没做,因为这些前置的处理都交给了其它的节点转换器先进行处理了,它直接返回了一个postTransformElement函数,直到其它的处理器都执行完了之后,就会回到这里。
  2. 最后通过createVNodeCall会生成一个创建vnode节点的JS AST节点,并赋值给当前节点的codegenNode属性。
ts 复制代码
export const transformElement: NodeTransform = (node, context) => {
  // perform the work on exit, after all child expressions have been
  // processed and merged.
  return function postTransformElement() {
    node = context.currentNode!

    // 此处省略100多行
    ...

    node.codegenNode = createVNodeCall(
      context,
      vnodeTag,
      vnodeProps,
      vnodeChildren,
      vnodePatchFlag,
      vnodeDynamicProps,
      vnodeDirectives,
      !!shouldUseBlock,
      false /* disableTracking */,
      isComponent,
      node.loc,
    )
  }
}

到此为止,我们对Vue transform已经有了较为宏观的认识,对于转换的很多其它细节,这里就先不深究了。

四、代码生成器的设计与实现

上文提到在执行transformElement的时候,node.codegenNode保存了创建一个vnode节点的JS AST节点,那么,接下来,我们就看看代码生成器是怎样处理这个节点,最终生成一个创建vnode节点的函数的。

在分析代码生成的过程之前,我们还是先看看代码生成器的一些关键设计。

  • 我们还是从代码生成器的入口函数(generate)开始接下来的分析
ts 复制代码
export function generate(
  ast: RootNode,
  options: CodegenOptions & {
    onContextCreated?: (context: CodegenContext) => void
  } = {},
): CodegenResult {
  const context = createCodegenContext(ast, options)
  if (options.onContextCreated) options.onContextCreated(context)
  const {
    mode,
    push,
    prefixIdentifiers,
    indent,
    deindent,
    newline,
    scopeId,
    ssr,
  } = context

  const helpers = Array.from(ast.helpers)
  const hasHelpers = helpers.length > 0
  const useWithBlock = !prefixIdentifiers && mode !== 'module'
  const genScopeId = !__BROWSER__ && scopeId != null && mode === 'module'
  const isSetupInlined = !__BROWSER__ && !!options.inline

  // preambles
  // in setup() inline mode, the preamble is generated in a sub context
  // and returned separately.
  const preambleContext = isSetupInlined
    ? createCodegenContext(ast, options)
    : context

  if (!__BROWSER__ && mode === 'module') {
    // 模块模式,则生成 import 相关导入语句和导出语句
    genModulePreamble(ast, preambleContext, genScopeId, isSetupInlined)
  } else {
    // 函数模式,生成闭包函数
    genFunctionPreamble(ast, preambleContext)
  }
  // enter render function
  const functionName = ssr ? `ssrRender` : `render`
  
  // 形参列表
  const args = ssr ? ['_ctx', '_push', '_parent', '_attrs'] : ['_ctx', '_cache']
  if (!__BROWSER__ && options.bindingMetadata && !options.inline) {
    // binding optimization args
    args.push('$props', '$setup', '$data', '$options')
  }

  // 函数形参字符串
  const signature =
    !__BROWSER__ && options.isTS
      ? args.map(arg => `${arg}: any`).join(',')
      : args.join(', ')

  // 渲染函数声明开始部分
  if (isSetupInlined) {
    push(`(${signature}) => {`)
  } else {
    push(`function ${functionName}(${signature}) {`)
  }
  indent()

  // 忽略,一般不用这个
  if (useWithBlock) {
    push(`with (_ctx) {`)
    indent()
    // function mode const declarations should be inside with block
    // also they should be renamed to avoid collision with user properties
    if (hasHelpers) {
      push(
        `const { ${helpers.map(aliasHelper).join(', ')} } = _Vue\n`,
        NewlineType.End,
      )
      newline()
    }
  }

  // generate asset resolution statements
  // 解析组件相关的处理代码语句
  if (ast.components.length) {
    genAssets(ast.components, 'component', context)
    if (ast.directives.length || ast.temps > 0) {
      newline()
    }
  }
  // 解析指令相关的处理代码语句
  if (ast.directives.length) {
    genAssets(ast.directives, 'directive', context)
    if (ast.temps > 0) {
      newline()
    }
  }
  // 解析过滤器相关的处理代码语句
  if (__COMPAT__ && ast.filters && ast.filters.length) {
    newline()
    genAssets(ast.filters, 'filter', context)
    newline()
  }

  if (ast.temps > 0) {
    push(`let `)
    for (let i = 0; i < ast.temps; i++) {
      push(`${i > 0 ? `, ` : ``}_temp${i}`)
    }
  }
  if (ast.components.length || ast.directives.length || ast.temps) {
    push(`\n`, NewlineType.Start)
    newline()
  }

  // generate the VNode tree expression
  if (!ssr) {
    push(`return `)
  }

  
  if (ast.codegenNode) {
    // 生成节点代码,这里才是真正的JS AST转代码的核心部分
    genNode(ast.codegenNode, context)
  } else {
    push(`null`)
  }

  if (useWithBlock) {
    deindent()
    push(`}`)
  }

  deindent()
  push(`}`)

  // 最终返回生成后的js代码和sourcemap
  return {
    ast,
    code: context.code,
    preamble: isSetupInlined ? preambleContext.code : ``,
    map: context.map ? context.map.toJSON() : undefined,
  }
}

从上面的代码可以看出,代码生成经过了下面几个大步骤:

  1. 创建代码生成器上下文对象,用于保存代码生成过程中的一些状态信息
  2. 根据模式不同,生成资源导入和前置代码
  3. 生成渲染函数函数名、入参列表以及拼接最终的函数签名
  4. 生成解析组件、指令和过滤器(vue2)相关的代码语句
  5. 根据Vue转换器生成的JS AST转换JS代码,并将其拼接起来
  6. 生成后置语句和块结束符等

整体来看,Vue3的代码生成器词法分析器和语法分析器 所做的事情正好相反,前者输入JS AST语法树,输出js代码字符串,而后者输入vue sfc字符串,输出Vue AST语法树。

总结

本文从整体入手分析了Vue3的编译器整体结构,而后又分别分析了Vue3的词法分析器、语法分析器和代码生成器的设计和实现。

整体看来,Vue3编译器的设计和代码结构都非常清晰易读,其难点在于编译、转换的过程中需要处理非常多的细节,而成败往往都是这些细节决定的。

由于笔者的水平所限,源码中有很多优化地方都没能分享 (比如:在词法分析阶段,有部分场景会大段地跳过,以提升性能),文章可能存在不足和谬误,还请大家不吝指正。

【原创声明】

本作品(包括但不限于文字、图片、音频、视频等)为(原心yunsin@vip.qq.com)原创作品,版权归原作者所有。未经授权,任何组织、机构、企业、个人不得以任何形式进行复制、转载、摘编、发表、发布、散布、传播等任何行为。

任何在未经授权的情况下使用本作品的行为均被视作侵权行为,我们将保留追究法律责任的权利。如需使用本作品,请联系(原心yunsin@vip.qq.com)并注明出处及署名,我们将酌情考虑授权。

本声明的最终解释权归(原心yunsin@vip.qq.com)所有,如有疑问请联系(邮箱: yunsin@vip.qq.com)。

相关推荐
ggdpzhk11 分钟前
VUE:基于MVVN的前端js框架
前端·javascript·vue.js
活宝小娜5 小时前
vue不刷新浏览器更新页面的方法
前端·javascript·vue.js
程序视点5 小时前
【Vue3新工具】Pinia.js:提升开发效率,更轻量、更高效的状态管理方案!
前端·javascript·vue.js·typescript·vue·ecmascript
coldriversnow5 小时前
在Vue中,vue document.onkeydown 无效
前端·javascript·vue.js
刚刚好ā6 小时前
js作用域超全介绍--全局作用域、局部作用、块级作用域
前端·javascript·vue.js·vue
会发光的猪。8 小时前
css使用弹性盒,让每个子元素平均等分父元素的4/1大小
前端·javascript·vue.js
天下代码客8 小时前
【vue】vue中.sync修饰符如何使用--详细代码对比
前端·javascript·vue.js
周全全9 小时前
Spring Boot + Vue 基于 RSA 的用户身份认证加密机制实现
java·vue.js·spring boot·安全·php
ZwaterZ9 小时前
vue el-table表格点击某行触发事件&&操作栏点击和row-click冲突问题
前端·vue.js·elementui·c#·vue
码农六六9 小时前
vue3封装Element Plus table表格组件
javascript·vue.js·elementui