vue3 的 parse 都做了啥

在看 vapor 源码时,我发现 compiler-vapor 包里面的 parse 函数也是调用的 compiler-core 里头的 baseParse,所以说 v3 的 parse 原理清楚了,那么 vapor 的 parse 你也就清楚了 (但本文的 debug 还是采用的 vapor 模式

为了方便称呼,后面称传统 vue3(vdom),我都说成 v3了

在 packages\compiler-dom\src\index.ts 目录下,我们可以看到 parse 函数其实就是 return baseParse,所以我们主要研究 baseParse 的原理就行

在深入 baseParse 前,我想抛出一个问题,为啥在 compiler-vapor 中不直接调用 baseParse,而是经历一层 compiler-dom ?

这就得了解 compiler-sfc,compiler-dom,compiler-core 的关系了,也可以理解为 vue 的编译架构

compiler- 包

可以看到 compiler-sfc 是后续编译的起点,在 compiler-sfc 中有这样的一段代码,他会根据入参去选择后续的 平台层(compiler-dom,compiler-ssr,compiler-vapor)

compiler-sfc 的作用其实就是去将 .vue 文件去拆分三个部分,template, script, 以及 style ,会有一个 SFCDescriptor 接口去描述

ts 复制代码
// 解析 .vue 文件为三个部分
export interface SFCDescriptor {
  template: SFCTemplateBlock | null
  script: SFCScriptBlock | null
  scriptSetup: SFCScriptBlock | null
  styles: SFCStyleBlock[]
  customBlocks: SFCBlock[]
}

像 setup,vapor 也只是语法糖处理

v3 中 compiler-ssr,compiler-dom 区分了平台,dom 是浏览器特有的,ssr 为服务端渲染,而如今的 vapor 强调了无虚拟 dom,那么也应该是 浏览器 特有的。

而 compiler-core 才是最终去解析 source 的包,也就是专注语法解析逻辑

现在回到开头抛出的问题,为何不直接调用 baseParse,而是经历一层 compiler-dom,那必然是传入了 dom 环境相关的入参

parse 函数的入参 parseOptions 这个接口属性我们只看 parseMode

ts 复制代码
  /**
   * Base mode is platform agnostic and only parses HTML-like template syntax,
   * treating all tags the same way. Specific tag parsing behavior can be
   * configured by higher-level compilers.
   *
   * HTML mode adds additional logic for handling special parsing behavior in
   * `<script>`, `<style>`,`<title>` and `<textarea>`.
   * The logic is handled inside compiler-core for efficiency.
   *
   * SFC mode treats content of all root-level tags except `<template>` as plain
   * text.
   */
  parseMode?: 'base' | 'html' | 'sfc'

比如 html 模式就会识别 html 标签,nameSpace 等

这里我随便写了个 template 去 debug,可以看到 vapor 模式下的 parse 的options 似乎有点不同于 v3

v3 为了减少运行时 diff,在编译阶段会有一个 hoistStatic,也就是静态提升,默认都是 true,而 vapor 这里为 false,这是因为 vapor 直接在编译过程中全部处理了

现在进入正题 baseParse

baseParse

由于是 debug 的方式看源码,为了不被层层调用栈所绕晕,我们在进入函数前一定要明白这个函数作用是什么,也就是关注拿到了什么入参,又得到了什么返回值

baseParse 后其实就是返回 ast

ts 复制代码
const ast = parse(source, resolvedOptions)

parse 的第二个入参前面已经讲了,就是用来传递一些环境特性值,而第一个参数 source 其实就是 template 字符串

现在看返回值 ast 是啥

可以看到,是个非常标准的 ast 结构,只不过目前的 Templated AST 不同于 Transformed AST,此时还保留着 v- 指令

所以说 baseParse 的作用就是依据不同的环境特性去处理 template 字符串,最后解析成 ast,然后识别出 v- 指令

baseParse 函数如下

ts 复制代码
export function baseParse(input: string, options?: ParserOptions): RootNode {
  	// 1. 初始化配置
    reset()                                    // 重置全局状态
    currentInput = input                       // 设置输入模板
    currentOptions = extend({}, defaultParserOptions, options) // 合并配置

  	// 2. 配置 Tokenizer
    tokenizer.mode = 
      parseMode === 'html' ? ParseMode.HTML :     // HTML 模式:处理 <script>, <style>
      parseMode === 'sfc'  ? ParseMode.SFC :      // SFC 模式:单文件组件
                             ParseMode.BASE       // BASE 模式:基础解析

    tokenizer.inXML = (ns === SVG || ns === MATH_ML)  // XML 命名空间处理

	// 3. 设置插值分隔符
    if (delimiters) {
      tokenizer.delimiterOpen = toCharCodes('{{')   // 默认 {{
      tokenizer.delimiterClose = toCharCodes('}}')  // 默认 }}
    }
	// 4. 解析
    const root = createRoot([], input)        // 创建根节点
    tokenizer.parse(currentInput)             // 核心:词法分析 + 语法分析
    root.loc = getLoc(0, input.length)       // 设置位置信息
    // 5. return
    root.children = condenseWhitespace(root.children)  // 压缩空白符
    currentRoot = null                                  // 清理全局状态
    return root                                         // 返回 AST
}

可以看到,这里所有的代码其实都是为 tokenizer.parse(currentInput) 这一行代码服务,因此 baseParse 的核心就是 tokenizer.parse 函数

tokenizer

tokenizer(词法分析器),一般都是将源码或者输入文本分解成一系列有意义的标记,也就是 tokens,有个很常见的🌰,一个数学运算: const num = 1 + 1;

可能被分解为:

md 复制代码
const (关键字)

age (标识符)

= (运算符)

1 (数字字面量)

+ (运算符)

1 (数字字面量)

; (分隔符)

一般词法分析都是先将源码 tokenizer 成 一系列 tokens,然后再去 parse 语法分析成 最终的 ast

baseParse 的核心就是 tokenizer.parse,再进入 tokenizer.parse 前,我们也需要理清前面的铺垫,其中我们先看下 实例对象 tokenizer 是如何被 new 出来的

ts 复制代码
const stack: ElementNode[] = []

const tokenizer = new Tokenizer(stack, {
  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
})

第一个参数 stack ,这个栈就是用来存放元素节点的

  1. [] → 遇到 <div>[div]
  2. [div] → 遇到 <span>[div, span]
  3. [div, span] → 遇到 </span>[div]
  4. [div] → 遇到 </div>[]

第二个参数是个回调集合,Tokenizer 在解析过程中会调用这些回调来通知 Parser 构建 AST

比如其中的 oninterpolation ,会提取 {{ }} 里面的内容,然后 addNode 到 root 中去

第一步:初始化配置
ts 复制代码
reset()                                    // 重置全局状态
currentInput = input                       // 设置输入模板
currentOptions = extend({}, defaultParserOptions, options) // 合并配置

拿到 baseParse 的两个入参,一个 template 字符串 currentInput,一个 parseOptions 为 currentOptions ,还会调用 reset,这个 reset 函数里面会调用 tokenizer.reset()

我们可以进入 tokenizer.reset 看看这个 reset 做了啥

ts 复制代码
  public reset(): void {
    this.state = State.Text
    this.mode = ParseMode.BASE
    this.buffer = ''
    this.sectionStart = 0
    this.index = 0
    this.baseState = State.Text
    this.inRCDATA = false
    this.currentSequence = undefined!
    this.newlines.length = 0
    this.delimiterOpen = defaultDelimitersOpen
    this.delimiterClose = defaultDelimitersClose
  }

可以看到这里是 重置默认全局状态,之前的 parseMode 有个 默认值 base 这里也可以体现,不过稍后这里的 parseMode 在 vapor 模式下肯定会被赋值为 html

第二步:配置 tokenizer
ts 复制代码
    tokenizer.mode = 
      parseMode === 'html' ? ParseMode.HTML :     // HTML 模式:处理 <script>, <style>
      parseMode === 'sfc'  ? ParseMode.SFC :      // SFC 模式:单文件组件
                             ParseMode.BASE       // BASE 模式:基础解析

    tokenizer.inXML = (ns === SVG || ns === MATH_ML)  // XML 命名空间处理

我们在 vapor 模式下 debug 会发现 parseMode 为 html 时 mode 为 1

这是因为 ts 枚举值,html 下标为 1

ts 复制代码
export enum ParseMode {
  BASE,
  HTML,
  SFC,
}
第三步:设置插值分隔符
ts 复制代码
    if (delimiters) {
      tokenizer.delimiterOpen = toCharCodes('{{')   // 默认 {{
      tokenizer.delimiterClose = toCharCodes('}}')  // 默认 }}
    }

这里就是 vue 的挖坑写法,在 tokenizer 的 reset 阶段就已经初始化好了 左右 分隔符

第四步:解析(核心步骤)
ts 复制代码
    const root = createRoot([], input)        // 创建根节点
    tokenizer.parse(currentInput)             // 🔥 词法分析 + 语法分析
    root.loc = getLoc(0, input.length)       // 设置位置信息

先创建了根节点,也就是把 ast 的结构先支撑起来

随后进入 parse 阶段

parse 阶段就是 tokenizer + 语法解析

tokenizer.parse 函数会遍历 template 字符串的每个字符的字符编码,然后根据当前状态去调用对应的处理函数,比如 Text 状态会对应一个 stateText 处理文本状态的函数,BeforeTagName 会有一个 stateBeforeTagName 处理标签名前状态的函数,每个状态对应的处理函数都会确定下一个状态,并且会触发回调

我们先看下 State,里面记录了 tokenizer 当前处于什么解析阶段,用于决定如何处理下一个字符

ts 复制代码
export enum State {
    // 文本、插值
    Text = 1,                    // 解析普通文本内容
    InterpolationOpen,           // 遇到 {{ 开始解析插值
    Interpolation,               // 在插值表达式内部
    InterpolationClose,          // 遇到 }} 结束插值
	// 标签
    BeforeTagName,               // 遇到 < 后,准备读取标签名
    InTagName,                   // 正在读取标签名
    InSelfClosingTag,            // 在自闭合标签中 />
    BeforeClosingTagName,        // 遇到 </ 后
    InClosingTagName,            // 正在读取结束标签名
    AfterClosingTagName,         // 结束标签名读取完毕
    // 属性
    BeforeAttrName,              // 准备读取属性名
    InAttrName,                  // 正在读取属性名
    AfterAttrName,               // 属性名读取完毕
    BeforeAttrValue,             // 准备读取属性值
    InAttrValueDq,               // 在双引号属性值中 "value"
    InAttrValueSq,               // 在单引号属性值中 'value'
    InAttrValueNq,               // 在无引号属性值中
    // v- 指令
    InDirName,                   // 在指令名中 v-if, v-for
    InDirArg,                    // 在指令参数中 v-on:click
    InDirDynamicArg,             // 在动态指令参数中 v-on:[event]
    InDirModifier,               // 在指令修饰符中 @click.prevent
    // 特殊标签
    BeforeDeclaration,           // 遇到 <! 后
    InDeclaration,               // 在声明中 <!DOCTYPE>
    InProcessingInstruction,     // 在处理指令中 <?xml?>
    BeforeComment,               // 准备读取注释
    InCommentLike,               // 在注释或CDATA中
    CDATASequence,               // 在CDATA序列中
    InSpecialComment,            // 在特殊注释中
}

state 在 Tokenizer 中初始值为 Text,也就是 0 ,那么必然会走到第一个 case,然后执行 this.stateText,入参 c 就是当前 source 字符的字符编码,比如 A 就是对应 65,我们看 stateText 函数

template 字符串开头必然是 <template> 打头,所以第一个字符对应着 < ,这里的 CharCodes.Lt 就是对应着 < ,那么在状态转移这里也刚好对应着 BeforeTagName 的状态,也就意味着下一次 while 循环就是去执行 stateBeforeTagName 函数

我们可以看下 CharCodes 都有哪些字符对应关系

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, // "]"
}

