响应式限制:值变了,谁收到了通知
Vue 2 初始化对象时,主要把当时已有的属性转换成 getter/setter。读取让当前观察者记录依赖,赋值通过 setter 通知依赖。新增属性没有经过这一步,数组的每一个数字下标也没有按普通对象属性逐个改写。因此“JavaScript 对象里有这个值”和“Vue 知道需要重新计算”是两件事。Vue 2.7 引入 Composition API 后仍然保留这些限制。
用缓存把遗漏的通知暴露出来
沿用本章环境,保存 reactivity.cjs,用 node reactivity.cjs 运行。先读取 computed,使它真正建立缓存,再修改;否则第一次读取直接看到最新对象,反而掩盖问题。
const Vue = require('vue')
const vm = new Vue({
data() { return { profile: {}, items: ['A', 'B'] } },
computed: {
name() { return this.profile.name || '未填写' },
first() { return this.items[0] }
}
})
console.log(vm.name, vm.first) // 未填写 A
vm.profile.name = '林'
vm.items[0] = 'X'
console.log(vm.profile.name, vm.items[0]) // 林 X
console.log(vm.name, vm.first) // 未填写 A:没有通知,缓存仍有效
Vue.delete(vm.profile, 'name')
Vue.set(vm.profile, 'name', '林')
vm.items.splice(0, 1, 'X')
console.log(vm.name, vm.first) // 林 X
vm.$destroy()Vue.set(object, key, value) 对一个尚不存在的嵌套属性安装观察,并通知对象层面的依赖。实例方法 this.$set 提供同样能力。数组使用 splice 替换、删除或插入,Vue 包装过的变更方法会通知依赖;Vue.set(array, index, value) 内部也能处理数组下标。截短数组用 items.splice(newLength),直接写 length 不会通知。
为什么修复前先 delete?因为失败变体已经直接创建了 name,随后对这个“存在但未观察”的属性调用 Vue.set,可能只走已有属性赋值分支,并不补装 setter。最稳妥的修复是从一开始就用 set,或者替换整个对象:vm.profile = { ...vm.profile, name: '林' }。后者触发 profile 原有 setter,让新对象重新进入观察。不要把 set 想象成扫描整棵对象的修复器。
根级字段应在 data 中预先声明,例如 error: null、loading: false。Vue.set 不是在运行时给根实例补任意响应式字段的通行证。非响应式 Map/Set 内部变化也不会因为放进 Vue 2 data 就得到 Vue 3 同样的集合追踪;需要时用数组/对象快照表达界面状态,或明确替换一个已观察的引用。
深度观察不等于历史快照
用 vm.$watch(() => vm.profile, callback, { deep: true }) 可以遍历嵌套响应式字段并建立依赖,但直接新增未观察字段仍会漏掉通知。若同一对象的已有字段被修改,回调中的新值、旧值通常还是同一对象身份;Vue 没有在每次修改前复制整棵树。需要审计差异时,在业务写入入口保存必要的旧字段,或者显式制作快照。
$watch 返回停止函数,适合在某项临时业务结束时提前退订。实例销毁会结束绑定在实例上的侦听器,但已发出的 Promise 不会因此消失。下面的实验把“停止观察”和“过期任务不再写入”分开。
Vue 2 的异步侦听也需要失效规则
保存 search.cjs。这里不用真实接口,手动控制完成顺序:先搜索 A,再搜索 B,先返回 B,最后返回 A。这样每次都能复现竞态,而不是依靠网络偶然变慢。
下面首次使用两个生命周期选项:created 在实例状态和方法初始化后运行,用来建立侦听;beforeDestroy 在实例即将销毁、成员仍可访问时运行,用来清理。实验主动调用 vm.$destroy() 结束实例,模拟页面不再需要它;完整挂载、停用和销毁过程在生命周期一篇展开。
const Vue = require('vue')
function deferred() {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const pending = new Map()
const vm = new Vue({
data() { return { query: '', result: '', status: 'idle', error: '' } },
created() {
let revision = 0
let alive = true
this.stopSearch = this.$watch('query', async function (query) {
const own = ++revision
this.result = ''
this.error = ''
if (!query) { this.status = 'idle'; return }
this.status = 'loading'
const task = deferred()
pending.set(query, task)
try {
const value = await task.promise
if (!alive || own !== revision) return
this.result = value
this.status = 'success'
} catch (error) {
if (!alive || own !== revision) return
this.error = error.message
this.status = 'error'
}
})
this.invalidateSearch = () => { alive = false; revision++ }
},
beforeDestroy() {
this.invalidateSearch()
this.stopSearch()
}
})
async function main() {
vm.query = 'A'
await Vue.nextTick()
vm.query = 'B'
await Vue.nextTick()
pending.get('B').resolve('结果 B')
await Promise.resolve()
pending.get('A').resolve('结果 A')
await Promise.resolve()
console.log(vm.result, vm.status) // 结果 B success
vm.query = 'C'
await Vue.nextTick()
vm.$destroy()
pending.get('C').resolve('结果 C')
await Promise.resolve()
console.log(vm.result, vm.status) // '' loading:销毁后没有回写
}
main().catch(console.error)revision 是这一次任务的身份。B 开始时编号增加,A 的完成处理发现自己过期,不能改变结果,也不能清除 B 的 loading。alive 则表达实例已经结束;它和 revision 都在闭包中,不承担页面状态,也不需要响应式。停止函数、失效函数在 created 中保存到实例,仅供命令式调用,不依赖它们触发渲染。
若换成 fetch,可以在新任务开始和销毁时 abort 上一次请求,减少浪费;仍应保留身份检查,因为取消不保证已经完成的业务回调撤销。本例的 Options watch / 实例 $watch 没有第三参数 onCleanup 契约,不能直接复制 Composition watch 的清理写法。Vue 2.7 内置的 Composition watch 支持 onCleanup;应先区分使用的是哪个 API,而不是只按 Vue 2/3 判断。
还有一个更窄的边界:默认 watch 在队列中运行,从同步修改 query 到回调开始之间有窗口。本例每次输入后显式等 nextTick,因而以“侦听任务开始”为失效边界。如果业务要求输入发生的同一刻旧请求就失效,应把 revision 增加与输入赋值放进同一个 setQuery 命令,再由任务读取这一编号;不要假定默认异步 watch 已满足即时失效。
练习:动态标签与过期失败
在 reactivity.cjs 中增加 labels: {},实现 setLabel(id, text) 和 removeLabel(id),用 Vue.set / Vue.delete 维护。computed 返回 Object.keys(labels).sort().join(',')。先读取空结果,再依次添加 b、a、删除 b、重复删除 b,观察 '' → 'b' → 'a,b' → 'a' → 'a'。
另把搜索实验中 B 的完成改为 reject(new Error('离线')),再让 A 成功。要求最后只有“离线”错误,旧结果不能覆盖错误状态。解释为什么统一 finally 中无条件写 loading=false 会破坏同类场景。