跳到主要内容
预计阅读 78 分钟

编译器原理与源码解析

上一章我们解剖了响应式系统和虚拟 DOM——它们解决的是”数据变了怎么更新”和”更新时怎么操作 DOM”的问题。但还有一个关键环节被我们跳过了:你在 <template> 中写的那些 HTML 式的模板,是如何变成可执行的渲染函数的?答案就藏在 Vue 3 的编译器中。

编译器是 Vue 能够在编译期进行大量性能优化的秘密武器。React 更侧重运行时优化(如 Fiber 架构、memo 等),而 Vue 通过编译器在构建时就已经提前”算好”了许多优化。理解编译器,不仅能让你真正吃透 Vue 的设计哲学,还能帮你在遇到模板报错、性能瓶颈时快速定位问题。

本章是精通篇的最后一章,也是全课程正文的收官之作。让我们深入编译器的内核。


📋 开篇自测

在开始之前,检验一下你的前置知识:

  1. AST(抽象语法树)是什么?它和 DOM 树有什么关系?
  2. Vue 3 的渲染函数(render)返回的是什么?它与模板之间是什么关系?
  3. 什么是”静态提升”?你能说出它优化了什么吗?

如果这三个问题你都有清晰的答案,本章的学习会非常顺畅。如果有些模糊,不用担心——我们会从零开始,一步步把编译器的全貌拼出来。


一、编译器全景——从模板到渲染函数

1.1 编译器解决什么问题

当你在 .vue 文件中写下这样的模板:

<template>
  <div class="greeting">
    <p>hello world</p>
    <p>{{ msg }}</p>
  </div>
</template>

Vue 需要把它变成一个可执行的 JavaScript 渲染函数:

import { createElementVNode, toDisplayString, openBlock, createElementBlock } from "vue"

const _hoisted_1 = /*#__PURE__*/createElementVNode("p", null, "hello world", -1)

export function render(_ctx, _cache) {
  return (openBlock(), createElementBlock("div", { class: "greeting" }, [
    _hoisted_1,
    createElementVNode("p", null, toDisplayString(_ctx.msg), 1 /* TEXT */)
  ]))
}

这个从”模板字符串”到”渲染函数代码”的转换过程,就是编译器的职责。

1.2 三阶段流水线

Vue 3 的编译器采用经典的三阶段设计,和大多数编程语言的编译器思路一致:

                    Vue 3 Compiler Pipeline

  Template String          AST              JS AST           Render Function
  ┌───────────┐     ┌───────────┐     ┌───────────┐     ┌───────────────┐
  │ <div>     │     │ Root      │     │ Root      │     │ function      │
  │  <p>text  │ ──> │  Element  │ ──> │  VNodeCall │ ──> │  render() {   │
  │  <p>{{m}} │     │   Text    │     │   ...     │     │   return ...  │
  │ </div>    │     │   Interp  │     │   flags   │     │ }             │
  └───────────┘     └───────────┘     └───────────┘     └───────────────┘
                         │                  │                    │
                       Parse            Transform            Generate
                    (词法+语法分析)     (语义分析+优化)       (代码生成)

用一段简化的伪代码来表达这个流程:

function compile(template) {
  // 第一步:解析——把模板字符串转换为 AST
  const ast = parse(template)

  // 第二步:转换——遍历 AST,添加语义信息和优化标记
  transform(ast)

  // 第三步:生成——把带有语义信息的 AST 转换为渲染函数代码字符串
  return generate(ast)
}

每一步的输入是上一步的输出,形成一条清晰的流水线。接下来我们逐一深入每个阶段。


二、模板解析(Parse)——从字符串到 AST

2.1 解析器的核心思想

解析阶段要做的事情,本质上和浏览器解析 HTML 是类似的:逐字符扫描模板字符串,识别出标签、属性、文本、插值表达式等不同的语法成分,然后组装成一棵树形的数据结构——AST(抽象语法树)。

AST 中的节点类型主要有以下几种:

┌──────────────────────────────────────────┐
│            AST Node Types                │
├──────────────────────────────────────────┤
│  ROOT (0)         ── 根节点              │
│  ELEMENT (1)      ── 元素节点 <div>      │
│  TEXT (2)         ── 纯文本 "hello"      │
│  COMMENT (3)      ── 注释 <!-- -->       │
│  SIMPLE_EXPRESSION (4) ── 表达式 msg     │
│  INTERPOLATION (5)── 插值 {{ msg }}      │
└──────────────────────────────────────────┘

2.2 有限状态机与逐步消费

解析器的工作方式可以理解为一个有限状态机(Finite State Machine):维护一个游标(cursor),从模板字符串的头部开始,每次根据当前字符判断进入哪个解析分支,解析完一段后将游标前移,“消费”掉已处理的部分,直到整个字符串被消费完毕。

我们来实现一个简化版的解析器,帮你理解这个过程:

// 简化版解析上下文——追踪解析进度
function createParseContext(template) {
  return {
    source: template,      // 尚未解析的剩余字符串
    offset: 0,             // 当前偏移量
    line: 1,               // 当前行号
    column: 1              // 当前列号
  }
}

// 核心操作:向前消费 n 个字符
function advanceBy(context, n) {
  context.source = context.source.slice(n)
  context.offset += n
}

advanceBy 是解析器最基础的操作。每解析完一个语法片段,就调用它把已解析部分从 source 中移除。举个例子:

初始: source = '<div>{{ msg }}</div>'
                ^