stateBeforeTagName 如下

ts 复制代码
  private stateBeforeTagName(c: number): void {
    if (c === CharCodes.ExclamationMark) {
      this.state = State.BeforeDeclaration
      this.sectionStart = this.index + 1
    } else if (c === CharCodes.Questionmark) {
      this.state = State.InProcessingInstruction
      this.sectionStart = this.index + 1
    } else if (isTagStartChar(c)) {
      this.sectionStart = this.index
      if (this.mode === ParseMode.BASE) {
        // no special tags in base mode
        this.state = State.InTagName
      } else if (this.inSFCRoot) {
        // SFC mode + root level
        // - everything except <template> is RAWTEXT
        // - <template> with lang other than html is also RAWTEXT
        this.state = State.InSFCRootTagName
      } else if (!this.inXML) {
        // HTML mode
        // - <script>, <style> RAWTEXT
        // - <title>, <textarea> RCDATA
        if (c === 116 /* t */) {
          this.state = State.BeforeSpecialT
        } else {
          this.state =
            c === 115 /* s */ ? State.BeforeSpecialS : State.InTagName
        }
      } else {
        this.state = State.InTagName
      }
    } else if (c === CharCodes.Slash) {
      this.state = State.BeforeClosingTagName
    } else {
      this.state = State.Text
      this.stateText(c)
    }
  }
  
  function isTagStartChar(c: number): boolean {
      return (
        (c >= CharCodes.LowerA && c <= CharCodes.LowerZ) ||
        (c >= CharCodes.UpperA && c <= CharCodes.UpperZ)
      )
    }

