非原始值的响应式方案(四、完全代理Map&Set)

非原始值的响应式方案(四、完全代理Map&Set)

我们在前面几章已经对 Object 与 Array 的响应式代理底层做了相对比较相近的剖析,接下来我们要深入到其他不同的对象类型,比如即将要讲的集合类型 `Set | Map | WeakMap | WeakSet`。

4696字

我们在前面几章已经对 Object 与 Array 的响应式代理底层做了相对比较相近的剖析,接下来我们要深入到其他不同的对象类型,比如即将要讲的集合类型 Set | Map | WeakMap | WeakSet。实际上 Set 与 Map 类型无论是属性与方法都是类似的,许多方法都是复用的,两者最大的不同仅仅体现在 Set 类型使用 add(value) 添加元素而 Map 类型使用 set(key, value)所以这也意味着,我们可以使用相同的方式一口气处理它们。

如何代理Set与Map

和一般的对象不同,Set 与 Map 类型有特定的属性与方法来操作、访问自身。比如,对于 Map,我们会使用 get(key) 方法来拿出数据,用 set(key, value) 来放数据。所以我们不能够像代理普通对象那样子去代理 Set 与 Map 类型。不过 track 与 trigger 的整体思路是相同的,归根结底还是看这些设置与读取操作的底层基本语义是什么

在此之前,我们需要先了解一下使用 Proxy 代理 Set 与 Map 遇到的坑,以免后面踩坑。我们可以先写一下如下的代码,一个很平常的使用 Proxy 对 Set 进行代理并使用 size 读取长度的内容:

javascript
const s = new Set([1, 2, 3])

const p = new Proxy(s, {})

console.log(p.size)

好~恭喜你报错啦

这个报错信息给的内容也比较明显:Method get Set.prototype.size called on incompatible receiver,在不兼容的 receiver 上调用了 get Set.prototype.size 方法。从这里能看得出来,size 其实应该是一个 getter,而它被作为方法调用。

我们进行 ECMA 规范的查阅,我们能够了解到 Set.prototype.size 是一个访问器属性,它的 set 访问器函数是 undefined,get 访问器函数首先初始化一个 count 变量为 0,之后会拿到 entries 中的每个元素然后进行遍历,每遍历到一次就把 count + 1。遍历完之后返回 count。而 entries 是通过 S.[[SetData]] 来拿到的,但问题是我们的代理对象 Proxy 内部是不存在 [[SetData]] 这个内部槽的,所以会发生一次报错。

为了修复它,我们可以只根据 size 属性做特殊处理,当读的属性为 size,那么我们可以直接拦截它并指定第三个 receiver 参数为原始对象。

javascript
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)
  },
})

console.log(p.size) // 3

这样子我们能够解决读取 size 属性报错的问题,于此同时我们也需要解决 delete 发生的报错:

javascript
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)
  },
})

p.delete(1)

发生的报错信息非常类似,因为 delete 与 size 都依赖于原始对象内部的 [[SetData]] 槽,但实际上访问 p.sizep.delete 是不一样的,因为前者是一个访问器属性而后者是一个具体的方法。在执行 delete 时,真正的 this 绑定是在 调用时 指定的,所以不能用 Reflect 的第三个方法来手动指定 receiver 来改变读取属性时的 this 值。

为了解决这个问题,我们要在调用方法(不只是 delete)的时候,与原始数据对象进行绑定来解决这个问题。

javascript
const s = new Set([1, 2, 3])

const p = new Proxy(s, {
  get(target, key) {
    if (key === 'size') {
      return Reflect.get(target, key, target)
    }
    return target[key].bind(target)
  },
})

p.delete(1)
console.log(p.size) // 2

最后我们把这些注意事项直接放到 createReactive 里面:

javascript
get(target, key, receiver) {
  if (key === 'size') {
    return Reflect.get(target, key, target)
  }

  return target[key].bind(target)
},

接下来我们就基于这些基础设置来进行 Map 与 Set 的响应式代理。

建立响应联系

基于以上的理解,我们先着手处理对于 Set 的响应式方案。我们如果在副作用函数内部使用了 size 属性来读取了长度,并在外界使用 add 方法添加了数据,这样子会影响 size 的值,从而应该触发副作用函数。

