1. 编译器在 Vue3 里的定位:一条三段式管线
很多人印象里,Vue3 的编译器就是把<template>里的模板字符串变成一段能跑的渲染函数代码。这个说法没错,但太粗了。真正看懂它之后你会发现,编译器不只是"翻译器",它还是整个框架性能策略的制定者——哪些节点是静态的可以跳过,哪些属性会变需要精确更新,哪些 vnode 能直接复用,这些决策几乎全是在编译期做出来的,运行时只是照着编译结果执行而已。
1.1 从模板到渲染函数,中间发生了什么
完整链路可以拆成三个独立阶段:
- Parse(解析):把模板字符串吃进去,产出 AST(抽象语法树)。这一步只解决"模板里写了什么",比如有哪些元素、属性、指令、插值。
- Transform(转换):把 AST 做各种改造,产出的是另一种更接近代码生成目标的 JavaScript AST。这一步会处理 v-if、v-for、v-model 等指令,同时完成性能相关信息的标记。
- Generate(生成):遍历转换后的 AST,拼接出最终的渲染函数代码。
三个阶段的产物是递进的:模板 → AST → 转换后 AST → 代码字符串。搞清楚这个顺序,你再去读源码就不会迷路。baseCompile这个函数名字很直接,它做的事就是:解析一次、转换一次、生成一次,把三段管线串起来。
我之所以强调"三段式",是因为它把复杂度拆开了。解析器不懂 v-if 的语义,它只负责把v-if="ok"识别成一条指令节点;转换器不关心标签怎么闭合,它只负责把指令节点改写成条件表达式;生成器就更纯粹,它只根据节点类型输出字符串。每一层都能独立测试,也方便做平台扩展——比如服务端渲染的编译器,解析和核心转换可以复用,只是生成逻辑不同。
1.2 为什么需要 AST 这一步而不是直接拼字符串
有人问过我:模板就那么大,为什么不直接用正则替换,或者直接遍历字符串生成代码?
真正的问题是,模板里的结构是嵌套的,指令之间是有关联的。比如<div v-for="item in list"><span>{{ item }}</span></div>,你要做的不只是把{{ item }}替换成表达式,还要搞清楚这个表达式在哪个作用域里——这里的item是 v-for 循环变量,不是组件数据。这种"作用域分析"在纯字符串层面几乎没法做,你只能先把它解析成一棵树,树的父子关系天然表达了嵌套结构。
AST 还有一个好处:它是可以被任意修改的中间表示。转换器可以在树上自由增删节点、改变节点类型。比如 v-if 的处理,转换器会把一个普通元素节点改造成"条件分支节点",这种结构性的手术在字符串上是不可想象的。所以不是"AST 很酷",而是这个问题的复杂度决定了我们必须先用树结构把模板结构化。
另外,AST 也是第三方工具扩展的入口。做模板静态检查、自动修复、国际化提取的工具,完全可以借用 Vue3 自己的 parser,拿到 AST 之后按自己的规则分析,不需要重新写一套解析器。
1.3 三个编译层的分工:core / dom / sfc
Vue3 的编译器不是一个大而全的包,而是分成三层,这是阅读源码时必须先建立的目录感:
- compiler-core:平台无关的核心逻辑。parser、transform 框架、generate 基础都在这一层,它不知道什么是 HTML、什么是浏览器事件。
- compiler-dom:在 core 之上做 DOM 平台适配。比如解析 HTML 时的实体解码规则、空标签列表、样式属性的驼峰化处理,以及 input、select、textarea 上 v-model 的专门逻辑。
- compiler-sfc:负责单文件组件(
.vue)的编译,把 template、script、style 三个块拆出来,分别交给上面的编译器和样式编译器处理。
这个分层带来的直接好处是,其他平台可以共享 core 的编译骨架。做 SSR 编译时,同样可以用 parse 和 transform 的结果,只是 generate 阶段输出的是服务端渲染用的代码。如果你只想在浏览器里动态编译一段模板字符串,用 compiler-dom 就够了,不需要引入 SFC 那套东西。
2. Parse 阶段:把模板字符串吃进状态机
Parse 是整个管线里最"机械"的一步,但也是出错率最高、细节最多的一部分。很多人写模板时遇到"end tag 不匹配"之类的报错,本质都是这层抛出来的。
2.1 解析器的骨架:parseChildren 的递归与元素栈
Vue3 的解析器核心是parseChildren这个递归函数。它做的事情可以概括为:不断扫描当前剩余字符串,遇到<就判断是开始标签、结束标签、注释还是插值,遇到文本就一直读到下一个<或{{为止。
遇到开始标签后,解析器会:
- 读取标签名;
- 循环读取属性列表,直到碰到
>或/>; - 判断是不是自闭合或 void 元素(如
<img>); - 如果不是,递归调用
parseChildren解析它的子节点; - 子节点解析结束后,再读取结束标签并做匹配校验。
这个递归过程中,解析器会维护一个"未闭合标签"的栈。每进一个元素就入栈,每匹配到一个结束标签就出栈。一旦结束标签和栈顶不匹配,就会触发错误。我在实际调试中见过最常见的报错就是</div>和<span>互相嵌套错位,这种结构问题在堆栈里一眼就能看出是谁先闭合错了。
2.2 节点类型和 Attribute / Directive 的区分
AST 里的每个节点都有type字段。这里我只列几个高频的:
| 类型名 | type 值 | 说明 |
|---|---|---|
| ROOT | 0 | 组件模板的根节点 |
| ELEMENT | 1 | 普通元素节点,如div |
| TEXT | 2 | 纯文本节点 |
| COMMENT | 3 | 注释节点 |
| SIMPLE_EXPRESSION | 4 | 表达式节点,如{{ msg }}里的msg |
| INTERPOLATION | 5 | 插值节点,包着表达式 |
| ATTRIBUTE | 6 | 普通属性,如id="app" |
| DIRECTIVE | 7 | 指令节点,如v-bind、v-on、v-model |
元素节点的props数组里,普通属性和指令会被区分存储。比如id="app"生成一个 ATTRIBUTE 节点,而:id="row.id"会生成一个 DIRECTIVE 节点,name是"bind",arg是id。
指令名的识别逻辑值得单独说一句:v-前缀走完整写法,:foo等价于v-bind:foo,@click等价于v-on:click,#header等价于v-slot:header。修饰符则是在指令名的点号后面拆出来的,比如@click.stop.prevent会解析出modifiers: ["stop", "prevent"]。这些修饰符在后面的转换阶段会被翻译成$event.stopPropagation()、$event.preventDefault()之类的代码。
2.3 实体解码、空白折叠和格式错误告警
模板解析不是简单的字符串扫描,还要处理 HTML 语义。比如&要解码成&,{这样的数字实体也要转义。这块逻辑在 compiler-dom 里通过decodeEntities实现,因为这是浏览器环境特有的能力。
空白处理同样容易被忽略。默认配置下,模板里元素之间的换行和缩进会被折叠,纯空白的文本节点会被删除。这也是为什么你写模板时怎么换行都不影响渲染——但要注意,被pre包裹的内容不会做空白折叠。如果你的业务里有代码高亮或文本展示场景,这一点必须心里有数,必要的时候可以通过编译配置调整whitespace选项。
解析阶段的错误告警也统一在这里做。比如标签未闭合、结束标签不匹配,解析器会调用onError回调上报。错误信息会带上行列号,定位到具体模板位置。我建议模板出问题时先看是不是这一层报的错,很多看起来玄学的渲染异常,其实是结构问题在 parse 阶段就埋下了。
3. Transform 阶段:AST 改造的重头戏
如果说 parse 是把模板"读"进来,transform 就是真正地对 AST "动手术"。所有指令语义、作用域分析、性能标记,都在这一层完成。这也是 Vue3 编译器和 Vue2 相比变化最大、最值得深入研究的部分。
3.1 转换器的挂载机制:nodeTransforms 与 directiveTransforms
Transform 的核心是一个可插拔的转换器体系。createTransformContext创建转换上下文,然后transform函数对 AST 做深度优先遍历。遍历到每个节点时,会依次调用所有注册的 nodeTransforms,这些转换器可以在进入节点时执行,也可以返回一个函数在退出节点时执行。
基础配置里,nodeTransforms 大致包含这些角色:处理 v-if 的transformIf、处理 v-for 的transformFor、处理表达式标识符前缀的transformExpression、处理元素 props 汇总的transformElement、处理插槽出口的transformSlotOutlet、处理文本调用的transformText。directiveTransforms 则是一张映射表,key 是指令名,value 是对应的转换函数,比如bind、on、model。
为什么要设计成 enter / exit 两套时机?因为有的改造必须在子节点处理之前定好框架,有的则必须等子节点都处理完再收口。比如 v-if 的转换器需要等分支里的内容全部转换完毕,才能拼出完整的条件表达式,所以它返回的是 exit 函数。
3.2 结构型指令的编译轨迹:v-if 与 v-for
v-if 属于"结构型指令",因为它会改变节点本身的组织方式。转换器会把v-if="ok"所在元素从普通的 ELEMENT 节点改造成 IF 节点,同时收集同级的v-else-if和v-else形成分支列表。生成代码时,这些分支会被翻译成三元表达式:
_ctx.ok ? (_openBlock(), _createElementBlock("div", null, "yes")) : _createCommentVNode("v-if", true)没有 else 分支时,会生成一个注释节点占位,这是为了保证切换时 diff 能正确对应位置。
v-for 的处理则是把元素包裹进renderList。循环变量item、索引index、可选的key都会被提取出来,并写入转换上下文的作用域栈。这一步非常关键,因为后面处理{{ item }}时,item必须指向循环变量而不是组件属性,作用域栈就是用来做这个区分的。
_renderList(_ctx.list, (item, index) => { return (_openBlock(), _createElementBlock("li", { key: index }, _toDisplayString(item), 1 /* TEXT */)) })3.3 v-model 与 v-on 的展开过程
v-model 是我觉得最能体现"编译器替运行时干活"的例子。它的本质是语法糖,但展开逻辑在不同元素上完全不同。
以文本输入框为例,编译器会把v-model="value"展开成一个valueprop 和一个onUpdate:modelValue监听器:
_createElementVNode("input", { value: _ctx.value, "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((_ctx.value) = $event)) }, null, 8 /* PROPS */, ["value", "onUpdate:modelValue"])如果输入框是 checkbox、radio 或 select,处理逻辑又不一样。checkbox 要区分数组和布尔两种模式,select 需要对比 option 的 value。这些平台相关的细节被放在 compiler-dom 的 model 指令转换器里,所以 compiler-core 只负责通用部分,DOM 平台特有逻辑不会污染核心层。
v-on 的转换器则主要负责事件名解析和修饰符展开。@click.stop.prevent会被包裹成$event => { $event.stopPropagation(); $event.preventDefault(); ... },同时触发事件缓存机制,让同一个事件处理函数在多次渲染间保持引用不变。
3.4 补丁标记与块树信息是怎么被标记上去的
Transform 阶段还有一个隐性任务:给每个动态节点打 patchFlag(补丁标记),并判断哪些节点应该成为 block(块)。
transformElement会扫描元素的所有 props,把动态的部分区分出来。比如:class会被归为 CLASS 类动态、:id归为 PROPS 类动态。多个动态类型用位运算合并成一个数字标记。纯静态节点则会被标记为 HOISTED,等待生成阶段做提升处理。
块树的判定逻辑是:哪些节点需要在运行时被"单独追踪"?根节点一定是一个块;组件节点、带 v-for 的节点、动态 slot 的节点,通常也会被标记为块。运行时维护一个 block 栈,创建 vnode 时把动态子节点收集进当前块的dynamicChildren数组。这样更新时只需要对比动态节点列表,不需要对整个 vnode 树做全量 diff。
4. Generate 阶段:生成可执行渲染函数
转换完的 AST 已经变成了"JavaScript 表达式"形态的节点,Generate 的工作就是把这些节点翻译成真正的代码字符串。
4.1 codegen 的上下文与输出控制
代码生成器也有自己的上下文对象,里面维护着输出数组、缩进层级、换行状态和 helper 集合。你可以把它理解成一个"极简的字符串构建器":push追加代码,indent/deindent控制缩进,newline换行。
生成时的模式选择直接影响输出形态。默认的function模式会把渲染函数包在with(_ctx)里,这样模板里的msg可以直接原样书写,运行时通过with作用域找到组件实例上的属性。而module模式是给预编译场景用的,它不生成with,而是逐一把表达式里的标识符加上_ctx.前缀,比如msg变成_ctx.msg。这也是为什么在 SFC 的编译产物里经常能看到_ctx前缀。
4.2 一段真实编译产物的逐行拆解
拿一段非常简单的模板看实际产物:
<div> <span>Hello</span> <p :class="active">{{ msg }}</p> </div>经过 compiler-sfc 预编译后,大致会生成这样(helper 名可能因版本略有差异):
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, normalizeClass as _normalizeClass, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" export function render(_ctx, _cache) { return (_openBlock(), _createElementBlock("div", null, [ _createElementVNode("span", null, "Hello", -1 /* HOISTED */), _createElementVNode("p", { class: _normalizeClass(_ctx.active) }, _toDisplayString(_ctx.msg), 3 /* TEXT, CLASS */) ])) }逐行看几个关键点:
<span>Hello</span>的 patchFlag 是-1,表示已提升,渲染函数每次执行都直接复用同一个 vnode。<p>的 patchFlag 是3,二进制是 TEXT(1)+ CLASS(2),表示这个节点只有文本和 class 可能变化,运行时 patch 时只处理这两类更新。_normalizeClass是用来统一处理字符串、对象、数组,所以:class的值才能同时支持"active"、{ active: true }、["a", "b"]这几种写法。_toDisplayString('msg')包裹了插值,保证对象会被序列化、null/undefined 会变空字符串。
这种产物看多了之后,你基本能"反向翻译"模板——看到 patchFlag 数字,就知道模板里用了哪些动态绑定。
4.3 导出 helper:编译产物与运行时握手
你可能已经注意到编译产物开头的 import:openBlock、createElementBlock、toDisplayString都是从vue运行时导出的。这就是编译器和运行时之间的"接口协议"。
转换阶段会把用到的 helper 收集进一个集合,生成阶段根据这个集合输出 import 语句。这意味着模板里没有用到renderList,运行时就不会引入renderList相关的代码,tree-shaking 才能成立。在编译产物的开发调试里,如果你想确认一个模板到底依赖哪些运行时函数,直接看这个 import 列表就行。
5. 编译期优化到了运行时到底省在哪
很多模板作者知道 Vue3 更快,但说不清快在哪。这一层在编译产物里其实非常直观,我把三个最核心的优化逐一看透。
5.1 静态提升:让不可变 vnode 只创建一次
纯静态的节点,比如<p class="title">Hello</p>,在每次渲染时创建 vnode 是完全没有必要的,因为它的结果永远一样。编译器会把这样的子树提升到渲染函数外面,变成模块级常量:
const _hoisted_1 = /*#__PURE__*/ _createElementVNode("p", { class: "title" }, "Hello", -1 /* HOISTED */) export function render(_ctx, _cache) { return (_openBlock(), _createElementBlock("div", null, [_hoisted_1])) }这样做的收益是两层的:渲染函数变得更短,少做一次对象创建;运行时 patch 时遇到 HOISTED 标记直接跳过,连比较都不做。但要注意,提升只能在"绝对安全"的时候进行。如果静态子树处于 v-for 循环里,同一个 vnode 被多个循环项复用就会出问题,所以编译器会保守处理,必要时改用缓存机制而不是模块级提升。
5.2 补丁标记 + 块树:diff 的定向打击
没有 patchFlag 的时代,更新一个动态文本需要沿着整棵 vnode 树逐层递归比较。有了 patchFlag 之后,运行时在 patch 节点时可以精确到"这个节点只有 class 变了"或"只有文本变了",直接走对应分支,静态 props 连比较都省略。
块树则是在更高维度做剪枝。每个 block 的dynamicChildren数组里,收集了它包含的所有动态后代节点。更新发生时,patchBlockChildren直接遍历这个数组,只对新旧动态节点做 patch。整棵树里大量的静态分支被跳过,diff 成本从"树的规模"降到"动态节点的数量"。
我刚接触这个设计时觉得"追平了"——其实这就是把"哪部分是动态的"这件事的判定,从运行时挪到了编译期。运行时只需要执行,不需要分析,效率自然上来了。
5.3 事件缓存:稳定函数引用背后的收益
如果你在 Vue2 里给子组件传过内联事件处理函数,应该对"父组件每次渲染子组件都跟着更新"有印象,因为函数引用每次都在变。
Vue3 的编译器默认会对事件处理做缓存(SFC 编译场景下默认开启),产物长这样:
onClick: _cache[0] || (_cache[0] = $event => (_ctx.count++))第一次渲染时把函数存进_cache[0],后续渲染直接复用同一个函数引用。子组件做 props 对比时,onClick的引用始终一致,就不会因为父组件重渲染而被迫重渲染。这是纯编译期的收益,模板作者什么都不用做就拿到了。
5.4 优化组合的真实效果观察
这三个优化不是孤立存在的。静态提升减少了 vnode 创建量,patchFlag 让每次 patch 变得精准,块树把 diff 范围从"整棵树"压缩到"动态节点",事件缓存则从 props 对比层面阻断无意义的组件更新。它们叠加后的效果是:模板里静态内容越多,动态绑定越"局部",性能越接近手写命令式 DOM 操作。
我在实际项目里验证过:一个长列表页面,核心逻辑不变,只把模板里几处过大的动态绑定范围改小,让编译器能更精细地打 patchFlag,滚动刷新时脚本耗时就有明显下降。个中原理就是本节说的这套机制。
6. SFC 编译与构建链路的最后一公里
上面聊的都是模板字符串怎么编译,但真实项目里绝大多数代码是写在.vue单文件组件里的。这一层由 compiler-sfc 负责,它要解决的问题又多了一层:如何把混合了三种语言的单个文件拆开、编译、再拼回去。
6.1 compiler-sfc 如何拆分 template / script / style
compiler-sfc 的parse方法会把.vue文件解析出一个 descriptor,里面包含template、script、style三个主要块,以及可能的自定义块。每个块都带着自己在原文件里的行列位置信息,这样编译报错才能定位到准确位置。
模板块交给 compiler-dom 的compile编译成渲染函数;script 块会通过 JS 解析器分析出组件选项、绑定名(比如<script setup>里声明了哪些变量),这些绑定名会被用来校验模板表达式的合法性;style 块则要走 CSS 编译器,支持 scoped 样式时还要给每个选择器加上[data-v-xxxx]这样的属性选择器,同时模板里所有元素也要补上对应的 scopeId 属性。
有个细节很多人不知道:scoped 样式的 hash 值,模板编译和样式编译必须使用同一个 scopeId,才能保证选择器和元素的属性对得上。如果你在做自定义的构建插件,这个一致性一定要维护好。
6.2 编译接入构建流程的两种典型姿势
一种是在常规打包工具里通过插件/loader 接入。开发服务器启动时,插件拦截.vue文件的请求,先 parse 出 descriptor,再分块编译,最终返回一个组装好的 JS 模块。模板编译和脚本编译通常会被缓存,配合热更新时只重新编译改动的块。
另一种是运行时代的运行时编译器。也就是 vue 的完整版构建包含了一个编译器,可以在浏览器里直接把模板字符串编译成渲染函数。这种方式在开发调试一些小模板时很方便,但也有明显代价:包体积更大,而且运行时执行new Function在某些严格 CSP 策略下会被直接拦截。
我的建议是把编译留在构建期。SFC 预编译产物体积小、边界清晰,还能把错误提前暴露在构建阶段而不是用户浏览器里。
6.3 实战中三个常见的编译期问题
第一个是 v-if 和 v-for 同时放在同一个元素上。编译会直接警告,而且行为和 Vue2 不同。Vue2 里 v-for 优先,Vue3 里 v-if 优先,语义变化很容易导致列表项被意外过滤。正确做法是把 v-for 放到<template>上,v-if 留在具体元素上,或者干脆先算好 filteredList。
第二个是模板表达式引用全局对象。预编译的 module 模式下没有with(_ctx)兜底,Math.random()这类全局对象虽然多数情况下还能用,但依赖平台和配置。最稳妥的办法是把需要的值通过 setup 暴露出来,不要在模板里依赖隐式全局。
第三个是过度依赖运行时编译。如果你的部署环境有 CSP 限制,或者你用的是仅运行时的构建产物,那么任何模板字符串都不会被编译。表现为模板渲染出来空白、控制台直接报错。解决方案很简单:所有模板都要走预编译,别留运行时编译的入口。
7. 从阅读源码到模板编写习惯的几点沉淀
把编译器源码通读一遍,并不会让你突然写出性能翻倍的模板,但会让你的很多判断从"感觉"变成"确定"。
7.1 我建议的源码阅读路径
不要一上来就啃 parser,那个状态机细节多、收益低。更顺的路径是:
- 先用 compiler-sfc 的
compileTemplate输出一段编译产物,对着模板逐行看 patchFlag 和 helper 变化; - 再读
baseCompile,把 parse → transform → generate 的主流程跑通; - 接着读
transform和transformElement,重点理解 props 如何汇总、patchFlag 如何累加; - 最后回头看 parser,这时候你对节点类型的理解已经足够,看解析过程就会顺畅很多。
想快速验证编译产物,可以写一个几行的小脚本:
const { compile } = require('@vue/compiler-dom') const { code } = compile('<p :class="active">{{ msg }}</p>', { mode: 'module' }) console.log(code)这个脚本是我调试模板问题时最常用的工具。遇到"为什么这段模板没有被静提"的疑问,直接把模板丢进去看产物,立刻就有答案。
7.2 面向模板作者的编译期自检清单
基于编译器的行为逻辑,我给自己总结了一套模板审查清单:
- 动态绑定的粒度是否足够细?不要给整个容器绑一个会在多处变化的对象,尽量让 patchFlag 落在真正变化的叶子节点。
- v-if 和 v-for 有没有放在同一节点?编译期报警的每一个点都值得处理,不要用
eslint-disable一关了事。 - v-for 的 key 是否稳定?key 的稳定性直接影响块树里 keyed fragment 的复用效率。
- 模板里是否有大量纯静态内容却被迫每次重建?如果一段静态内容被包在一个大动态容器里,可以考虑用子组件或插槽把它隔离出来。
- 是否在运行时依赖模板字符串编译?如果是,先确认构建产物是否真的包含编译器。
我自己的体会是,Vue3 的编译器把框架的优化策略前置到了编译期,模板作者其实是在和编译器"合作"。你在模板里写得越有规律、越静态化,编译器能做的提前量就越大。反过来,如果模板写得毫无章法,编译器也只能回到相对保守的路径。所以与其背下一堆性能口诀,不如学会看编译产物——它能告诉你,编辑器里的那行模板,最终变成了什么样的代码、带着什么样的性能标记。