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 的响应式系统是怎么追踪变化的。本文从底层原理讲透,让你不再踩坑。

🗺️ Vue 3 运行时学习路线 Tier 1 · 理启动 ① createApp/mount ② 响应式原理(Proxy) 搞清Vue的"追踪器" Tier 2 · 明更新 ③ 依赖收集(track) ④ 派发更新(trigger) 理解数据→界面的桥梁 Tier 3 · 会实战 ⑤ 虚拟DOM diff ⑥ nextTick+computed/watch ❌✅对比避坑 ⑦ 响应式陷阱❌✅ + ⑧ 面试速答(8题) reactive解构/数组下标/ref vs reactive/nextTick误区 实战避坑 + 面试巩固
3层递进:理启动 → 明更新 → 会实战,最后避坑+面试速答
① createApp → mount:应用启动时发生了什么
一行 createApp(App).mount('#app') 背后,Vue 做了大量的初始化工作
Vue 3 应用启动流程 ① createApp(App) 创建 app 实例 + 注册全局API ② mount('#app') 开始渲染:创建根组件的虚拟DOM ③ setup() 执行 初始化 ref/reactive → 建立响应式 ④ render → patch → 真实DOM 编译模板 → 生成虚拟DOM → 挂载到页面 此时页面还空白 #app 是空 div setup() 只执行一次 之后数据变化不再走setup 页面出现内容! 用户看到界面 step1 step2 step3
createApp 时不渲染,mount 时才真正开始执行 setup + render + 挂载DOM
📁 Vue 项目
📁 src
📄 main.ts ←
📄 App.vue
📁 views
📄 Home.vue
// 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 包装原始对象 原始对象(普通JS对象) const state = { count: 0, name: 'Vue' } 读写不会触发任何通知 reactive() Proxy 代理对象(响应式) get 拦截器 读取属性时 → track(收集依赖) set 拦截器 修改属性时 → trigger(派发更新) 所有读写都经过代理 → 自动通知 Vue 响应式系统的两大核心操作 track(依赖收集) 在 get 拦截器中执行 记录"谁在读这个属性" 谁 = 当前正在运行的 effect trigger(派发更新) 在 set 拦截器中执行 找到所有"依赖这个属性的effect" → 重新执行它们(重新渲染)
reactive() = 用 Proxy 包装原始对象,所有读写操作被拦截
📁 响应式原理
📁 核心概念
📄 reactive.ts ←
📄 track.ts
📄 trigger.ts
// 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 必须知道"谁依赖这个属性",才能在属性变化时通知它
依赖收集:建立"属性 → effect"的映射关系 渲染函数 (effect) 模板编译后的函数 读取 state.count 读取 state.name get → track 依赖映射表(WeakMap → Map → Set) state 对象 count → Set{ effect1 } ↑ 渲染函数读过 count name → Set{ effect1 } ↑ 渲染函数也读过 name user 对象 age → Set{ effect2 } ↑ computed 读过 email → Set{ } ↑ 没人读过 = 空 当 state.count 变化时 → 查表:count 对应 Set{ effect1 } → 执行 effect1(重新渲染模板) email 没人读 → 变了也不触发更新(性能优化)
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 算法只更新变化的部分
虚拟 DOM diff 流程 旧虚拟DOM(渲染前) <div> <span>Vue</span> <p>count=0</p> <button>+</button> 4个节点 diff 对比 新虚拟DOM(渲染后) <div> ✓ <span>Vue</span> ✓ <p>count=5</p> ★ <button>+</button> ✓ 只有 p 标签的内容变了 diff 结果:只更新变化的部分 div → 不变 ✅ 同类型同key,跳过 p → 更新文本 ★ patch 修改 textContent button → 不变 ✅ 同类型同key,跳过
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——它到底等的是什么
📁 nextTick 原理
📁 执行顺序
同步代码
Vue 更新队列 ←
nextTick 回调
浏览器渲染
// ❌ 错误:立即读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 vs watch
📁 composables
📄 useCart.ts ←
// ✅ 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 解构后失去响应式

❌ 错误版本
📁 views
📄 BadDestructure.vue
// ❌ 解构 reactive = 值拷贝,失去 Proxy 代理 const state = reactive({ count: 0, name: 'Vue' }) const { count, name } = state // ← ❌ count 变成普通数字! // 修改 count 不会触发更新! function increment() { count++ // ← ❌ 这只是修改了一个局部变量 // 等价于:let count = 0; count++ → 和 state 无关 // 界面永远不会更新! }
✅ 正确版本
📁 views
📄 GoodDestructure.vue
// ✅ 方案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:数组下标直接赋值

❌ 错误版本
📁 views
📄 BadArray.vue
// ❌ 直接用下标赋值,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
✅ 正确版本
📁 views
📄 GoodArray.vue
// ✅ 用 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 用错场景

❌✅ 对比
📁 views
📄 ToggleDemo.vue
// ❌ 频繁切换用 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

📁 运行原理图解 / 04-Vue3运行时 — 配套阅读:⑤ React运行时 · ① Java请求全链路
核心三原则速记

Vue三原则:① 响应式数据用 ref/reactive,解构 reactive 会丢失响应 → ② watch 的依赖自动追踪,但 watchEffect 更适合有副作用的场景 → ③ nextTick 保证 DOM 更新后再执行后续操作