先记住这个答案
watch 的回调如果修改了自己的监听源,且每次写入都让源继续变化,就可能不断触发下一轮执行。无条件加一、两个监听互相反向写入都是常见反馈环。应优先把纯派生逻辑改为 computed,或把输入转换放到更新入口;确实要在监听中修正时,必须存在可验证的收敛条件,写入规范值后下一次检查不再修改。
- 持续制造新值才会形成自触发链
- 输入修正需要稳定的规范形式
- 改变 flush 只改变时机,不能消除反馈
自触发不是随机的框架故障
假设监听 count,每次回调都把 count 再加一,那么触发来源、回调和写入目标恰好构成闭环。即使每一轮都成功执行,系统也无法自然稳定,问题出在状态关系而不是缺少一次异步等待。
另一类隐蔽循环来自相互转换,例如金额监听写折扣价、折扣价监听再写金额。舍入误差或不一致的转换规则会让两边反复变化,应确定单一可编辑源,再通过派生计算得到另一侧。
用可收敛的输入规范化作对照
示例只去除首尾空白,规范化结果再次 trim 不会变化。赋值发生时会再触发一次检查,但第二次已经满足约束,不继续写回;这和每次都递增的无界行为有本质区别。
在真实表单中仍更建议在输入事件或提交入口执行这类转换,避免影响输入法组合过程和光标位置。此处选择监听是为了展示终止条件,并不是建议把所有输入清洗都放进 watch。
import { ref, watch, nextTick } from 'vue'
const name = ref('')
const visits: string[] = []
const stop = watch(name, (value) => {
visits.push(value)
const normalized = value.trim()
if (normalized !== value) name.value = normalized
})
name.value = ' Ada '
await nextTick()
console.log(name.value, visits)
stop()更新后 name 稳定为 Ada,记录依次包含带空白输入和规范化输入。第二轮条件不成立,因此不会继续写入;若转换函数每次制造不同结果,这个判断也无法保证收敛。
调度和防抖不能替代状态设计
改用 post 或 nextTick 只是把下一次反馈挪到更晚,循环仍然可能跨多轮继续。防抖降低执行频率也不等于消除依赖环,尤其两个监听互相写入时,延迟反而可能让问题更难关联。
排查时画出每个监听读取什么、写入什么,再找回路。对于服务端回填,要区分用户编辑事件和远端同步事件,避免把收到的值又无条件作为一次新修改提交回去。
容易答错的地方
- 认为 watch 内任何赋值都会死循环
- 赋值到无关状态不会自动成为该 watch 的源依赖,原始值写回相同值通常也不会触发变化。应检查实际监听源与写入结果,不能仅凭回调中存在赋值就判断一定递归。
- 依赖框架达到次数上限才停止
- 递归保护用于发现开发错误,不能保证业务状态正确,也不适合作为生产流程控制。需要从更新关系中删除反馈边,或证明转换会到达稳定值,并对边界输入进行验证。
面试官还会怎么问?
改成 watchEffect 就能保证没有循环吗?
不能把另一种依赖收集方式当成通用防循环工具。效果中触发其他任务、跨异步边界写回或与别的监听互相作用仍可能形成反馈,应先明确数据流,再选择合适的响应式接口。
对象每次重新创建会影响收敛吗?
会。即使对象字段看起来相同,新对象的引用也可能被识别为变化。如果规范化每次返回全新对象且无条件写回,就可能持续触发,需要比较业务字段或避免重复替换已规范的数据。
纯派生结果为什么更适合 computed?
派生结果可以直接由一个源计算得到,不需要把结果再写回源或维护另一份同步状态。这样减少了反馈路径与一致性负担,但计算函数仍应保持纯粹,不能在内部发请求或修改它依赖的值。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。