09|数组、Map 与 Set:先问 effect 读了什么
第三章:响应式系统纵览 / 前置:track / trigger / 下一篇:Vue 2 响应式边界
集合更新不能只问“数据变了吗”。一个 effect 可能只读数组长度、某个索引、某个 Map key、key 迭代或 value 迭代;不同写操作影响的依赖集合不同。
运行上下文
本篇实验使用 Vue 3.5+,可保存为 .mjs 在 Node 20+ 运行。模板的 v-for 最终也会形成迭代读取,但先在无 DOM 环境里观察触发,因果更清楚。
数组:索引、length 与迭代
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 = 1 | length、被截断索引、迭代 |
items.sort() | 多个索引与迭代;还会修改原数组 |
一个只读取 items[0] 的 effect 不应因远处索引变化而重新执行。排序后的显示列表应优先用 computed 返回副本,避免“读取派生值”顺便重排 source。
Map:按 key 读取与按集合迭代
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:成员关系与迭代
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 不自动解包
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 查找做归一化:
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
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 仍为同一个数字。
排错与性能判断
- 写出 effect 的真实读取:单 key、
size、keys、values 还是整集合。 - 区分
set新增与修改、add新成员与重复成员。 - 检查元素是不是 ref;集合边界不会替你解包。
- 对象 key 跨第三方边界时检查 raw/proxy 身份。
- 大集合不要默认 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。