这个互相影响的属性原理跟我们之前写的 Array 的 length 与索引是类似的。我们需要在访问 size 属性的时候触发依赖追踪,然后在特定的名为 add 的方法执行时触发响应。

javascript
get(target, key, receiver) {
  if (key === 'size') {
    track(target, ITERATE_KEY)
    return Reflect.get(target, key, target)
  }

  return target[key].bind(target)
},

在注册依赖阶段,只要读取 size 属性的时候,调用 track 函数建立响应联系即可。在这里我们使用 ITERATE_KEY 作为副作用函数的关联键而不是 size 本身。一开始我们引入 ITERATE_KEY 的目的是为了解决使用 for ... in 循环遍历一个 Object 的 key 的时候我们拦截不到具体的 key 有哪些,Proxy 对应的 ownKeys 的 trap 也没提供对应的 key 参数。在这里我们也使用它的理由是任何新增、删除操作都会影响 size 属性,而 size 本身是一个 getter,并不是真正意义上的一个属性值。为了便于集中进行管理,我们对任何会造成 size 变化的副作用函数对应的 key 设置为 ITERATE_KEY

接下来我们需要对它触发响应。为了正确对其触发响应,需要实现一个自定义的 add 方法,然后在 get trap 下将对应的自定义方法返回即可。为了与 get trap 中原有的功能进行集成,我们需要判断当前操作的 target 是否为 Map 或者 Set 类型,如果是的话,就进入我们的自定义方法。

javascript
const mutableInstrumentations = {
  add(key) {
    const target = this.raw
    const res = target.add(key)
    trigger(target, key, TriggerType.ADD)
    return res
  },
}
javascript
// 判断某个对象是否是 Map 类型
function isMap(target) {
  return Object.prototype.toString.call(target) === '[object Map]'
}

// 判断某个对象是否是 Set 类型
function isSet(target) {
  return Object.prototype.toString.call(target) === '[object Set]'
}

// 创建响应式数据
function createReactive(data, isShallow = false, isReadonly = false) {
  return new Proxy(data, {
    get(target, key) {
      if (key === RAW) {
        return target
      }

      if (isMap(target) || isSet(target)) {
        if (key === 'size') {
          track(target, ITERATE_KEY)
          return Reflect.get(target, key, target)
        }

        return mutableInstrumentations[key]
      }

      if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
        return Reflect.get(arrayInstrumentations, key, receiver)
      }

      if (!isReadonly && typeof key !== 'symbol') {
        track(target, key)
      }

      const res = Reflect.get(target, key, receiver)
      if (isShallow) {
        return res
      }
      if (typeof res === 'object' && res !== null) {
        return isReadonly ? readonly(res) : reactive(data)
      }
      return res
    },
  })
}

比较简单,我们自定义了一个 mutableInstrumentations 结构体专门用来存自定义方法,然后在 Proxy 的 get trap 内部将对应 key 的方法返回。此时对这个方法进行调用的是响应式变量,所以 this 指的也是响应式变量,所以需要用 raw 取到原始值,之后直接调用它身上的 add 方法即可,省去了 bind 绑定 this 的过程。调用好了之后使用 trigger 触发。在这里我们传入了 TriggerType.ADD 作为 trigger 的第三个参数,因为在我们之前对 trigger 函数的实现当中,当传入的第三个参数为 TriggerType.ADD | TriggerType.DELETE 的时候会自动取出与 ITERATE_KEY 相关联的副作用函数并加以执行。不过为了与用户可能自定义的 raw 属性区分开,我们最好使用 const RAW = Symbol() 来自定义一个 Symbol 类型的键,在之后的代码中统一用这个键来对 .raw 属性进行替代。

当然,对于 Set 而言,重复的元素实际上是加不进去的,这个时候重新触发响应实际上是很耗费性能的。我们可以在 add 方法内部添加对于这一情况的判断从而优化性能:

javascript
const mutableInstrumentations = {
  add(key) {
    const target = this[RAW]
    const hadKey = target.has(key)
    const res = target.add(key)
    if (!hadKey) {
      trigger(target, key, TriggerType.ADD)
    }
    return res
  },
}

