Vue3之编译器

80 阅读13分钟

前言

编译技术是一个庞大的学习,不同用途的编译器或者编译技术的难度可能相差很大,对知识的掌握也相差甚远。作为前端工程师,我们应用编译技术的场景通常是:表格、报表中的自定义公式计算器,设计一种领域特定语言(DSL)等。Vue.js的模板和JSX都属于领域特点语言,它们的实现难度属于中低级别。

编译器其实就是一段程序,用来将一种语言A翻译成另外一种语言B,其中A通常叫做源代码,B常叫做目标代码,翻译的过程就是编译。一个完整的编译过程,通常包含词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成等步骤。其中词法分析、语法分析、语义分析通常语目标平台无关,仅是分析源代码;中间代码生成、优化也不一定语目标代码有关,取决于具体场景和实现;目标代码生成通常语目标平台有关。

Vue的模板编译器

Vue的模板编译器是用于将组件模板编译为渲染函数,它的工作流程大致分为三个步骤

  1. 分析模板,将其解析为模板AST(Parse)
  2. 将模板AST转换为用于描述渲染函数的JavaScript AST(Transformer)
  3. 根据JavaScript AST生成渲染函数代码(Generate)

AST 是 Abstract Syntax Tree 的首字母缩写,即抽象语法树。AST 是对源代码进行语法分析(Parsing) 后生成的一种中间表示(Intermediate Representation)。它去除了源代码中的无关细节(如括号、分号、空格等),只保留程序的结构和语义信息

Parse