stateBeforeTagName 的作用就是判断 < 后接的是什么类型的字符,比如 <template> 后面就是 t,那么就会走 isTagStartChar 里面的分支

前面两个 if 走的都是属于特殊字符,比如 <!-- --> 这种

在 isTagStartChar 分支中,又会判断之前在 compiler-dom 层传递的 parseMode,这里自然是 HTML 模式,因此走到

ts 复制代码
        if (c === 116 /* t */) {
          this.state = State.BeforeSpecialT
        } else {
          this.state =
            c === 115 /* s */ ? State.BeforeSpecialS : State.InTagName
        }

更新状态为 BeforeSpecialT

t 和 s 都是特殊字符串,t 有可能是 <title>, <textarea> s 有可能是 <script>, <style>

template 我们就不再这样往下看了,中间会将状态从 specialT 中撤出来,遇到 > 时会触发 onopentagname 将记录的 下标 去 slice 切割出 template 然后设置到 当前 root 的 tag 属性

这里面的 nameSpace 由于是 HTML mode 会单独再次判断 svg 这个 tag,而 getLoc 则是依据传入的起始位置和终止位置解析出 column ,line 信息,方便做 sourceMap 源码地图

因此我们明白了 onopentagname 的目的就是去更新当前 root 的节点信息

执行完 onopentagname 这个回调后还会去执行 stateBeforeAttrName 函数,stateBeforeAttrName 的目的则是将当前 root addNode 到 currentRoot 中