按照类似的思路,我们也来实现一下 delete 方法:

javascript
delete(key) {
  const target = this[RAW]
  const hadKey = target.has(key)
  const res = target.delete(key)
  if (hadKey) {
    trigger(target, key, TriggerType.DELETE)
  }
  return res
},

不过与 add 方法有点不一样,delete 方法是只有要删除的元素真的在集合中时才触发响应,与 add 是相反的。

避免污染原始数据

Map 类型有 get 与 set 两个方法。当用 get 方法读数据时,需要使用 track 来追踪依赖;当使用 set 方法设置数据时,需要使用 trigger 方法触发响应。我们可以基于对 Set 的 add、delete 等方法的经验,自定义 get 与 set 方法:

javascript
get(key) {
  const target = this[RAW]
  const had = target.has(key)
  track(target, key)
  if (had) {
    const res = target.get(key)
    return typeof res === 'object' ? reactive(res) : res
  }
},

对于 get 方法,因为对于一般对象而言,都是直接用属性来读值,很少有用方法返回值的,所以我们得手动改写它来让它的执行逻辑中触发对依赖的追踪,并且需要实现如深响应的逻辑。如果存在,则拿到值并返回,不存在就拿不到且不返回,也符合一般的逻辑 。

对于 set 方法,在触发响应的时候需要区分操作的类型为 TriggerType.SET 还是 TriggerType.ADD,也就是通过判断要设置的 key 在原对象上面是否存在。如果是 ADD,会对 size 属性产生影响,需要触发 ITERATE_KEY 对应的副作用函数。

javascript
set(key, value) {
  const target = this[RAW]
  const had = target.has(key)
  const oldValue = target.get(key)
  target.set(key, value)
  if (!had) {
    trigger(target, key, TriggerType.ADD)
  } else if (
    oldValue !== value ||
    (oldValue === oldValue && value === value)
  ) {
    trigger(target, key, TriggerType.SET)
  }
},

这样子我们能够初步的实现了 get 与 set 方法,但是实际上目前的 set 方法存在对原始数据的污染问题。

我们可以写一个 demo:

javascript
const m = new Map()

const p1 = reactive(m)

const p2 = reactive(new Map())

p1.set('p2', p2)

effect(() => {
  console.log(m.get('p2').size)
})

m.get('p2').set('foo', 1)

m 是原始数据,我们用标准的 new Map() 进行创建,p1 是对 m 的代理,p2 是响应式数据。

接着我们在 p1 上用我们改写好的 set 方法设置了一个键为 p2 值为 p2 响应式数据的数据。

然后我们在副作用函数里面,用原始数据 m 拿到 p2 的值,然后调用其 size 属性

最后,我们 用原始数据控制响应式数据 p2 调用 set 方法添加键值对

我们看看效果:

按照我们目前的实现,这样子做是可以正常触发响应式变量的,但是问题是 原始数据怎么可以操作响应式数据呢 ?这不符合常理也不符合直觉。

问题其实出在我们自定义的 set 方法里面,我们使用了 target.set(key, value) 把 value 原封不动的放到了原始数据 target 上面。如果要放的数据是响应式变量,那也照放不误

至此我们可以引出一个 Vue 响应式系统中的一个核心概念:把响应式数据设置到原始数据上的行为称为数据污染

为了解决它,我们需要在原始数据上使用 set 方法前对要设置的值进行一次检查。如果是响应式数据,调用 raw 拿到原始值再进行设置。

javascript
set(key, value) {
  const target = this[RAW]
  const had = target.has(key)
  const oldValue = target.get(key)
  const rawValue = value[RAW] || value
  target.set(key, rawValue)
  if (!had) {
    trigger(target, key, TriggerType.ADD)
  } else if (
    oldValue !== value ||
    (oldValue === oldValue && value === value)
  ) {
    trigger(target, key, TriggerType.SET)
  }
},

不过目前的实现还有一个新的问题,那就是我们使用 raw 这个属性来从响应式数据里面拿原始值,但是这个 raw 属性可能会与用户自定义的 raw 属性导致冲突。所以我们应该使用一个唯一的标识来作为访问原始数据的键,比如使用 Symbol 类型进行替代。

