编程 抛错的 computed 卡住了共享 notify buffer:Vue 3.6 alpha.1–rc.9 的 watcher 停摆

2026-11-05 21:31:43

抛错的 computed 卡住了共享 notify buffer:Vue 3.6 alpha.1–rc.9 的 watcher 停摆

两个 watch 回调读取同一个 ref,两者之间没有依赖关系。其中一个回调存在 bug 并抛出异常,另一个完全正常的回调也停止更新了。它并非永久失效;等到应用里某个完全无关的 ref 下一次变化时,它又会恢复运行。3.6.0-alpha.1 到 3.6.0-rc.9 的每一个 3.6 预发布版本都能复现。

问题:一个「随机」停止触发的 watcher

两个独立的 watch 回调读取同一数据源:一个更新 UI 标签,另一个写入分析队列。为了便于推理执行顺序,两者都设置为 flush: 'sync':

import { ref, computed, watch } from 'vue'

const user = ref({ name: 'Ava', age: 29 })
const label = computed(() => {
  // bug:name 缺失时这里会抛错
  return user.value.name.toUpperCase()
})

watch(label, (v) => console.log('[ui] label →', v), { flush: 'sync' })
watch(user, (v) => console.log('[analytics] user changed →', v.age), {
  flush: 'sync',
  deep: true
})

const unrelated = ref(0)

触发 bug:

user.value = { age: 30 }   // 没有 name,computed getter 抛错

3.5 以及 rc.10 之后的 3.6 行为符合预期:[ui] 的 getter 抛错经 Vue 的错误处理上报,与 label 无关的 [analytics] 依然会运行。

在 alpha.1 到 rc.9 中,第二行日志不会出现:

console.clear()
user.value = { age: 30 }
// 没有任何输出——两个 watcher 都沉默

unrelated.value++
// [analytics] user changed → 30   ← 现在才出现,挂在了一次无关的写入上

回调并没有为真正触发它的那次写入执行,而是被一次 unrelated.value++ 顺带触发。如果 unrelated 此后再也不变,这个 watcher 就永远不会为那次更新运行。调用栈里没有任何一帧指向 Vue 内部,看起来就是「watcher 漏了一次更新」。

该问题记录为 vuejs/core#15674,由 PR #15676 修复,随 3.6.0-rc.10 发布。

心智模型:写入不会直接通知,而是先入队再 flush

直觉上会认为,ref.value = x 时 Vue 会遍历依赖、就地逐个执行。实际并非如此。

一次写入会把依赖标记为脏(dirty),并推入一个共享的通知缓冲区。在整个写入期间,全应用所有 ref 和 computed 共用这一个数组,并配有一对游标 notifyIndex / notifyBufferLength。写入结束后,flush() 循环遍历缓冲区,按顺序对每一项调用 effect.notify()。只有进入 notify(),watcher 回调或 computed 重算才真正执行。

这层间接性让批处理成为可检查的真实对象。它也解释了为什么两个无关 ref 上的 watcher 会共享同一个缓冲区:上例中 label 与 user 通过 user.value.name 关联。computed 的缓存正是依赖这条路径:写入时它只是通过同一条通知路径被标记为脏,并不会立刻重算。

这里有两个容易混淆的队列,需要分开:

  • nextTick 的渲染调度:把组件重渲染排到微任务,保证每个 tick 只更新一次 DOM。
  • effect notify buffer:更底层。组件渲染任务本身也只是被推入这个缓冲区、由同一个 flush() 遍历的一个 effect。

flush: 'sync' 的 watcher 会跳过组件调度器,直接在这个循环里运行。因此 sync watcher 最先暴露问题,但它不是唯一受影响的。

深入 flush()

下面是 packages/reactivity/src/system.ts 中真实算法的简化模型,不是比喻。

阶段 1 —— 一个 effect,一次写入

function makeEffect(fn) {
  return { notify: () => fn() }
}
const e1 = makeEffect(() => console.log('effect 1 ran'))
e1.notify() // effect 1 ran

notify() 是真正执行 watcher 回调、重跑 computed getter 的地方。后面所有内容都关乎它在多个 effect 之间何时、以什么顺序被调用。

阶段 2 —— 共享缓冲区与 flush 循环

let notifyBuffer = []
let notifyIndex = 0
let notifyBufferLength = 0

function queue(effect) {
  notifyBuffer[notifyBufferLength++] = effect
}

function flush() {
  while (notifyIndex < notifyBufferLength) {
    const effect = notifyBuffer[notifyIndex]
    notifyBuffer[notifyIndex++] = undefined
    effect.notify()
  }
  notifyIndex = 0
  notifyBufferLength = 0
}

阶段 3 —— 抛错的 effect,无保护

这正是 alpha.1 到 rc.9 实际发布的代码:

