深入 Vue3 模板编译器:从模板字符串到高性能渲染函数
一场关于 Vue3 Compiler 的原理剖析与实战总结
分享脉络:是什么 → 为什么 → 解决了什么问题 → 底层怎么实现的 → 编译期性能优化
🎤 一句话开场
模板是写给人看的,渲染函数是给机器跑的,编译器就是中间的"翻译官"——而且这位翻译官还会顺手帮你把代码优化到性能极致。
一、是什么?
Vue 编译器(Compiler)做的事:把你写的 <template> 模板字符串,编译成 JS 渲染函数。
<div><p>Vue</p><p>React</p></div>
⬇ 编译后
function render() {
return h('div', [h('p', 'Vue'), h('p', 'React')])
}
内部是一条三步流水线:
| 阶段 | 输入 → 输出 | 一句话职责 |
|---|---|---|
| ① 解析器 Parser | 模板字符串 → 模板 AST | 看懂这串字符 |
| ② 转换器 Transformer | 模板 AST → JS AST | 换一种语言重新描述 |
| ③ 生成器 Generator | JS AST → 代码字符串 | 把代码"打印"出来 |
// 编译器整体流程
function compile(template){
const ast = parse(template) // 1. 解析器:模板 → 模板AST
transform(ast) // 2. 转换器:模板AST → JS AST
const code = generate(ast) // 3. 生成器:JS AST → 渲染函数代码
return code;
}
二、为什么需要它?(核心矛盾)
矛盾:人类爱写声明式的 HTML,引擎只认命令式的 JS。
- 手写
h('div', [h('p', ...)])?—— 嵌套地狱,没人受得了 - 浏览器运行时现场解析模板?—— 每次渲染都解析一遍,白白浪费性能
Vue 的选择:把脏活累活放到构建时一次性做完。你写得爽(模板),它跑得爽(渲染函数)。
三、解决了什么问题?(四个价值)
- 开发体验 —— 用熟悉的 HTML 语法写界面,上手成本极低
- 运行时性能 —— 编译前置到打包阶段,浏览器拿到手就是可执行代码
- 优化的前提 —— 有了 AST 才能做静态分析,Vue3 的静态提升、PatchFlag 全靠这一步
- 跨平台能力 —— AST 是平台无关的中间层,同一模板可生成 Web、小程序等不同目标代码
四、怎么做到的?(技术核心)
① 解析器:状态机"断句" + 栈"认亲"
问题 1:模板对计算机只是一串字符 '<p>Vue</p>',怎么"看懂"?
👉 有限状态机(FSM):定义一组状态(初始态、标签开始态、文本态、结束态……),逐字符读,边读边切状态,产出 token:
'<p>Vue</p>' → [
{ type: 'tag', name: 'p' }, // 开始标签
{ type: 'text', content: 'Vue' },// 文本
{ type: 'tagEnd', name: 'p' }, // 结束标签
]
🎯 类比:听歌识谱,读到什么音符就进入什么状态。浏览器解析 HTML 用的也是这一招。
问题 2:token 是平铺的列表,怎么还原嵌套关系?
👉 一个栈:栈顶永远是"当前的爸爸"——
- 开始标签 → 建节点 → 挂到栈顶名下 → 自己入栈(成为新爸爸)
- 文本 → 挂到栈顶名下
- 结束标签 → 出栈(孩子收齐了)
扫完 token,就得到了模板 AST(树)。
② 转换器:模板 AST → JS AST(插件化的"树改造工厂")
这一步的设计思想比代码更重要:
a) 职责解耦:遍历 vs 转换分离
遍历函数 traverseNode 只负责走树;具体怎么改节点,交给一组插件式的转换函数:
nodeTransforms: [transformRoot, transformElement, transformText]
想加新转换?注册一个新函数即可。
b) 上下文对象 context:随身携带的"工具包"
const context = {
currentNode: null, // 当前节点
childIndex: 0, // 在父节点 children 中的索引
parent: null, // 父节点
replaceNode(node){…}, // 替换节点
removeNode(){…}, // 删除节点
nodeTransforms: [...] // 转换函数列表
}
c) 进入/退出双阶段处理(进入一次,退出一次)
为什么需要?因为父节点要等子节点全部处理完才能收尾(比如 h('div', […孩子们…]) 必须先有孩子们的 jsNode)。
做法:转换函数可以返回一个函数,exitFns 数组存起来,子树遍历完后倒序执行。
🎯 这个思想到处都有:Koa 洋葱模型、React Fiber 的 beginWork/completeWork。
d) 产出 JS AST
自己设计一套节点结构,每个模板节点处理后把结果挂到 node.jsNode 上:
| 模板节点 | JS AST 节点 |
|---|---|
| 文本 | StringLiteral |
| 元素 | CallExpression(h 函数调用) |
| 子节点列表 | ArrayExpression |
| Root | FunctionDecl(render 函数) |
最终 Root 节点包出一个 function render(){ return … }。
③ 生成器:照图纸施工,拼字符串
问题:有了 JS AST(一堆对象),怎么变成代码文本?
👉 递归遍历 JS AST,不同节点类型拼不同的字符串,靠 context 管缩进和换行:
FunctionDecl→ 拼function render() { … }CallExpression→ 拼h(参数…)StringLiteral→ 拼'div'ArrayExpression→ 拼[…]
🎯 类比:AST 是建筑图纸,生成器就是施工队,照着图纸一砖一瓦把代码"砌"出来。
五、Vue3 的 5 大编译优化(升华部分)
铺垫一句:这些优化之所以可能,全因为编译器在编译期就拿到了 AST,知道哪些是静的、哪些是动的。 Vue2 没有这个信息,只能"全量傻对比"。
| 优化 | 解决什么问题 | 一句话原理 |
|---|---|---|
| 静态提升 | 静态节点每次渲染都重复创建 VNode | 不变的节点/属性提到渲染函数外面,只创建一次 |
| 预字符串化 | 大量静态内容生成大量 VNode,diff 慢 | 连续静态节点(约≥10个)直接编译成一段 HTML 字符串,VNode 都没了,diff 自然快;SSR 也受益 |
| 缓存内联事件 | @click="count++" 每次渲染都新建函数 |
cache[0] || (cache[0] = …),有缓存直接取 |
| Block Tree | diff 时静态节点也被逐层对比 | Block 节点带 dynamicChildren 数组只收集动态后代,对比时直奔名单,跳过静态节点 |
| PatchFlag | 动态节点内部也是全属性对比 | 编译期给节点打标记(TEXT/CLASS/STYLE…),更新时只比标记的那几项 |
递进关系(演讲时讲出来很显深度):
- 静态提升 → 解决"不要重复创建"
- Block Tree → 解决"不要重复比较"(静态节点)
- PatchFlag → 解决"比较也要比得精准"(动态节点内部)
PatchFlag 常见标记类型:
TEXT:文本内容会变化CLASS:class 绑定是动态的STYLE:style 绑定是动态的PROPS:有动态属性FULL_PROPS:多个动态属性,需全量对比 propsSTABLE_FRAGMENT/KEYED_FRAGMENT/UNKEYED_FRAGMENT:子节点序列的更新策略
哪些节点会成为 Block 节点?
- 模板的根节点
- 带有
v-if/v-else-if/v-else/v-for的节点(这些指令会让树结构不稳定,必须独立成 Block,否则动态节点收集会出 bug)
六、总结
- 状态机负责断句,栈负责认亲,转换器负责换语言,生成器负责砌代码
- 静态提升管"创建",Block Tree 管"找谁比",PatchFlag 管"比什么" —— Vue3 性能三板斧
- AST 是前端工具链的通用语言:Babel、ESLint、Webpack 全是同一个套路

浙公网安备 33010602011771号