升级之后,两个「本来没问题」的地方坏了
前段时间,我们把前端工程的底层框架从 Vue 2 升级到了 Vue 3。升级带来新能力、也让产品后续更可控;但没过多久,两个以前「看上去一直正常」的地方开始出问题。
这次遇到的两个现象都和响应式有关:
- 创建任务第一步选择源端数据库时,对端数据库没有正确映射;
- 设置目标主键后,视图没有正确生效。
最终的临时解决方式是通过更新 key 强制重建组件,让视图重新读取状态。但也带来了一个值得追问的问题:为什么 Vue 3 的响应式会在这些场景「丢失」,而 Vue 2 看起来却是正常的?
两个现场
场景 1:Select 下拉框联动的 v-model 失效
1 | <Select |
源端数据库变化后,对端数据库应该重新映射。在问题发生时,状态虽然发生了变化,但下拉框没有以预期方式更新。通过将源端数据库值带入 key,可以使目标 Select 被销毁并重新创建,重新读取最新状态。
场景 2:$forceUpdate 没能更新视图
1 | forceUpdateView() { |
单独调用 $forceUpdate() 并不能解决问题,最终仍需要更新 mqSchemaKey,让对应组件被重新挂载。
先说我的判断
Vue 3 的响应式更高效,也更严谨:它会主动避免自己判断为不必要的更新。
Vue 2 中,混乱的数据流和频繁的视图重渲染有时反而能掩盖 Bug;Vue 3 使用 Proxy 后,依赖追踪更加精确,那些没有正确建立、已经断开,或被绕过的响应式依赖不再会被「顺带」刷新。
这并不意味着 Vue 3 的响应式变差了。相反,它把 Vue 2 中曾经存在的模糊地带清理了出来,让问题变得可见。
回头看 Vue 2:Object.defineProperty
Vue 2 的核心是为对象的每个已观察属性定义 Getter / Setter。下面是极简化后的实现:
1 | // Vue 2 core/observer/index.js(简化示意) |
在这个模型里,响应式能力被直接定义在对象属性上。只要拿到的是同一个已观察对象并修改对应属性,就会执行 Setter,并调用 dep.notify()。
这会带来一种「模糊地带」:即使数据流已经不够清晰,只要最终仍然改到了被观察的属性,更新往往还能被触发。它在开发体验上显得宽容,但也让依赖关系和状态来源更难追踪。
这里的示意刻意省略了 Watcher 收集、对象新增删除时的 $set / $delete 等细节,重点只在于 Getter / Setter 挂在对象属性上的更新模型。
再看 Vue 3:Proxy + Effect
Vue 3 使用 Proxy 在原始对象外包裹一层代理,通过代理的 get / set 拦截来追踪和触发依赖:
effect用来标记当前正在运行的副作用函数,并在读取属性时收集依赖;- 对属性的写入会只通知真正依赖该属性的 Effect;
- Proxy 代理对象和原始对象是不同引用,绕过代理去修改原始对象不会触发代理侧的依赖;
WeakMap用于保存对象与依赖映射,不阻止对象在不再被引用时被垃圾回收;- Proxy 能覆盖属性新增、删除、数组下标等 Vue 2 难以自然覆盖的操作。
核心变化不只是 API 升级,而是依赖模型从「属性上的 Getter / Setter 通知」变成了「运行时精确追踪哪些 Effect 读取了哪些属性」。
因此,当我们直接替换对象、在多层级解构中丢失代理、或将非响应式对象混入状态时,Vue 3 不会再用无关的重渲染意外掩盖这些问题。
为什么 $forceUpdate 救不了场
Vue 2 的响应式依赖收集本身不够精确。在一些场景下,调用 $forceUpdate 往往会让整个组件模板再次执行、重新生成 VNode 并进行 Diff,因此即使真实数据依赖不够清晰,DOM 也可能「恰好」刷新到最新状态。
Vue 3 则引入 Patch Flag 与 Block Tree,使更新具备更细粒度的优化能力:
- Diff 会根据 Patch Flag 判断节点是否需要更新;
- 静态节点与未变化的动态节点可以被跳过;
- 若字段未被正确追踪,或代理链已被绕过,重新运行组件更新并不会自动修复这个依赖关系。
所以,$forceUpdate 能请求组件重新更新,但它不能修复状态来源、依赖读取或组件内部缓存已经出错的问题。这就是它在这些问题中看起来「没有效果」的原因。
相较之下,更新 key 会让组件被销毁并重新创建。它能够重新初始化组件内部状态、重新执行挂载逻辑,也会重新读取输入数据,因此能作为短期兜底方案。
这次排查之后
类似的响应式 Bug 在经过一到两个月测试后已经基本修复完成。目前的修复仍偏向「更新 key,强制组件刷新」这一类方式。
这次排查的收获是:Vue 3 的精确依赖不应被当作限制,而应该用来定位真正断开的数据链路。后续会围绕以下方向逐步重构:
- 排查直接替换对象是否导致 Proxy 代理链断裂;
- 排查多层级数据解构是否丢失响应性;
- 排查是否将非响应式对象混入了响应式状态;
- 从组件输入、状态更新到模板读取,梳理清晰且单向的数据流。
与其把更新都交给一次强制重渲染,不如让每一个状态变更都在正确的响应式依赖上发生。