主要工作

  • 扫描模板字符流,识别:
    • 元素标签(如div)
    • 文本内容(如Vue)
    • 插值表达式(如 {{ msg }}
    • 指令(如 v-ifv-bind:id
    • 注释、DOCTYPE 等
  • 构建嵌套的 AST 节点树,反映模板结构
  • 对插值和绑定表达式进行 基础解析(生成简单表达式 AST

将模板字符串解析为模板抽象语法树,使用有限状态自动机(状态机)来高效、准确地将 HTML 模板字符串转换为 AST,即使用状态控制解析器在不同状态间流转解析。

<div>Vue</div>

解析器的入参是字符串模板,解析器会逐个读取字符串模板中的字符,并将根据一定的规则将整个字符串切割成一个个Token。比如上面的例子解析器就会切割成三个Token

  • 开始标签:<div>
  • 文本标签:Vue
  • 结束标签:</div> parse函数会逐个读取字符串,根据当前状态和当前读取的字符,进入到对应设定好的分支处理和状态变更,一直到解析完成。大致意思,用代码表示如下
const State = {
	initial: 1, // 初始状态
	tagOpen: 2, // 开始标签状态
	tagName: 3, // 开始标签名称状态
	text: 4, // 文本状态
	tagEnd: 5, // 结束标签状态
	tagEndName: 6 // 开始标签名称状态
}


 // 一个辅助函数,用于判断是否是字母 
function isAlpha(char) { 
	return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' 
}

function tokenize(str) {
	let currentState = State.initial
	const chars = []
	const tokens = []
	
	while(str) {
		const char = str[0]
		swith (currentState) {
			case State.initial:
				if (char === '<') {
					currentState = State.tagOpen
					str = str.slice(1)
				} else if (isAlpha(char)) {
					currentState = State.text
					chars.push(char)
					str = str.slice(1)
				}
				break
			case State.tagOpen:
				if (isAlpha(char)) {
					currentState = State.tagName
					chars.push(char)
					str = str.slice(1)
				} else if (char === '/') {
					currentState = State.tagEnd
					str = str.slice(1)
				}
				break
			case State.tagName:
				if (isAlpha(char)) {
					chars.push(char)
					str = str.slice(1)
				} else if (char === '>') {
					currentState = State.initial
					tokens.push({
						type: 'tag',
						name: chars.join('')
					})
					chars.length = 0
					str = str.slice(1)
				}
				break
			......
			......
			......
		}
	}
}

为 Vue.js 的模板构造 AST 是一件比较简单的事。HTML 是一种标记语言,它的格式非常固定,标签元素之间天然嵌套,形成父子关系。 因此,一棵用于描述 HTML 的 AST 将拥有与 HTML 标签非常相似的树型结构。 使用程序根据模板解析后生成的 Token 构造出对应的AST。

// 对模板字符串进行扫描,识别出不同的 token 类型
const token = tokenize(`<div><p>Vue</p><p>Template</p><p>{{ message }}</p></div>`)


// 根据模板字符串解析出的token
const tokens = [
  { type: "tag", name: "div" },
  
  { type: "tag", name: "p" },
  { type: "text", content: "Vue" },
  { type: "tagEnd", name: "p" },
  
  { type: "tag", name: "p" },
  { type: "text", content: "Template" },
  { type: "tagEnd", name: "p" },
  
  { type: "tag", name: "p" },
  { type: "interpolation", expression: "message" },
  { type: "tagEnd", name: "p" },
  
  { type: "tagEnd", name: "div" }
];

// 根据token构建出模板的AST
const ast = {
  "type": 0, // ROOT
  "children": [
    {
      "type": 1, // ELEMENT (<div>)
      "tag": "div",
      "props": [],
      "children": [
        {
          "type": 1, // ELEMENT (<p>)
          "tag": "p",
          "props": [],
          "children": [
            { "type": 2, "content": "Vue" } // TEXT
          ]
        },
        {
          "type": 1, // ELEMENT (<p>)
          "tag": "p",
          "props": [],
          "children": [
            { "type": 2, "content": "Template" } // TEXT
          ]
        },
        {
          "type": 1, // ELEMENT (<p>)
          "tag": "p",
          "props": [],
          "children": [
            {
              "type": 5, // INTERPOLATION ({{ message }})
              "content": {
                "type": 4, // SIMPLE_EXPRESSION
                "content": "message",
                "isStatic": false,
                "constType": 0
              }
            }
          ]
        }
      ]
    }
  ]
}

正则表达式的本质就是有限自动机。正则表达式本质上是对有限自动机的一种“声明式”描述 比如这个正则表达式 a*b,标识任意数量a(包括0个),后面跟着一个b。 对应的DFA(确定性有限自动机)为

状态 0 --a--> 状态 0
状态 0 --b--> 状态 1(接受)
状态 0 --任意(非 a、非 b)--> 拒绝
状态 1 --任意--> 拒绝

Transform

主要工作

  • 遍历模板AST节点(深度优先)
  • 注入 Vue 特有逻辑:
    • 处理指令:v-if → 条件渲染函数,v-for → 循环函数,v-model → 事件 + 绑定
    • 解析属性绑定:v-bind:id="x" → 动态 props
  • 静态分析与优化:
    • 标记 static 节点(完全静态)
    • 静态提升(hoist):将静态节点提取到 render 函数外部
    • 生成 patchFlag(更新标记):如 TEXT=1PROPS=2
    • 事件处理器缓存(cacheHandlers
  • 上下文绑定:
    • 将 message 转换为 _ctx.message(绑定到组件上下文)
  • 生成 codegenNode
    • 为每个节点生成对应的 VNode 创建调用结构(如 _createElementVNode("p", ..., 1)
  • 注册并收集运行时辅助函数(Helpers)
    • 编译器内部维护一个 helper(name) 函数 和一个 context.helpers Set(挂载在根 AST 上),每当某个转换逻辑需要用到运行时能力时,就调用 context.helper(HELPER_NAME),如遇到插值 {{ x }} → 调用 helper(TO_DISPLAY_STRING)、 生成动态元素 VNode → 调用 helper(CREATE_ELEMENT_VNODE)

Transorm用于将模板 AST 转为 J avaScript AST,我们需要将模板编译为渲染函数。而渲染函数是由 JavaScript 代码来描述的,因此,我们需要将模板 AST 转换为用于描述渲染函数 的 JavaScript AST

为了对 AST 进行转换,我们需要能访问 AST 的每一个节点,这样才有机会对特定节点进行修改、替换、删除等操作。由于 AST 是树型数据结构,所以我们需要编写一个深度优先的遍历算法,从而实现对 AST 中节点的访问。

在转换 AST 节点的过程中,往往需要根据其子节点的情况来决定如何对当前节点进行转换。这就要求父节点的转换操作必须等待其所 有子节点全部转换完毕后再执行;(这里有点像洋葱模型)

{
  type: 0, // ROOT
  children: [
    {
      type: 1, // <div>
      tag: 'div',
      props: [],
      children: [
        /* 第一个 <p>Vue</p> */
        {
          type: 1,
          tag: 'p',
          props: [],
          children: [{ type: 2, content: 'Vue' }],
          static: true,                     // ✅ 完全静态
          hoisted: { /* VNode 调用对象 */ }, // 被提升(实际指向 _hoisted_1)
          codegenNode: {
            type: 13, // VNODE_CALL
            tag: '"p"',
            props: null,
            children: '"Vue"',
            patchFlag: -1, // HOISTED
            isBlock: false
          }
        },

        /* 第二个 <p>Template</p> */
        {
          type: 1,
          tag: 'p',
          props: [],
          children: [{ type: 2, content: 'Template' }],
          static: true,
          hoisted: { /* 指向 _hoisted_2 */ },
          codegenNode: {
            type: 13,
            tag: '"p"',
            props: null,
            children: '"Template"',
            patchFlag: -1,
            isBlock: false
          }
        },

        /* 第三个 <p>{{ message }}</p> */
        {
          type: 1,
          tag: 'p',
          props: [],
          children: [
            {
              type: 5, // INTERPOLATION
              content: {
                type: 4,
                content: 'message',
                isStatic: false,
                // 表达式在 transform 中被包装为 _ctx.message
                ast: { /* JS AST: Identifier("message") */ }
              },
              // 插值本身无 codegenNode,由父元素处理
            }
          ],
          static: false,                    // ❌ 含动态内容
          codegenNode: {
            type: 13, // VNODE_CALL
            tag: '"p"',
            props: null,
            children: {
              type: 8, // COMPOUND_EXPRESSION
              children: [
                { type: 4, content: '_ctx.message' } // 已绑定上下文
              ]
            },
            patchFlag: 1, // TEXT —— 仅文本可能变化
            isBlock: false
          }
        }
      ],
      static: false, // 因含动态子节点
      codegenNode: {
        type: 13, // VNODE_CALL
        tag: '"div"',
        props: null,
        children: [ /* 三个子节点的 codegenNode 引用 */ ],
        patchFlag: 0,
        isBlock: true // 根元素或含动态内容的元素会创建 Block
      }
    }
  ],
  // 根节点额外字段:用于 hoist 静态节点
  hoists: [
    { /* _hoisted_1: <p>Vue</p> 的 VNode 调用 */ },
    { /* _hoisted_2: <p>Template</p> 的 VNode 调用 */ }
  ],
  // 其他元信息
  helpers: [ 'createElementVNode', 'openBlock', 'createElementBlock', 'toDisplayString' ]
}

Generate

将 Transform 后的 AST 编译为可执行的 JavaScript 渲染函数代码字符串;代码生成的过程就是字符串拼接的过程。我们需要为不同的 AST 节点编写对应 的代码生成函数

主要工作

  • 生成辅助函数导入:
// 将 `helpers` 转为 `import { ... } from "vue"`
import { createElementVNode as _createElementVNode, ... } from "vue"
  • 生成 hoisted 静态节点:
const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "Vue", -1)
  • 生成 render 函数体:
    • 遍历 codegenNode,递归生成函数调用代码
    • 处理 block(_openBlock()_createElementBlock()
    • 插入 patchFlag 和动态绑定表达式
  • 输出完整代码字符串
import {
  createElementVNode as _createElementVNode,
  openBlock as _openBlock,
  createElementBlock as _createElementBlock,
  toDisplayString as _toDisplayString
} from "vue"

// 静态提升:仅创建一次
const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "Vue", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createElementVNode("p", null, "Template", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,
    _hoisted_2,
    // 动态节点:每次渲染需更新文本
    _createElementVNode("p", null, _toDisplayString(_ctx.message), 1 /* TEXT */)
  ]))
}

编译优化

实际上,模板的结构非常稳定。通过编译手段,我们可以分析出很多关键信息,例如哪些节点是静态的,哪些节点是动态的。结合这些关键信息,编译器可以直接生成原生 DOM 操作的代码,这样甚至能够抛掉虚拟 DOM,从而避免虚拟 DOM 带来的性能开销。但是,考虑到渲染函数的灵活性,以及 Vue.js 2 的兼容问题,Vue.js 3 最终还是选择了保留虚拟 DOM。

Vue 3 的编译优化是其性能优势的核心之一,它通过在编译阶段对模板进行静态分析,将大量原本需要在运行时完成的工作提前完成,从而显著减少运行时开销、提升更新效率。总体思想就是在编译的时候做尽可能多的事,在运行的时候做尽可能少的事

Patch Flags(更新标记)

为动态节点打上补丁标志,让运行时精准知道哪些部分可能变化,避免不必要的全量 diff

  • 为每个动态节点打上 patchFlag(位掩码),标识其动态类型。
  • 运行时根据 patchFlag 决定执行哪些更新操作。

常见 Patch Flags

Flag(值)名称含义
1TEXT文本内容可能变化(如 {{ msg }}
2CLASSclass 绑定可能变化
4STYLEstyle 绑定可能变化
8PROPS其他 props(如 id、自定义 prop)可能变化
16FULL_PROPSprops 对象整体动态(如 v-bind="obj"
32HYDRATE_EVENTS服务端渲染事件绑定
64STABLE_FRAGMENT稳定的 Fragment(如静态 v-for
128KEYED_FRAGMENT带 key 的 Fragment(如 v-for with key)
256UNKEYED_FRAGMENT无 key 的 Fragment
-1HOISTED静态提升节点(永不更新)
-2BAIL无法优化,需全量 diff
  • 更新时只比对可能变化的部分,跳过静态属性
  • 减少内存分配和 DOM 操作

Block Tree 与动态节点追踪

它与 Patch Flags 紧密配合,在组件或元素有多个动态子节点时,避免遍历所有子节点来查找变化。

在传统虚拟DOM中,如果父组件更新时,即使只有一个子节点变化,也需要整个子树递归diff,如果子节点有 1000 个,999 个静态,1 个动态 → 仍需检查 1000 次!

在Vue3中,使用动态节点收集 + Block作用域来解决这一问题。

首先编译器在Transform阶段就识别出哪些节点是动态的,哪些是静态的,据此我们可以做以下操作

  • 遍历AST,标记每个节点是否static
  • 对于非静态节点或含动态子节点的节点
    • 设置isBlock = true
    • 生成codegenNode时使用CREATE_ELEMENT_BLOCK
  • 收集动态子节点
    • 在生成codegenNode时,递归收集所有patchFlag > 0 或 isBlock 的后代
    • 这些节点被记录在 Block 的 dynamicChildren 中(运行时填充)
<div> <!-- Block (根) -->
  <p>Static</p>
  <section> <!-- 静态,非 Block -->
    <span>Deep static</span>
  </section>
  <p>{{ a }}</p> <!-- 动态 -->
  <div> <!-- Block(含动态子) -->
    <em>{{ b }}</em> <!-- 动态 -->
    <i>Static in block</i>
  </div>
</div>
const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "Static", -1);
const _hoisted_2 = /*#__PURE__*/_createElementVNode("span", null, "Deep static", -1);
const _hoisted_3 = /*#__PURE__*/_createElementVNode("i", null, "Static in block", -1);

export function render(_ctx) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,
    _createElementVNode("section", null, [_hoisted_2]),
    _createElementVNode("p", null, _toDisplayString(_ctx.a), 1),
    (_openBlock(), _createElementBlock("div", null, [
      _createElementVNode("em", null, _toDisplayString(_ctx.b), 1),
      _hoisted_3
    ]))
  ]))
}

