模板编译:parse、transform 与 generate
🎯 引言
我们在 Vue3 使用课程 里写过无数次 <template>,里面是大家熟悉的 HTML 结构和 {{ }} 插值。但有个容易被忽略的事实:浏览器根本不认识这套模板语法。Vue 真正执行的,是一个叫 render 函数的东西,它就是一个普通的 JavaScript 函数,调用后返回虚拟 DOM。
那模板是怎么变成 render 函数的?中间这道「翻译」工作,就是本篇的主角:模板编译。这一篇,我们就来看编译器是怎么一步步完成这道翻译的。
上一篇讲虚拟 DOM 与 patch 时,我们已经看到 render 函数返回的 vnode 带着 PatchFlag(补丁标记),更新时能做到定向对比。那这些标记是从哪来的?答案正是编译器。
学完这篇,你能够:
- 说清楚模板编译 parse、transform、generate 三个阶段各自做什么。
- 看懂一个简单模板解析出的 AST 长什么样。
- 理解 PatchFlag、静态提升这些优化标注是在哪个阶段打上的。
- 讲明白「编译期能确定的,绝不留给运行时」这句话的含义。
本篇源码基于 Vue 3.5.x(GitHub vuejs/core monorepo),主要涉及 packages/compiler-core 目录。
🧱 编译总览:三段式流程
模板字符串是怎么一步步变成 render 函数的?这一节先看全貌,后面再逐阶段展开。
Vue3 编译器的核心入口是 baseCompile,它的主干逻辑就三步:
// 简化自 packages/compiler-core/src/compile.ts 的 baseCompile
function baseCompile(template, options) {
// 第一步:把模板字符串解析成 AST(一种树状的对象结构,下面马上讲)
const ast = baseParse(template);
// 第二步:挨个检查 AST 上的节点,做转换和优化标注
transform(ast, options);
// 第三步:把处理好的 AST 转成 render 函数的代码字符串
return generate(ast, options);
}
三个阶段各司其职,一句话版本:
- parse:读模板,产出 AST。
- transform:给 AST 节点做处理,找出会变化的节点打上标记,把不变的节点「提升」出去。
- generate:照着处理好的 AST,写出 render 函数的代码。
下面逐个展开。
🧱 parse:模板变 AST
编译的第一步是「读懂」模板。可模板说到底只是一串字符,计算机怎么理解它的结构?这一节回答这个问题。
先解释一个新词。AST 是「抽象语法树」的缩写,名字唬人,说白了就是:用一堆对象,把模板的结构描述成一棵树。每个节点记录自己的类型和内容,子节点套子节点,模板的层次结构就这样被完整记了下来。
parse 从头到尾扫描模板字符串,遇到标签、属性、文本,就创建对应类型的节点挂到树上。以 <div>{{ count }}</div> 为例,解析出的 AST 大致是这样(省略了位置信息等次要字段):
// parse 后的 AST 结构示意
{
type: 0, // ROOT:根节点,装下整个模板
children: [
{
type: 1, // ELEMENT:元素节点,对应一个标签
tag: 'div', // 标签名
props: [], // 标签上的属性,这里没有
children: [
{
type: 5, // INTERPOLATION:插值节点,对应 {{ }}
content: {
type: 4, // SIMPLE_EXPRESSION:表达式节点
content: 'count', // 表达式的内容就是 count
},
},
],
},
],
}
对照着看就很直观:
- 根节点
ROOT装下整个模板。 <div>是ELEMENT节点,记录标签名和属性。{{ count }}是INTERPOLATION节点,里面包着一个表达式节点,内容是count。
type 字段里的数字不用背。源码里有一张叫 NodeTypes 的对照表,1 是元素、5 是插值,读源码时对着查就行。有了这棵树,后续所有处理都围绕对象进行,不用再碰原始字符串。这就是 parse 的全部意义:把字符变成结构。
🧱 transform:转换与优化标注
读懂模板之后,编译器会顺手做一件对性能影响很大的事:提前标注「哪里会变、哪里不变」。这一节看它是怎么标的。transform 会把 AST 上的每个节点挨个访问一遍(这个动作叫遍历),执行一批内置的处理逻辑。核心就两件事,我们一件一件看。
第一件:给会变化的节点打上 PatchFlag。
PatchFlag 直译是「补丁标记」,作用就是给节点做记号:标明这个节点将来更新时,哪一部分会变。有了这个记号,更新时就只检查标记指出的部分,其余直接跳过。
{{ count }} 引用了响应式数据,将来会变。编译器一眼就能看出这一点,于是在它所在的 div 节点上打上一个标记:
// transform 之后,div 节点上多了一个 patchFlag 字段
{
type: 1,
tag: 'div',
patchFlag: 1, // 1 表示 TEXT:这个节点只有文本会变
children: [/* ... */],
}
这个标记的意思是:将来更新这个节点时,只对比文本就够了,属性、子节点结构都不用看。上一篇 patch 里讲的定向对比,靠的就是这里打上的标记。
第二件:把不变的节点提升出去(静态提升)。
如果模板里有完全静态的节点,比如 <p>静态文本</p>,它从头到尾都不会变。transform 会把创建它的代码从 render 函数里「挪」到函数外面。这样无论组件重新渲染多少次,这个静态 vnode 都只创建一次,之后直接复用。
这两件事背后是同一个思想:编译期能确定的,绝不留给运行时。 模板是一串写死的字符,哪里会动、哪里永远不变,编译时就能看出来。把结论提前标好,运行时就不用再花时间分析了。
🧱 generate:生成 render 函数
标注做完了,最后一步是把处理好的 AST 变成真正的 render 函数代码。这一节看生成结果长什么样。
<div>{{ count }}</div> 最终生成的代码大致是:
// 简化自 generate 的输出(教学简化版)
function render(_ctx, _cache) {
// _ctx 是组件实例,_ctx.count 对应模板里的 {{ count }}
// _toDisplayString 负责把值转成字符串显示
// 最后的 1 就是 transform 打上的 PatchFlag,表示只有文本会变
return _openBlock(), _createElementBlock('div', null, _toDisplayString(_ctx.count), 1 /* TEXT */);
}
最关键的是最后一个参数 1 /* TEXT */:transform 阶段打的那个标记,在这里被正式写进了代码。上一篇讲的 patch 函数,正是读这个参数来决定「只对比文本」。(后面的 /* TEXT */ 只是给人看的注释,运行时不参与逻辑。)
再看一个带静态节点的例子,模板是:
<div>
<p>静态文本</p>
<p>{{ count }}</p>
</div>
生成的代码大致是:
// 简化自 generate 的输出(教学简化版)
// 静态节点被提升到 render 函数外面,只创建一次
const _hoisted_1 = _createElementVNode('p', null, '静态文本', -1 /* HOISTED */);
function render(_ctx, _cache) {
return (
_openBlock(),
_createElementBlock('div', null, [
_hoisted_1, // 静态节点:直接引用,不再重新创建
_createElementVNode('p', null, _toDisplayString(_ctx.count), 1 /* TEXT */), // 动态节点:带补丁标记
])
);
}
注意 _hoisted_1 定义在 render 函数外面:它就是静态提升的成果。重新渲染时它直接被引用,不参与创建,也基本不参与对比。
🧱 编译优化带来的收益
打了标记、做了提升,到底省了多少事?这一节把前几篇的知识串起来看。
- 静态提升:静态节点的 vnode 只创建一次,后续渲染直接复用,省掉重复的创建和对比。
- PatchFlag + Block:Block 是上一篇讲的「动态节点收集器」。带标记的动态节点会被收进一份列表,更新时只遍历这份列表,按标记定向对比,静态子树整个跳过。
这正是 Vue3 更新性能优于 Vue2 的重要原因之一。Vue2 更新时,需要在组件粒度内对新旧两棵 vnode 树做全量对比,静态节点也躲不开;Vue3 把「哪里会动」的分析提前到编译期完成,运行时的对比范围被大幅收窄。机制上的差异,比任何数字都更能说明问题。
⚡ 与 Vue2 编译对比
如果你在 Vue2 源码课程 里学过模板编译,会发现整体流程很像:都是 parse、transform、generate 三段式(Vue2 的第二步叫 optimize,只标记静态节点)。区别在于 transform 这一步能做的事:Vue3 基于新的 Block 机制,新增了 PatchFlag、动态节点收集、静态提升等优化标注;Vue2 的 optimize 只标记静态根节点,优化力度有限。可以说,编译器是 Vue3 性能升级里改动较大的一块。
🧾 小节总结
- 浏览器不认识模板语法,编译器负责把模板「翻译」成 render 函数,分 parse、transform、generate 三步。
- parse 把模板字符串解析成 AST:一种用对象描述模板结构的树,每个节点有 type、内容和 children。
- transform 挨个处理 AST 节点,核心做两件事:给动态节点打上 PatchFlag、把静态节点提升到 render 函数外。
- generate 把 AST 写成 render 函数代码,PatchFlag 出现在 createVNode 调用的参数里,patch 更新时据此定向对比。
- 核心思想是「编译期能确定的,绝不留给运行时」,这也是 Vue3 更新性能优于 Vue2 的重要原因之一。
❓ 知识问答
Q1:编译发生在什么时候?
A:两种情况。主流是构建时编译:@vitejs/plugin-vue 或 vue-loader 在打包时就把 .vue 文件里的模板编译成 render 函数,浏览器拿到的是编译好的产物。另一种是运行时编译:在浏览器里直接传模板字符串给 Vue,由内置编译器现场编译。运行时编译会让包体积变大、首屏多花编译时间,日常项目基本都走构建时编译。
Q2:runtime-only 和 full 构建有什么区别?
A:runtime-only 版本不含编译器,体积小,但要求模板必须在构建时编译好。full 版本(带 compiler 的构建)内置了编译器,可以在浏览器里编译模板字符串。Vite、Webpack 默认指向 runtime-only 版本,所以我们在 .vue 文件里写模板完全没问题;但想在运行时动态编译 template 字符串,就得换 full 版本。
Q3:既然编译发生在构建时,我能看到编译结果吗?
A:能。Vue 官方提供了一个在线工具「Vue Template Explorer」,左边贴模板、右边实时显示编译出的 render 函数,非常适合验证本篇的内容。也可以在本地构建产物里搜 _createElementBlock 之类的辅助函数,找到编译后的代码。
Q4:手写 render 函数更灵活,为什么一般不推荐?
A:手写 render 确实灵活,可以用完整的 JavaScript 表达力处理复杂的渲染逻辑,比如 JSX 场景。但代价也明显:可读性和维护性变差,而且模板编译自动做的 PatchFlag、静态提升这些优化,手写时要自己操心。模板语法是开发体验和性能之间一个很好的平衡点,只有模板表达不了的场景才值得手写 render。
🧪 小练习
不看前文,试着把下面这个模板手写成它编译后的 render 函数(教学简化版即可,可以用 _createElementVNode 等辅助函数):
<div class="box">
<span>欢迎</span>
<span>{{ name }}</span>
</div>
要求:静态的 <span>欢迎</span> 要提升到 render 函数外,动态的插值要带上 1 /* TEXT */ 补丁标记。
// 请在这里编写代码:先写提升出去的静态 vnode
function render(_ctx, _cache) {
// 请在这里编写代码:返回根节点 div 的 vnode
}
写完后对照本篇「generate」一节的例子检查:静态节点有没有放到函数外面?动态节点的 PatchFlag 写对了吗?也可以把你的模板贴进 Vue Template Explorer,看官方编译结果和你的答案差在哪。
🎉 恭喜你已经走完「Vue3 源码」课程的完整主线!我们一起从 createApp 的挂载流程出发,深入 reactive 与 effect 的响应式核心,弄清 scheduler 的调度时机,看懂 patch 的定向更新,最后回到起点,揭开了模板如何编译成 render 函数。这条链路就是你每天在用的 Vue 的全貌。课程到这里就结束了,但源码的世界远不止这些:组件更新、keep-alive、Teleport 还有大片值得探索的领域。带上这八篇教给你的读源码方法,继续在 vuejs/core 仓库里挖下去吧!
