Skip to content

响应式限制:值变了,谁收到了通知 ​

Vue 2 初始化对象时,主要把当时已有的属性转换成 getter/setter。读取让当前观察者记录依赖,赋值通过 setter 通知依赖。新增属性没有经过这一步,数组的每一个数字下标也没有按普通对象属性逐个改写。因此“JavaScript 对象里有这个值”和“Vue 知道需要重新计算”是两件事。Vue 2.7 引入 Composition API 后仍然保留这些限制。

用缓存把遗漏的通知暴露出来 ​

沿用本章环境,保存 reactivity.cjs,用 node reactivity.cjs 运行。先读取 computed,使它真正建立缓存,再修改;否则第一次读取直接看到最新对象,反而掩盖问题。

js
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() 结束实例,模拟页面不再需要它;完整挂载、停用和销毁过程在生命周期一篇展开。

js
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 会破坏同类场景。

核对答案 · 继续:组件通信契约

用心记录每一步成长