除了以上的 set 方法需要避免污染原始数据以外,Set 类型的 add 方法、普通对象的写值操作、为数组添加元素的方法等,都需要进行类似的处理。由于篇幅所限,在这里先不展开。

处理forEach

Set、Map 的 forEach 遍历两罚类似于数组的遍历方法。以 Map 为例,forEach 的回调函数会被传入 value、key、原始 map 对象 三个参数。

可以看到遍历操作只与其键值对有关,所以任何会修改 Map 键值对数量的操作都应该触发副作用函数重新执行。所以类似的,我们要把包含 forEach 遍历操作的副作用函数与 ITERATE_KEY 关联起来,这需要我们重写 forEach 方法:

javascript
// 对于 Map & Set 类型的方法重写
const mutableInstrumentations = {
  // ...
  forEach(callback) {
    const target = this[RAW]
    track(target, ITERATE_KEY)
    target.forEach(callback)
  },
}

不过这么做还是存在一定缺陷的,因为我们是直接在原始对象上面调用了 forEach 方法。如果原始 Map 上的 key 或者是 value 是一个对象,那么我们对这个对象进行修改的时候,理应也会触发这个包含 forEach 操作的副作用函数重新执行,不过目前还做不到这点。而解决这个问题的方法是需要采取一些手段来将遍历到的所有能够被转为响应式数据的数据都转为响应式再进行 forEach 遍历以建立响应联系,就可以啦!

我们需要改写一下 forEach 方法:

javascript
// 对于 Map & Set 类型的方法重写
const mutableInstrumentations = {
  // ...
  forEach(callback) {
    const reactiveWrapper = val =>
      typeof val === 'object' ? reactive(val) : val
    const target = this[RAW]
    track(target, ITERATE_KEY)
    target.forEach((v, k) => {
      callback(reactiveWrapper(v), reactiveWrapper(k), this)
    })
  },
}

我们自定义了一个 reactiveWrapper 函数,用来将不是响应式的变量转为响应式的变量,然后把传给 forEach 的参数能变成响应式的都变成响应式,这样就可以了。最后,forEach 实际上还能够指定其被调用时的 this,也就是第二个参数。我们也处理一下:

javascript
// 对于 Map & Set 类型的方法重写
const mutableInstrumentations = {
  // ...
  forEach(callback, thisArg) {
    const reactiveWrapper = val =>
      typeof val === 'object' ? reactive(val) : val
    const target = this[RAW]
    track(target, ITERATE_KEY)
    target.forEach((v, k) => {
      callback.call(thisArg, reactiveWrapper(v), reactiveWrapper(k), this)
    })
  },
}

在上面的实现中,我们应该能看得出来,目前的实现在

  • 使用 for ... in 循环遍历一个对象
  • 使用 forEach 循环遍历一个集合

它们的响应联系都是建立在 ITERATE_KEY 这个 Symbol 类型之上的。但问题是,这两种遍历方式实际上存在着本质的不同,因为前者 只关心对象的键,而不关心对象的值,这也就意味着 只有当新增、删除对象的 key 时才会重新执行副作用函数。对于 TriggerType.SET 类型的操作来说,这个操作实际上并没有更改对象的键的数量,所以这个操作发生时不需要重新触发副作用函数执行。

以上是对于一般对象的 for ... in 循环而言的,对于 Map 数据类型而言就没那么简单了,因为我们知道 Map 的 forEach 循环是包含了 key 与 value 的,当我们用 set(key, value) 修改值的时候应该要触发 forEach 相关的副作用函数执行,即使它的操作类型是 TriggerType.SET 。因此,我们需要针对这种情况,修改 trigger 函数来弥补这个缺陷:

javascript
function trigger(target, key, type, newValue) {
  const depsMap = bucket.get(target)
  if (!depsMap)
    return

  const effects = depsMap.get(key)
  const iterateEffects = depsMap.get(ITERATE_KEY)

  const effectsToRun = new Set()

  effects
  && effects.forEach((effectFn) => {
    if (effectFn !== activeEffectFn) {
      effectsToRun.add(effectFn)
    }
  })

  if (
    type === TriggerType.ADD
    || type === TriggerType.DELETE
    || (type === TriggerType.SET && isMap(target))
  ) {
    iterateEffects
    && iterateEffects.forEach((effectFn) => {
      if (effectFn !== activeEffectFn) {
        effectsToRun.add(effectFn)
      }
    })
  }

  // ...

  effectsToRun.forEach((effectFn) => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    }
    else {
      effectFn()
    }
  })
}