消费 '<div>':
      source = '{{ msg }}</div>'
                ^
消费 '{{ msg }}':
      source = '</div>'
                ^
消费 '</div>':
      source = ''   (解析完毕)

2.3 parseChildren——递归下降的解析引擎

解析器的核心是 parseChildren 函数。它在一个 while 循环中不断检查剩余字符串的开头,根据特征字符分派到不同的子解析器:

function parseChildren(context, ancestors) {
  const nodes = []

  while (!isEnd(context, ancestors)) {
    const s = context.source
    let node

    if (s.startsWith('{{')) {
      // 情况 1: 遇到 {{ 开头,解析插值表达式
      node = parseInterpolation(context)
    } else if (s[0] === '<') {
      if (s[1] === '!') {
        if (s.startsWith('<!--')) {
          // 情况 2: 注释节点
          node = parseComment(context)
        }
      } else if (s[1] === '/') {
        // 情况 3: 遇到闭合标签——不创建节点,交给上层处理
        break
      } else if (/[a-z]/i.test(s[1])) {
        // 情况 4: 遇到开始标签,解析元素
        node = parseElement(context, ancestors)
      }
    }

    // 情况 5: 以上都不匹配,当作文本节点处理
    if (!node) {
      node = parseText(context)
    }

    nodes.push(node)
  }

  return nodes
}

这段代码的判断逻辑用一张决策树来表示:

当前字符 source[0]

  ├── 以 '{{' 开头 ──────────> parseInterpolation()  → 插值节点

  ├── 以 '<' 开头
  │    ├── '<!' 开头
  │    │    └── '<!--' ──────> parseComment()         → 注释节点
  │    ├── '</' 开头 ────────> 结束标签,跳出循环
  │    └── '<[a-z]' 开头 ───> parseElement()          → 元素节点

  └── 其他 ──────────────────> parseText()            → 文本节点

2.4 解析元素——开始标签、子节点、闭合标签

元素的解析分为三步,对应 HTML 标签的三个部分:

function parseElement(context, ancestors) {
  // 第一步:解析开始标签 <div class="test">
  const element = parseTag(context)

  // 如果是自闭合标签 <br/>,直接返回
  if (element.isSelfClosing) return element

  // 第二步:把当前元素压入祖先栈,递归解析子节点
  ancestors.push(element)
  element.children = parseChildren(context, ancestors)
  ancestors.pop()

  // 第三步:解析闭合标签 </div>,消费掉它
  if (context.source.startsWith(`</${element.tag}>`)) {
    parseTag(context)  // 消费闭合标签
  }

  return element
}

这里的 ancestors 栈是关键设计。它记录了从根节点到当前节点的完整路径,有两个作用:

  1. 判断结束条件:当遇到 </tag> 时,通过栈顶元素判断是否匹配当前元素的闭合标签。
  2. 建立父子关系:栈顶元素就是当前正在解析节点的父元素。

用一个例子演示栈的变化过程:

<div class="app">
  <p>{{ msg }}</p>
  一段文本
</div>
解析过程中 ancestors 栈的变化:

[]           ← 初始状态
[div]        ← 遇到 <div>,div 入栈
[div, p]     ← 遇到 <p>,p 入栈
[div]        ← 遇到 </p>,p 出栈
[]           ← 遇到 </div>,div 出栈

2.5 解析插值表达式

插值表达式 {{ msg }} 的解析比较直观:找到 {{}} 的位置,提取中间的内容:

function parseInterpolation(context) {
  const openDelimiter = '{{'
  const closeDelimiter = '}}'

  // 找到 }} 的位置
  const closeIndex = context.source.indexOf(closeDelimiter, openDelimiter.length)

  // 消费掉 {{
  advanceBy(context, openDelimiter.length)

  // 提取中间的内容并去除空格
  const rawContentLength = closeIndex - openDelimiter.length
  const rawContent = context.source.slice(0, rawContentLength)
  const content = rawContent.trim()

  // 消费掉内容 + }}
  advanceBy(context, rawContentLength + closeDelimiter.length)

  return {
    type: 5,  // INTERPOLATION
    content: {
      type: 4,  // SIMPLE_EXPRESSION
      content,  // 'msg'
      isStatic: false
    }
  }
}

2.6 解析结果:一棵完整的 AST

当整个模板解析完毕后,我们得到一棵树形结构。以 <p>{{ msg }}</p> 为例:

AST Root (type: 0)
  └── Element (type: 1, tag: "p")
        └── Interpolation (type: 5)
              └── Expression (type: 4, content: "msg", isStatic: false)

每个节点都携带了类型信息、位置信息(行号、列号、偏移量),以及与该节点相关的属性和子节点。这棵树就是下一个阶段——Transform 的输入。

2.7 解析文本节点

