Skip to content

09|数组、Map 与 Set:先问 effect 读了什么 ​

第三章:响应式系统纵览 / 前置:track / trigger / 下一篇:Vue 2 响应式边界

集合更新不能只问“数据变了吗”。一个 effect 可能只读数组长度、某个索引、某个 Map key、key 迭代或 value 迭代;不同写操作影响的依赖集合不同。

运行上下文 ​

本篇实验使用 Vue 3.5+,可保存为 .mjs 在 Node 20+ 运行。模板的 v-for 最终也会形成迭代读取,但先在无 DOM 环境里观察触发,因果更清楚。

数组:索引、length 与迭代 ​

ts
const items = reactive(['a', 'b'])

watchEffect(() => console.log('first', items[0]))
watchEffect(() => console.log('length', items.length))
watchEffect(() => console.log('all', [...items]))
写操作可能影响
items[0] = 'A'索引 0、值迭代
items.push('c')新索引、length、迭代
items.length = 1length、被截断索引、迭代
items.sort()多个索引与迭代;还会修改原数组

一个只读取 items[0] 的 effect 不应因远处索引变化而重新执行。排序后的显示列表应优先用 computed 返回副本,避免“读取派生值”顺便重排 source。

Map:按 key 读取与按集合迭代 ​

js
import { nextTick, reactive, watchEffect } from 'vue'

const prices = reactive(new Map([['tea', 10]]))
const events = []

watchEffect(() => events.push(`tea:${prices.get('tea')}`))
watchEffect(() => events.push(`keys:${[...prices.keys()].join(',')}`))

prices.set('tea', 12)
await nextTick()
// get('tea') 的 effect 更新;key 集合没变,keys effect 不必更新

prices.set('coffee', 20)
await nextTick()
// 新增 key,会影响 keys 迭代

map.set(existingKey, sameValue) 不产生语义变化;delete 和 clear 会影响对应 key 与迭代消费者。读取 size 也是集合级依赖。

Set:成员关系与迭代 ​

ts
const selected = reactive(new Set<string>())

watchEffect(() => console.log('ada?', selected.has('ada')))
watchEffect(() => console.log('all', [...selected]))

selected.add('ada')
selected.add('ada') // 重复 add 不应再次触发
selected.delete('ada')

不要为了让 Vue “看见”而把 Set 每次复制成数组再存回 source。Vue 3 已适配 add/delete/clear/has/size 与迭代;是否触发仍取决于 effect 读取的入口。

集合中的 ref 不自动解包 ​

js
import { isRef, reactive, ref } from 'vue'

const list = reactive([ref(1)])
const map = reactive(new Map([['count', ref(2)]]))

console.log(isRef(list[0]))          // true
console.log(list[0].value)           // 1
console.log(isRef(map.get('count'))) // true
console.log(map.get('count').value)  // 2

这与 deep reactive 对象普通属性的 ref 解包不同。数组和集合保留元素本身的 ref 身份,因此 TypeScript 类型与运行时读取都要保留 .value。

raw/proxy key:区分 Vue 集合与外部集合 ​

Vue 的 reactive Map 会对常见 raw/proxy key 查找做归一化:

js
const rawKey = { id: 1 }
const proxyKey = reactive(rawKey)
const reactiveMap = reactive(new Map())

reactiveMap.set(rawKey, 'cached')
console.log(reactiveMap.get(proxyKey)) // 'cached'

但普通第三方 Map / Set 只认 JavaScript 身份,rawKey !== proxyKey。一旦 key 跨出 Vue 管理边界,仍应统一使用业务 ID,或在单一适配层用 toRaw() 归一化。不要把 Vue instrumentation 的便利误当成所有集合都具备的语言规则。

source → derived → effect ​

ts
const cart = reactive(new Map<string, { price: number; quantity: number }>())
const total = computed(() => {
  let sum = 0
  for (const item of cart.values()) sum += item.price * item.quantity
  return sum
})
watch(total, publishCheckoutTotal)

source 是 Map 的条目;derived 读取 value 迭代并汇总;effect 只在总价结果变化后对外发布。若只新增一个价格为 0、数量为 0 的条目,computed 需要重算,但 Vue 3.4+ 的结果稳定性可以让下游 total watcher 不执行,因为 derived 仍为同一个数字。

排错与性能判断 ​

  1. 写出 effect 的真实读取:单 key、size、keys、values 还是整集合。
  2. 区分 set 新增与修改、add 新成员与重复成员。
  3. 检查元素是不是 ref;集合边界不会替你解包。
  4. 对象 key 跨第三方边界时检查 raw/proxy 身份。
  5. 大集合不要默认 deep watch;优先让 computed 读取必要迭代,或 watch 明确的版本 ref。

练习:预测 Map 与 Set 的触发矩阵 ​

创建响应式 Map<string, number>,分别用三个 watchEffect 读取 get('tea')、keys()、values(),并为每个 effect 计数。

  • 依次执行:修改 tea 的值、写回同值、新增 coffee、删除 coffee。
  • 每步先预测三个计数,再 await nextTick() 验证。
  • 再创建响应式 Set,证明重复 add 不产生新触发。
  • 加一个 ref 作为 Map value,断言读取时仍是 ref,并正确使用 .value。

查看第 09 题参考答案。

用心记录每一步成长