10|Vue 2 响应式:维护补丁与迁移边界
需要从原实例开始排错时,先读Vue 2 响应式限制与异步观察,完成新增字段、数组和过期请求的实验。本篇进一步比较迁移边界;2.7 回移能力见版本与工具链。
第三章:响应式系统纵览 / 前置:Proxy 与 Reflect / 下一篇:调试响应式
本篇只服务于 Vue 2 存量代码和迁移。Vue 3 新代码不应继续引入 Vue.set、this.$set 或 Vue.delete;这些 API 是旧观察模型的补丁,不是跨版本最佳实践。
先确认运行版本
| 环境 | 响应式基础 | 本篇补丁是否需要 |
|---|---|---|
| Vue 2.6 | Object.defineProperty;Composition API 需插件 | 需要 |
| Vue 2.7 | 仍是 getter/setter;内置部分 Composition API | 仍有同类检测边界 |
| Vue 3 | Proxy 对象 + ref getter/setter | 不需要,也不应照搬 |
Vue 2 已结束维护。若无法立即迁移,至少把版本、浏览器范围和补丁调用集中记录;不要让旧兼容写法继续扩散。
为什么新增字段没有 setter
Vue 2 在实例初始化时遍历 data 的现有属性,用 Object.defineProperty 安装 getter/setter。之后直接增加的新 key 没有经过这一步:
new Vue({
data: {
profile: { name: 'Ada' },
rootMessage: ''
}
})
// 以下放在实例方法内,选择一条运行;每次恢复初始profile再做另一条。
this.profile.email = 'ada@example.com' // 新增嵌套key不可检测
// 或在email尚不存在的初始对象上:
this.$set(this.profile, 'email', 'ada@example.com') // 创建可检测字段不能先直接添加email,再以为 $set 同名字段能自动补装setter:已存在的普通字段可能只被赋值。对已错误新增的字段应重建整个对象,或在确定可删除的前提下移除后再 $set。
新增根实例响应式属性不受支持,应在 data 中提前声明。批量加入嵌套字段时,Vue 2 官方建议替换对象:
this.profile = Object.assign({}, this.profile, {
email: 'ada@example.com',
active: true
})直接 Object.assign(this.profile, fields) 仍是在旧对象上添加未观察的 key。
数组的两个检测缺口
Vue 2 无法检测直接索引赋值与直接改 length:
this.items[index] = next // 不可检测
this.items.length = newLength // 不可检测
this.$set(this.items, index, next) // 可检测
this.items.splice(index, 1, next) // 可检测
this.items.splice(newLength) // 可检测地截断push、pop、shift、unshift、splice、sort、reverse 等变更方法会被 Vue 2 包装并发出通知。Map/Set 也没有 Vue 3 那套集合 instrumentation;不要期望在 Vue 2 模板里直接把它们当响应式 source。
source → derived → effect 在 Vue 2 中哪里断
初始化已有 data.key
└─ getter 被渲染读取 → watcher 订阅
└─ setter 写入 → watcher 入队 → DOM 更新
初始化后直接新增 data.newKey
└─ 没有 getter,也没有 setter
├─ 渲染无法为该 setter 建边
└─ 后续写入没有通知入口Vue.set(target, key, value) 的职责是创建可观察属性并通知相关依赖。Vue.delete 则在删除后通知。它们解决的是拦截入口缺失,不是 flush 或 deep watch 问题。
迁移时怎样删掉补丁
| Vue 2 代码 | Vue 3 迁移 | 注意 |
|---|---|---|
this.$set(obj, key, value) | obj[key] = value | obj 必须仍是 reactive proxy |
this.$delete(obj, key) | delete obj[key] | 写入不能绕到 raw 对象 |
this.$set(items, i, value) | items[i] = value | 直接索引在 Vue 3 可拦截 |
items.splice(n) 仅为响应式截断 | items.length = n 或保留 splice | 根据可读性与返回值需求选择 |
迁移前先给 Vue 2 行为写回归测试,特别是新增、删除、索引替换和异步 DOM 更新。迁移后删除 adapter,并证明 Vue 3 代理路径触发相同用户可见结果。不要同时保留两套写法“以防万一”,否则测试无法证明究竟哪条路径生效。
受控实验:defineProperty 的可见边界
下面不是 Vue 2 的完整实现,只用于证明“初始化时存在的 key 才有 setter”:
import assert from 'node:assert/strict'
const events = []
const state = { known: 1 }
let known = state.known
Object.defineProperty(state, 'known', {
enumerable: true,
configurable: true,
get: () => known,
set: (value) => {
known = value
events.push('known')
}
})
state.known = 2
state.added = 3
assert.deepEqual(events, ['known'])它只验证语言机制,不替代在真实 Vue 2.6/2.7 应用中的组件回归测试。数组变更方法包装、依赖通知和异步队列都属于 Vue 2 运行时的额外工作。
排错顺序
- 锁定实际 Vue 版本,不根据 Options API 写法猜版本。
- 检查字段是否在实例初始化时存在;根字段必须预声明。
- 检查是否直接设置数组索引或 length。
- 检查是否误把 Map/Set 当作 Vue 3 响应式集合。
- 补丁后
await this.$nextTick()再验证 DOM;检测缺口和异步队列是两件事。 - 记录补丁的删除条件:升级到 Vue 3、回归测试通过、调用方都经 proxy 写入。
练习:把 Vue 2 补丁包进可删除边界
为存量代码实现两个纯函数 adapter:
setField(setter, target, key, value)只能通过注入的setter(target, key, value)写入,便于 Vue 2 传入Vue.set。replaceAt(items, index, value)使用splice,越界时抛出RangeError,且不得改变失败输入。- 用严格断言覆盖已有字段、新增字段、合法索引和越界失败。
- 再写出 Vue 3 迁移后的直接赋值版本,并列出可以删除的 adapter 与测试仍需保留的行为合同。
版本依据可对照 Vue 2 的“Reactivity in Depth”与 Vue 3 Migration Guide;迁移时以项目锁定的小版本文档为准。