当字符串的起始位置既不是 < 也不是 {{ 时,解析器将其视为纯文本。文本节点的解析策略是:向前扫描,直到遇到 <{{,中间的内容就是一个完整的文本节点。

function parseText(context) {
  // 文本的结束标志:遇到 '<' 或 '{{'
  const endTokens = ['<', '{{']
  let endIndex = context.source.length

  for (const token of endTokens) {
    const index = context.source.indexOf(token)
    if (index !== -1 && index < endIndex) {
      endIndex = index
    }
  }

  // 提取文本内容
  const content = context.source.slice(0, endIndex)
  advanceBy(context, endIndex)

  return {
    type: 2,  // TEXT
    content
  }
}

举个例子,对于这段模板:

hello {{ name }} world

解析过程如下:

第 1 轮: source = 'hello {{ name }} world'
         遇到 '{{' 之前的 'hello ' 是文本 → parseText → 消费 'hello '

第 2 轮: source = '{{ name }} world'
         以 '{{' 开头 → parseInterpolation → 消费 '{{ name }}'

第 3 轮: source = ' world'
         不以 '<' 或 '{{' 开头 → parseText → 消费 ' world'

最终生成三个平级的子节点:TEXT("hello ")INTERPOLATION("name")TEXT(" world")

2.8 解析注释节点

注释节点 <!-- content --> 的解析同样遵循”找到边界,提取内容”的模式:

function parseComment(context) {
  // 消费掉 <!--
  advanceBy(context, 4)

  // 找到 --> 的位置
  const closeIndex = context.source.indexOf('-->')
  const content = context.source.slice(0, closeIndex)

  // 消费掉注释内容 + -->
  advanceBy(context, content.length + 3)

  return {
    type: 3,  // COMMENT
    content
  }
}

Vue 3 在生产环境默认会移除注释节点,但在开发环境保留它们以便调试。这个决策也是在编译阶段通过配置项控制的。

2.9 小结:解析阶段的设计模式

回顾整个 Parse 阶段,它遵循了编译器设计中经典的递归下降解析(Recursive Descent Parsing)模式:

  1. 自顶向下:从根节点开始,遇到子结构就递归深入。
  2. 逐步消费:每解析一段就通过 advanceBy 移除已处理部分,保证 source 始终指向下一个待解析片段。
  3. 栈辅助:用 ancestors 栈维护嵌套层级,确保正确的父子关系和闭合标签匹配。
  4. 错误友好:每个解析分支都会对异常情况(如未闭合标签、缺少结束分隔符)做出明确的错误报告。

三、AST 转换(Transform)——语义分析与优化标记

3.1 为什么需要转换

Parse 阶段生成的 AST 忠实地描述了模板的语法结构,但它缺少两类关键信息:

  1. JS 语义:模板中的 <div> 最终要变成 createElementVNode("div", ...) 调用,AST 需要记录”该调用哪个运行时函数、传什么参数”。
  2. 优化标记:哪些节点是静态的、哪些是动态的、动态部分具体是文本变化还是 class 变化——这些信息将直接影响运行时的更新效率。

Transform 阶段就是给 AST 节点”注入”这些信息。

3.2 遍历与插件架构

Transform 的核心是一个深度优先遍历 + 插件式转换的设计:

function transform(root) {
  // 创建转换上下文
  const context = {
    currentNode: null,
    helpers: new Set(),       // 记录需要导入的运行时辅助函数
    hoists: [],               // 记录可以被静态提升的节点

    // 转换插件列表——每个插件负责一种特定的转换
    nodeTransforms: [
      transformElement,       // 处理元素节点
      transformText,          // 处理文本节点
      transformIf,            // 处理 v-if
      transformFor,           // 处理 v-for
      transformExpression     // 处理表达式
    ]
  }

  // 从根节点开始遍历
  traverseNode(root, context)
}

遍历函数 traverseNode 的设计有一个巧妙之处——退出函数模式

function traverseNode(node, context) {
  context.currentNode = node
  const exitFns = []

  // 正序执行所有转换插件
  for (const transform of context.nodeTransforms) {
    // 插件可以返回一个"退出函数"
    const onExit = transform(node, context)
    if (onExit) exitFns.push(onExit)
  }

  // 递归处理子节点
  if (node.children) {
    for (let i = 0; i < node.children.length; i++) {
      traverseNode(node.children[i], context)
    }
  }

  // 倒序执行退出函数(子节点已全部处理完毕)
  let i = exitFns.length
  while (i--) {
    exitFns[i]()
  }
}

为什么要用退出函数?因为很多转换需要在子节点全部处理完之后才能执行。例如 transformElement 需要知道子节点的完整信息,才能确定当前元素的 patchFlagdynamicChildren

遍历过程示意(以 <div><p>{{ msg }}</p></div> 为例):

traverseNode(div)
  ├── 执行 transforms → 收集退出函数
  ├── traverseNode(p)
  │     ├── 执行 transforms → 收集退出函数
  │     ├── traverseNode(interpolation)
  │     │     ├── 执行 transforms
  │     │     └── 执行退出函数
  │     └── 执行 p 的退出函数 ← 此时 p 的所有子节点已处理完毕
  └── 执行 div 的退出函数 ← 此时 div 的所有子节点已处理完毕

3.3 transformElement——生成 codegenNode

转换阶段最核心的产物是为每个节点生成 codegenNode——一个描述”该节点在代码生成阶段应该生成什么样的 JS 代码”的对象:

const transformElement = (node, context) => {
  // 返回退出函数,在子节点处理完之后执行
  return () => {
    if (node.type !== 1) return  // 只处理元素节点

    // 为元素节点创建 VNodeCall 描述对象
    node.codegenNode = {
      type: 13,  // VNODE_CALL
      tag: `"${node.tag}"`,
      props: node.props,
      children: node.children,
      patchFlag: analyzePatchFlag(node),  // 动态标记
      isBlock: false
    }

    // 标记需要导入的辅助函数
    context.helpers.add('createElementVNode')
  }
}

转换前后的对比:

转换前 (Parse 的产物):               转换后 (Transform 的产物):
{                                    {
  type: 1,                             type: 1,
  tag: "p",                            tag: "p",
  children: [                          children: [...],
    { type: 5, content: "msg" }        codegenNode: {        ← 新增!
  ]                                      type: 13,
}                                        tag: '"p"',
                                         children: ...,
                                         patchFlag: 1,       ← 标记为动态文本
                                       }
                                     }

3.4 PatchFlags——精准的动态标记

PatchFlags 是 Vue 3 编译时优化的核心武器。它用二进制位来标记节点中”哪些部分是动态的”:

const PatchFlags = {
  TEXT:            1,        // 0000000001  动态文本
  CLASS:           1 << 1,   // 0000000010  动态 class
  STYLE:           1 << 2,   // 0000000100  动态 style
  PROPS:           1 << 3,   // 0000001000  动态 props(非 class/style)
  FULL_PROPS:      1 << 4,   // 0000010000  有动态 key 的 props
  HYDRATE_EVENTS:  1 << 5,   // 0000100000  SSR hydration 时需要添加事件监听
  STABLE_FRAGMENT: 1 << 6,   // 0001000000  子节点顺序不变的 fragment
  KEYED_FRAGMENT:  1 << 7,   // 0010000000  有 key 的 fragment
  UNKEYED_FRAGMENT:1 << 8,   // 0100000000  无 key 的 fragment
  NEED_PATCH:      1 << 9,   // 1000000000  需要 patch(ref、指令等)
  HOISTED:         -1,       // 静态提升节点
  BAIL:            -2        // 退出优化模式
}

使用二进制位的好处是可以用位运算高效地组合和检测多个标记:

// 同时标记文本和样式是动态的
const flag = PatchFlags.TEXT | PatchFlags.STYLE  // 0000000101

// 检测是否有动态样式
if (flag & PatchFlags.STYLE) {
  // 只更新 style,跳过其他 props
}

// 检测是否有动态文本
if (flag & PatchFlags.TEXT) {
  // 只更新文本内容
}

举一个实际例子:

<div :class="cls" id="static">hello</div>

编译器分析后发现:id 是静态的,class 是动态绑定的,文本是静态的。因此 patchFlag = 2(即 CLASS)。运行时在 diff 这个节点时,只需要比较 class 属性,完全跳过 id 和文本内容的比较。

再看一个稍复杂的例子:

<p :class="cls" :style="stl">{{ msg }}</p>

这里 classstyle、文本内容都是动态的,编译器会设置:

patchFlag = PatchFlags.TEXT | PatchFlags.CLASS | PatchFlags.STYLE
// 即: 1 | 2 | 4 = 7 (二进制 0000000111)

运行时只需要对这三项做 diff,其余属性一概跳过。

3.5 helpers 收集机制

在转换过程中,编译器还需要知道最终生成的代码需要从 Vue 运行时导入哪些辅助函数。这通过 context.helpers 这个集合来追踪:

// Transform 过程中的 helper 收集
function traverseNode(node, context) {
  // ...
  switch (node.type) {
    case 3:  // COMMENT
      context.helpers.add('createCommentVNode')
      break
    case 5:  // INTERPOLATION
      context.helpers.add('toDisplayString')
      break
  }
  // ...
}

转换结束后,context.helpers 中收集到的所有辅助函数名会被挂载到 AST 根节点上,供代码生成阶段使用。比如一个包含插值和注释的模板,最终 helpers 可能是:

['createElementVNode', 'toDisplayString', 'openBlock', 'createElementBlock']

代码生成阶段就会据此生成相应的 import 语句。

3.6 创建根代码生成节点

Transform 的最后一步是调用 createRootCodegen,为 AST 根节点创建 codegenNode。这一步需要处理一个 Vue 3 特有的情况——模板可以有多个根节点:

<!-- Vue 3 支持多根节点 -->
<template>
  <header>...</header>
  <main>...</main>
  <footer>...</footer>
</template>

当只有一个根节点时,直接将该节点的 codegenNode 作为根的 codegenNode,并标记为 Block。当有多个根节点时,编译器会创建一个 Fragment(片段)节点来包裹它们:

function createRootCodegen(root, context) {
  const { children } = root

  if (children.length === 1) {
    // 单根节点:直接使用子节点的 codegenNode
    const child = children[0]
    if (child.codegenNode) {
      child.codegenNode.isBlock = true  // 标记为 Block
      root.codegenNode = child.codegenNode
    }
  } else if (children.length > 1) {
    // 多根节点:创建一个 Fragment 来包裹
    root.codegenNode = {
      type: 13,  // VNODE_CALL
      tag: 'Fragment',
      children: root.children,
      patchFlag: 64,  // STABLE_FRAGMENT
      isBlock: true
    }
    context.helpers.add('Fragment')
  }
}

至此,Transform 阶段完成。我们得到了一棵包含完整语义信息和优化标记的增强 AST,可以交给下一阶段——代码生成。


四、代码生成(Generate)——从 AST 到渲染函数字符串

4.1 生成器的工作模型

代码生成阶段的任务很直接:遍历 Transform 后的 AST,根据每个节点的 codegenNode,拼接出 JavaScript 代码字符串。

生成器维护一个上下文对象,其中最关键的工具是 push 函数——它负责将代码片段逐段拼接:

function createCodegenContext() {
  const context = {
    code: '',          // 最终生成的代码字符串
    indentLevel: 0,    // 当前缩进层级

    push(code) {
      context.code += code
    },
    indent() {
      context.indentLevel++
      context.push('\n' + '  '.repeat(context.indentLevel))
    },
    deindent() {
      context.indentLevel--
      context.push('\n' + '  '.repeat(context.indentLevel))
    },
    newline() {
      context.push('\n' + '  '.repeat(context.indentLevel))
    }
  }
  return context
}

4.2 代码生成的完整流程

生成器按以下顺序构建代码:

┌─────────────────────────────────────────────────┐
│  1. 生成导入语句 (import { ... } from "vue")    │
│  2. 生成静态提升变量 (const _hoisted_1 = ...)    │
│  3. 生成 render 函数签名                         │
│  4. 生成 render 函数体 (return ...)              │
│  5. 闭合函数                                     │
└─────────────────────────────────────────────────┘

我们用教学代码来演示这个过程:

function generate(ast) {
  const context = createCodegenContext()
  const { push, indent, deindent, newline } = context

  // 第一步:生成导入语句
  if (ast.helpers.length > 0) {
    const helpers = ast.helpers
      .map(h => `${h} as _${h}`)
      .join(', ')
    push(`import { ${helpers} } from "vue"`)
    newline()
    newline()
  }

  // 第二步:生成静态提升变量
  ast.hoists.forEach((exp, i) => {
    push(`const _hoisted_${i + 1} = `)
    genNode(exp, context)
    newline()
  })

  // 第三步:生成 render 函数
  push('export function render(_ctx, _cache) {')
  indent()
  push('return ')

  // 第四步:生成 VNode 创建表达式
  if (ast.codegenNode) {
    genNode(ast.codegenNode, context)
  } else {
    push('null')
  }

  // 第五步:闭合
  deindent()
  push('}')

  return context.code
}

4.3 genNode——递归生成节点代码

genNode 是代码生成的递归引擎,根据节点类型调用不同的生成函数:

function genNode(node, context) {
  switch (node.type) {
    case 2:   // TEXT
      genText(node, context)
      break
    case 4:   // EXPRESSION
      genExpression(node, context)
      break
    case 5:   // INTERPOLATION
      genInterpolation(node, context)
      break
    case 13:  // VNODE_CALL
      genVNodeCall(node, context)
      break
    // ... 其他节点类型
  }
}

function genText(node, context) {
  context.push(JSON.stringify(node.content))
}

function genExpression(node, context) {
  context.push(node.isStatic ? JSON.stringify(node.content) : node.content)
}

function genInterpolation(node, context) {
  context.push('_toDisplayString(')
  genNode(node.content, context)
  context.push(')')
}

function genVNodeCall(node, context) {
  const { push } = context
  const { tag, props, children, patchFlag, isBlock } = node

  if (isBlock) {
    push('(_openBlock(), _createElementBlock(')
  } else {
    push('_createElementVNode(')
  }

  // 依次生成:tag, props, children, patchFlag
  push(tag)
  push(', ')
  push(props ? JSON.stringify(props) : 'null')
  push(', ')

  if (Array.isArray(children)) {
    push('[')
    children.forEach((child, i) => {
      if (i > 0) push(', ')
      genNode(child, context)
    })
    push(']')
  } else if (children) {
    genNode(children, context)
  } else {
    push('null')
  }

  if (patchFlag) {
    push(', ')
    push(String(patchFlag))
  }

  push(')')
  if (isBlock) push(')')
}

4.4 两种生成模式

Vue 3 的代码生成支持两种模式:

module 模式(默认,用于构建工具):

import { createElementVNode as _createElementVNode } from "vue"

export function render(_ctx, _cache) {
  return _createElementVNode("div", null, "hello")
}

function 模式(用于运行时编译):

const { createElementVNode: _createElementVNode } = Vue

return function render(_ctx) {
  with (_ctx) {
    return _createElementVNode("div", null, "hello")
  }
}

区别在于:module 模式使用 ES Module 的 import/export,变量通过 _ctx.xxx 访问;function 模式使用 with(_ctx) 包裹,变量可以直接访问(因为 with 会将 _ctx 作为作用域链的一部分)。需要注意 with 语句在 JavaScript 严格模式下不可用,这也是 SFC 编译和生产构建推荐使用 module 模式的原因之一。


五、编译优化——让运行时尽可能少干活

编译器最大的价值不在于”能编译”,而在于”编译时能做多少优化”。Vue 3 的编译器实现了三大编译时优化策略。

5.1 静态提升(hoistStatic)

核心思想:把永远不会变化的节点从渲染函数中”提出来”,只创建一次。

没有静态提升时:

export function render(_ctx) {
  return (openBlock(), createElementBlock("div", null, [
    createElementVNode("p", null, "static text"),  // 每次 render 都重新创建
    createElementVNode("p", null, toDisplayString(_ctx.msg), 1)
  ]))
}

开启静态提升后:

// 提升到模块作用域——只在模块加载时创建一次
const _hoisted_1 = /*#__PURE__*/createElementVNode("p", null, "static text", -1)

export function render(_ctx) {
  return (openBlock(), createElementBlock("div", null, [
    _hoisted_1,  // 直接引用,不再重新创建
    createElementVNode("p", null, toDisplayString(_ctx.msg), 1)
  ]))
}

编译器在 Transform 阶段通过 walk 函数递归判断每个节点是否可以被提升:

function walk(node, context) {
  for (const child of node.children) {
    // 只有纯元素节点可以被提升
    if (child.type === 1 && isStaticNode(child)) {
      // 标记为 HOISTED
      child.codegenNode.patchFlag = -1

      // 存入 context.hoists 数组
      child.codegenNode = context.hoist(child.codegenNode)
    } else {
      // 递归检查子节点
      walk(child, context)
    }
  }
}

当连续的静态节点数量足够多时,Vue 3 还会进行预字符串化——把多个静态节点合并为一个 HTML 字符串,通过 innerHTML 一次性创建。触发预字符串化只需满足以下任一条件:连续静态节点数量达到阈值(NODE_COUNT = 20),其中含有静态属性绑定的元素节点达到阈值(ELEMENT_WITH_BINDING_COUNT = 5)。注意预字符串化只处理纯静态子树,这些节点必须是纯元素节点(不包含组件等),具体数值可能随版本调整,建议以源码为准:

// 不做预字符串化:20 个 createElementVNode 调用
const _hoisted_1 = createElementVNode("p", null, "text1", -1)
const _hoisted_2 = createElementVNode("p", null, "text2", -1)
// ... 重复 20 次

// 预字符串化后:一个 createStaticVNode 调用
const _hoisted_1 = createStaticVNode("<p>text1</p><p>text2</p>...<p>text20</p>", 20)

这既减少了函数调用开销,也减小了生成代码的体积。

5.2 缓存事件处理函数(cacheHandlers)

考虑这个模板:

<button @click="handleClick">Click</button>

每次渲染时,如果 handleClick 是内联函数,就会创建一个新的函数引用,导致子组件收到的 props 总是”不同的”,从而触发不必要的更新。

开启 cacheHandlers 后,编译器会将事件处理函数缓存到组件实例上:

// 未缓存
export function render(_ctx) {
  return createElementVNode("button", {
    onClick: _ctx.handleClick  // 每次 render 获取新引用
  }, "Click")
}

// 缓存后
export function render(_ctx, _cache) {
  return createElementVNode("button", {
    onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleClick(...args)))
  }, "Click")
}

_cache[0] 在第一次渲染时创建闭包函数并缓存,后续渲染直接复用——函数引用不变,子组件就不会被误触发更新。

5.3 Block Tree 与 dynamicChildren

这是 Vue 3 最核心的运行时优化,而它的基础完全由编译器搭建。

传统 diff 的问题:虚拟 DOM 的 diff 算法需要逐层遍历整棵树,即使大部分节点是静态的,也无法跳过。

传统 diff:遍历整棵树             Block Tree:只 diff 动态节点

  div                              div (Block)
  ├── p "static"   ← 比较          ├── p "static"     ← 跳过
  ├── p {{ msg }}  ← 比较          └── dynamicChildren:
  └── span "text"  ← 比较              └── p {{ msg }} ← 只比较这个

Block Tree 的工作原理

编译器在生成代码时,会在根元素或 v-if/v-for 等结构化指令处创建”Block”。渲染函数执行时,openBlock() 开启一个动态节点收集器,所有动态子节点(patchFlag > 0)都会被收集到 dynamicChildren 数组中:

// 编译生成的渲染函数
export function render(_ctx) {
  return (openBlock(), createElementBlock("div", null, [
    _hoisted_1,  // 静态节点,patchFlag = -1,不会被收集
    createElementVNode("p", null, toDisplayString(_ctx.msg), 1 /* TEXT */)
    // ↑ patchFlag = 1 > 0,被收集到 dynamicChildren
  ]))
}

执行后生成的 VNode 结构:

const vnode = {
  type: 'div',
  children: [
    { type: 'p', children: 'static text' },      // 静态
    { type: 'p', children: ctx.msg, patchFlag: 1 } // 动态
  ],
  dynamicChildren: [
    { type: 'p', children: ctx.msg, patchFlag: 1 } // 只收集动态节点
  ]
}

运行时更新时,如果发现节点有 dynamicChildren,就只遍历这个数组,直接跳过所有静态节点:

// 简化版运行时 patch 逻辑
function patchElement(n1, n2) {
  if (n2.dynamicChildren) {
    // 优化路径:只 diff 动态节点
    patchBlockChildren(n1.dynamicChildren, n2.dynamicChildren)
  } else {
    // 回退路径:全量 diff
    patchChildren(n1, n2)
  }

  // 利用 patchFlag 精准更新 props
  if (n2.patchFlag & PatchFlags.CLASS) {
    // 只更新 class
  }
  if (n2.patchFlag & PatchFlags.TEXT) {
    // 只更新文本
  }
}

这样做的效果是:更新的复杂度从”与模板大小成正比”降低为”与动态节点数量成正比”。一个有 100 个节点但只有 3 个动态绑定的模板,更新时只需要 diff 3 个节点。

需要注意的是,v-ifv-for 会创建新的 Block 边界。因为条件渲染和列表渲染会导致子节点的结构发生变化(节点可能增删),在一个固定的 dynamicChildren 数组中无法追踪这种结构性变化。所以每个 v-if 分支和 v-for 循环都是一个独立的 Block,它们内部各自收集自己的动态节点。

Block Tree 的结构示意:

Root Block (div)
  ├── dynamicChildren: [p.dynamic]

  └── v-if Block (条件为 show)
        └── dynamicChildren: [span.dynamic]

与 Vue 2 的对比:Vue 2 在编译时只做了”标记静态根节点”的优化——如果一个节点的所有子节点都是静态的,就标记为 staticRoot,渲染时缓存其 VNode。但这个优化的粒度很粗:只有纯静态子树才能被跳过,一旦子树中包含任何动态内容,整棵子树都需要重新 diff。而 Vue 3 的 Block Tree + dynamicChildren 实现了节点级别的精准追踪,即使一棵大树中只有一个叶子节点是动态的,也只需要 diff 那一个节点。

5.4 三大优化的协同

让我们用一个完整的例子把三大优化串起来:

<template>
  <div>
    <h1>Vue 3 Compiler</h1>
    <p class="info">This is static content.</p>
    <p :class="dynamicCls">{{ message }}</p>
    <button @click="handleClick">Update</button>
  </div>
</template>

编译结果:

import { createElementVNode, toDisplayString, openBlock,
         createElementBlock, normalizeClass } from "vue"

// 静态提升:h1 和 p.info 只创建一次
const _hoisted_1 = createElementVNode("h1", null, "Vue 3 Compiler", -1)
const _hoisted_2 = createElementVNode("p", { class: "info" },
                     "This is static content.", -1)

export function render(_ctx, _cache) {
  return (openBlock(), createElementBlock("div", null, [
    _hoisted_1,       // 引用静态提升节点
    _hoisted_2,       // 引用静态提升节点
    createElementVNode("p", {
      class: normalizeClass(_ctx.dynamicCls)
    }, toDisplayString(_ctx.message), 3 /* TEXT | CLASS */),
    createElementVNode("button", {
      onClick: _cache[0] || (_cache[0] = (...args) => _ctx.handleClick(...args))
      //       ↑ 事件处理函数缓存
    }, "Update")
  ]))
}

运行时更新时:

  • h1p.info 完全跳过(静态提升,不在 dynamicChildren 中)
  • 第三个 p 标签只检查 text 和 class(patchFlag = 3
  • button 的事件处理函数引用不变,不触发多余更新

六、从模板到运行时——SFC 编译流程

6.1 单文件组件的编译全貌

前面讨论的编译器只处理 <template> 部分。但一个 .vue 文件(SFC,Single-File Component)还包含 <script><style>,它们是如何被处理的?

┌─────────────────────────────────────────────────────┐
│                   App.vue (SFC)                     │
│  ┌────────────┐ ┌────────────┐ ┌────────────┐      │
│  │ <template> │ │ <script>   │ │ <style>    │      │
│  └──────┬─────┘ └──────┬─────┘ └──────┬─────┘      │
│         │              │              │             │
└─────────┼──────────────┼──────────────┼─────────────┘
          │              │              │
          v              v              v
   @vue/compiler-dom  @vue/compiler-sfc  PostCSS/etc.
          │              │              │
          v              v              v
    render function   setup function   CSS Module/Scoped
          │              │              │
          └──────────────┼──────────────┘
                         v
              final component object

这个过程由 @vue/compiler-sfc 包负责协调。它会:

  1. 解析 SFC 结构:将 .vue 文件拆分为 templatescriptstyle 三个 descriptor。
  2. 分别编译template 交给 @vue/compiler-domscript 由 JS 编译器处理,style 交给 CSS 处理器。
  3. 组装产物:将渲染函数、组件逻辑、样式整合为一个完整的组件模块。

6.2 <script setup> 的编译转换

<script setup> 是 Vue 3.2 引入的语法糖,它大幅简化了组合式 API 的使用。但它能如此简洁,完全是编译器在背后做了大量工作。

你写的代码:

<script setup>
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'

const count = ref(0)
const increment = () => count.value++
</script>

<template>
  <MyComponent />
  <button @click="increment">{{ count }}</button>
</template>

编译器实际生成的代码(简化):

import { ref } from 'vue'
import MyComponent from './MyComponent.vue'
import { toDisplayString, createElementVNode, openBlock,
         createElementBlock, createVNode, Fragment } from 'vue'

export default {
  __name: 'App',
  setup(__props, { expose: __expose }) {
    __expose()

    const count = ref(0)
    const increment = () => count.value++

    // 编译器自动识别模板中使用的变量并返回
    return (_ctx, _cache) => {
      return (openBlock(), createElementBlock(Fragment, null, [
        createVNode(MyComponent),
        createElementVNode("button", {
          onClick: increment
        }, toDisplayString(count.value), 1)
      ]))
    }
  }
}

编译器在这里做了几件关键的事:

1. 自动推断暴露的绑定

编译器分析 <script setup> 中声明的所有顶层变量、函数、import,自动将它们暴露给模板。你不需要手动 return

2. 组件自动注册

import MyComponent from './MyComponent.vue' 这行代码在普通 <script> 中只是导入,需要在 components 选项中注册。但在 <script setup> 中,编译器发现模板使用了 <MyComponent />,会自动将其识别为组件,无需注册。

3. 宏的编译时处理

definePropsdefineEmitsdefineExpose 等编译器宏在编译时被转换为对应的运行时代码:

<script setup>
const props = defineProps({ title: String })
const emit = defineEmits(['update'])
</script>

编译后:

export default {
  props: { title: String },
  emits: ['update'],
  setup(__props, { emit: __emit }) {
    const props = __props
    const emit = __emit
    // ...
  }
}

definePropsdefineEmits 在运行时并不存在——它们是纯粹的编译时指令,编译器会把它们转换为组件选项和 setup 参数的解构。

6.3 编译器的运行时机

Vue 的编译可以发生在两个时机:

┌─────────────────────────────────────────────────┐
│  构建时编译 (Build-time)                         │
│  ─────────────────────                           │
│  通过 Vite / Webpack + vue-loader 在打包时完成    │
│  产物中不包含编译器,体积更小                      │
│  绝大多数项目使用这种方式                         │
│                                                  │
│  运行时编译 (Runtime)                             │
│  ─────────────────                               │
│  使用包含编译器的完整版 Vue (vue.global.js)        │
│  在浏览器中实时编译 template 选项                  │
│  适用于需要动态模板的特殊场景                      │
└─────────────────────────────────────────────────┘

构建时编译是推荐方式:编译发生在开发/构建阶段,运行时只需要执行已编译好的渲染函数,无需携带编译器代码(减少约 30% 的 bundle 体积)。

6.4 Scoped CSS 的编译实现

你可能好奇,<style scoped> 是怎么做到样式隔离的?答案同样在编译器中。

当编译器遇到 <style scoped> 时,会做两件事:

第一步:给模板中的每个元素添加唯一属性

编译器会为当前 SFC 生成一个唯一的 hash(如 data-v-7a7a37b1),然后在模板编译时,给每个元素节点添加这个属性:

<!-- 编译前 -->
<template>
  <div class="wrapper">
    <p>hello</p>
  </div>
</template>

<!-- 编译后的渲染结果 -->
<div class="wrapper" data-v-7a7a37b1>
  <p data-v-7a7a37b1>hello</p>
</div>

第二步:给 CSS 选择器添加属性选择器

/* 编译前 */
.wrapper { color: red; }
p { font-size: 14px; }

/* 编译后 */
.wrapper[data-v-7a7a37b1] { color: red; }
p[data-v-7a7a37b1] { font-size: 14px; }

通过属性选择器的限定,样式只能匹配带有对应 hash 属性的元素,从而实现了组件级别的样式隔离。这个过程完全发生在编译时,运行时不需要任何额外的开销。


🤔 思考题

  1. 为什么 Vue 不在运行时做 PatchFlags 标记,而是在编译时? 提示:考虑运行时 diff 的时机和信息量。

  2. 如果一个组件完全使用 render 函数(不用 template),Block Tree 优化还能生效吗? 提示:思考 openBlock()createElementBlock() 的调用时机。

  3. <script setup> 中的 defineProps 为什么不需要 import 就能使用? 提示:想想编译器宏和普通函数的区别。

  4. 考虑这个模板:<div v-if="show"><p>{{ msg }}</p></div>。当 showtrue 变为 false 时,Block Tree 是如何处理的? 提示:v-if 会创建新的 Block 边界。


📝 本章自测

  1. Vue 3 编译器的三个阶段分别是什么?每个阶段的输入和输出是什么?

    答案:Parse(输入:模板字符串,输出:AST)、Transform(输入:AST,输出:带有 codegenNode 和优化标记的增强 AST)、Generate(输入:增强 AST,输出:渲染函数代码字符串)。

  2. 解析阶段中,ancestors 栈的作用是什么?

    答案:记录从根节点到当前节点的路径。它用于判断闭合标签是否匹配(结束条件),以及确定当前节点的父元素(父子关系)。

  3. PatchFlags 为什么使用二进制位而不是普通数字?

    答案:二进制位可以通过按位或(|)组合多个标记,通过按位与(&)检测是否包含某个标记,运算效率极高。例如 TEXT | CLASS 表示文本和 class 都是动态的,检测时 flag & CLASS 即可判断是否有动态 class。

  4. 静态提升和 Block Tree + dynamicChildren 分别在什么阶段生效?

    答案:静态提升在编译阶段生效(生成代码时将静态节点提升到 render 函数外部);Block Tree 和 dynamicChildren 在运行时生效(渲染函数执行时收集动态节点,更新时只 diff 动态节点)。但 Block Tree 的基础是编译器在生成代码时插入了 openBlock()createElementBlock() 调用。

  5. <script setup>defineProps 的编译结果是什么?

    答案defineProps({ title: String }) 会被编译为组件的 props 选项(props: { title: String }),同时在 setup 函数参数中通过 __props 提供访问。defineProps 本身是编译器宏,不存在于运行时,不需要 import。


总结

本章我们深入了 Vue 3 编译器的内核,完整走过了模板从字符串到可执行代码的全过程:

  • Parse 阶段通过有限状态机逐字符消费模板,用 ancestors 栈维护嵌套关系,最终生成忠实描述模板语法结构的 AST。
  • Transform 阶段采用插件式架构遍历 AST,为每个节点生成 codegenNode,标记 PatchFlags,收集可提升的静态节点。
  • Generate 阶段递归遍历增强后的 AST,通过 push 函数逐段拼接出渲染函数代码字符串。
  • 编译优化是 Vue 3 性能的关键:静态提升避免重复创建不变的 VNode;事件缓存避免子组件不必要的更新;Block Tree + dynamicChildren 让运行时 diff 的复杂度只与动态节点数量相关。
  • SFC 编译<template><script><style> 分别处理后整合,<script setup> 的简洁写法背后是编译器的自动推断和宏转换。

至此,你已经完整理解了 Vue 3 从模板编写到页面渲染的全链路:模板 → 编译器 → 渲染函数 → 虚拟 DOM → 响应式更新 → 真实 DOM。这条链路上的每一环,我们都在精通篇中做了深入解析。

编译器是连接”开发体验”和”运行时性能”的桥梁。Vue 之所以能在提供声明式模板语法的同时保持出色的运行时性能,核心原因就是编译器能够在构建阶段提前完成大量优化工作。理解了这一点,你就真正理解了 Vue 的设计哲学。

购买课程解锁全部内容

渐进式到全面掌控:12 章系统精通 Vue 3

¥29.90