模板的本质

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>
DOM树结构

组件树 vs 虚拟 DOM 树

组件的本质就是对一组 DOM 进行复用。将上面的结构封装为 Fruit 组件后,它可以被其他组件引用,组件之间形成树结构——这就是组件树。每个组件内部有一组虚拟 DOM,虚拟 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,导致资源消耗严重

Vue1.x响应式架构

Vue 2.x / 3.x——响应式 + 虚拟 DOM:

Vue 2.0 引入虚拟 DOM 后,将 watcher 的粒度从「节点级别」放大到「组件级别」。也就是说,一个组件对应一个 watcher

Vue2.x/3.x响应式架构

但这带来了新问题:

  • 以前:知道是哪个具体节点要更新。
  • 现在:只知道是哪个组件要更新,但组件内部具体哪个节点变了一无所知。

这时候虚拟 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() { ... }

关键认知总结

  1. 模板只是语法糖:Vue 运行时不认识模板,只认识渲染函数。模板的存在是为了降低开发者的心智负担。
  2. 编译三阶段是流水线:解析器 -> 转换器 -> 生成器,每个阶段都依赖前一个的输出,职责分明,易于理解和维护。
  3. 预编译是工程化最佳实践:在构建阶段完成编译,避免用户在运行时加载编译器,提升性能和减小体积。
  4. 响应式 + 虚拟 DOM 是互补关系:响应式负责「哪个组件变了」,虚拟 DOM 负责「组件内哪个节点要更新」。两者协同,既精准又高效。
  5. 使用 vite-plugin-inspect:直观查看每个组件编译后的渲染函数代码,是学习和调试模板编译的最佳工具。