Block(通过_createElementBlock创建)是一个带有dynamicChildren数组的VNode,该数组显式记录了其子树中所有动态子节点,包括深层嵌套的。另外:根节点总是 Block(因为 render 函数返回的是 Block)

  • 如果一个元素包含动态子节点,它会被编译为一个 Block(使用 _createElementBlock)。
  • Block 会收集其所有动态子节点(包括深层嵌套的),存入 dynamicChildren 数组。
  • 更新时,运行时只遍历 dynamicChildren,忽略静态子节点

编译时:识别动态节点,构建 Block 结构,生成 dynamicChildren 收集逻辑 运行时:通过 dynamicChildren 实现精准更新,跳过静态子树 Block Tree + Patch Flags = Vue 3 虚拟 DOM 的“智能 diff”引擎

v-once 包裹的动态节点 不会被父级 Block 收集。因此,被 v-once 包裹的动态节点在组件更新时,不会参与 Diff 操作。

另外,和指令相关如v-if,v-for等节点会影响动态节点收集,这里没有进一步说明。v-if、v-for 等结构化指令会影响 DOM 层级结构,使之不稳定。这会间接导致基于 Block 树的比对算法失效。而解决方式很简单,只需要让带有 vif、v-for 等指令的节点也作为 Block 角色即可