const good1 = makeEffect(() => console.log('good watcher A ran'))
const bad = makeEffect(() => { throw new Error('getter blew up') })
const good2 = makeEffect(() => console.log('good watcher B ran'))

queue(good1); queue(bad); queue(good2)
flush()
// good watcher A ran
// Uncaught Error: getter blew up
// good watcher B never ran
// notifyIndex / notifyBufferLength 停在缓冲区中间,非零

逐步还原:

  1. good1.notify() 正常返回,notifyIndex 推进到 1。
  2. bad.notify() 抛出异常。
  3. 调用外没有 try/catch,异常直接穿出 while 循环,跳过了把 notifyIndex 和 notifyBufferLength 重置为 0 的两行。
  4. good2 已经在缓冲区里,但循环再也到不了它。

缓冲区没被清空,索引没重置,整个结构被遗弃在遍历中途,good2 仍留在里面。

下一次应用中任何写入调用 flush(),while (notifyIndex < notifyBufferLength) 因残留状态仍为真,循环从上次中断处继续,最终调用 good2.notify(),相当于把这次调用挂在了触发这次 flush 的某个无关写入上。

阶段 4 —— 修复:隔离每一次 notify 调用

rc.10 发布、恢复 3.5 行为的版本:

function flush() {
  let hasError = false
  let error
  while (notifyIndex < notifyBufferLength) {
    const effect = notifyBuffer[notifyIndex]
    notifyBuffer[notifyIndex++] = undefined
    try {
      effect.notify()
    } catch (e) {
      if (!hasError) {
        hasError = true
        error = e
      }
    }
  }
  notifyIndex = 0
  notifyBufferLength = 0
  if (hasError) throw error
}

关键点在于:try/catch 包住每一次 effect.notify(),单个 effect 抛错不会阻止循环到达下一个 effect,循环本身也不会因为 effect 抛错而中止。因此循环之后的重置行无条件执行,notifyIndex 和 notifyBufferLength 总会在下次写入前归零。hasError / error 只记录第一个错误,等所有入队 effect 都获得执行机会后再重新抛出。

用这个版本跑阶段 3 的例子,good watcher B ran 会立刻打印,并且和抛错处于同一次 flush。

边界情况

  • 同一批次有两个抛错 effect:只有第一个错误被捕获并重新抛出。第二个及之后的抛错者仍会获得 notify() 调用,其 throw 之前的副作用仍会发生,但异常被静默丢弃。要拿到每一次失败,需要在每个 effect 内部自行捕获。
  • 不是只有 flush: 'sync' 受影响:默认的 'pre' 和 'post' watcher 同样通过完全相同的缓冲区和循环被通知,只是由组件级调度任务入队。这条链上任何位置抛错都走上述逐 effect 的 try/catch 逻辑。
  • 版本边界:这不是稳定版 bug。基于 alien-signals 的重写只存在于 3.6 线,3.5 不运行这段代码;未受保护的 flush() 循环从 3.6.0-alpha.1 起原样发布,历经每个 beta 和 rc,直到 rc.9,才在 rc.10 修复。

排查:是不是这个原因

看到「watcher 随机漏更新」时,按下面几步判断:

  1. 确认版本:执行 npm ls vue 或查看 lockfile,确认该问题是否正好落在 3.6.0-alpha.1 ~ 3.6.0-rc.9 范围内。3.5.x 和 rc.10+ 不会出现该问题。
  2. 查看 flush 配置:flush: 'sync' 的 watcher 直接在 notify 循环里运行,最容易暴露;'pre' / 'post' 也会受影响,只是入队者换成了渲染 job。
  3. 检查 computed getter:被 watch 的 computed 是否依赖了可能缺失的字段,例如 user.value.name.toUpperCase()。可以在 getter 里临时加 try/catch,确认请求是否抛出错误,以及错误是否被静默吞掉。
  4. 确认真实触发点:在可疑写入之后再加一条无关 ref 的写入。如果回调跟随这条无关写入打印,而不是跟随触发它的写入,这就是缓冲区残留的典型特征。再让这条无关写入之后不再有任何写入,观察 watcher 是否彻底不动。
  5. 检查错误是否被外层吞掉:app.config.errorHandler、全局 window.onerror、调用处的 try/catch、Promise 链的 catch,任何一层吞掉异常后,控制台都不会报错,只剩下「漏更新」这一个现象。
  6. 对照验证:把抛错点改成返回兜底值,或升级到 rc.10,再跑一遍同一段复现。

链接

  • 原文:https://dev.to/parsajiravand/vue-36-when-a-watcher-throws-your-other-watchers-stall-25ge
  • issue:https://github.com/vuejs/core/issues/15674
  • PR:https://github.com/vuejs/core/pull/15676
复制全文 生成海报 Vue3 响应式 前端 源码

推荐文章

程序员茄子在线接单