06|调度器与 flush:触发了,不代表已经刷新
第三章:响应式系统纵览 / 前置:watchers / 下一篇:响应式解构
trigger 只说明某个 effect 需要处理变化。Vue 的调度器决定它何时运行、同一轮运行几次,以及运行时组件 DOM 处在哪个阶段。
运行上下文
纯时序实验可在 Node 20+ 运行;DOM 时序必须放进 Vue 组件,在浏览器或组件测试环境中验证。以下结论针对 Vue 3.5+ 默认调度器,不把 JavaScript 微任务的实现细节当成可依赖的公共 API。
同步写入与批处理
import { nextTick, ref, watch } from 'vue'
const count = ref(0)
const events = []
watch(count, (value) => events.push(`pre:${value}`))
count.value = 1
count.value = 2
events.push('stack:end')
await nextTick()
console.log(events) // ['stack:end', 'pre:2']两次写入都完成了 trigger,但默认 watcher 被去重后只处理最终值 2。nextTick() 返回的 Promise 在当前 Vue 更新队列完成后解决;它适合“发起状态变化后,等待整轮 DOM 提交”。
三种 flush 的位置
| flush | 何时运行 | 适用场景 | 代价 |
|---|---|---|---|
'pre'(默认) | 父组件更新后、所属组件 DOM 更新前 | 请求、日志、与 DOM 无关的副作用 | 读取所属组件 DOM 会拿到旧内容 |
'post' | 所属组件 DOM 更新后 | 测量、聚焦、桥接只接受 DOM 的库 | 比同步代码晚一轮 |
'sync' | 检测到响应式写入时立即运行 | 极少量简单标志的同步桥接 | 无批处理,每次写入都执行 |
受控输入能直接看见差异:
import assert from 'node:assert/strict'
import { nextTick, ref, watch } from 'vue'
const source = ref(0)
const events = []
watch(source, (value) => events.push(`pre:${value}`))
watch(source, (value) => events.push(`post:${value}`), { flush: 'post' })
watch(source, (value) => events.push(`sync:${value}`), { flush: 'sync' })
source.value = 1
source.value = 2
events.push('stack')
await nextTick()
assert.deepEqual(events, [
'sync:1',
'sync:2',
'stack',
'pre:2',
'post:2'
])sync 不等于“更正确”。它绕过批处理,若同步 push 一千项就可能执行一千次;也更容易在两个 watcher 相互写入时形成反馈环。
source → derived → effect 的提交过程
事件处理函数
├─ 写 source(同步完成)
├─ computed 标记失效
├─ sync watcher 立即运行
└─ 当前调用栈结束
↓ Vue 更新队列
pre watcher
↓ 组件渲染与 DOM patch
post watcher
↓
nextTick() 后续代码这是理解模型,不是跨所有父子组件的完整内部排序规范。组件层级、递归更新和 Suspense 会增加队列工作;业务代码应依赖 pre/post/sync/nextTick 的公开语义,而非猜某个内部 job ID。
为什么改完状态读到旧 DOM
<script setup lang="ts">
import { nextTick, ref, useTemplateRef, watch } from 'vue'
const count = ref(0)
const output = useTemplateRef<HTMLParagraphElement>('output')
watch(count, () => {
console.log('post sees', output.value?.textContent)
}, { flush: 'post' })
async function incrementAndMeasure() {
count.value++
console.log('same stack sees', output.value?.textContent)
await nextTick()
console.log('nextTick sees', output.value?.textContent)
}
</script>
<template>
<button @click="incrementAndMeasure">增加</button>
<p ref="output">count={{ count }}</p>
</template>同一调用栈里 source 已经是新值,但 DOM patch 仍在队列里;post watcher 与 await nextTick() 后都能读更新后的所属组件 DOM。若只是显示 count,不需要 watcher;这里读取 DOM 是为了教学,真实项目只在测量或第三方库集成时这样做。
清理与调度的交点
一个watcher实际准备重新执行回调时,会先执行上一轮登记的清理;停止watcher也会清理。默认批处理下,写source的瞬间不等于旧资源已经取消。取消旧请求、清除旧 timer 应依赖这个失效边界,不要依赖“post 比网络快”。flush 选择解决的是执行位置,onCleanup / onWatcherCleanup 解决的是资源生命周期,两者不能互相替代。
排错时间线
- 在写 source 前后、watcher 入口、
await nextTick()后各记录一次。 - 把 source 值和 DOM 文本分开记录;它们本来就可能处于不同阶段。
- 记录回调次数,确认看到的是批处理还是依赖缺失。
- 临时将简单 watcher 改为
sync只用于定位;若问题消失,说明是时序假设,不代表最终就该保留 sync。 - 检查 watcher 是否互相写入,形成同一轮递归更新。
练习:预测三种 watcher 的日志
用一个数字 ref 注册 pre、post、sync 三个 watcher,同步写入 1、2、2,然后追加 stack 日志并 await nextTick()。
- 先预测完整日志顺序和每种 watcher 的次数。
- 用严格断言验证:写回同值不触发;sync 看到两次不同值;pre/post 各只看到最终值。
- 将 source 改成数组并同步 push 1000 项,解释为什么不应保留 sync watcher;不要求做耗时基准。