比较简单粗暴的,我们在需要拿出 ITERATE_KEY 键的副作用函数 Set 的条件里面加了一条:如果是 TriggerType.SET 操作,并且当前的数据类型为 [object Map],那就需要拿出来重新执行。

有人可能会问为啥 Map 要这么处理,Set 就不用吗?Set 实际上没有所谓 键值对 的概念,所以遍历它跟遍历普通对象是一样的,没有涉及到所谓 设置某个特定位置的值 的操作,所以不用处理。而 Set 的 forEach 之所以也返回三个参数,只是为了保持和 Map 类型一致的调用方式而已。

迭代器

接下来我们来讲讲对于集合类型而言我们比较熟也是比较方便的方法:迭代器方法。集合类型总共有三种迭代器方法,Map 和 Set 都共有的:

  • entries()
  • keys()
  • value()

我们调用这些方法之后都会返回对应的迭代器,然后可以使用 for ... of 循环进行迭代,并且 Map 与 Set 两种类型本身也都部署了 Symbol.iterator 方法,并且默认是 entries() 方法相同的返回,所以也可以直接对它们的实例使用 for ... of

基于这些了解,我们可以看看在现有的实现模式下,直接在副作用函数里面使用 for ... of 循环遍历响应式变量会发生什么~

javascript
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 是不可迭代的!

实际上,我们在对代理对象 p 上面使用 for ... of 的时候,内部肯定首先是拿它的迭代器,调用 p[Symbol.iterator] 这个方法,拿到返回值。但问题是肯定拿不到,因为是代理对象, 没实现这个方法。不过我们可以把这个操作写到 get trap 里面,然后在 mutableInstrumentations 里面去自己手动手写一遍就行了。

当然不要忘记,跟上面实现的 forEach 类似,我们在进行循环迭代的时候,如果迭代产生的值也是能够被代理的(对象),也需要进行深层包装。为此,我们不仅需要自定义 [Symbol.iterator] 方法,还需要自定义一个迭代器。

在最后,为了追踪 for ... of 对数据的迭代操作,需要调用 track 进行依赖追踪,使副作用函数与 ITERATE_KEY 建立联系即可。

总体实现的手写 [Symbol.iterator] 方法如下:

javascript
// 对于 Map & Set 类型的方法重写
const mutableInstrumentations = {
  // ...
  [Symbol.iterator]() {
    const target = this[RAW]
    const itr = target[Symbol.iterator]()
    const reactiveWrapper = val =>
      typeof val === 'object' && val !== null ? reactive(val) : val
    track(target, ITERATE_KEY)
    return {
      next() {
        const { value, done } = itr.next()
        return {
          value: value
            ? [reactiveWrapper(value[0]), reactiveWrapper(value[1])]
            : value,
          done,
        }
      },
    }
  },
}

为什么要与 ITERATE_KEY 建立联系呢?其实还是那个问题,因为只要某些操作使得集合的 size 发生变化后就说明肯定集合的键值对或者键的数量发生了变化,此时的迭代器的返回肯定发生了变化,那就得进行副作用函数的重新执行了。

当然不要忘记,p.entries 实际上与 p[Symbol.iterator] 是等价的操作,所以我们需要使用同样的代码来实现对 p.entries() 的拦截。为了更好的重用我们的代码,我们可以将代码重构一下:

javascript
// 对于 Map & Set 类型的方法重写
const mutableInstrumentations = {
  // ...
  entries: iterationMethod,
  [Symbol.iterator]: iterationMethod,
}

// Map & Set 类型的自定义迭代器函数
function iterationMethod() {
  const target = this[RAW]
  const itr = target[Symbol.iterator]()
  const reactiveWrapper = val =>
    typeof val === 'object' && val !== null ? reactive(val) : val
  track(target, ITERATE_KEY)
  return {
    next() {
      const { value, done } = itr.next()
      return {
        value: value
          ? [reactiveWrapper(value[0]), reactiveWrapper(value[1])]
          : value,
        done,
      }
    },
  }
}

