深入 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 的选择:把脏活累活放到构建时一次性做完。你写得爽(模板),它跑得爽(渲染函数)。


三、解决了什么问题?(四个价值)

  1. 开发体验 —— 用熟悉的 HTML 语法写界面,上手成本极低
  2. 运行时性能 —— 编译前置到打包阶段,浏览器拿到手就是可执行代码
  3. 优化的前提 —— 有了 AST 才能做静态分析,Vue3 的静态提升、PatchFlag 全靠这一步
  4. 跨平台能力 —— 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:多个动态属性,需全量对比 props
  • STABLE_FRAGMENT / KEYED_FRAGMENT / UNKEYED_FRAGMENT:子节点序列的更新策略

哪些节点会成为 Block 节点?

  1. 模板的根节点
  2. 带有 v-if / v-else-if / v-else / v-for 的节点(这些指令会让树结构不稳定,必须独立成 Block,否则动态节点收集会出 bug)

六、总结

  • 状态机负责断句,栈负责认亲,转换器负责换语言,生成器负责砌代码
  • 静态提升管"创建",Block Tree 管"找谁比",PatchFlag 管"比什么" —— Vue3 性能三板斧
  • AST 是前端工具链的通用语言:Babel、ESLint、Webpack 全是同一个套路
posted @ 2026-10-10 12:46  HuangBingQuan  阅读(9)  评论(0)    收藏  举报