大多数人没用过的 WeakMap / WeakSet 有啥用

1,871 阅读6分钟

下一篇,打算聊聊绝大多数人都没在代码里写过的 WeakMap WeakSet 上一篇文章 《JavaScript 面试题外的【this】》中的承诺如期而至。


在聊 WeakSet 之前,我们还是先温习一下 Set 这个相对简单的数据结构。

Set对象是值的集合,你可以按照插入的顺序迭代它的元素。 Set中的元素只会出现一次,即 Set 中的元素是唯一的。

它提供了如下的接口:

  • Set.prototype.add 在集合中增加一个元素
  • Set.prototype.delete 在集合中删除一个元素
  • Set.prototype.has 判断一个元素是否存在于集合中
  • Set.prototype.clear 清空集合
  • Set.prototype.entries/forEach/values 以各种不同的方式遍历这个集合

再拎出来 WeakSet 对比一下

WeakSet 对象允许你将弱保持对象存储在一个集合中。

首先,WeakSet 里面的引用是弱引用。对于这点,mdn 内也有详尽的描述。

WeakSet持弱引用:集合中对象的引用为弱引用。 如果没有其他的对WeakSet中对象的引用,那么这些对象会被当成垃圾回收掉。 这也意味着WeakSet中没有存储当前对象的列表。 正因为这样,WeakSet 是不可枚举的。

也正由于不可枚举,所以在 Set 中涉及到遍历的方法在 WeakSet 均不存在。于是, WeakSet 的接口也就设下这么几个

  • WeakSet.prototype.add 在集合中增加一个元素
  • WeakSet.prototype.delete 在集合中删除一个元素
  • WeakSet.prototype.has 判断一个元素是否存在于集合中

adddelete 都是对 WeakSet 内部进行修改的接口,真正有用的接口实质上就只有判断元素是否存在于集合中 —— has 这一个。

这就让 WeakSet 显得很鸡肋。 其实我们用集合类型的数据结构,其实最多的操作还是用来遍历,无论是 Array 还是 Set

我们用 Set 而不用 Array,是因为场景需要一个无序且值唯一集合。而我们本以为 WeakSet 可以用在解决需要一个无序、值为一且弱引用集合的场景。然而结果却大相径庭,这货根本无法遍历,怎么能当作集合来用呢。


如果有心翻 mdn 的话,其实能够看到一个小插曲 —— 一个被废弃的接口 WeakSet.prototype.clear

很明显类似于 Set.prototype.clear 这是一个用来清空的接口,没什么特别的。被废弃了也没啥影响,因为所有你可以用到 clear 的情况,都可以通过 new WeakSet() 以创建一个新的实例的方式进行代替。

但是有意思的是这句

没有规范或草案。该方法原本计划包括在 ECMAScript 6,但是在草案 revision 28 (October 14, 2014) 被抛弃了。浏览器原先的实现不久后也被移除了,它从来不是标准的一分子。

没错,这个接口原本包含在 ES6 中,并且也已经被实现了,却还是被遗弃了。至于原因,其实上面对弱引用的描述已经初现端倪了。

这也意味着WeakSet中没有存储当前对象的列表。

连存储当前对象的列表都没有当然也就没有办法“清空”了。同时也印证了我们之前的判断 —— 这个东西被设计出来根本不是如同 Set 一样,它并不是被当作一个特殊场景下的集合来用的


在继续讨论 WeakSet 到底是用来做啥之前,再讨论一个挺有意思的事情。

有些人看到弱引用,就想到垃圾回收,进而开始研究起 js 引擎来,想到要实现这么厉害的功能,必定是改了很多底层的架构啥的……

然而有趣的是,在 WeakSet 刚出的时候就已经有 polyfill 能够模拟这个功能了。

写几行代码表示一下原理……

export class WeakSet {
    #id = ''

    constructor() {
        this.#id = Symbol()
    }

    add(obj) {
        Object.defineProperty(obj, this.#id, {
            enumable: false,
            configurable: true,
        })
    }

    delete(obj) {
        if (obj.hasOwnProperty(this.#id)) {
            delete obj[this.#id]
        }
    }

    has(obj) {
        return obj.hasOwnProperty(this.#id)
    }
    
    clear(){
        this.#id = Symbol()
    }
}

简而言之,就直接在对象上创建一个无法枚举的属性表示这个元素是否存在于当前 WeakSet 内。

如果是 WeakMap 的话,就直接把对应的值写入到这个属性内就可以了。


这个 polyfill 让事情变得更有意思了。它意味着 WeakMap WeakSet 拥有一个等价的写法。

// 很简单的一个类,拥有一个 val 的私有属性,和对应的 set/get
class Foo {
    #val = null
    
    setVal(v) { this.#val = v }
    getVal(v) { return this.#val }
}

// 通过 WeakMap,讲 val 从对象内部拆分出来,对应的 set/get 也变成了静态方法
class Bar {
    static map = new WeakMap()

    static getVal(obj) {
       if( Bar.map.has(obj)){
           return Bar.map.get(obj)
       }else{
           return null
       }
    }

    static setVal(obj,v){
        Bar.map.set(obj,v)
    }
}

这个等价写法说明我们用到某个 WeakMap 的地方,可以直接把其当作一个属性写到这个对象上面去,反之也意味着对于一个对象上的某个属性,都可以通过 WeakMap 拆分到外部。

而这就是 WeakMap WeakSet 的用途。


值写在对象上,代码又不是不能跑,何必费力气拆分到外部,还要专门整一个特殊的数据结构实现这种写法?

能跑不代表写得好!!!

写业务的时候或许遇到的比较少,其实如果写一些架构模块不难发现这种场景:

我需要对一些对象进行一些拓展,譬如记录一些 log 信息,你可以在原来封装好的类里面加属性,加接口。但带来的麻烦是,类会变得越来越臃肿,你也没法保证其他模块以后不会用到这些东西,影响你这个功能的重构。更何况有些对象来自于第三方库,也没法改。

当然,不去修改类,直接加字段也不是不行,但这种破坏封装性的代码有时候会带来意想不到的情况,比如传来一个被 freeze 的对象。

使用 WeakMap WeakSet,既能不破坏原有代码的封装,又能控制这些数据的可见性,何乐而不为呢?


上一篇文章讨论的广为人知的 this,这一篇文章讨论的鲜为人知的 WeakMap WeakSet

两文讨论的东西大相径庭,其实想表达的意思却是一致的。对于一些语言特性亦或者第三方库,死背接口、甚至于一句一句地去扒源码,并不能让人用好它,理解这些接口背后的设计意图才是关键。

下一篇,打算整点写代码的东西,如果大家觉得本文还有点内容,还请高抬贵手点个赞哈。