Vue3 响应式系统的调度机制:nextTick、scheduler 与批量更新的时序分析

📅 2026/7/14 12:00:08
Vue3 响应式系统的调度机制:nextTick、scheduler 与批量更新的时序分析
Vue3 响应式系统的调度机制nextTick、scheduler 与批量更新的时序分析一、响应式不仅仅是 ProxyVue3 的响应式系统常被简化为基于 Proxy 的数据劫持。但从工程角度看响应式系统的核心能力不在于检测变化而在于如何调度变化引发的副作用。当一行代码触发 10 个 watch 回调、5 个 computed 重算和 3 个组件重渲染时这些副作用以什么顺序执行、是否合并、何时刷新 DOM——这些调度机制的细节决定了应用在高频更新场景下的性能和正确性。本文深入拆解 Vue3 调度机制的三层结构响应式追踪层、调度队列层和 DOM 刷新层。二、调度机制的三层架构flowchart TB subgraph 第一层: 响应式追踪 A[Proxy get/set] -- B[track 收集依赖] B -- C[trigger 触发副作用] end subgraph 第二层: 调度队列 C -- D{是否有活跃 effect?} D --|有| E[加入父 effect 的 deps] D --|无| F[加入调度队列] F -- G[pre 队列br/watch/pre] F -- H[普通队列br/computed/watch] F -- I[post 队列br/watchPost/updated] end subgraph 第三层: DOM 刷新 G -- J[nextTickbr/微任务调度] H -- J I -- J J -- K[DOM 更新完成] K -- L[触发 updated 钩子] end2.1 第一层依赖追踪与触发Vue3 在track阶段建立target - key - dep的映射关系在trigger阶段遍历该映射将关联的ReactiveEffect推入调度队列。关键点是同一同步代码块内的多次 trigger 不会立即执行副作用而是延迟到当前同步任务结束后批量处理。// 简化的 track/trigger 示意非 Vue 源码仅展示核心机制 type Dep SetReactiveEffect; type KeyToDepMap Mapstring | symbol, Dep; const targetMap new WeakMapobject, KeyToDepMap(); function track(target: object, key: string | symbol): void { let depsMap targetMap.get(target); if (!depsMap) { depsMap new Map(); targetMap.set(target, depsMap); } let dep depsMap.get(key); if (!dep) { dep new Set(); depsMap.set(key, dep); } // 将当前活跃的 effect 加入依赖集合 if (activeEffect !dep.has(activeEffect)) { dep.add(activeEffect); activeEffect.deps.push(dep); } } function trigger(target: object, key: string | symbol): void { const depsMap targetMap.get(target); if (!depsMap) return; const dep depsMap.get(key); if (!dep) return; // 拷贝一份再遍历防止在遍历过程中修改集合 const effectsToRun new SetReactiveEffect(); dep.forEach((effect) { if (effect ! activeEffect) { effectsToRun.add(effect); } }); // 将 effect 推入调度队列而非立即执行 effectsToRun.forEach((effect) { if (effect.scheduler) { effect.scheduler(effect); } else { effect.run(); } }); }2.2 第二层三级调度队列Vue3 将副作用分为三个优先级队列队列执行时机典型用途执行顺序pre队列DOM 更新前watch(flush: pre)、组件beforeUpdate最先普通队列DOM 更新前watch(默认)、computed依赖的效果次之post队列DOM 更新后watch(flush: post)、组件updated最后// 简化的调度队列实现 interface SchedulerJob { id: number; run: () void; } // 任务 ID 自增保证同 ID 任务去重 let uid 0; const pendingPreFlushJobs: SchedulerJob[] []; const pendingPostFlushJobs: SchedulerJob[] []; let isFlushing false; let isFlushPending false; /** 将任务推入 pre 队列 */ function queuePreFlushJob(job: SchedulerJob): void { // 去重相同 id 不重复入队 if (!pendingPreFlushJobs.some((j) j.id job.id)) { pendingPreFlushJobs.push(job); } queueFlush(); } /** 将任务推入 post 队列 */ function queuePostFlushJob(job: SchedulerJob): void { if (!pendingPostFlushJobs.some((j) j.id job.id)) { pendingPostFlushJobs.push(job); } queueFlush(); } /** 通过 Promise 微任务调度刷队 */ function queueFlush(): void { if (isFlushing || isFlushPending) return; isFlushPending true; // 使用已 resolved 的 Promise 创建微任务 currentFlushPromise Promise.resolve().then(flushJobs); } /** 真正执行队列中的所有任务 */ function flushJobs(): void { isFlushPending false; isFlushing true; // 1. 先执行 pre 队列按 id 排序 pendingPreFlushJobs.sort((a, b) a.id - b.id); for (const job of pendingPreFlushJobs.splice(0)) { job.run(); } // 2. 执行组件更新在 Vue 内部处理 // flushJobs 内部会递归处理 pre 队列中新增的任务 // 3. 最后执行 post 队列 pendingPostFlushJobs.sort((a, b) a.id - b.id); for (const job of pendingPostFlushJobs.splice(0)) { job.run(); } isFlushing false; }三、nextTick 的机制与边界3.1 nextTick 的实现nextTick本质是将回调函数挂在当前刷新周期的末尾。如果当前没有正在进行的刷新它会创建一个新的微任务。// nextTick 简化实现 let currentFlushPromise: Promisevoid | null null; function nextTickT void( fn?: (this: T) void, ): Promisevoid { const p currentFlushPromise || Promise.resolve(); return fn ? p.then(fn) : p; }3.2 典型使用场景与常见误区template div refcontainer :style{ height: height px } {{ message }} /div /template script setup langts import { ref, nextTick, watch } from vue; const height ref(100); const message ref(Hello); const container refHTMLElement | null(null); // 场景1在数据变更后读取最新的 DOM async function expandContainer() { height.value 300; // 此时 container.value 的高度还是 100px console.log(同步读取:, container.value?.offsetHeight); // 100 await nextTick(); // DOM 已更新 console.log(nextTick 后读取:, container.value?.offsetHeight); // 300 } // 场景2watch 中使用 nextTick watch(message, async (newVal, oldVal) { console.log(watch 触发:, newVal); // 此时 DOM 可能还没更新 await nextTick(); // DOM 已更新完毕 }); // 场景3误区——在 nextTick 中修改数据会递归触发 let counter 0; async function badPattern() { message.value A; await nextTick(); message.value B; // 会触发新一轮的 DOM 更新 await nextTick(); message.value C; // 又会触发 // 这不是 bug但对性能有影响 // 更好做法批量修改后再统一 nextTick } /script3.3 微任务与宏任务的选择Vue3 默认使用微任务Promise调度 DOM 更新。但在特定场景下——如需要在浏览器完成一次完整的渲染周期后再执行逻辑——nextTick的微任务时机可能太早。// 在微任务中浏览器还没完成渲染 await nextTick(); // 此时 DOM 已变但用户屏幕上的像素还没更新 // 需要真正的渲染后执行时使用双 rAF function afterPaint(callback: () void): void { requestAnimationFrame(() { requestAnimationFrame(() { callback(); }); }); }四、批量更新机制对照sequenceDiagram participant Code as 同步代码 participant React as ReactiveEffect participant Queue as 调度队列 participant DOM as DOM Code-Code: a.value 1 Code-React: trigger(a) React-Queue: 入队(effect_a, id1) Code-Code: b.value 2 Code-React: trigger(b) React-Queue: 入队(effect_b, id2) Code-Code: a.value 3 Code-React: trigger(a) React-Queue: 发现 effect_a 已入队跳过 Note over Code,DOM: 同步代码块结束 Queue-Queue: microtask: flushJobs() Queue-Queue: 执行 effect_a (a3) Queue-Queue: 执行 effect_b (b2) Queue-DOM: 统一渲染 DOM--Queue: 渲染完成Vue3 的批量更新是自动的——在同一同步 tick 内对同一响应式数据的多次修改只会在最后触发一次副作用。而 React 18 之前的批量更新仅在事件处理函数中生效需要通过unstable_batchedUpdates手动合并。理解这一差异有助于在两者间迁移时避免意外行为。五、总结Vue3 的调度机制通过追踪-排队-刷新三阶段设计在自动批量更新和细粒度响应之间取得了平衡。三个优先级的队列分工明确pre处理 DOM 更新前的准备工作如更新计算属性普通队列执行组件渲染函数post处理 DOM 更新后的回调。对于日常开发需要记住两条原则修改响应式数据后DOM 读取操作应放在nextTick回调中同一个同步代码块内的多次数据修改只会触发一次副作用执行。当遇到watch回调执行时机不符合预期时检查flush选项是排查问题的第一站。