《Vuejs设计与实现》第 5 章(非原始值响应式方案)下 Set 和 Map 的响应式代理
目录
5.8 Set 和 Map 的响应式代理
5.8.1 如何代理 Set 和 Map
5.8.2 建立响应联系
5.8.3 避免污染原始数据
5.8.4 处理 forEach
5.8.5 迭代器方法
5.8.6 实现 values 与 keys 方法
5.8 Set 和 Map 的响应式代理
我们简要回顾一下 Set 和 Map 这两种数据类型的原型属性和方法:
Set 数据类型的原型属性和方法包括:
- size:返回集合中元素的数量。
- add(value):向集合添加新元素。
- clear():清空集合内所有元素。
- delete(value):从集合中移除特定元素。
- has(value):检查集合中是否包含特定元素。
- keys()和 values():在 Set 中,这两个方法是等价的,它们都返回一个可用于 for...of 循环的迭代器,用于遍历集合中的元素。
- entries():返回一个迭代器,遍历集合中的每个元素,每次产生一个形如 [value, value] 的数组。
- forEach(callback[, thisArg]):遍历集合中的所有元素,为每个元素执行 callback 函数。可选参数 thisArg 用于设定 callback 函数执行时的 this 值。
Map 数据类型的原型属性和方法包括:
- size:返回 Map 中的键值对数量。
- clear():清空 Map 中的所有元素。
- delete(key):从 Map 中移除特定键的键值对。
- has(key):检查 Map 中是否包含特定键的键值对。
- get(key):获取特定键对应的值。
- set(key, value):在 Map 中添加新的键值对。
- keys():返回一个迭代器,用于遍历 Map 中的所有键。
- values():返回一个迭代器,用于遍历 Map 中的所有值。
- entries():返回一个迭代器,遍历 Map 中的每个键值对,每次产生一个形如 [key, value] 的数组。
- forEach(callback[, thisArg]):遍历 Map 中的所有键值对,为每个键值对执行 callback 函数。可选参数 thisArg 用于设定 callback 函数执行时的 this 值。
Map 和 Set 的操作方法有很多相似之处。主要的区别在于,Set 使用 add(value) 方法添加元素,而 Map 则使用 set(key, value) 方法添加键值对,并且 Map 还可以通过 get(key) 方法获取特定键的值。
5.8.1 如何代理 Set 和 Map
Set 和 Map 类型的数据具有专属的属性和方法来进行操作数据,这一点与普通对象存在显著差异,所以我们不能简单地像代理普通对象那样来代理 Set 和 Map 类型的数据。
然而,代理的基本思路依然不变:当读取操作发生时,我们需要调用 track 函数建立依赖关系;当设置操作发生时,我们需要调用 trigger 函数以触发响应。例如:
const proxy = reactive(new Map([['key', 1]]));effect(() => {console.log(proxy.get('key')); // 读取键为 key 的值
});proxy.set('key', 2); // 修改键为 key 的值,应该触发响应
以上代码展示的是我们最终希望实现的效果。
在实现之前,我们先注意一些细节:
const s = new Set([1, 2, 3]);
const p = new Proxy(s, {});console.log(p.size); // 报错 TypeError: Method get Set.prototype.size called on incompatible receiver
代理对象 p 并不包含 size,这就是我们在上面的例子中所遇到的错误。size 属性应该是一个访问器属性,所以它作为方法被调用了。
为了解决这个问题,我们可以调整访问器属性的 getter 函数中 this 的指向,如下:
const s = new Set([1, 2, 3])
const p = new Proxy(s, {get(target, key, receiver) {if (key === 'size') {// 如果读取的是 size 属性// 通过指定第三个参数 receiver 为原始对象 target 从而修复问题return Reflect.get(target, key, target)}// 读取其他属性的默认行为return Reflect.get(target, key, receiver)}
})console.log(s.size) // 3
在这段代码中,我们在创建代理对象时添加了 get 拦截函数。然后检查读取的属性名是否为 size,通过 Reflect.get 函数时将第三个参数设为原始的 Set 对象保证了 this 原始 set 对象。
然后,我们试图从 Set 中删除数据:
const s = new Set([1, 2, 3])
const p = new Proxy(s, {get(target, key, receiver) {if (key === 'size') {return Reflect.get(target, key, target)}return Reflect.get(target, key, receiver)}
})// 尝试删除值为 1 的元素
// 我们得到了错误:TypeError: Method Set.prototype.delete called on incompatible receiver [object Object]
p.delete(1)
在 delete 方法执行时,this 总是指向代理对象 p,而不是原始的 Set 对象。我们可以通过将 delete 方法与原始的数据对象绑定来修复这个问题:
const s = new Set([1, 2, 3])
const p = new Proxy(s, {get(target, key, receiver) {if (key === 'size') {return Reflect.get(target, key, target)}// 将方法与原始数据对象 target 绑定后返回return target[key].bind(target)}
})// 调用 delete 方法删除值为 1 的元素,正确执行
p.delete(1)
我们用 bind 函数将用于操作数据的方法与原始数据对象 target 进行了绑定,使得代码能够正确执行。
最后,为了方便后续讲解和提高代码的可扩展性,我们将 new Proxy 也封装进之前介绍过的 createReactive 函数中:
const reactiveMap = new Map()
// reactive 函数与之前相比没有变化
function reactive(obj) {const existionProxy = reactiveMap.get(obj)if (existionProxy) return existionProxyconst proxy = createReactive(obj)reactiveMap.set(obj, proxy)return proxy
}
// 在 createReactive 里封装用于代理 Set/Map 类型数据的逻辑
function createReactive(obj, isShallow = false, isReadonly = false) {return new Proxy(obj, {get(target, key, receiver) {if (key === 'size') {return Reflect.get(target, key, target)}return target[key].bind(target)}})
}
现在,我们可以很简单地创建代理数据了:
const p = reactive(new Set([1, 2, 3]))
console.log(p.size) // 输出:3
通过这种方式,我们成功地代理了 Set 或 Map 类型的响应式数据,使其在使用上与普通对象无异,同时维持了其原有的特性和操作方式。
5.8.2 建立响应联系
开始实现 Set 类型数据的响应式解决方案,让我们以下面的代码为例:
const p = reactive(new Set([1, 2, 3]))effect(() => {// 在副作用函数内部,我们访问了 size 属性console.log(p.size)
})// 向集合中添加一个元素,间接改变 size,这应该会触发响应
p.add(1)
这段代码我们需要在访问 size 属性时调用 track 函数来进行依赖跟踪,然后在执行 add 方法时调用 trigger 函数来触发响应,下面的代码演示了如何进行依赖跟踪:
function createReactive(obj, isShallow = false, isReadonly = false) {return new Proxy(obj, {get(target, key, receiver) {if (key === 'size') {// 我们在这里调用了 track 函数来建立响应联系track(target, ITERATE_KEY)return Reflect.get(target, key, target)}return target[key].bind(target)}})
}
当我们读取 size 属性时,我们只需要调用 track 函数来建立响应联系即可。这是因为任何新增、删除操作都会影响 size 属性。
当我们调用 add 方法向集合中添加新元素时,我们应该如何触发响应?我们需要实现一个自定义的 add 方法:
// 我们定义一个对象,并在这个对象上定义我们自定义的 add 方法
const mutableInstrumentations = {add(key) {/* ... */}
}function createReactive(obj, isShallow = false, isReadonly = false) {return new Proxy(obj, {get(target, key, receiver) {// 如果读取的是 raw 属性,那么我们返回原始数据对象 targetif (key === 'raw') return targetif (key === 'size') {track(target, ITERATE_KEY)return Reflect.get(target, key, target)}// 我们返回在 mutableInstrumentations 对象上定义的方法return mutableInstrumentations[key]}})
}
通过上面代码后, p.add 获取方法时,得到的就是我们自定义的 mutableInstrumentations.add 方法了,有了自定义实现的方法 后,就可以在其中调用 trigger 函数触发响应了:
// 定义一个对象,将自定义的 add 方法定义到该对象下
const mutableInstrumentations = {add(key) {// this 仍然指向的是代理对象,通过 raw 属性获取原始数据对象const target = this.raw// 通过原始数据对象执行 add 方法添加具体的值,// 注意,这里不再需要 .bind 了,因为是直接通过 target 调用并执行的const res = target.add(key)// 调用 trigger 函数触发响应,并指定操作类型为 ADDtrigger(target, key, 'ADD')// 返回操作结果return res}
}
在我们自定义的 add 方法中,this 仍然指向代理对象,因此我们需要通过 this.raw 来获取原始数据对象。然后通过一系列操作最后触发 操作类型为 ADD 的响应,有了原始数据对象后,就可以通过它调用target.add 方法,这样就不再需要 .bind 绑定了。
还记得 trigger 函数的实现吗?让我们回顾一下:
function trigger(target, key, type, newVal) {const depsMap = bucket.get(target)if (!depsMap) returnconst effects = depsMap.get(key)// 省略无关内容// 当操作类型 type 为 ADD 时,会取出与 ITERATE_KEY 相关联的副作用函数并执行if (type === 'ADD' || type === 'DELETE') {const iterateEffects = depsMap.get(ITERATE_KEY)iterateEffects &&iterateEffects.forEach(effectFn => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn)}})}effectsToRun.forEach(effectFn => {if (effectFn.options.scheduler) {effectFn.options.scheduler(effectFn)} else {effectFn()}})
}
当操作类型是 ADD 或 DELETE 时,我们会获取和 ITERATE_KEY 相关联的副作用函数并执行它们。这样我们就可以触发通过访问 size 属性收集的副作用函数了。
当然,如果调用 add 方法时所添加的元素已经存在于 Set 集合中,那么就不需要触发响应,这样可以提高性能。因此,我们可以优化代码如下:
const mutableInstrumentations = {add(key) {const target = this.raw// 先判断值是否已经存在const hadKey = target.has(key)// 只有在值不存在的情况下,才需要触发响应const res = target.add(key)if (!hadKey) {trigger(target, key, 'ADD')}return res}
}
这段代码调用 target.has 方法判断值是否已存在,只有在值不存在的情况下才需要触发响应。
基于此,我们可以通过类似的逻辑轻松地实现 delete 方法:
const mutableInstrumentations = {delete(key) {const target = this.rawconst hadKey = target.has(key)const res = target.delete(key)// 当要删除的元素确实存在时,才触发响应if (hadKey) {trigger(target, key, 'DELETE')}return res}
}
delete 方法只在要删除的元素确实存在于集合中时才需要触发响应,这与 add 方法的逻辑相反。
5.8.3 避免污染原始数据
Map 数据类型拥有 get 和 set 这两个方法。当我们通过 get 方法读取数据时,需要调用 track 函数来追踪依赖并建立响应关系;而当我们通过 set 方法设置数据时,则需要调用 trigger 方法来触发响应。以下面的代码为例:
const p = reactive(new Map([['key', 1]]))effect(() => {console.log(p.get('key'))
})p.set('key', 2) // 触发响应
让我们看看 get 方法的具体实现:
const mutableInstrumentations = {get(key) {// 获取原始对象const target = this.raw// 判断读取的 key 是否存在const had = target.has(key)// 追踪依赖,建立响应联系track(target, key)// 如果存在,则返回结果。这里要注意的是,如果得到的结果 res 仍然是可代理的数据,// 则要返回使用 reactive 包装后的响应式数据if (had) {const res = target.get(key)return typeof res === 'object' ? reactive(res) : res}}
}
这段代码在非浅响应模式下,如果返回的数据还可以被代理,我们需要调用 reactive(res) 将数据转换为响应式数据后再返回。
我们来看看 set 方法的实现,注意触发响应我们需要区分操作的类型是 SET 还是 ADD:
const mutableInstrumentations = {set(key, value) {const target = this.rawconst had = target.has(key)// 获取旧值const oldValue = target.get(key)// 设置新值target.set(key, value)// 如果不存在,则说明是 ADD 类型的操作,意味着新增if (!had) {trigger(target, key, 'ADD')} else if (oldValue !== value || (oldValue === oldValue && value === value)) {// 如果不存在,并且值变了,则是 SET 类型的操作,意味着修改trigger(target, key, 'SET')}}
}
代码的关键在于我们需要判断待设置的 key 是否已存在,任何依赖 size 属性的副作用函数都需要在 ADD 类型的操作发生时重新执行。
上面存在一个问题,set 方法会污染原始数据:
// 原始 Map 对象 m
const m = new Map()
// p1 是 m 的代理对象
const p1 = reactive(m)
// p2 是另外一个代理对象
const p2 = reactive(new Map())
// 为 p1 设置一个键值对,值是代理对象 p2
p1.set('p2', p2)effect(() => {// 注意,这里我们通过原始数据 m 访问 p2console.log(m.get('p2').size)
})
// 注意,这里我们通过原始数据 m 为 p2 设置一个键值对 foo --> 1
m.get('p2').set('foo', 1)
上述代码我们通过原始数据 m 来读取和设置数据值,却发现副作用函数重新执行了,但是原始数据不应该具备响应式。
这个问题需要我们观察下之前实现的 set 方法:
const mutableInstrumentations = {set(key, value) {const target = this.rawconst had = target.has(key)const oldValue = target.get(key)// 我们把 value 原封不动地设置到原始数据上target.set(key, value)if (!had) {trigger(target, key, 'ADD')} else if (oldValue !== value || (oldValue === oldValue && value === value)) {trigger(target, key, 'SET')}}
}
在 set 方法中,我们将 value 原样设置到了原始数据 target 上,但是如果 value 是响应式数据,设置上去也是响应式数据,这就是数据污染。
解决数据污染,我们可以在调用 target.set 函数设置值之前对值进行检查,即发现设置的是响应式数据,则通过 raw 属性获取原始数据设置到 target 上即可:
const mutableInstrumentations = {set(key, value) {const target = this.rawconst had = target.has(key)const oldValue = target.get(key)// 获取原始数据,如果 value.raw 不存在,则直接使用 valueconst rawValue = value.raw || valuetarget.set(key, rawValue)if (!had) {trigger(target, key, 'ADD')} else if (oldValue !== value || (oldValue === oldValue && value === value)) {trigger(target, key, 'SET')}},
}
现在已经不会造成数据污染了。但是我们一直使用 raw 属性来访问原始数据,这可能会与用户自定义的 raw 属性冲突。因此,在一个更为严谨的实现中,我们需要使用一个唯一的标识来作为访问原始数据的键,例如使用 Symbol 类型来代替。
5.8.4 处理 forEach
集合类型的 forEach 方法类似于数组的 forEach 方法,让我们一起看看它的工作原理:
const m = new Map([[{ key: 1 }, { value: 1 }]
])effect(() => {m.forEach(function (value, key, m) {console.log(value) // { value: 1 }console.log(key) // { key: 1 }})
})
Map 的 forEach 方法接收一个回调函数作为参数,回调函数接收三个参数,分别是 Map 的每个值、键以及原始 Map 对象。
任何修改 Map 对象键值对数量的操作,例如 delete 和 add 方法,都应该触发副作用函数重新执行。
因此,当 forEach 函数被调用时,我们应该让副作用函数与 ITERATE_KEY 建立响应联系:
const mutableInstrumentations = {forEach(callback) {// 取得原始数据对象const target = this.raw// 与 ITERATE_KEY 建立响应联系track(target, ITERATE_KEY)// 通过原始数据对象调用 forEach 方法,并把 callback 传递过去target.forEach(callback)}
}
这样我们就实现了对 forEach 操作的追踪。但是回调函数接收的参数是非响应式数据,如果修改则无法触发副作用函数重新触发。如下所示:
const key = { key: 1 }
const value = new Set([1, 2, 3])
const p = reactive(new Map([[key, value]]))effect(() => {p.forEach(function (value, key) {console.log(value.size) // 3})
})p.get(key).delete(1)
我们尝试删除 Set 类型数据中值为 1 的元素,却发现没能触发副作用函数重新执行。
原因是通过 value.size 访问 size 属性时,这里的 value 是原始数据对象,即 new Set([1, 2, 3]),而非响应式数据对象,因此无法建立响应联系。
不符合直觉,reactive 本身是深响应,forEach 方法的回调函数所接收到的参数也应该是响应式数据才对。
为了解决这个问题我们需要调用原始 forEach 方法之前,先将参数转换为响应式数据:
const mutableInstrumentations = {forEach(callback, thisArg) {// wrap 函数用来把可代理的值转换为响应式数据const wrap = val => (typeof val === 'object' ? reactive(val) : val)const target = this.rawtrack(target, ITERATE_KEY)// 通过 target 调用原始 forEach 方法进行遍历target.forEach((v, k) => {// 手动调用 callback,用 wrap 函数包裹 value 和 key 后再传给 callback,这样就实现了深响应callback.call(thisArg, wrap(v), wrap(k), this)})}
}
上述代码我们使用 wrap 函数将参数包装成响应式的,这样就实现了深响应。
当我们使用 for...in 循环遍历一个对象时,一般只关心对象的键,而不关心对象的值,我们使用 forEach 遍历集合时,既关心键,又关心值。
但这个规则不适用于 Map 类型的 forEach 遍历,如以下代码所示:
const p = reactive(new Map([['key', 1]]))effect(() => {p.forEach(function (value, key) {// forEach 循环不仅关心集合的键,还关心集合的值console.log(value) // 1})
})p.set('key', 2) // 即使操作类型是 SET,也应该触发响应
所以对于 Map 类型的数据,即使操作类型是 SET,只要值发生了变化,也应该触发副作用函数重新执行。因此,我们需要进一步修改 trigger 函数的代码:
function trigger(target, key, type, newVal) {console.log('trigger', key)const depsMap = bucket.get(target)if (!depsMap) returnconst effects = depsMap.get(key)const effectsToRun = new Set()effects &&effects.forEach(effectFn => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn)}})if (type === 'ADD' ||type === 'DELETE' ||// 如果操作类型是 SET,并且目标对象是 Map 类型的数据,// 也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行(type === 'SET' && Object.prototype.toString.call(target) === '[object Map]')) {const iterateEffects = depsMap.get(ITERATE_KEY)iterateEffects &&iterateEffects.forEach(effectFn => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn)}})}// 省略部分内容effectsToRun.forEach(effectFn => {if (effectFn.options.scheduler) {effectFn.options.scheduler(effectFn)} else {effectFn()}})
}
上述代码中即使操作类型是 SET,只要值发生了变化,也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行。
5.8.5 迭代器方法
集合类型拥有三个迭代器方法:
- entries
- keys
- values
当我们调用这些方法时,将会得到对应的迭代器,然后我们可以使用 for...of 循环进行迭代。例如:
const m = new Map([['key1', 'value1'],['key2', 'value2']
])for (const [key, value] of m.entries()) {console.log(key, value)
}
// 输出:
// key1 value1
// key2 value2
此外,由于 Map 或 Set 类型本身就实现了 Symbol.iterator 方法,因此我们也可以直接使用 for...of 进行迭代:
for (const [key, value] of m) {console.log(key, value)
}
// 输出:
// key1 value1
// key2 value2
当然,我们也可以先获取迭代器对象,然后手动调用迭代器对象的 next 方法来获取对应的值:
const itr = m[Symbol.iterator]()console.log(itr.next()) // { value: ['key1', 'value1'], done: false }
console.log(itr.next()) // { value: ['key2', 'value2'], done: false }
console.log(itr.next()) // { value: undefined, done: true }
实际上,m[Symbol.iterator] 和 m.entries 是等价的:
console.log(m[Symbol.iterator] === m.entries) // true
这也是上面为什么使用 for...of 循环迭代 m.entries 和 m 会得到同样的结果。
理解了这些后,我们就可以尝试去实现对迭代器方法的代理:
const p = reactive(new Map([['key1', 'value1'],['key2', 'value2']
]))effect(() => {// TypeError: p is not iterablefor (const [key, value] of p) {console.log(key, value)}
})p.set('key3', 'value3')
上述代理对象 p 没有实现 Symbol.iterator 方法,所以我们得到了上面的错误。
当我们试图使用 for...of 循环遍历代理对象时,系统会尝试从代理对象 p 上获取 p[Symbol.iterator] 属性,这会触发 get 拦截函数,我们可以把 Symbol.iterator 方法的实现放到 mutableInstrumentations 中:
const mutableInstrumentations = {[Symbol.iterator]() {// 获取原始数据对象 targetconst target = this.raw// 获取原始迭代器方法const itr = target[Symbol.iterator]()// 将其返回return itr}
}
上述代码只是返回了原始的迭代器对象后,就可以使用 for...of 循环遍历代理对象 p 了。
但是如果迭代产生的值可以被代理,那么我们也应该将其包装成响应式数据:
const mutableInstrumentations = {[Symbol.iterator]() {// 获取原始数据对象 targetconst target = this.raw// 获取原始迭代器方法const itr = target[Symbol.iterator]()const wrap = val => (typeof val === 'object' && val !== null ? reactive(val) : val)// 返回自定义的迭代器return {next() {// 调用原始迭代器的 next 方法获取 value 和 doneconst { value, done } = itr.next()return {// 如果 value 不是 undefined,则对其进行包裹value: value ? [wrap(value[0]), wrap(value[1])] : value,done}}}}
}
上述代码,我们自定义了迭代器,如果值 value 不为 undefined,则对其进行包装,最后返回包装后的代理对象。
为了让我们能够追踪 for...of 循环对数据的处理,我们需要调用track函数以建立 ITERATE_KEY 与副作用函数的联系:
const mutableInstrumentations = {[Symbol.iterator]() {const target = this.rawconst itr = target[Symbol.iterator]()const wrap = val => (typeof val === 'object' && val !== null ? reactive(val) : val)// 调用 track 函数建立响应联系track(target, ITERATE_KEY)return {next() {const { value, done } = itr.next()return {value: value ? [wrap(value[0]), wrap(value[1])] : value,done}}}}
}
由于迭代操作与集合元素的数量相关,集合 size 的变化应该触发迭代操作的重新执行,我们通过以下代码测试:
const p = reactive(new Map([['key1', 'value1'],['key2', 'value2']
]));effect(() => {for (const [key, value] of p) {console.log(key, value);}
});p.set('key3', 'value3'); // 触发响应
我们之前提到,由于 p.entries 和 p[Symbol.iterator] 等效,所以我们可以使用相同的代码来拦截 p.entries 函数:
const mutableInstrumentations = {// 共用 iterationMethod 方法[Symbol.iterator]: iterationMethod,entries: iterationMethod
}// 抽离为独立的函数,便于复用
function iterationMethod() {const target = this.rawconst itr = target[Symbol.iterator]()const wrap = val => (typeof val === 'object' ? reactive(val) : val)track(target, ITERATE_KEY)return {next() {const { value, done } = itr.next()return {value: value ? [wrap(value[0]), wrap(value[1])] : value,done}}}
}
但当你尝试运行代码使用 for...of 进行迭代时,会得到一个错误:
// TypeError: p.entries is not a function or its return value isnot iterable
for (const [key, value] of p.entries()) {console.log(key, value)
}
因为 p.entries 的返回的具有 next 方法的对象不具有 Symbol.iterator 方法,不是一个可迭代对象。
可迭代协议指的是一个对象实现了 Symbol.iterator 方法,而迭代器协议指的是一个对象实现了 next 方法。
但一个对象可以同时实现可迭代协议和迭代器协议,例如:
const obj = {// 迭代器协议next() {// ...},// 可迭代协议[Symbol.iterator]() {return this;}
}
所以,我们可以在iterationMethod函数中实现可迭代协议来解决上述问题:
// 独立函数,方便重复使用
function iterationMethod() {const target = this.raw;const itr = target[Symbol.iterator]();const wrap = val => (typeof val === 'object') ? reactive(val) : val;track(target, ITERATE_KEY);return {next() {const { value, done } = itr.next();return {value: value ? [wrap(value[0]), wrap(value[1])] : value,done}},// 实现可迭代协议[Symbol.iterator]() {return this;}}
}
现在,无论是使用 for...of 循环还是 p.entries() 方法,都能正常运行且能触发响应。
5.8.6 实现 values 与 keys 方法
values 方法的实现和 entries 方法相似,只不过我们使用 for...of迭代 values 时获取的是值:
for (const value of p.values()) {console.log(value)
}
values 方法的实现如下:
const mutableInstrumentations = {// 共用 iterationMethod 方法[Symbol.iterator]: iterationMethod,entries: iterationMethod,values: valuesIterationMethod
}function valuesIterationMethod() {// 获取原始数据对象 targetconst target = this.raw// 通过 target.values 获取原始迭代器方法const itr = target.values()const wrap = val => (typeof val === 'object' ? reactive(val) : val)track(target, ITERATE_KEY)// 将其返回return {next() {const { value, done } = itr.next()return {// value 是值,而非键值对,所以只需要包裹 value 即可value: wrap(value),done}},[Symbol.iterator]() {return this}}
}
iterationMethod 和 valuesIterationMethod 存在以下差异:
- iterationMethod 通过 target[Symbol.iterator] 获取迭代器对象,而 valuesIterationMethod 通过 target.values 获取迭代器对象。
- iterationMethod 处理键值对 [wrap(value[0]), wrap(value[1])],而 valuesIterationMethod 只处理值 wrap(value)。
keys 方法和 values 方法相似,只是它处理的是键,而不是值,我们只需在 valuesIterationMethod 方法中修改一行代码,即可以实现对 keys 方法的代理:
const itr = target.values();
替换成:
const itr = target.keys()
但是,如果我们尝试运行以下测试用例,我们会发现一个问题:
const p = reactive(new Map([['key1', 'value1'],['key2', 'value2']
]))effect(() => {for (const value of p.keys()) {console.log(value) // key1 key2}
})p.set('key2', 'value3') // 这是一个 SET 类型的操作,它修改了 key2 的值,不应该触发响应
p.set('key3', 'value3') // 能够触发响应
上述代码,我们使用 for...of 循环遍历 p.keys,并调用 p.set 修改 key2 的值,理论上Map 类型数据的所有键没有变化,副作用函数不应该执行,然后却执行了。因为之前我们做了特殊处理,即使操作类型为 SET,也会触发与 ITERATE_KEY 相关的副作用函数。
虽然对于 values 或 entries 方法这是必要的,但对于 keys 方法来说这并不必要,因为 keys 方法只关心 Map 类型数据的键的变化,而不关心值的变化:
解决方案如下所示:
const MAP_KEY_ITERATE_KEY = Symbol()function keysIterationMethod() {// 获取原始数据对象 targetconst target = this.raw// 获取原始迭代器方法const itr = target.keys()const wrap = val => (typeof val === 'object' ? reactive(val) : val)// 调用 track 函数追踪依赖,在副作用函数与 MAP_KEY_ITERATE_KEY 之间建立响应联系track(target, MAP_KEY_ITERATE_KEY)// 将其返回return {next() {const { value, done } = itr.next()return {value: wrap(value),done}},[Symbol.iterator]() {return this}}
}
上述代码我们使用 MAP_KEY_ITERATE_KEY 取代了 ITERATE_KEY 来追踪依赖。
此时当 SET 类型的操作只触发与 ITERATE_KEY 相关的副作用函数时,与 MAP_KEY_ITERATE_KEY 相关的副作用函数则会被忽略。
而在 ADD 或 DELETE 类型的操作中,除了触发与 ITERATE_KEY 相关的副作用函数,还需要触发与 MAP_KEY_ITERATE_KEY 相关的副作用函数,需要修改 trigger 函数:
function trigger(target, key, type, newVal) {// 省略其他代码if (// 操作类型为 ADD 或 DELETE(type === 'ADD' || type === 'DELETE') &&// 并且是 Map 类型的数据Object.prototype.toString.call(target) === '[object Map]') {// 则取出那些与 MAP_KEY_ITERATE_KEY 相关联的副作用函数并执行const iterateEffects = depsMap.get(MAP_KEY_ITERATE_KEY)iterateEffects &&iterateEffects.forEach(effectFn => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn)}})}// 省略其他代码
}
这样,我们就可以避免不必要的更新:
const p = reactive(new Map([['key1', 'value1'],['key2', 'value2']])
)effect(() => {for (const value of p.keys()) {console.log(value)}
})p.set('key2', 'value3') // 不会触发响应
p.set('key3', 'value3') // 能够触发响应