Skip to content

06|调度器与 flush:触发了,不代表已经刷新 ​

第三章:响应式系统纵览 / 前置:watchers / 下一篇:响应式解构

trigger 只说明某个 effect 需要处理变化。Vue 的调度器决定它何时运行、同一轮运行几次,以及运行时组件 DOM 处在哪个阶段。

运行上下文 ​

纯时序实验可在 Node 20+ 运行;DOM 时序必须放进 Vue 组件,在浏览器或组件测试环境中验证。以下结论针对 Vue 3.5+ 默认调度器,不把 JavaScript 微任务的实现细节当成可依赖的公共 API。

同步写入与批处理 ​

js
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'检测到响应式写入时立即运行极少量简单标志的同步桥接无批处理,每次写入都执行

受控输入能直接看见差异:

js
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 的提交过程 ​

text
事件处理函数
  ├─ 写 source(同步完成)
  ├─ computed 标记失效
  ├─ sync watcher 立即运行
  └─ 当前调用栈结束
       ↓ Vue 更新队列
     pre watcher
       ↓ 组件渲染与 DOM patch
     post watcher
       ↓
     nextTick() 后续代码

这是理解模型,不是跨所有父子组件的完整内部排序规范。组件层级、递归更新和 Suspense 会增加队列工作;业务代码应依赖 pre/post/sync/nextTick 的公开语义,而非猜某个内部 job ID。

为什么改完状态读到旧 DOM ​

vue
<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 解决的是资源生命周期,两者不能互相替代。

排错时间线 ​

  1. 在写 source 前后、watcher 入口、await nextTick() 后各记录一次。
  2. 把 source 值和 DOM 文本分开记录;它们本来就可能处于不同阶段。
  3. 记录回调次数,确认看到的是批处理还是依赖缺失。
  4. 临时将简单 watcher 改为 sync 只用于定位;若问题消失,说明是时序假设,不代表最终就该保留 sync。
  5. 检查 watcher 是否互相写入,形成同一轮递归更新。

练习:预测三种 watcher 的日志 ​

用一个数字 ref 注册 pre、post、sync 三个 watcher,同步写入 1、2、2,然后追加 stack 日志并 await nextTick()。

  • 先预测完整日志顺序和每种 watcher 的次数。
  • 用严格断言验证:写回同值不触发;sync 看到两次不同值;pre/post 各只看到最终值。
  • 将 source 改成数组并同步 push 1000 项,解释为什么不应保留 sync watcher;不要求做耗时基准。

查看第 06 题参考答案。

用心记录每一步成长