D02 · 调度器与 nextTick
对应主课: L35 调度器原理 最后核对: 2026-09-23
1. 为什么需要异步更新
下面假设 count 已被一个挂载组件的模板读取,且三次修改发生在同一同步回调中:
import { ref } from 'vue'
const count = ref(0)
// 连续修改 3 次
count.value = 1
count.value = 2
count.value = 3
// 假设每次修改都同步 render,会重复执行渲染工作。
// Vue 将同一个组件 job 合并,更新时读取最终值 3。Vue 的状态值立即改变;普通组件更新 job 通常排入微任务批处理。同一个同步回调里的三次修改可以合成一次组件更新,但 render 次数、DOM 写次数与浏览器绘制次数是不同的指标。手动 DOM 操作、sync watcher 等也不遵循“所有事情都异步”的说法。
2. 更新队列
Vue 3.5.43 的主队列使用有序数组与 job 标记去重;默认 pre watcher 也进入主队列。§6 用 Set 演示稳定函数身份去重,属于简化模型;L35 的完整 mini 还演示了 id/pre 排序和 post 回调。
同一次刷新里重复传入同一个 updateA 才能去重;每次新建 () => updateA() 是不同函数,不能指望调度器知道它们表达同一工作。
3. nextTick 详解
nextTick 等待当前已安排的刷新 Promise;没有待刷新工作时才使用已完成的 Promise。下面是关键关系的示意,完整可执行版本见 §6:
// currentFlushPromise 由 queueJob 创建,并在 flushJobs 完成时清空。
const p = currentFlushPromise ?? resolvedPromise
return fn ? p.then(fn) : p先修改状态,再 await nextTick,才能等待这次已安排的 Vue 更新(包括相关 post 回调及刷新中加入的工作)。它既不是固定的“第二个微任务”,也不保证浏览器已经绘制;若其他异步操作将来才修改状态,要先等那个操作完成。nextTick
使用场景
以下是可单独挂载的 Vue 3.5 SFC,演示更新 DOM 后聚焦新出现的输入框:
<script setup lang="ts">
import { ref, nextTick } from 'vue'
const showInput = ref(false)
const inputRef = ref<HTMLInputElement | null>(null)
async function reveal() {
showInput.value = true
await nextTick()
inputRef.value?.focus()
}
</script>
<template>
<button @click="reveal">显示并聚焦</button>
<input v-if="showInput" ref="inputRef" aria-label="新输入框">
</template>新增列表项后滚动也是同一原则:先改列表,再 await nextTick,最后读容器的 scrollHeight;L35 给出了完整列表组件。
4. 事件循环中的位置
5. watch 的 flush 选项
下表描述由状态变更触发的 watcher 回调,时序以其所属组件为参照:
| flush | 变更时的行为 |
|---|---|
| pre(默认) | 在父组件更新之后、所属组件 DOM 更新之前执行 |
| post | 在所属组件 DOM 更新之后执行,适合读取该组件的更新结果 |
| sync | 同步执行,不经过批处理;适合必须立刻响应的简单状态,避免大量密集修改 |
watchPostEffect 是 watchEffect(..., { flush: 'post' }) 的便利写法,不是 watch 的等价替换;回调必须实际读取响应式依赖才会因其变化重跑。watch 默认惰性、watchEffect 默认立即运行、immediate watch 首次调用等初始行为,也不能只从此表推导。Watcher flush 时序
6. 动手实验:mini 调度器
在现代浏览器控制台运行下面的完整代码。它只模拟同步 job 的去重、刷新期间继续入队与 nextTick;不实现组件 id、pre/post 阶段或 Vue 的错误处理。这里的递归阈值用于终止有问题的实验,不是可以依赖的应用逻辑:
(async () => {
const queue = new Set()
const resolvedPromise = Promise.resolve()
let currentFlushPromise = null
let flushCount = 0
function queueJob(job) {
queue.add(job)
currentFlushPromise ??= resolvedPromise.then(flushJobs)
}
function flushJobs() {
const runs = new Map()
const errors = []
flushCount++
console.log('flush', flushCount)
try {
while (queue.size) {
// 执行前移出队列,允许运行期间重新排入;新任务留到下一轮。
const jobs = [...queue]
for (const job of jobs) {
queue.delete(job)
const times = (runs.get(job) ?? 0) + 1
runs.set(job, times)
if (times > 100) {
errors.push(new Error('mini scheduler: 递归任务超过阈值'))
continue
}
try { job() } catch (error) { errors.push(error) }
}
}
} finally {
queue.clear()
currentFlushPromise = null
}
if (errors.length) throw new AggregateError(errors, 'mini scheduler job 失败')
}
function nextTick(fn) {
const p = currentFlushPromise ?? resolvedPromise
return fn ? p.then(fn) : p
}
let countA = 0
let nameB = 'Vue'
const updateA = () => console.log('A:', countA)
const updateB = () => console.log('B:', nameB)
countA = 1; queueJob(updateA)
countA = 2; queueJob(updateA)
countA = 3; queueJob(updateA)
nameB = 'Vue 3'; queueJob(updateB)
console.log('同步结束:job 尚未执行')
await nextTick()
console.log('第一次刷新完成')
queueJob(() => {
nameB = '由 A 的工作触发'
queueJob(updateB)
})
await nextTick()
console.log('第二次刷新包含了新排入的 B')
})().catch(console.error)第一次刷新各执行 A、B 一次,输出最终值 3 与 Vue 3;第二次刷新会先执行外层 job,再执行新排入的 B,然后 await nextTick 才继续。此实验没有实际 DOM;真实父子组件与 watcher 时序由 L35 的 flush-demo 验证。
7. 组件更新顺序
Vue 3.5.43 为组件 job 使用实例 uid 作为 id,入队时保持尚未执行区间的顺序。父组件通常创建更早,因此在子组件之前处理;如果父更新卸载了子组件,子任务还可跳过:
当前实现不是每次 flush 前简单执行一次 sort:刷新期间仍可能加入任务,必须在未执行区间找到插入位置。pre watcher 的 PRE 标记与所属组件 id 共同决定顺序;L35 的 SchedulerJob 演示了这一关系。3.5.43 scheduler.ts
8. 主队列、Post 回调与 nextTick
Vue 3.5.43 的主 queue 同时包含组件 job 和带 PRE 标记的 watcher job,另有 post 回调队列。不能把主队列统称为“DOM 更新前的 Pre 队列”;nextTick 回调也不是 post 队列中的一个 job。
这是单次刷新的概念图,不表示所有 pre watcher 都在所有组件之前执行,也不表示所有生命周期钩子都在 post 阶段。beforeUpdate 在对应组件更新前调用;DOM 更新完成与浏览器已经绘制仍要分开。
9. 常见陷阱
陷阱 1:修改数据后立即读 DOM
以下操作放在已经显示 count 初值 0 的组件事件处理器内,el 为该文本元素:
count.value = 100
// ❌ 此时 DOM 还没更新!
console.log(el.textContent) // 仍然是 '0'
// ✅ 等待 DOM 更新
await nextTick()
console.log(el.textContent) // '100'陷阱 2:watcher 无条件反写自己的来源
下面是应避免的错误模式;不要运行这个无终止条件的循环:
watch(count, async () => {
await nextTick()
count.value++ // ⚠️ 再次触发更新 → 再次 nextTick → 再次修改...
// await 后跨越新的刷新,不能指望本轮递归检查将它终止。
})修改应有明确的业务条件,或由用户事件驱动。nextTick 之后修改数据本身合法,问题是无条件形成反馈循环。
陷阱 3:flush: 'sync' 的性能问题
// 独立场景 A:同步 watch,每次实际变化都立即执行回调
watch(count, callback, { flush: 'sync' })
count.value = 1 // callback 执行 1 次
count.value = 2 // callback 执行 1 次
count.value = 3 // callback 执行 1 次
// 共 3 次!没有批量优化
// 独立场景 B:默认 watch,从初值 0 连续修改(不要接在场景 A 后运行)
watch(count, callback)
count.value = 1
count.value = 2
count.value = 3
// 只执行 1 次 callback,值为 310. 总结
- 组件 job 按身份去重并排队,状态本身同步改变;Vue 3.5.43 使用主数组队列与标记
- nextTick 等待已安排的当前刷新,不保证浏览器绘制或未来异步数据完成
- pre/post 以所属组件为参照;nextTick 不属于 post 队列
- sync watcher 有适用场景,但没有默认批处理
- 避免无终止条件的响应式反馈循环,不能把开发期递归检查当作业务终止条件