模板的本质
Q: 我们每天都在写 Vue 单文件组件的
<template>,但 Vue 在运行时真的认识这些模板语法吗?
A: 答案是否定的。Vue 在运行的时候并不需要模板——它只需要渲染函数。调用渲染函数后会得到虚拟 DOM,虚拟 DOM 最终映射为真实 DOM。模板的存在,纯粹是为了让开发者在描述视图时更加轻松。
这是一个框架设计者的经典权衡:
- 框架少做一些,用户心智负担更重(如直接写渲染函数)。
- 框架多做一些,用户心智负担更轻(如提供模板语法)。
Vue 选择了后者——通过模板编译器,在背后默默将模板转化为渲染函数。
渲染函数:模板编译的目标产物
Q: 模板最终会被编译成什么样子?可以手动写出和模板等价的效果吗?
A: 完全可以。Vue 的 h 函数(渲染函数)就是模板编译后的目标形式。以下是一个用纯 JavaScript 替代模板的示例。
原本的 UserCard 组件用模板来写是这样的:
<template>
<div class="user-card">
<img :src="avatarUrl" class="avatar" />
<div class="user-info">
<h2>{{ name }}</h2>
<p>{{ email }}</p>
</div>
</div>
</template>
改用纯 JS 渲染函数书写:
import { defineComponent, h } from 'vue';
import styles from './UserCard.module.css';
export default defineComponent({
name: 'UserCard',
props: {
name: String,
email: String,
avatarUrl: String,
},
setup(props) {
return () =>
h('div', { class: styles.userCard }, [
h('img', {
class: styles.avatar,
src: props.avatarUrl,
alt: 'User avatar',
}),
h('div', { class: styles.userInfo }, [
h('h2', props.name),
h('p', props.email),
]),
]);
},
});
也可以使用 Options API 的 render 选项:
import { h } from 'vue';
import styles from './UserCard.module.css';
export default {
name: 'UserCard',
props: { name: String, email: String, avatarUrl: String },
render() {
return h('div', { class: styles.userCard }, [
h('img', { class: styles.avatar, src: this.avatarUrl }),
h('div', { class: styles.userInfo }, [
h('h2', this.name),
h('p', this.email),
]),
]);
},
};
这些代码运行起来,效果和模板版本完全一致。这证明了:模板只是语法糖,本质是渲染函数。
模板编译三阶段
Q: 从一段
<template>字符串到最终的渲染函数,编译器具体做了哪些事情?
A: 模板编译器的工作分为三个连续的阶段,每个阶段都依赖上一个阶段的产物。
这三个阶段分别是:
| 阶段 | 组件 | 输入 | 输出 |
|---|---|---|---|
| 1 | 解析器(Parser) | 模板字符串 | 模板 AST(抽象语法树) |
| 2 | 转换器(Transformer) | 模板 AST | JavaScript AST |
| 3 | 生成器(Generator) | JavaScript AST | 渲染函数代码 |
编译器的整体结构可以用以下伪代码表示:
function compile(template) {
// 1. 解析器
const ast = parse(template);
// 2. 转换器:将模板 AST 转换为 JS AST
transform(ast);
// 3. 生成器
const code = generate(ast);
return code;
}
下面我们用一个具体示例来逐步拆解每个阶段的工作细节。
解析器:从字符串到 AST
Q: 编译器怎么理解一段 HTML 字符串?它如何将
<div><p>Vue</p></div>转变为结构化的数据?
A: 解析器的工作分为两步:词法分析(Tokenizer) 和 语法分析(Parser)。
假设有一段模板:
<template>
<div>
<p>Vue</p>
<p>React</p>
</div>
</template>
对于模板编译器来说,这只是一串字符串:
'<div><p>Vue</p><p>React</p></div>';
第一步:词法分析——生成 Token
解析器扫描字符串,将其切割为有意义的 Token:
[
{ type: 'tag', name: 'div' },
{ type: 'tag', name: 'p' },
{ type: 'text', content: 'Vue' },
{ type: 'tagEnd', name: 'p' },
{ type: 'tag', name: 'p' },
{ type: 'text', content: 'React' },
{ type: 'tagEnd', name: 'p' },
{ type: 'tagEnd', name: 'div' },
]
每个 Token 描述了一个最小语法单元:开始标签、结束标签、文本内容等。
第二步:语法分析——生成模板 AST
解析器根据 Token 序列构建抽象语法树(AST):
{
"type": "Root",
"children": [
{
"type": "Element",
"tag": "div",
"children": [
{
"type": "Element",
"tag": "p",
"children": [{ "type": "Text", "content": "Vue" }]
},
{
"type": "Element",
"tag": "p",
"children": [{ "type": "Text", "content": "React" }]
}
]
}
]
}
这个 AST 是一个树形结构,完整且结构化地描述了模板的层级关系。解析器的工作至此完成。
转换器:从模板 AST 到 JavaScript AST
Q: 模板 AST 描述的还只是 HTML 结构,怎么把它变成 JavaScript 代码的抽象表示?
A: 转换器的职责就是做这个「翻译」工作——将模板 AST转换为JavaScript AST。
对于上面的模板,转换器生成如下 JavaScript AST:
{
"type": "FunctionDecl",
"id": { "type": "Identifier", "name": "render" },
"params": [],
"body": [
{
"type": "ReturnStatement",
"return": {
"type": "CallExpression",
"callee": { "type": "Identifier", "name": "h" },
"arguments": [
{ "type": "StringLiteral", "value": "div" },
{
"type": "ArrayExpression",
"elements": [
{
"type": "CallExpression",
"callee": { "type": "Identifier", "name": "h" },
"arguments": [
{ "type": "StringLiteral", "value": "p" },
{ "type": "StringLiteral", "value": "Vue" }
]
},
{
"type": "CallExpression",
"callee": { "type": "Identifier", "name": "h" },
"arguments": [
{ "type": "StringLiteral", "value": "p" },
{ "type": "StringLiteral", "value": "React" }
]
}
]
}
]
}
}
]
}
这个 JavaScript AST 描述了:
- 一个名为
render的函数声明。 - 函数体只有一个 return 语句。
- return 的是一个
h函数调用表达式。 h的参数是一个字符串字面量'div'和一个数组表达式。- 数组中有两个子
h调用,分别对应两个<p>标签和它们的文本内容。
核心转换逻辑:模板中的每个 HTML 元素被映射为 h(tagName, props, children) 的形式;文本节点被映射为字符串字面量;指令、插值表达式等也会被转换为对应的 JS 表达式。
生成器:从 JavaScript AST 到渲染函数代码
Q: 最后一步,怎么把 JavaScript AST 变成真正可执行的代码字符串?
A: 生成器遍历 JavaScript AST,生成最终的渲染函数源码:
function render() {
return h('div', [h('p', 'Vue'), h('p', 'React')]);
}
这和我们手写的渲染函数完全一致。这段代码就是 Vue 在运行时实际执行的——调用 render() 得到虚拟 DOM 树,最终映射到真实 DOM。
编译时机:运行时 vs 预编译
Q: 模板编译是在什么时候发生的?浏览器里还是构建阶段?
A: 根据使用方式的不同,模板编译有两个时机:
运行时编译
当通过 CDN 方式直接引入 Vue 时(不使用构建工具),模板编译在浏览器运行时进行:
<div id="app">
<user-card :name="name" :email="email" :avatar-url="avatarUrl" />
</div>
<template id="user-card-template">
<div class="user-card">
<img :src="avatarUrl" class="avatar" />
<div class="user-info">
<h2>{{ name }}</h2>
<p>{{ email }}</p>
</div>
</div>
</template>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue;
const UserCard = {
name: 'UserCard',
props: { name: String, email: String, avatarUrl: String },
template: '#user-card-template',
};
createApp({
components: { UserCard },
data() {
return {
name: 'John Doe',
email: 'john@example',
avatarUrl: './yinshi.jpg',
};
},
}).mount('#app');
</script>
这种方式的缺点是:用户浏览器需要加载模板编译器(增加体积),并且每次渲染都需要编译(影响性能)。
预编译(工程化环境)
在使用 Vite / Webpack 等构建工具的工程化环境中,模板编译发生在项目打包阶段。浏览器拿到的代码中完全没有模板,只有编译好的渲染函数。
这是推荐的方式,优势明显:
- 打包产物不包含编译器,体积更小。
- 运行时零编译开销,性能更好。
- 可以在编译阶段做静态分析和优化(如静态提升、预字符串化等)。
推荐安装 vite-plugin-inspect 来直观地查看编译结果:
// vite.config.js
import Inspect from 'vite-plugin-inspect';
export default {
plugins: [Inspect()],
};
启动项目后访问 http://localhost:5173/__inspect/,可以看到每个组件编译后的渲染函数代码——非常直观。
组件树与虚拟 DOM 树
Q: 组件树和虚拟 DOM 树是一回事吗?它们之间是什么关系?
A: 这是两个不同但相关的概念。理解它们的区别,是深入理解 Vue 响应式+虚拟 DOM 架构的关键。
从 DOM 树说起
HTML 结构天然形成树形关系:
<div>
<h1>你喜欢的水果</h1>
<ul>
<li>西瓜</li>
<li>香蕉</li>
<li>苹果</li>
</ul>
</div>
组件树 vs 虚拟 DOM 树
组件的本质就是对一组 DOM 进行复用。将上面的结构封装为 Fruit 组件后,它可以被其他组件引用,组件之间形成树结构——这就是组件树。每个组件内部有一组虚拟 DOM,虚拟 DOM 背后映射到真实 DOM:
明确两个定义:
| 概念 | 定义 |
|---|---|
| 组件树 | 各个组件实例之间形成的树形结构 |
| 虚拟 DOM 树 | 某一个组件内部的虚拟 DOM 数据结构,不是整个应用的虚拟 DOM 结构 |
为什么 Vue 既有响应式又有虚拟 DOM
回顾 Vue 的演变历程,能帮我们理解这套架构的设计动机。
Vue 1.x 时代——纯响应式,无虚拟 DOM:
<template>
<div class="wrapper">
<div class="msg1">{{ msg }}</div>
<div class="msg2">{{ msg }}</div>
</div>
</template>
<script>
export default {
data() {
return { msg: 'Hello Vue 1.0' };
},
};
</script>
Vue 1.x 中,模板里每引用一次响应式数据就会生成一个 watcher(观察者)。设计如下:
- 数据通过
Object.defineProperty实现响应式。 - 每个数据对应一个
Dep(发布者)。 - 模板中每个引用点对应一个
Watcher(观察者)。
优点:能够精准地知道哪个数据变化、哪个节点需要更新。
缺点:当应用规模变大时,一个组件可能包含大量 watcher,导致资源消耗严重。
Vue 2.x / 3.x——响应式 + 虚拟 DOM:
Vue 2.0 引入虚拟 DOM 后,将 watcher 的粒度从「节点级别」放大到「组件级别」。也就是说,一个组件对应一个 watcher。
但这带来了新问题:
- 以前:知道是哪个具体节点要更新。
- 现在:只知道是哪个组件要更新,但组件内部具体哪个节点变了一无所知。
这时候虚拟 DOM 就派上了用场——通过对组件内部的虚拟 DOM 进行 diff 计算,精准定位到真正需要更新的节点。
Vue 3 的架构总结:
| 机制 | 粒度 | 作用 |
|---|---|---|
| 响应式(基于 Proxy) | 组件级别 | 知道哪个组件需要更新 |
| 虚拟 DOM + diff 算法 | 节点级别 | 知道组件内部哪个节点需要更新 |
两者配合,Vue 既保持了响应式的精确追踪能力,又避免了大量 watcher 造成的性能开销。同时 Vue 3 的 diff 算法相比 Vue 2 也做了优化升级。
总结
本文深入剖析了 Vue3 模板编译的完整链路:
| 阶段 | 核心步骤 | 产物 |
|---|---|---|
| 解析器 | 词法分析(字符串 -> Token) -> 语法分析(Token -> 模板 AST) | 模板 AST |
| 转换器 | 模板 AST 转换为 JavaScript AST | JavaScript AST |
| 生成器 | JavaScript AST 转换为渲染函数代码 | function render() { ... } |
关键认知总结:
- 模板只是语法糖:Vue 运行时不认识模板,只认识渲染函数。模板的存在是为了降低开发者的心智负担。
- 编译三阶段是流水线:解析器 -> 转换器 -> 生成器,每个阶段都依赖前一个的输出,职责分明,易于理解和维护。
- 预编译是工程化最佳实践:在构建阶段完成编译,避免用户在运行时加载编译器,提升性能和减小体积。
- 响应式 + 虚拟 DOM 是互补关系:响应式负责「哪个组件变了」,虚拟 DOM 负责「组件内哪个节点要更新」。两者协同,既精准又高效。
- 使用 vite-plugin-inspect:直观查看每个组件编译后的渲染函数代码,是学习和调试模板编译的最佳工具。