在开发中你经常会写出这样的代码:
function updateUser() {
this.name = '新名字'
this.age = 25
this.email = 'new@example.com'
}
你连续修改了三个响应式数据,但 Vue 并不会触发三次组件重新渲染,而是只会在本轮事件循环结束后统一更新一次视图。这个设计不是随意的,它是 Vue 异步更新队列的核心优化。
为什么需要批量更新
响应式系统的“数据 → 视图”更新链路是有成本的:
- 数据变更 → 通知对应的依赖(Watcher)
- Watcher 重新求值或执行组件渲染函数
- 虚拟 DOM Diff
- 真实 DOM 更新
如果每改一个属性就立即走一次完整流程,当一段逻辑中有多个数据连续变化时,就会产生大量无意义的中间态渲染。更严重的是,如果第一个 name 变更触发了渲染,而此时 age 还没更新,用户会短暂看到数据不一致的 UI。批量更新就是把这些密集的同步修改合并成一次异步的视图更新,既提升了性能,也保证了状态一致性。
如何实现:异步更新队列与去重
Vue 内部维护了一个异步更新队列,大致流程如下:
- 数据变更触发依赖通知
当响应式数据被修改,会走到 setter,进而调用 dep.notify(),通知所有收集到的 Watcher。
- Watcher 不是立即执行,而是先排队
每个 Watcher 内部有一个 update() 方法,它并不会直接执行渲染函数,而是调用 queueWatcher(this) 将自己推入全局的 queue 数组。
- 队列去重,确保一个 Watcher 只推一次
queueWatcher 会检查这个 Watcher 是否已经存在于队列中(通过 watcher.id 去重)。这意味着哪怕你在同一个同步代码块中反复修改同一个依赖的数据,这个组件也只会被调度一次。
- 异步执行队列
推入队列的动作会触发一个异步刷新任务(nextTick(flushSchedulerQueue))。flushSchedulerQueue 负责遍历队列中的所有 Watcher 并执行它们。这里的“异步”是通过微任务(Promise)或降级方案(setTimeout)实现的,确保它在本次同步代码全部执行完毕后,才去执行队列。
- 最终一次 DOM 更新
在下一个微任务中,Vue 会一次性运行队列中所有待更新的 Watcher,生成新的虚拟 DOM,进行 Diff,并将差异统一应用到真实 DOM。所以你看到的总是最终状态,而不是中间过渡状态。
直观例子
export default {
data() {
return { a: 1, b: 2 }
},
methods: {
changeBoth() {
this.a = 10
this.b = 20
console.log(this.$el.textContent) // 还是旧值
}
}
}
调用 changeBoth 时,a 和 b 的修改会各自触发 Watcher 的 update(),但因为这两个属性都在同一个组件实例中,它们共享同一个渲染 Watcher。队列去重后该渲染 Watcher 只会进入队列一次。当 console.log 执行时,视图还没有更新,因为更新被推迟到了当前同步代码块结束之后的微任务中。
如果你确实需要在数据修改后立刻获取更新后的 DOM,可以使用 nextTick:
this.a = 10
this.b = 20
this.$nextTick(() => {
console.log(this.$el.textContent) // 现在是最新值
})
nextTick 的回调会在队列刷新(即 DOM 更新完毕)后执行,这是非常经典的实用技巧。
批量更新的边界与注意事项
- 异步回调内的修改依然会正确排队
如果你在 setTimeout 或一个接口回调中修改数据,它依然会进入新的异步更新周期,并不会“丢失”响应。批量更新的核心是“同一个同步执行栈内”的修改会合并,而不是所有修改都合并。
watch回调的触发时机
当你在 watch 中侦听某个数据变化时,回调默认也是在 DOM 更新前被调用的(pre 模式)。如果侦听器内部又修改了其他数据,这些修改会再次进入队列,可能导致“多余的”一次更新。所以应尽量避免在 watch 中引发链式数据修改,或合理使用 flush: 'post' 和 watchEffect。
computed的惰性求值配合
计算属性本身也是异步队列中的一环。当依赖变化时,计算属性会被标记为“脏”,但不会立即求值。直到该计算属性在队列中被渲染 Watcher 或某个侦听器访问时,才会进行求值。这进一步避免了不必要的计算。
一句话总结
Vue 会将同一个同步代码块中产生的所有数据变更收集起来,使用微任务合并成一次异步队列刷新,最终只产生一次组件重渲染。 你几乎不需要额外操作就能享受这个优化,但当你依赖渲染后的 DOM 状态时,记得用 $nextTick 控制执行顺序。这是一种非常务实的性能策略:为了让你写出直观且高效的代码,Vue 把复杂的调度细节藏在了幕后。