静态提升

避免重复创建永远不会变化的VNode

  • 如果一个节点及其所有子节点不含任何动态内容(无插值、无指令、无组件、无事件监听器等),则标记为 静态(static)
  • 编译器将这些静态节点的 VNode 创建调用提取到 render 函数外部,作为常量(hoisted)。
  • 渲染时直接复用同一个 VNode 对象,跳过创建和 diff
// 静态提升:只创建一次
const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "Static Text", -1);
const _hoisted_2 = /*#__PURE__*/_createElementVNode("span", { class: "icon" }, null, -1);

关键点

  • patchFlag: -1 表示 HOISTED(永不更新)
  • /*#__PURE__*/ 注释帮助打包工具进行 Tree-shaking

事件处理器缓存(Event Handler Caching)

避免在每次渲染时重复创建内联函数(如 @click="() => doX(id)"

  • 如果事件处理器是内联函数表达式,且启用了 cacheHandlers(默认在 DOM 编译中开启),编译器会将其缓存到 _cache 数组中。
  • 只要组件实例不变,就复用同一个函数引用。

优势

  • 避免不必要的事件监听器重新绑定
  • 减少垃圾回收压力
  • 提升列表渲染性能(尤其 v-for 中的内联函数)

其他优化

静态 Props 提取

  • 静态属性(如 <div id="app">)被编译为字面量对象,而非响应式绑定。

 Slot 编译优化

  • 作用域插槽被编译为函数,静态插槽被提升。

v-for key 分析

  • 带 key 的 v-for 生成 KEYED_FRAGMENT,启用高效 diff 算法。

 表达式简化

  • 常量表达式(如 {{ 1 + 2 }})在编译时直接计算为 "3"

结语

Vue 3 编译器通过静态分析将模板的结构稳定性动态变化点提前分离,使得运行时无需猜测、无需遍历、无需全量比对,从而在保持声明式语法简洁性的同时,实现了接近手动优化的性能。其核心目标是:在编译时尽可能多地提取静态信息,将运行时开销降至最低