我们来测测!

javascript
const p = reactive(
  new Map([
    ['key1', 'value1'],
    ['key2', 'value2'],
  ])
)

effect(() => {
  for (const [key, value] of p) {
    console.log(key, value)
  }
})

p.set('key3', 'value3')

结果是!

成功了!目前是的!

我们前面提到了,对于 Map 与 Set 类型而言,我们直接对它们的实例使用 for ... of 进行迭代,但是我们如果想直接用 entries() 方法拿迭代器来进行 for ... of,实际上会报错的:

p.entries() 不是一个函数或者它的返回值使不可迭代的。这个时候,我们回过头看看之前写的 iterationMethod 函数吗,它返回的是 一个含有 next() 方法的对象

???这是不是不符合我们的 可迭代协议,只符合我们的 迭代器协议?一个对象如果能够被 for ... of 迭代,必须得符合可迭代协议啊!

所以为了让一个对象同时支持可迭代协议与迭代器协议,我们可以使一个对象 同时拥有 [Symbol.iterator] 方法与 next() 方法。所以我们可以直接这么加:

javascript
// Map & Set 类型的自定义迭代器函数
function iterationMethod() {
  const target = this[RAW]
  const itr = target[Symbol.iterator]()
  const reactiveWrapper = val =>
    typeof val === 'object' && val !== null ? reactive(val) : val
  track(target, ITERATE_KEY)
  return {
    next() {
      const { value, done } = itr.next()
      return {
        value: value
          ? [reactiveWrapper(value[0]), reactiveWrapper(value[1])]
          : value,
        done,
      }
    },
    [Symbol.iterator]() {
      return this
    },
  }
}

在返回的对象里面添加 [Symbol.iterator] 方法,这个方法递归返回 iterationMethod 的返回值,这个返回值本身就是同时实现了迭代器协议与可迭代协议的对象。不得不说这是一个很巧妙的解决方法。

至此,现在实现的一切都可以正常工作了。

values与keys方法

最后的这两个方法实际上和 entries() 类似,只不过前者返回的只是 Map 的值,后者返回的只是 Map 的键。

我们可以用类似的逻辑,来重写 values() 方法:

javascript
// 对于 Map & Set 类型的方法重写
const mutableInstrumentations = {
  // ...
  entries: iterationMethod,
  [Symbol.iterator]: iterationMethod,
  values: valueIteratorMethod,
}

// Map & Set 类型的值迭代器函数
function valueIteratorMethod() {
  const target = this[RAW]
  const itr = target.values()
  const reactiveWrapper = val =>
    typeof val === 'object' && val !== null ? reactive(val) : val
  track(target, ITERATE_KEY)
  return {
    next() {
      const { value, done } = itr.next()
      return {
        value: reactiveWrapper(value),
        done,
      }
    },
    [Symbol.iterator]() {
      return this
    },
  }
}

对于 keys() 函数而言,只用把 const itr = target.values() 这一行改成 const itr = target.keys() 就行了。

等一下...这几个迭代器函数的逻辑是不是有点过于类似了?我们可以将其再进行一层封装,自定义一个高阶函数,通过传进去的 type 来指定具体的返回逻辑,并保留上下文的 this:

javascript
// TS 中直接用 enum
const IterationMethodType = {
  KEY: 'KEY',
  VALUE: 'VALUE',
  PAIR: 'PAIR',
}

// 对于 Map & Set 类型的方法重写
const mutableInstrumentations = {
  // ...
  entries: iterationMethod(IterationMethodType.PAIR),
  [Symbol.iterator]: iterationMethod(IterationMethodType.PAIR),
  values: iterationMethod(IterationMethodType.VALUE),
}

