11|调试响应式:把“没更新”定位到一条断边
第三章:响应式系统纵览 / 前置:本章 01–10 / 练习答案
响应式故障至少有四类:没有 track、写入没有 trigger、effect 已入队但时机理解错、旧 effect 没清理。先分类,再修复;随手增加 deep: true、nextTick() 或第二个 watcher 只会掩盖证据。
运行上下文
onRenderTracked、onRenderTriggered、computed/watch 的 onTrack 与 onTrigger 只在开发模式工作。它们用于本地诊断,不应成为生产业务逻辑。Node 运行本项目开发构建时可验证 watcher 事件;组件渲染钩子需在开发模式 Vue 组件中观察。
先画 source → derived → effect
假设页面显示购物车总价,并把总价同步给支付组件:
source: cart items
└─ derived: computed total
├─ render effect: <output>{{ total }}</output>
└─ watch effect: payment.setAmount(total)故障报告“支付金额变了,页面没变”已经说明 source 和 derived 大概率可用,断点更可能位于渲染依赖或 DOM 调度。反过来,二者都不变,应先检查写入是否经过 proxy。
调试事件告诉你什么
调试事件常见字段包括:
| 字段 | 含义 | 例子 |
|---|---|---|
effect | 被收集或被触发的响应式 effect | 组件更新、computed、watcher |
target | 依赖所属原始 target | ref 或 reactive 背后的对象 |
type | 操作类型 | track: get/has/iterate;trigger: set/add/delete/clear |
key | 属性或集合 key | 'price'、'value' |
newValue/oldValue | trigger 的值证据 | 某次 set 前后值 |
不要在钩子里修改被调试状态;那会让诊断工具自己制造 trigger。
组件渲染:谁被模板读取,谁让组件更新
<script setup lang="ts">
import { onRenderTracked, onRenderTriggered, reactive } from 'vue'
const cart = reactive({ total: 0, note: '' })
onRenderTracked((event) => {
debugger
console.debug('render track', event.type, event.key)
})
onRenderTriggered((event) => {
debugger
console.debug('render trigger', event.type, event.key)
})
</script>
<template>
<output>{{ cart.total }}</output>
<button @click="cart.total++">增加</button>
</template>首次渲染应出现对 total 的 track。点击后应先看到 total 的 trigger,再发生组件更新。修改 note 不应触发这个组件模板更新,因为模板没有读取它。
computed 与 watcher 的局部探针
const total = computed(
() => cart.items.reduce((sum, item) => sum + item.price, 0),
{
onTrack(event) {
console.debug('computed track', event.type, event.key)
},
onTrigger(event) {
console.debug('computed trigger', event.type, event.key)
}
}
)
watchEffect(
() => payment.setAmount(total.value),
{
onTrack: (event) => console.debug('effect track', event.key),
onTrigger: (event) => console.debug('effect trigger', event.key)
}
)调试 computed 时必须让某个消费者读取 total.value;惰性的 computed 若从未读取,就不会执行 getter,也不会留下完整 track 证据。
四段式排错
1. 未收集
症状:source 变了,onTrack 中没有预期 key。检查直接解构、展开、toRaw 后读取、异步 watchEffect 在第一个 await 后才读取。
2. 未触发
症状:track 有 key,写入后没有 onTrigger。检查写回同值、直接修改 raw、shallowRef 内层原地修改、Vue 2 新增字段/数组索引。
3. 已触发但观察时机错
症状:trigger 有记录,回调或 DOM 看似仍旧。记录 sync/pre/post、调用栈末尾与 await nextTick() 后的值,区分 source 已更新与 DOM 尚未提交。
4. 已执行但旧工作未清理
症状:日志显示新 effect 正常运行,最终结果却被旧请求覆盖,或卸载后仍有输出。给每轮副作用加 run ID、AbortSignal 和 cleanup 日志;检查异步创建的 watcher 与 detached scope。
可运行最小探针
import assert from 'node:assert/strict'
import { nextTick, reactive, watchEffect } from 'vue'
const state = reactive({ count: 0, ignored: 0 })
const events = []
watchEffect(
() => void state.count,
{
onTrack: (event) => events.push(['track', event.type, event.key]),
onTrigger: (event) => events.push(['trigger', event.type, event.key])
}
)
state.ignored++
await nextTick()
assert.equal(events.some((event) => event[2] === 'ignored'), false)
state.count++
await nextTick()
assert.equal(events.some((event) =>
event[0] === 'trigger' && event[1] === 'set' && event[2] === 'count'
), true)成功断言证明“未读取字段不在依赖图”和“代理 set 触发已订阅 key”。它没有证明 DOM 时序,后者必须用组件实验或组件测试验证。
从临时证据变成回归测试
定位后删除 debugger 和高噪声日志,把故障压缩成稳定断言:
- 依赖丢失:修改 source 后断言 derived / 渲染输出变化;
- 竞态:慢旧请求后完成,断言最终仍是新结果;
- 过度触发:对明确操作断言 effect 次数;
- 清理泄漏:stop / unmount 后修改 source,断言外部调用次数不再增加。
DevTools 时间线适合探索,自动化断言负责防止复发。不要把“我看见 onTrigger 了”当成交付证据;它只证明触发阶段,后面仍可能有调度和清理错误。
练习:诊断一个被解构切断的 effect
给定 reactive({ enabled: true, count: 0 }),错误代码先解构 count,再在 watchEffect 中读取该快照。
- 用
onTrack证明错误 effect 没有收集count。 - 修改 source 并
await nextTick(),断言 effect 运行次数没有增加。 - 用
toRef修复;用onTrack/onTrigger证明 key 与操作类型。 - stop 修复后的 watcher,再修改 source,断言无新运行。
- 成功与失败路径都要由严格断言表达,不以控制台目测代替。