《Vuejs设计与实现》第 5 章(非原始值响应式方案)下 Set 和 Map 的响应式代理

目录

[5.8 Set 和 Map 的响应式代理](#5.8 Set 和 Map 的响应式代理)

[5.8.1 如何代理 Set 和 Map](#5.8.1 如何代理 Set 和 Map)

[5.8.2 建立响应联系](#5.8.2 建立响应联系)

[5.8.3 避免污染原始数据](#5.8.3 避免污染原始数据)

[5.8.4 处理 forEach](#5.8.4 处理 forEach)

[5.8.5 迭代器方法](#5.8.5 迭代器方法)

[5.8.6 实现 values 与 keys 方法](#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 existionProxy
  const 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 属性,那么我们返回原始数据对象 target
      if (key === 'raw') return target
      if (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 函数触发响应,并指定操作类型为 ADD
    trigger(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) return
  const 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.raw
    const 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.raw
    const 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 访问 p2
  console.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.raw
    const 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.raw
		const had = target.has(key)
		const oldValue = target.get(key)
		// 获取原始数据,如果 value.raw 不存在,则直接使用 value
		const rawValue = value.raw || value
		target.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.raw
    track(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) return
  const 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 iterable
  for (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]() {
        // 获取原始数据对象 target
        const target = this.raw
        // 获取原始迭代器方法
        const itr = target[Symbol.iterator]()
        // 将其返回
        return itr
    }
}

上述代码只是返回了原始的迭代器对象后,就可以使用 for...of 循环遍历代理对象 p 了。

但是如果迭代产生的值可以被代理,那么我们也应该将其包装成响应式数据:

复制代码
const mutableInstrumentations = {
  [Symbol.iterator]() {
    // 获取原始数据对象 target
    const target = this.raw
    // 获取原始迭代器方法
    const itr = target[Symbol.iterator]()

    const wrap = val => (typeof val === 'object' && val !== null ? reactive(val) : val)

    // 返回自定义的迭代器
    return {
      next() {
        // 调用原始迭代器的 next 方法获取 value 和 done
        const { 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.raw
    const 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.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
      }
    }
  }
}

但当你尝试运行代码使用 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() {
  // 获取原始数据对象 target
  const 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() {
  // 获取原始数据对象 target
  const 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') // 能够触发响应
相关推荐
快乐是一切3 小时前
PDF底层格式之水印解析与去除机制分析
前端·数据结构
MHJ_4 小时前
Multi-Metric Integration(多指标集成)
数据结构
WWZZ20254 小时前
ORB_SLAM2原理及代码解析:SetPose() 函数
人工智能·opencv·算法·计算机视觉·机器人·自动驾驶
小马学嵌入式~5 小时前
堆排序原理与实现详解
开发语言·数据结构·学习·算法
青岛少儿编程-王老师5 小时前
CCF编程能力等级认证GESP—C++6级—20250927
java·c++·算法
一人の梅雨5 小时前
1688 拍立淘接口深度开发:从图像识别到供应链匹配的技术实现
人工智能·算法·计算机视觉
_给我学起来5 小时前
数据结构:树
数据结构
Miraitowa_cheems6 小时前
LeetCode算法日记 - Day 64: 岛屿的最大面积、被围绕的区域
java·算法·leetcode·决策树·职场和发展·深度优先·推荐算法
Christo36 小时前
关于K-means和FCM的凸性问题讨论
人工智能·算法·机器学习·数据挖掘·kmeans