我们现在可以发现,在 tokenizer 实例化传入的回调函数 cbs ,会在 parse 的 while 循环中的 状态转移函数中进行执行,而这些回调函数会将当前解析好的 token 去 parse 到 currentRoot 中,这就是 addNode

第五步:return
ts 复制代码
    root.children = condenseWhitespace(root.children)  // 压缩空白符
    currentRoot = null                                  // 清理全局状态
    return root                                         // 返回 AST

总结

其实 parse 的过程还是非常复杂的,本文还是简单用了一个 <template> 的例子去作讲解,讲了从 <template> 到 ast 的过程。

一段话总结就是 template 到 templated AST 需要调用 compiler-core 的 baseParse 函数,这个函数的本质就是调用的 Tokenizer.parse,parse 的过程中需要遍历每个字符,根据当前状态去执行对应的状态函数,得到下一个状态,然后触发回调 cbs,通过 addNode 去构建 AST

字符串 → 状态机 → 状态函数 → 回调触发 → Parser处理 → AST构建

相关推荐
武昌库里写JAVA1 分钟前
iView Table组件二次封装
vue.js·spring boot·毕业设计·layui·课程设计
三口吃掉你3 分钟前
Web服务器(Tomcat、项目部署)
服务器·前端·tomcat
Trust yourself2435 分钟前
在easyui中如何设置自带的弹窗,有输入框
前端·javascript·easyui
烛阴9 分钟前
Tile Pattern
前端·webgl
前端工作日常42 分钟前
前端基建的幸存者偏差
前端·vue.js·前端框架
Electrolux1 小时前
你敢信,不会点算法没准你赛尔号都玩不明白
前端·后端·算法
a cool fish(无名)2 小时前
rust-参考与借用
java·前端·rust
只有干货2 小时前
前端传字符串 后端比较date类型字段
前端
波波鱼દ ᵕ̈ ૩3 小时前
学习:JS[6]环境对象+回调函数+事件流+事件委托+其他事件+元素尺寸位置
前端·javascript·学习
cypking4 小时前
解决electron+vue-router在history模式下打包后首页空白问题
javascript·vue.js·electron