模板编译:template 如何变成 render 函数
🎯 引言
我们在 Vue2 课程 里写过无数次 {{ message }}、v-for,但 Vue 并不认识这些模板语法,它真正执行的是 render 函数。中间这道"翻译"工作,就是模板编译。
学完这篇,你能说清 <div>{{ message }}</div> 这样的模板是如何经过 parse、optimize、generate 三步变成 render 函数的,也能讲明白完整版和 runtime-only 版 Vue 的区别。
🗺 总览:模板编译三步
模板编译的入口在 src/compiler/index.js,核心逻辑就三行(简化自源码,去掉 Flow 类型注解):
// 简化自 src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile(template, options) {
// 1. parse:模板字符串 → AST
const ast = parse(template.trim(), options);
// 2. optimize:标记静态节点
optimize(ast, options);
// 3. generate:AST → render 代码字符串
const code = generate(ast, options);
return {
ast,
render: code.render,
};
});
三个阶段各司其职:
- parse:把模板字符串解析成 AST(抽象语法树)。
- optimize:遍历 AST,标记静态节点,为后续更新做优化。
- generate:把 AST 转成
_c('div', ...)这样的代码字符串,最终通过new Function变成 render 函数。
你可以把它想象成翻译一篇文章:先读懂原文断句(parse),再划出不用每次重翻的固定段落(optimize),最后写出译文(generate)。
🧱 parse:模板 → AST
parse 的源码在 src/compiler/parser/index.js,有几百行,但核心思路很朴素:用正则从头到尾逐段扫描模板字符串,遇到开始标签、属性、结束标签、文本就分别处理,边解析边用一个栈维护父子关系。
简化后的工作流程:
// 简化自 src/compiler/parser/index.js 的核心思路
function parse(template) {
const stack = []; // 栈:维护当前解析到哪一层
let root; // AST 根节点
while (template) {
if (template 以结束标签开头) {
// 如 </div>:栈顶元素出栈,表示这一层解析完了
stack.pop();
} else if (template 以开始标签开头) {
// 如 <div id="app">:创建元素节点
const element = { type: 1, tag: 'div', attrsList: [...], children: [] };
if (!root) root = element;
// 挂到栈顶元素的 children 里,然后自己入栈
stack.push(element);
} else {
// 文本:创建文本节点,挂到栈顶元素下
// 带 {{}} 的文本 type 为 2,纯文本 type 为 3
}
// 每处理完一段,就把这段从 template 中截掉,继续循环
}
return root;
}
栈的作用是记住"当前元素的父亲是谁":遇到开始标签就入栈,遇到结束标签就出栈,栈顶永远是当前正在解析的父元素。
AST 节点的三种 type
parse 产出的 AST 节点用一个 type 字段区分类型,只有三种:
| type | 含义 | 例子 |
|---|---|---|
| 1 | 元素节点 | <div>、<p> |
| 2 | 带表达式的文本节点 | {{ message }} |
| 3 | 纯文本节点 | hello |
一个简单模板的 AST 示意
模板:
<div id="app">
<p>{{ message }}</p>
hello
</div>
对应的 AST 大致长这样:
{
type: 1,
tag: 'div',
attrsList: [{ name: 'id', value: 'app' }],
children: [
{
type: 1,
tag: 'p',
children: [
{ type: 2, expression: '_s(message)', text: '{{ message }}' },
],
},
{ type: 3, text: 'hello' },
],
}
可以看到 AST 就是一棵普通的 JS 对象树,模板里的嵌套结构变成了对象的 children 嵌套,每个节点都记录了标签名、属性、文本或表达式。
✨ optimize:标记静态节点
optimize 的源码在 src/compiler/optimizer.js,做的事情只有一件:递归遍历 AST,给不依赖响应式数据的节点打上 static: true 标记。
什么叫"静态"?就是这个节点的内容永远不会因为数据变化而改变。比如 <p>hello</p>,无论 message 怎么变,它都长这样,这就是静态节点。
简化后的核心逻辑:
// 简化自 src/compiler/optimizer.js
function optimize(root) {
markStatic(root);
}
function markStatic(node) {
// 纯文本节点、没有动态绑定的元素 → 静态
node.static = isStatic(node);
if (node.type === 1) {
for (const child of node.children) {
markStatic(child);
// 只要有一个子节点是动态的,父元素就不是静态的
if (!child.static) node.static = false;
}
}
}
function isStatic(node) {
if (node.type === 2) return false; // 带表达式的文本,动态
if (node.type === 3) return true; // 纯文本,静态
// 元素节点:没有 v-if、v-for、动态绑定等才是静态
return !node.if && !node.for && !node.hasBindings /* ... */;
}
另外还会标记静态根(staticRoot):一个静态节点如果本身有子节点、且不是只有一个文本子节点,就可以作为静态根。
标记了有什么用
这些标记是给后面的 patch 阶段用的:
- 静态子树直接跳过:数据变化触发重新渲染时,patch 发现新旧节点都是静态的,就不用对比它们的子树了,整棵跳过。
- 静态节点可缓存复用:静态内容渲染一次后可以缓存起来,下次直接复用,不用重新创建。
也就是说,optimize 是在编译期提前做功课,把"哪些部分永远不变"这个信息算好存起来,让运行时的 Diff 少干活。这和我们在 Snabbdom 课程 里看到的"逐节点对比"形成互补:Vue 先用编译时信息剪枝,再做运行时对比。
🛠 generate:AST → render 代码
generate 的源码在 src/compiler/codegen/index.js,负责把 AST 转成一串代码字符串。还是上面的例子:
<div id="app">
<p>{{ message }}</p>
hello
</div>
生成结果大致是:
with (this) {
return _c('div', { attrs: { id: 'app' } }, [_c('p', [_v(_s(message))]), _v('hello')]);
}
这串字符串再经过 new Function 包装,就变成了真正的 render 函数。执行后返回一棵虚拟节点树(vnode),交给 patch 渲染成真实 DOM。
常用的渲染辅助函数
代码里的 _c、_v、_s 不是编译器发明的黑话,而是 Vue 挂在组件实例上的一组辅助函数的简写:
| 简写 | 完整函数 | 作用 |
|---|---|---|
_c | createElement | 创建元素节点的 vnode,对应 Snabbdom 的 h() |
_v | createTextVNode | 创建文本节点的 vnode |
_s | toString | 把值转成字符串({{ message }} 编译成 _s(message)) |
_l | renderList | 渲染列表,v-for 会编译成 _l 的循环 |
比如一个 v-for 模板:
<ul>
<li v-for="item in list">{{ item.name }}</li>
</ul>
编译后大致是:
with (this) {
return _c('ul', _l(list, function (item) {
return _c('li', [_v(_s(item.name))]);
}));
}
_l(list, fn) 会遍历 list,对每个 item 执行回调生成 vnode,最后收集成一个数组作为 ul 的子节点。看到这里的 h() 风格调用,是不是和 Snabbdom 课程 里手写的 h('li', ...) 对上了?模板编译的最终产物,就是一棵用代码描述的虚拟 DOM。
💡 with(this) 的作用
你一定注意到了,生成的 render 函数外面包了一层 with (this)。它的作用是:把 this(组件实例)设为作用域查找的第一站。
有了它,模板里写的 message 在 render 函数里就会先去 this 上找,等价于 this.message。这就是为什么我们写模板时可以直接写 {{ message }},而不用写 {{ this.message }}。
with 语句在 JavaScript 严格模式('use strict')下是被禁用的,这也是 Vue2 模板编译生成的代码不使用严格模式的原因之一。Vue3 改用了其他方案,不再依赖 with。另外 with 也有代价:它会让作用域查找变慢,而且变量来源不明确。不过模板里的变量一般就几个,实际影响有限。
⚖️ 完整版 vs runtime-only
既然编译器能把模板转成 render 函数,那是不是每次运行都要编译一遍?不一定,这取决于你用的是哪个版本的 Vue。
通过 npm 安装 Vue 时(本地环境:Node.js v22,vue@2.7.16):
# Node.js v22
npm install vue@2.7.16
默认导出的构建(vue.runtime.common.js / 打包工具默认解析的 vue.runtime.esm.js)是 runtime-only 版,不含模板编译器。它假设你的模板已经在构建阶段被 vue-loader(处理 .vue 文件)或 vue-template-compiler 提前编译成了 render 函数,运行时只管渲染,体积更小。
只有显式引用完整版(vue/dist/vue.js,编译器 + 运行时),才能在浏览器里实时编译 template 选项或 el 挂载点的 HTML:
// 完整版才能这样用:运行时编译 template
new Vue({
el: '#app',
template: '<div>{{ message }}</div>',
data: { message: 'hello' },
});
.vue 文件里写 <template> 不需要关心编译器:构建时已经替你编译完了。一句话总结:编译器和运行时是分开的。完整版 = 运行时 + 编译器,可以随时编译模板;runtime-only 版 = 只有运行时,模板必须在构建时编译。
🧾 小节总结
- 模板编译分三步:parse(模板 → AST)、optimize(标记静态节点)、generate(AST → render 代码字符串)。
- parse 用正则逐段扫描模板,用栈维护父子关系,产出 AST;AST 节点分三种 type:1 元素、2 带表达式的文本、3 纯文本。
- optimize 标记不依赖响应式数据的静态节点和静态根,让 patch 时可以跳过或复用静态子树。
- generate 产出
_c('div', [_v(_s(message))])这样的代码字符串,_c建元素、_v建文本、_s转字符串、_l渲染列表。 - 生成的 render 函数包在
with(this)里,所以模板中可以直接写变量名。 - npm 安装的
vue@2.7.16默认是 runtime-only 版(不含编译器),运行时编译模板需要完整版vue/dist/vue.js。
❓ 知识问答
Q1:AST 和虚拟 DOM(vnode)是一回事吗?
A:不是。AST 是编译期的中间产物,描述模板的结构和表达式;vnode 是运行时的产物,由 render 函数执行后生成,描述要渲染的节点。流程是:模板 → AST → render 函数 → vnode → 真实 DOM。
Q2:optimize 标记静态节点,是不是静态节点就不会出现在 vnode 里?
A:会。静态节点照常生成 vnode、照常渲染,只是 patch 对比时认出它是静态的,就跳过子树的精细对比,并用缓存复用。标记是优化手段,不改变渲染结果。
Q3:为什么 {{ message }} 编译后是 _s(message) 而不是直接用 message?
A:因为插值需要的是字符串。_s(toString)负责把数字、对象等各种类型安全地转成字符串,避免直接把对象拼进文本出现 [object Object]。
Q4:runtime-only 版遇到 template 选项会直接报错吗?
A:在开发环境下,runtime-only 版检测到传入了 template 且没有对应 render 函数时,会给出警告提示你改用完整版或改用 render 函数。正常走构建工具的项目不会遇到这个问题,因为模板早已被编译。
Q5:模板编译发生在什么时候?
A:看版本。完整版在浏览器运行 new Vue 时实时编译;runtime-only 版的项目里,编译发生在打包构建阶段(由 vue-loader 完成),浏览器拿到的已经是 render 函数。
🧪 小练习
不看上文,手写下面这个模板编译后的 render 代码(用 _c、_l、_v、_s):
<div>
<h1>{{ title }}</h1>
<ul>
<li v-for="todo in todos">{{ todo.text }}</li>
</ul>
</div>
with (this) {
// 请在这里编写代码
}
写完后对照检查:h1 里的插值用 _v(_s(title));ul 的第二个参数是 _l(todos, function (todo) { ... }),回调里返回 _c('li', [_v(_s(todo.text))])。
🎉 恭喜你已经掌握了模板编译的完整流程!从模板字符串到 AST,再到 render 函数,Vue 的"模板魔法"其实就是这三步踏实的转换。
