网络日志

聊聊 Vue3 中对 Set/Map 的处理 | toRaw 的实现

前言

最近看 Vue3 源码,看到 Vue 对 Map/Set 做了很多特殊处理,把他们身上的所有方法都又实现了一遍,引起了一些思考与尝试,写篇文章分享出来

先说一个新奇的发现:

Proxy 是无法直接拦截 Set/Map 的!因为 Set/Map 的方法必须得在它们自己身上调用

看到这句话你不禁会想 Vue 是如何代理他们的,继续看下去吧

方法的三种调用形式

本节探讨 Set/Map 它们实例方法的三种调用形式

这里不包括在它们示例身上调用,自己调用当然能正常运行

在 Proxy 对象上调用

用 Proxy 代理一个集合,不做任何拦截,然后调用 add 方法,寄!

const p = new Proxy(new Set(), {})
p.add(1)
// TypeError: Method Set.prototype.add called on incompatible receiver #<Set>

虽然没法运行方法,但好消息是 Proxy 能拦截到方法的读取,这是下文能够使用 Proxy 包装 Set/Map 的基础

const p = new Proxy(new Set(), {
    get(target, key) {
        console.log('get:', key)
        return Reflect.get(target, key)
    },
})
p.add(1) // get: add
// TypeError: Method Set.prototype.add called on incompatible receiver #<Set>

在继承集合的对象上调用

创建一个对象继承一个集合,尝试调用 add 方法,也是寄!

const obj = Object.create(new Set())
obj.add(1)
// TypeError: Method Set.prototype.add called on incompatible receiver #<Set>

const obj = {}
Object.setPrototypeOf(obj, new Set())
obj.add(1)
// TypeError: Method Set.prototype.add called on incompatible receiver #<Set>

在子类身上调用

难道就没有办法在其他对象身上调用 Map/Set 的方法了吗?

还是有的,就是它们的子类示例

class mySet extends Set {
    constructor() {
        super()
    }
    add(value) {
        super.add(value)
        console.log('终于成功运行了')
        return this
    }
}
let set = new mySet()
set.add(1) // 终于成功运行了
console.log(set) // mySet(1) [Set] { 1 }

结果

经过上述实验,我们知道了想要拦截 Set/Map,最简单的方式是为它们设置子类

但是,Vue 并没有采用这种方法,原因也很简单,class 关键期 IE13(Edge13)才出,而 Vue 想兼容到 IE12。

而且这一特性是 babel 解决不了的,就是垫不起来

所以呢,Vue3 还是选择用 Proxy 重写方法来解决,接下来让我们看看具体是怎么实现的

用 Proxy 包装 Set

实现思路

既然 Set/Map 的方法只能在原对象上调用,那我们就封装一套方法,先获取原对象,再在它们身上调用方法就好了

就像下面这样

const p = new Proxy(new Set(), {
    get(target, key) {
        if (key === 'add') return add // 返回自己实现的方法
        return Reflect.get(target, key)
    },
})

function add(value) {
    const rawTarget = toRaw(this) // 获取代理的原对象
    rawTarget.add(value) // 原对象再调用 add 方法
    return this // add方法会返回集合本身
}

toRaw 是 Vue 实现的一个 api,用来获取代理对象的原对象

实现 toRaw

toRaw 实现起来也很简单,毕竟代理对象的拦截器是咱们自己写的,只要在其中定义一个特殊的属性,让拦截器返回原对象就行

const p = new Proxy(new Set(), {
    get(target, key) {
        if (key === '__v_raw') return target // 访问特殊属性,返回原对象
        if (key === 'add') return add // 返回自己实现的方法
        return Reflect.get(target, key)
    },
})
// 获取原对象的方法
function toRaw(p) {
    return p['__v_raw']
}

Vue 中考虑到多层代理嵌套的问题,所以源码中 toRaw 的实现是递归调用的,直至对象没有 '__v_raw' 属性

toRaw 实现后,add 函数就已经能够正常运行了

p.add(1)
p.add(2)
console.log(p)
// Proxy { 1, 2 }   浏览器控制台输出
// Set(2) { 1, 2 }   node控制台输出

Vue 就是使用这一方式,实现了对 Set/Map 的代理

Vue 中的具体实现

在这里展示一部分 Vue3 的源码,主要是 reactive 方法中对 Set/Map 做的特殊处理

展开或修改了一些函数的调用,但逻辑不变

function reactive(target) {
  let proxy // 代理对象
  const type = Object.prototype.toString.call(target) // 获取类签名
  // 对 Set 和 Map 特殊处理
  if (type === '[object Map]' || type === '[object Set]') {
    // 使用 collectionHandlers
    proxy = new Proxy(target, collectionHandlers)
    // 将代理对象设置到全局Map中,我们不具体实现
    proxyMap.set(target, proxy)
  }
  return proxy
}

const collectionHandlers = {
  get(target, key) {
    if (key === '__v_raw') return target // 访问特殊属性,返回原对象
    // 如果是Set/Map的原生方法,返回自己封装的方法
    // 否则返回对象身上的属性
    return Reflect.get(instrumentations.hasOwnProperty(key) ? instrumentations : target, key)
  },
}
// 重写了Set/Map的所有原生方法和属性
const instrumentations = {
  get,
  set,
  add,
  has,
  delete: deleteEntry,
  clear,
  forEach,
  get size() {
    return size(this)
  },
}

instrumentations 中方法的重写代码就不展示了,简单总结一下,感兴趣的自行去查看源码

  • 所有方法都是通过 toRaw(this) 获取了原对象,在其身上尝试调用方法。并且对所有传入的参数也解了代理 rawKey = toRaw(key) ,以确保存入 Set/Map 中的都是原对象。
  • 在执行 get forEach 方法获取数据时,会再次使用 reactive 包装
  • get has forEach size 函数中跟踪依赖(track)
  • set delete clear add 函数中触发扳机(trigger)
  • Vue 还重写了迭代器属性/方法(['keys', 'values', 'entries', Symbol.iterator]),以确保迭代器产生的值都被 reactive 包装记录

最后,Vue 对 Set/Map 代理后的结果是:真正存入的对象都是解代理后的原对象,但想从其中取出对象都会自动代理后再返回

其实重写的很多方法都做了两手准备,对已代理和未代理的参数都尝试执行了一遍,这是为了避免有小可爱先用 Set 存了代理对象,再将其传给 Vue

结语

如果喜欢或有所帮助的话,希望能点赞关注,鼓励一下作者。

如果文章有不正确或存疑的地方,欢迎评论指出。