我最早写模板只当它是语法糖,后来查过 nextTick 和 v-model 的编译产物,才明白模板到 DOM 中间隔着一层编译:PatchFlag 和静态提升都发生在这里。
Q1: 模板是怎么变成真实 DOM 的?
1
模板是语法糖
你写的
<template> 不是真实 DOM,也不是虚拟 DOM—它是一种DSL(领域特定语言)。Vue 的编译器会把它转换成 JavaScript 函数(渲染函数),这个函数返回虚拟 DOM 树。2
编译器的工作
编译器做三件事:解析(把模板字符串变成 AST)、转换(对 AST 做优化标记,如 PatchFlag、静态提升)、生成(把 AST 变成渲染函数代码)。
3
运行时的工作
渲染函数执行时创建虚拟 DOM 树。数据变化时重新执行渲染函数得到新的虚拟 DOM 树,用 diff 算法比较新旧树,把差异应用到真实 DOM。
4
编译时优化:PatchFlag 与 Block Tree
Vue 3 的编译器在编译阶段为每个动态节点打上 PatchFlag 标记,告诉运行时”这个节点的哪些部分会变化”—比如只有文本变化(TEXT)、只有 class 变化(CLASS)。配合 Block Tree 结构,运行时只需遍历有标记的节点,跳过所有静态节点,将 diff 的开销降到最低。
5
关键启示: 编译时 + 运行时的协同优势
Vue 的模板编译策略证明了一个设计理念:让编译器做尽可能多的优化,减轻运行时的负担。比起 React 的 JSX(运行时才做 diff),Vue 的模板在编译时就掌握了结构信息,这使得 Vue 3 在大型应用中的更新性能通常优于”纯运行时”方案。面试中说清这一点,比单纯背诵编译流程更能打动对方。
模板是给开发者用的语法糖,编译器把它变成渲染函数,运行时负责把渲染函数的输出变成真实 DOM。
手写渲染函数
用
h('div', { class: 'app' }, [...]) 描述 UI,代码冗长难以维护。就像用机器码写程序—你能做到,但效率极低,而且编译器无法帮你优化。模板语法
用
<div class="app">...</div> 描述 UI,简洁直观。就像用高级语言写程序—编译器帮你做 PatchFlag、静态提升、Block Tree 等优化,你只管描述”要什么”。Q2: v-model 的本质是什么?
v-model 是 Vue 中最常用的指令之一,但很多人不知道它在底层是怎么实现的。原生 HTML 元素的 v-model
v-model 在原生元素上的本质是value + input 事件的语法糖:
组件上的 v-model
v-model 在组件上的本质是modelValue prop + update:modelValue 事件的语法糖:
v-model 是编译器语法糖——原生元素编译为
value + input 事件,组件编译为 modelValue + update:modelValue 事件。Q3: nextTick 的原理?为什么能拿到更新后的 DOM?
基础梳理
专家视角深度剖析
为什么需要 nextTick
很多人知道”nextTick 能拿到更新后的 DOM”,但说不清为什么。 Vue 的数据更新是异步批量的—你修改this.count = 1 时,Vue 不会立刻更新 DOM,而是把更新任务放入队列(queueJob),等同步代码全部执行完后,在微任务中统一刷新队列、更新 DOM。
nextTick 的底层实现
nextTick 的原理是:它也用 Promise.resolve().then() 创建一个微任务,但这个微任务在队列刷新之后执行。所以你在 nextTick 回调中访问 DOM,拿到的一定是更新后的状态。
Q4: [script setup] 到底做了什么?
[script setup] 是 setup() 函数的语法糖。编译器会自动做以下事情:
- 自动暴露:顶层变量(ref、reactive、函数)自动暴露给模板,不需要手动 return
- 自动导入:编译器会把 import 的组件自动注册,不需要 components 选项
- 编译为 setup 函数:
[script setup]的代码被编译为setup()函数的函数体
script setup 到底做了什么?(编译时转换详解)
[script setup] 不是运行时 API,而是编译时语法糖。它在编译阶段被 Vue 的编译器转换为标准的 setup() 函数,同时做了四件事:
- 自动暴露:顶层变量(ref、reactive、函数)自动暴露给模板,编译器生成
return { ... }语句 - 自动注册组件:import 的
.vue文件自动注册为子组件,编译器生成components: { MyButton } - 自动注册指令:import 的自定义指令自动注册,编译器生成
directives: { myDirective } - defineProps/defineEmits 编译时处理:
defineProps和defineEmits不是运行时函数,而是编译器宏——编译器在编译时把它们转换为组件选项,运行时不存在
[script setup] 是 setup() 的编译时语法糖——编译器自动暴露顶层变量、注册导入组件、处理 defineProps/defineEmits 编译器宏。Q5: 模板为什么比 render 函数性能好?
编译器的三层优化
- PatchFlag:编译器标记每个元素有哪些动态绑定(文本、class、style、props),运行时用位运算检查,只比较标记的部分。
- Block Tree:扁平化动态后代数组,静态子树完全跳过。
- 静态提升:不依赖响应式的节点提升到渲染函数外部,只创建一次。
render 函数为什么没有这些优化
render 函数是运行时执行的,编译器无法分析它的代码结构。编译器不知道哪些是静态节点、哪些是动态节点,所以只能全量 diff。编译时优化的本质:让编译器做尽可能多的工作
Vue 3 的设计理念是”让编译器做尽可能多的优化,减轻运行时的负担”。尤雨溪在 Vue 3 RFC 中明确表示:模板的”受限语法”不是缺点,而是优势——正是因为模板的结构是可预测的,编译器才能在编译时提取出优化信息。React 的 JSX 是任意 JavaScript 表达式,编译器无法静态分析哪些是动态部分,所以只能在运行时全量比较。底层源码:模板编译器是怎么工作的?
模板编译的三个阶段是什么?
Vue 的模板编译器分三个阶段(详见 Vue 3 源码 packages/compiler-core/src/compile.ts):
-
parse(解析):把模板字符串
<div>{{ count }}</div>解析成 AST 节点树。每个节点记录了标签名、属性、子节点等信息。 -
transform(转换):遍历 AST,给节点打标记。比如给动态文本节点打上
PatchFlag = TEXT,把不依赖响应式的节点标记为”可提升”。 -
generate(生成):把 AST 转换成 JavaScript 代码字符串,就是你看到的
createElementVNode("div", null, ...)这种代码。
v-model 在编译器中是怎么处理的?
v-model 的编译实现在 packages/compiler-core/src/transforms/vModel.ts 中(详见 Vue 3 源码 packages/compiler-core/src/transforms/vModel.ts)。
编译器遇到 v-model 时,会根据元素类型生成不同的代码:
- 原生元素(input/textarea/select):生成
valueprop +onInput/onChange事件 - 组件:生成
modelValueprop +onUpdate:modelValue事件 - 带参数的 v-model(如
v-model:name):生成nameprop +onUpdate:name事件
十、及格回答 vs 满分回答
问题:“Vue 的模板是怎么编译的?”
及格回答(60-70分):“模板经过编译器变成渲染函数,渲染函数返回虚拟 DOM,然后 diff 更新真实 DOM。”问题出在:知道流程但不理解优化。 满分回答(90-100分):
Vue 的模板编译分三个阶段:解析(模板字符串 → AST)、转换(AST 优化标记)、生成(AST → 渲染函数代码)。编译器在转换阶段做了三层优化,这是模板比手写 render 函数性能好的根本原因。[1]
PatchFlag:编译器在 createElementVNode 调用中附加位掩码数字,标记元素有哪些动态绑定(TEXT=1、CLASS=2、STYLE=4、PROPS=8)。运行时用位运算检查,只比较标记的部分,跳过静态属性。React 没有这个—每次 diff 要比较所有属性。[2]
Block Tree:编译器把模板中结构稳定的部分(没有 v-if/v-for)标记为 Block,内部维护一个扁平化的动态后代数组。重渲染时只遍历这个数组中的动态节点,静态子树完全跳过。v-if/v-for 会创建新的子 Block,破坏父 Block 的稳定性。[3]
静态提升:不依赖响应式数据的节点(如<div>静态文本</div>)被提升到渲染函数外部,只创建一次。后续更新时直接复用,不需要 diff。这就是为什么 Vue 3 的模板编译后代码里经常出现_hoisted_1、_hoisted_2这种变量。
常见追问
v-model 的本质是什么?
v-model 的本质是什么?
答题要点: 原生元素是 value + input 事件,组件是 modelValue + update:modelValue 事件。高分回答:
“v-model 是双向绑定的语法糖。在原生元素上,它编译为 ”
value prop + onInput 事件。在组件上,它编译为 modelValue prop + onUpdate:modelValue 事件。nextTick 的原理?为什么能拿到更新后的 DOM?
nextTick 的原理?为什么能拿到更新后的 DOM?
答题要点: Vue 的数据更新是异步批量的,nextTick 的微任务在队列刷新之后执行。高分回答:
“Vue 的数据更新是异步批量的—修改响应式数据后,更新任务被放入队列(”
queueJob),等同步代码执行完后在微任务中统一刷新。nextTick 也用 Promise.resolve().then() 创建微任务,但它在队列刷新之后执行,所以能拿到更新后的 DOM。模板和 render 函数的性能差异?
模板和 render 函数的性能差异?
答题要点: 模板可以被编译器静态分析,render 函数不行。高分回答:
“模板可以被编译器静态分析,生成的代码包含 PatchFlag(标记动态绑定)、Block Tree(扁平化动态节点)、静态提升(静态节点只创建一次)。render 函数是运行时执行的,编译器无法分析这些信息,只能全量 diff。所以 Vue 官方推荐用模板。”
编译器的静态提升是怎么实现的?
编译器的静态提升是怎么实现的?
答题要点: 编译器在转换阶段识别不依赖响应式数据的节点,把它提升到渲染函数外部。高分回答:
“编译器在转换阶段遍历 AST,检查每个节点是否依赖响应式数据。如果一个节点的所有子节点和属性都是静态的(没有绑定变量),编译器会把它从渲染函数中提取出来,赋值给一个外部变量(如
_hoisted_1)。这样每次执行渲染函数时,这个节点只被引用,不被重新创建。Vue 3 的源码中,这个优化在 hoistStatic 转换中实现。”[script setup] 和普通 setup() 函数有什么区别?
[script setup] 和普通 setup() 函数有什么区别?
答题要点: script setup 是编译时语法糖,自动暴露顶层变量和导入的组件。高分回答:
“
[script setup] 是 setup() 函数的编译时语法糖,编译器会自动做三件事:- 自动暴露:顶层变量(ref、reactive、函数)自动暴露给模板,不需要手动 return。
- 自动注册组件:import 的组件自动注册,不需要 components 选项。
- 编译为 setup 函数:代码被编译为
setup()的函数体。
[script setup] 编译后的代码有更好的 tree-shaking 和类型推断。因为编译器在编译时就知道哪些变量被模板使用了,可以精确地生成代码。普通 setup() 是运行时函数,编译器无法静态分析。”v-show 和 v-if 在编译器中有什么区别?
v-show 和 v-if 在编译器中有什么区别?
答题要点: v-if 编译为条件 Block,v-show 编译为带 display 样式的节点。高分回答:
“
v-if 在编译器中被转换为三元表达式:v-if="cond" 编译为 cond ? createVNode(...) : createCommentVNode(...)。当条件为 false 时,节点不会被创建(卸载),这会影响 Block Tree 的结构——v-if 的每个分支都是一个独立的 Block。v-show 在编译器中不改变节点结构,只在运行时通过 transitionHooks 切换 display: none。节点始终存在于 DOM 中,不影响 Block Tree。选型建议:频繁切换用 v-show(只改 CSS,不触发组件卸载/重建),条件很少改变用 v-if(不渲染的节点不占 DOM 空间)。”编译器是怎么处理 slot 的?
编译器是怎么处理 slot 的?
答题要点: 具名 slot 编译为函数,作用域 slot 通过参数传递数据。高分回答:
“Vue 3 的 slot 编译为函数。具名 slot
<slot name="header"> 在父组件中编译为 h(Comp, { header: () => h('div', 'content') })。子组件通过 renderSlot($slots, 'header') 调用这个函数来渲染。作用域 slot <slot :data="items"> 编译时会在 slot 函数中传入参数:header: (data) => h('div', data)。父组件通过 #header="{ data }" 解构获取数据。这种函数式设计让 slot 的内容是惰性渲染的——只有当子组件实际调用 renderSlot 时,slot 的内容才会被渲染。如果子组件有条件地渲染 slot(v-if 控制),不满足条件时 slot 的 VNode 不会被创建。”Vue 3 的模板编译和 Vue 2 有什么区别?
Vue 3 的模板编译和 Vue 2 有什么区别?
答题要点: Vue 3 的编译器新增了 PatchFlag、Block Tree、静态提升等优化。高分回答:
“Vue 2 的编译器只做基本的模板到 render 函数的转换,不附加任何优化信息。生成的 render 函数和手写的一样——每次渲染都遍历整棵 VNode 树。Vue 3 的编译器在转换阶段做了三层优化:
- PatchFlag:给动态节点附加位掩码,告诉运行时哪些属性会变化。
- Block Tree:把模板中结构稳定的区域标记为 Block,维护扁平化的动态后代数组。
- 静态提升:不依赖响应式的 VNode 提升到模块级别,只创建一次。
Composition API 的详细对比和 Composables 最佳实践等内容,详见 Vue 生态与工程。
延伸阅读与引用参考
- Vue 3 编译器源码 packages/compiler-core:Vue 3 模板编译器的核心实现,包含 parse、transform、generate 三个阶段。[1]
- Vue 3 源码 packages/compiler-core/src/transforms/vModel.ts:v-model 的编译实现。[2]
- Vue 3 源码 packages/runtime-core/src/scheduler.ts:nextTick 和 queueJob 的实现。[3]
- Vue 3 官方文档 - 渲染机制:尤雨溪对模板编译和渲染机制的深度解析,包含 PatchFlag、Block Tree、静态提升的完整说明。
- 霍春阳《Vue.js 设计与实现》:从编译器 parse 到渲染器 patch 的完整源码剖析,包含模板编译三阶段的详细实现。
- 黄轶《Vue.js 技术揭秘》:从 Vue 2 编译器到 Vue 3 编译器的演进脉络,对 v-model 的编译转换有源码级分析。