// Map & Set 类型的迭代器函数
function iterationMethod(type) {
  return function () {
    const target = this[RAW]
    const itr
      = type === IterationMethodType.PAIR
        ? target[Symbol.iterator]()
        : type === IterationMethodType.KEY
          ? target.keys()
          : target.values()
    const reactiveWrapper = val =>
      typeof val === 'object' && val !== null ? reactive(val) : val
    track(target, ITERATE_KEY)
    return {
      next() {
        const { value, done } = itr.next()
        return {
          value:
            type === IterationMethodType.PAIR
              ? value
                ? [reactiveWrapper(value[0]), reactiveWrapper(value[1])]
                : value
              : reactiveWrapper(value),
          done,
        }
      },
      [Symbol.iterator]() {
        return this
      },
    }
  }
}

注意,这里不能返回一个箭头函数,因为得保留上下文的 this 引用。这个居然书上没提,可能是篇幅所限吧,当我自己的扩展了。

不过现在的实现还是有一定缺陷的,当我们用 keys() 方法拿到键的迭代器之后会对其进行遍历,之后使用 set(key, value) 方法修改了某个已存在的键的值之后,也会触发副作用函数执行。因为我们之前是对 TriggerType.SET 类型且操作的 target 类型为 [object Map] 有过特殊处理的,这样的 TriggerType.SET 操作实际上是会直接从 ITERATE_KEY 那边拿副作用函数出来执行的,不管你的 key 个数是否发生变化。

为了解决它,我们可以用一个自定义的新 Symbol 类型作为键:MAP_KEY_ITERATE_KEY ,作为专门用来存有使用 map.keys() 操作的副作用函数的键,当然需要同步处理一下 trigger 函数那边,也得把这个新 Symbol 类型的触发纳为考虑。

javascript
const MAP_KEY_ITERATE_KEY = Symbol()

// Map & Set 类型的迭代器函数
function iterationMethod(type) {
  return function () {
    const target = this[RAW]
    const itr
      = type === IterationMethodType.PAIR
        ? target[Symbol.iterator]()
        : type === IterationMethodType.KEY
          ? target.keys()
          : target.values()
    const reactiveWrapper = val =>
      typeof val === 'object' && val !== null ? reactive(val) : val
    type === IterationMethodType.KEY
      ? track(target, MAP_KEY_ITERATE_KEY)
      : track(target, ITERATE_KEY)
    return {
      next() {
        const { value, done } = itr.next()
        return {
          value:
            type === IterationMethodType.PAIR
              ? value
                ? [reactiveWrapper(value[0]), reactiveWrapper(value[1])]
                : value
              : reactiveWrapper(value),
          done,
        }
      },
      [Symbol.iterator]() {
        return this
      },
    }
  }
}

// 触发副作用函数的重新执行
function trigger(target, key, type, newValue) {
  // ...

  if (
    type === TriggerType.ADD
    || type === TriggerType.DELETE
    || (type === TriggerType.SET && isMap(target))
  ) {
    iterateEffects
    && iterateEffects.forEach((effectFn) => {
      if (effectFn !== activeEffectFn) {
        effectsToRun.add(effectFn)
      }
    })
  }

  if (
    (type === TriggerType.ADD || type === TriggerType.DELETE) && isMap(target)
  ) {
    const iterateEffects = depsMap.get(MAP_KEY_ITERATE_KEY)
    iterateEffects
    && iterateEffects.forEach((effectFn) => {
      if (effectFn !== activeEffectFn) {
        effectsToRun.add(effectFn)
      }
    })
  }

  // ...
}

我们只在使用 keys() 方法的副作用函数依赖收集中使用这个新 key,这样就完成了依赖收集的分离。当进行正儿八经的 map.set(key, value) 操作的时候,是用 ITERATE_KEY 拿副作用函数集的,而如果是涉及到对 Map 类型的键个数的增、删的,就单独用 MAP_KEY_ITERATE_KEY 来拿副作用函数集,因此上面那个判断不用改。

这样子之后,我们就可以避免不必要的更新了,可以来测测:

javascript
const p = reactive(
  new Map([
    ['key1', 'value1'],
    ['key2', 'value2'],
  ])
)

effect(() => {
  for (const key of p.keys()) {
    console.log(key)
  }
})

p.set('key2', 'value3')

可以看到结果已经符合我们的预期了:

至此,我们对于 Map 与 Set 的代理也告一段落了。

评论0