Vue 3 运行时全揭秘
createApp 做了什么 → 响应式是怎么追踪的 → 虚拟 DOM 怎么 diff → nextTick 到底在等什么
createApp/mount
响应式系统
虚拟DOM/patch
nextTick
🔴 "数据变了但界面没更新!"——Vue 响应式失效的一天
新同事小王写了一个用户列表页:点击"添加"按钮后,users.push(newUser),但页面死活不刷新。检查数据——数组里确实多了一条!
原因:小王用的是 reactive,然后直接给数组 赋了下标:users[index] = newUser——Vue 3 的 Proxy 对数组下标赋值的拦截和 Vue 2 不同,某些场景下不会触发 trigger。
另一种场景:解构 reactive 后 const { count } = state——count 变成了一个普通数字,失去了 Proxy 代理,修改它界面永远不更新。
这类 Bug 的根源:不理解 Vue 3 的响应式系统是怎么追踪变化的。本文从底层原理讲透,让你不再踩坑。
3层递进:理启动 → 明更新 → 会实战,最后避坑+面试速答
① createApp → mount:应用启动时发生了什么
一行 createApp(App).mount('#app') 背后,Vue 做了大量的初始化工作
createApp 时不渲染,mount 时才真正开始执行 setup + render + 挂载DOM
// main.ts — 应用入口
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App) // ← 只创建实例,不渲染
// app 是一个对象,包含 use/mixin/component/directive 等方法
// 注册插件(在 mount 之前)
app.use(router)
app.use(pinia)
app.mount('#app') // ← ★ 这里才真正开始渲染!
// mount 内部做了什么:
// 1. 找到 #app 对应的 DOM 元素(空的 div)
// 2. 执行 App 组件的 setup() → 初始化响应式数据
// 3. 执行 render 函数 → 生成虚拟DOM树(VNode)
// 4. 遍历VNode → 创建真实DOM元素 → 挂载到 #app 下
// 5. 此时页面出现内容,用户看到界面
🎬 类比:电影上映
📋 createApp = 拍完了电影,拿到了拷贝胶片,但还没在影院放。
🎫 注册插件(app.use) = 影院布置座椅、调试音响、安装3D设备(注册全局功能)。
▶️ mount = 开场放映!观众终于看到画面了。
如果只 createApp 不 mount,就像电影拍完不放映——所有准备就绪但永远不开始。
👆 你现在知道了Vue 应用怎么启动(createApp → mount → setup → render)
⬇️
接下来看 响应式系统是怎么用 Proxy 追踪数据变化的
② 响应式原理:Proxy 是怎么拦截读写的
Vue 3 用 Proxy(不是 Vue 2 的 Object.defineProperty)实现响应式
reactive() = 用 Proxy 包装原始对象,所有读写操作被拦截
// Vue 3 源码简化版:reactive() 的本质
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
const result = Reflect.get(obj, key, receiver)
track(obj, key) // ★ 记录:当前 effect 在读这个属性
return result
},
set(obj, key, value, receiver) {
const result = Reflect.set(obj, key, value, receiver)
trigger(obj, key) // ★ 通知:这个属性变了,重新执行依赖
return result
}
})
}
// 使用:
const state = reactive({ count: 0 })
state.count // → 触发 get → track(记录谁在读 count)
state.count = 1 // → 触发 set → trigger(通知那些 effect 重新跑)
📡 类比:快递追踪系统
📦 原始对象 = 一个没有追踪功能的包裹——你存进去/取出来,没人知道。
📡 Proxy 代理 = 给包裹装了GPS追踪器。每次有人取包裹(get),系统记一笔"谁取的"。每次有人往里放东西(set),系统通知所有关注这个包裹的人。
📝 track = 登记表:"模板渲染函数在读 count" → 记下来。
🔔 trigger = 通知:"count 变了!" → 查登记表,通知模板重新渲染。
⚠️ ref 和 reactive 的区别
ref(0):用 .value 访问。内部其实是一个 { value: x } 对象 + 对 value 的 get/set 拦截。适合基本类型(String/Number/Boolean)。
reactive({...}):用 Proxy 包装整个对象。直接 state.count 访问,不需要 .value。适合对象/数组。
为什么 ref 需要 .value? 因为 Proxy 只能代理对象,不能代理基本类型。Vue 用一个 { value: x } 的包装对象来间接实现基本类型的响应式。
👆 你现在知道了Proxy 怎么拦截读写(get→track, set→trigger)
⬇️
接下来看 track 到底在记录什么——依赖映射表的内部结构
③ 依赖收集:track 到底在记录什么
Vue 必须知道"谁依赖这个属性",才能在属性变化时通知它
track 建立映射表:对象的每个属性 → 依赖它的 effect 集合。只有被读过的属性变化才会触发更新
核心原理
Vue 响应式的本质就是建立一张映射表:哪个属性的 get 被哪个函数读过 → 记下来。属性变化时查表找到对应的函数 → 重新执行。这就是 track(记录依赖)+ trigger(触发更新) 的完整逻辑。所有响应式 API(computed、watch、模板渲染)都是 effect 的不同变体。
👆 你现在知道了track 记录的是什么(属性→effect 映射表)
⬇️
接下来看 trigger 怎么派发更新——以及为什么更新是异步的
④ 派发更新:数据变了,界面怎么更新
从 set 到界面变化,中间还有好几步——不是立即同步的
① 数据被修改
state.count = 5
→ Proxy 的 set 拦截器被触发
② trigger 执行
→ 查依赖映射表:count 对应 Set{ effect1, effect2 }
→ 把 effect1、effect2 放入微任务队列(不是立即执行!)
③ 异步等待(关键!)
Vue 不会立即重新渲染,而是把更新任务放到队列里等待
如果接下来又有 state.count = 6; state.count = 7 → 同一个 effect 只入队一次
这就是为什么 nextTick 存在——更新是异步的!
④ nextTick 回调执行
当前同步代码全部执行完 → 进入微任务阶段 → Vue 刷新队列
→ effect1 执行:重新生成虚拟DOM → diff → 更新真实DOM
⑤ 页面更新完成
用户看到新的界面。此时 DOM 已经是最新的。
如果你在 nextTick 回调中操作 DOM,一定能拿到更新后的元素。
🚇 类比:地铁发车机制
🚶 乘客上车(数据被修改)→ 不是来一个乘客就发一班车
⏰ 而是等固定间隔后发车(nextTick 微任务阶段)
🚶🚶🚶 如果短时间内来了很多乘客(多次修改同一个数据)→ 只发一班车(去重)
🚇 发车后所有乘客一起到达目的地(批量更新DOM)
这个"等一班再走"就是 Vue 的异步更新策略——避免频繁操作 DOM 导致的性能问题。
⚠️ 为什么不能立即操作 DOM?
当你执行 state.count = 5 后立即 document.querySelector('#count') → 拿到的是旧值。因为 Vue 还没来得及更新 DOM(更新在微任务中,还没轮到)。
正确做法:nextTick(() => { /* DOM已更新 */ })。这就是 nextTick 存在的根本原因。
👆 你现在知道了更新是异步的(微任务队列 + 去重)
⬇️
接下来看 Vue 怎么最小化更新真实 DOM——虚拟 DOM diff 算法
⑤ 虚拟 DOM 与 diff:怎么最小化更新真实 DOM
Vue 不是每次都重建所有 DOM,而是通过 diff 算法只更新变化的部分
diff 算法逐层对比新旧VNode,只修改真正变化的DOM节点,避免全量重建
diff 算法的核心规则
1. 同层比较:只对比同一层级的节点,不跨层。如果父节点类型变了(如 div→span),直接销毁整个子树重建。
2. 类型判断:同类型同 key → 复用(只更新属性和子节点)。不同类型 → 销毁旧的,创建新的。
3. key 的作用:列表渲染时(v-for),key 帮助 diff 识别"哪个节点是哪个"。没有 key,Vue 默认按位置对比——可能导致性能差或状态错乱。
⚠️ 为什么 v-for 必须加 key?
假设列表 [A, B, C] 变成 [A, C, B](B 和 C 交换了位置)。
没有 key:Vue 按位置对比 → 位置1的 A→A(不变),位置2的 B→C(更新内容),位置3的 C→B(更新内容)。所有元素都要更新。
有 key:Vue 按key对比 → A→A(不变),B→B(移动位置),C→C(移动位置)。只需移动DOM节点,不需要更新内容。大幅提升性能。
绝对不要用 index 做 key——如果列表中间插入/删除元素,index 会错位,导致 diff 算法判断错误,状态绑定到错误的元素上。
⑥ nextTick:你在等的是什么
一个最常被问到但最容易搞混的 API——它到底等的是什么
// ❌ 错误:立即读DOM,拿到旧值
const count = ref(0)
function increment() {
count.value++ // 修改数据
const el = document.querySelector('#count')
console.log(el.textContent) // ← 打印 0!(旧值)
}
// ✅ 正确:用 nextTick 等待DOM更新
function increment() {
count.value++ // 修改数据
nextTick(() => { // ← 等DOM更新完
const el = document.querySelector('#count')
console.log(el.textContent) // ← 打印 1!(新值)
})
}
// ============ 执行时间线 ============
// count.value++ (同步)
// ↓
// Proxy set → trigger → effect 入队(不立即执行)
// ↓
// nextTick(callback) → callback 也入微任务队列
// ↓
// === 当前同步代码结束 ===
// ↓
// 微任务阶段:
// 1. Vue 刷新更新队列 → 重新render → patch → 真实DOM更新
// 2. nextTick 的 callback 执行 ← DOM已经是最新的!
// ↓
// === 微任务结束 ===
// ↓
// 浏览器渲染(paint)→ 用户看到变化
面试金句
nextTick 等的是什么? 等的是 Vue 的异步 DOM 更新。Vue 不会在数据修改的瞬间立即更新 DOM(避免频繁操作),而是把更新放到微任务队列。nextTick 把你的回调也放到同一个微任务队列的后面,保证回调执行时 DOM 已经更新完毕。本质上 nextTick = Promise.resolve().then(fn)。
⑦ computed 和 watch:两个特殊的 effect
它们和模板渲染一样,都是响应式系统的 effect——但行为不同
| 对比项 |
computed(计算属性) |
watch(侦听器) |
| 本质 |
一个有缓存的 effect |
一个有副作用的 effect |
| 触发时机 |
被读取时(惰性求值) |
依赖变化时(立即/异步) |
| 缓存 |
★ 有缓存!依赖没变不重新计算 |
无缓存,每次变化都执行 |
| 返回值 |
返回一个值(用在模板中) |
不返回值(执行副作用) |
| 类比 |
Excel 公式(=A1+B1) |
VBA 宏(A1变化时弹窗) |
| 使用场景 |
从已有数据计算出新数据 |
数据变化时执行异步/开销大的操作 |
// ✅ computed:从已有数据派生新数据
const cart = reactive({ items: [] as CartItem[] })
const totalPrice = computed(() =>
cart.items.reduce((sum, item) => sum + item.price * item.qty, 0)
)
// ★ 依赖没变 → 直接返回缓存值(不重新计算)
// ★ 依赖变了 → dirty=true → 下次读取时重新计算
// ✅ watch:数据变化时执行副作用
watch(() => cart.items.length, (newLen, oldLen) => {
// 购物车商品数变了 → 发请求同步到后端
api.syncCart(cart.items)
}, { flush: 'post' }) // ← post = DOM更新后执行
// ❌ 常见错误:该用 computed 的用了 watch
let total = ref(0)
watch(() => cart.items, () => {
total.value = cart.items.reduce(...) // ← 不该用watch!用computed!
}) // 没有缓存,每次都重新计算
📊 类比:Excel 公式 vs 宏
computed = Excel 公式:你在 C1 写 =A1+B1。A1 或 B1 没变时,C1 直接显示缓存值。改了 A1 → C1 自动重新计算。但它只返回值,不会执行其他操作。
watch = VBA 宏:你设了一个规则"A1 变化时发邮件通知"。A1 改了 → 宏被触发 → 执行发邮件(副作用)。它不返回值,只执行动作。
⑧ 响应式三大陷阱:❌ 错误 vs ✅ 正确
生产环境最常见的三种"数据变了但界面不更新"——每种都配对给出错误和正确代码
陷阱1:reactive 解构后失去响应式
// ❌ 解构 reactive = 值拷贝,失去 Proxy 代理
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = state // ← ❌ count 变成普通数字!
// 修改 count 不会触发更新!
function increment() {
count++ // ← ❌ 这只是修改了一个局部变量
// 等价于:let count = 0; count++ → 和 state 无关
// 界面永远不会更新!
}
// ✅ 方案1:用 toRefs 保持响应式
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = toRefs(state) // ← ★ count 变成 ref!
function increment() {
count.value++ // ← ✅ 通过 .value 修改,触发响应式
}
// ✅ 方案2:直接用 ref,不需要 toRefs
const count = ref(0)
const name = ref('Vue')
// ✅ 方案3:不解构,直接用 state.count
function increment() {
state.count++ // ← ✅ 直接通过 Proxy 对象修改
}
PRO TIP
记住:reactive 解构 = 断开代理连接。解构是值拷贝——把 state.count 的值(0)赋给 count 变量,count 和 state 之间断开了关联。两种解法:toRefs() 把每个属性转成 ref,或者直接用 ref 替代 reactive。
陷阱2:数组下标直接赋值
// ❌ 直接用下标赋值,Vue 3 可能不触发更新
const list = reactive(['A', 'B', 'C'])
list[0] = 'X' // ← ❌ Vue 3 能检测到(Proxy支持)
list[5] = 'Z' // ← ❌ 给越界索引赋值,不会触发!
list.length = 0 // ← ❌ 直接修改 length 也不会触发!
// 更隐蔽的:动态添加的对象属性
const obj = reactive({ name: 'Tom' })
obj.age = 25 // ← Vue 3 的 Proxy 能检测到(不像Vue2)
// 但如果 obj 是 ref 包裹的,要 obj.value.age = 25
// ✅ 用 Vue 提供的响应式数组方法
const list = reactive(['A', 'B', 'C'])
// 替换元素 → 用 splice
list.splice(0, 1, 'X') // ← ✅ 触发响应式
// 添加元素 → 用 push/unshift
list.push('Z') // ← ✅ 触发响应式
// 清空数组 → 用 splice
list.splice(0) // ← ✅ 触发响应式
// ✅ 终极方案:整体替换(最简洁)
const items = ref(['A', 'B', 'C'])
items.value = ['X', 'Y', 'Z'] // ← ★ 替换整个数组,触发更新
PRO TIP
数组操作口诀:push/pop/shift/unshift/splice/sort/reverse 都能被 Proxy 拦截。但 list[index] = x(越界索引)和 list.length = 0 不一定触发更新。最安全的做法:用 ref 包裹数组,整体替换 items.value = newArray。
陷阱3:v-if 和 v-show 用错场景
// ❌ 频繁切换用 v-if → 反复创建/销毁DOM → 卡顿
<Modal v-if="showModal" /> // ← 每次打开都创建DOM
// 关闭 → DOM 销毁 → 组件状态丢失
// 适合:条件几乎不变(如权限判断)
// ✅ 频繁切换用 v-show → 只改CSS → 丝滑
<Modal v-show="showModal" /> // ← DOM始终存在
// 只是 display:none ↔ display:block
// 适合:Tab切换、弹窗、下拉菜单
// ⚠️ v-if 的特殊用法:条件渲染 + key 重置状态
<UserForm v-if="mode === 'edit'" :key="userId" />
// key 变了 → 旧组件销毁,新组件创建
// ← ★ 用 key 强制重置表单状态!
⚠️ 响应式陷阱速查表
| 陷阱 | 表现 | 根因 | 修复 |
| reactive 解构 | 修改数据界面不更新 | 值拷贝断开代理 | toRefs() 或改用 ref |
| 数组下标/length | 数组操作不触发更新 | 越界索引不被拦截 | splice 或 ref 整体替换 |
| v-if 频繁切换 | 切换卡顿/状态丢失 | 反复创建/销毁DOM | 改用 v-show |
| computed 有副作用 | 不可预期的更新 | computed 里改数据 | 副作用放 watch 里 |
⑨ 面试高频速答
点击展开答案——把零散概念变成面试"金句"
Q1:Vue 3 的 Proxy 比 Vue 2 的 defineProperty 好在哪?
1. 能监听新增属性:defineProperty 只能拦截已存在的属性,新增属性需要 Vue.set()。Proxy 天然拦截所有属性操作。
2. 能监听数组:defineProperty 拦截不了 push/pop/splice 等。Vue 2 需要重写数组方法。Proxy 直接拦截。
3. 性能更好:defineProperty 需要递归遍历每个属性(初始化慢)。Proxy 懒代理——访问到才代理子属性。
4. 不支持 IE:Proxy 是 ES6 特性。Vue 3 放弃了 IE 支持。
Q2:ref 解包:为什么模板里不需要 .value,但 script 里需要?
在 <script setup> 中,ref 必须用 .value 访问——因为 ref 本质是 { value: x } 对象。
但在 <template> 中,Vue 编译器自动帮你加 .value(称为"解包")。这是编译时做的,不是运行时。
如果 ref 是 reactive 对象的属性,也会自动解包:const state = reactive({ count: ref(0) }) → state.count 直接就是 0,不需要 state.count.value。
Q3:为什么 reactive 解构后会失去响应式?怎么解决?
const { count } = state → count 是一个普通数字(基本类型值),不再是 Proxy 代理的属性访问。
因为解构是值拷贝——把 state.count 的值(0)赋给 count 变量。count 和 state 之间断开了关联。
解决方案:用 toRefs():const { count } = toRefs(state) → count 变成一个 ref,保持响应式。
Q4:v-if 和 v-show 底层有什么区别?
v-if:条件为 false 时,DOM 节点根本不创建(虚拟DOM中也没有)。条件变成 true 时才创建。频繁切换时开销大(反复创建/销毁)。
v-show:DOM 节点始终创建,只是通过 display: none 控制显隐。切换时只修改 CSS 属性,开销小。
类比:v-if = 临时工(需要时招来,不需要就辞退)。v-show = 常驻员工(一直在,只是让他歇着或干活)。
Q5:组件的 setup 只执行一次,那数据更新时谁在重新渲染?
setup() 确实只在组件创建时执行一次——初始化响应式数据、定义方法、注册生命周期。
但 setup 返回的渲染函数(或模板编译的 render)是一个 effect。数据变化时,trigger 找到这个 effect → 重新执行 → 生成新的虚拟DOM → diff → 更新DOM。
所以:setup 执行一次(创建响应式系统),render 执行多次(每次数据变化时重新渲染)。
Q6:nextTick 的实现原理?
nextTick 本质是 Promise.resolve().then(fn)——把回调放到微任务队列。
Vue 的 DOM 更新也在微任务中执行(通过 queueFlush)。nextTick 把你的回调追加到更新队列之后,保证回调执行时 DOM 已经更新完毕。
具体流程:数据变化 → trigger → effect 入队 → 同步代码结束 → 微任务阶段:1. Vue flush 队列(render + patch) 2. nextTick 回调执行 → DOM 已更新。
Q7:computed 的缓存机制是怎么实现的?
computed 有一个 dirty 标志:① 第一次读取 → 执行计算 → 存结果 → dirty=false;② 后续读取 → dirty为false → 直接返回缓存;③ 依赖变了 → dirty=true;④ 下次读取 → dirty为true → 重新计算 → dirty=false。
如果一个 computed 依赖的数据没变,访问1000次也只计算1次。这就是为什么 computed 比 method 性能好——method 每次都执行。
Q8:为什么 v-for 不能和 v-if 一起用在同一元素?
Vue 3 中 v-if 的优先级高于 v-for(和 Vue 2 相反)。这意味着 v-if 先执行,但此时 v-for 的变量还没定义 → 报错。
正确做法:用 <template v-for> 包裹,在里面用 v-if;或者用 computed 先过滤列表,再 v-for 渲染过滤后的结果。
computed 方案性能也更好——不会每次渲染都重新过滤。
🎯 Vue 3 运行时心智模型
Vue 3 的核心就三件事:响应式系统(Proxy + track/trigger,建立数据→界面的映射)、虚拟DOM(diff算法,最小化真实DOM操作)、异步更新(微任务队列 + nextTick,批量处理更新避免频繁操作DOM)。
当你修改 count.value++ 时,发生了:set 拦截 → trigger → effect 入队 → 同步代码结束 → 微任务阶段重新渲染 → DOM更新 → nextTick回调。这就是一切"魔法"的本质。
避坑三原则:① reactive 不要解构(用 toRefs 或改用 ref) ② 数组用 splice/push 不要直接下标赋值 ③ 频繁切换用 v-show 不要 v-if
核心三原则速记
Vue三原则:① 响应式数据用 ref/reactive,解构 reactive 会丢失响应 → ② watch 的依赖自动追踪,但 watchEffect 更适合有副作用的场景 → ③ nextTick 保证 DOM 更新后再执行后续操作