ES6 之前,我们如果想保存数据,一般都是放到数组或对象中。ES6 开始,新增了 Set、WeakSet 与 Map、WeakMap 这 4 种可以存储数据的数据结构。本篇文章就先来介绍下 Set 与 WeakSet。
Set
Set 对象是值的集合,并且这些值是不会重复的,之前写过一篇文章介绍了什么是集合,并基于对象实现了简单的 Set 。
创建
使用 Set 需要先通过 new 创建出一个 Set 对象:
javascript
// 例 1.1
const set = new Set()
方法和属性
- 方法
往 Set 对象添加元素使用的是 add 方法,比如我们往例 1.1 创建的 Set 对象中添加一个数字 1:
javascript
// 例 1.2
set.add(1)
因为 Set 中的元素只会出现一次,所以如果我们继续添加一个 1,set 中依旧只有一个 1。但是,如果我们添加的不是基本数据类型,而是引用类型的,比如一个对象:
javascript
// 例 1.2.1
set.add({})
set.add({})
那么添加两次,在 set 中会有两个 {}
,因为在内存中这两个空对象的地址是不一样的。而如果是下面这种情况,那么 Set 中将只有一个空对象,原因也很简单 ------ 它们在内存中的地址是一样的:
javascript
// 例 1.2.2
const obj = {}
set.add(obj)
set.add(obj)
除了 add 方法,Set 还有 clear()
、delete()
、entries()
、has()
、keys()
和 values()
等实例方法,都比较简单,基本看名字就知道是干啥用的,具体可以直接参看 MDN 文档,不再赘述。如果要遍历 Set 对象,可以使用 forEach()
或 for..of
。
- 属性
Set 有一个实例属性 size,返回 Set 对象中元素的个数:
javascript
// 例 1.2.3
const arr = [1, 2, 3, 1, 4, 5, 1]
const set = new Set(arr)
console.log(set.size) // 5
注意这里我们是直接在 new Set()
的时候,传入可迭代对象 arr 作为参数,该对象中所有迭代值都会被加入到生成的 set 中
应用
在一些涉及到数组去重的面试题里,用 Set 能够快速的实现效果(至于真实项目可以使用 lodash):
javascript
// 例 1.3
const arr = [1, 2, 3, 1, 4, 5, 1]
const set = new Set(arr)
const newArr = [...set]
例 1.3 中,先在第 2 行直接传入 arr 生成 Set 对象 set,然后我们再利用展开语法通过 set 构造出不带重复元素的数组 newArr,因为 set 也是可迭代对象,满足数组中使用展开语法的要求。
WeakSet
WeakSet 是一种与 Set 类似的数据结构,内部存储的元素也不能重复。其与 Set 的区别主要有 2 点:
- WeakSet 只能存储对象类型的数据,而 Set 则既可以存储基本数据类型,又可以存储对象;
- WeakSet 对存储的对象都是弱引用 ,意思是如果某个对象没有在其它地方被引用,就会被 GC 处理回收。但是存储在 Set 中的对象都是强引用,只要从 GC Roots (根节点,可以看成是 VE)出发能找到 Set 对象,那么存储在其中的对象就不会被当成垃圾回收。
弱引用
这里对弱引用相关的知识做点补充。在例 2.1 中,我们将对象 { name: 'Jay' }
的引用 obj1 直接赋值给另一个变量 obj2,那么即使在后面我们将 obj1 指向了 null,打印 obj2.name
得到的依然是 Jay
。因为 obj1 和 obj2 对于 { name: 'Jay' }
的引用都是强引用,只要二者存其一,那么内存中的 { name: 'Jay' }
对象就不会被 GC 回收:
javascript
// 例 2.1
let obj1 = { name: 'Jay' }
const obj2 = obj1
obj1 = null
console.log(obj2.name) // Jay
WeakRef
在 ES12(ES2021)中,新增了 WeakRef 对象,它包含了对对象的弱引用。使用方式如下:
javascript
// 例 2.2
let obj1 = { name: 'Jay' }
const obj2 = new WeakRef(obj1)
想获取 WeakRef 对象所绑定的 target 对象的属性,比如例 2.2 中的 obj2.name
,是无法直接打印获取的,需要借助 WeakRef 的实例方法 deref()
先获取到 { name: 'Jay' }
,再去获取属性:
javascript
// 例 2.2.1
console.log(obj2.deref().name) // Jay
如此,obj2 对 { name: 'Jay' }
的引用就为弱引用,如果我们在之后也将 obj1 赋值为了 null,那么内存中 { name: 'Jay' }
对象就会在某个时刻被 GC 回收,之后再想查看 obj2 的 name 属性,得到的会是 undefined。 注意:WeakRef 与下面介绍的 FinalizationRegistry 相关内容比较新,所以只有比较新的版本的浏览器或 Node.js 才支持。
FinalizationRegistry
想要验证的话可以利用同为 ES12 的新对象 FinalizationRegistry ------ 可以让我们在某个对象被垃圾回收时请求一个回调:
javascript
// 例 2.2.2
let obj1 = { name: 'Jay' }
const obj2 = new WeakRef(obj1)
const registry = new FinalizationRegistry(heldValue => {
console.log(heldValue + '被销毁了') // obj1被销毁了
console.log(obj2.deref()?.name) // undefined
})
registry.register(obj1, 'obj1')
obj1 = null
例 2.2.2 中,我们通过 new FinalizationRegistry()
的方式构建了一个 FinalizationRegistry 对象 registry,在构建时可以传入一个回调函数 ------ 当在注册表中注册的对象被回收时 ------ 就会被调用。第 8 行代码所做的就是通过 FinalizationRegistry 对象的实例方法 register()
往注册表中注册对象 obj1。注册表中是可以注册多个对象的,为了方便知道是哪个对象被销毁从而触发了回调,可以在 register()
传入第二个参数('obj1')作为标识,该值可以在回调中获取(heldValue)。如此,当第 9 行 obj1 指向 null 后,由于 obj2 的引用是个弱引用,所以{ name: 'Jay' }
对象会被回收,obj2.deref() 为 undefined。因为第 6 行用到了 ES11 的 ?.
可选链操作符,故而打印结果为表达式 obj2.deref()?.name
的短路返回值 undefined。
方法与遍历
WeakSet 没有 size
属性,实例方法也没有 Set 那么多,只有 add()
、delete()
和 has()
。而且由于 WeakSet 只是对对象的弱引用,所以不能使用 forEach()
或 for..of
遍历获取其中的对象,以免这些对象不能正常的销毁。比如打印查看 WeakSet 对象会发现结果为 { <items unknown> }
,因为打印需要先遍历:
javascript
// 例 2.2.3
let obj1 = { name: 'Jay' }
const ws = new WeakSet()
ws.add(obj1)
console.log(ws) // WeakSet { <items unknown> }
应用
平常开发中没用到过 WeakSet,所以它的应用场景我就举个案例来说明。比如我们有个 Singer 类,并且有个实例方法 sing,如果我们只想让 sing 是被 Singer 的实例对象直接调用,也就是保证 this 的指向只能是 Singer 的实例,而无法通过诸如 apply、call 或 bind 之类的方法改变,那么就可以如下例 2.3 那样,在 constructor 方法中将 this 保存到一个 WeakSet 对象中,然后在调用 sing 时进行判断:
javascript
// 例 2.3
const ws = new WeakSet()
class Singer {
constructor() {
ws.add(this)
}
sing() {
if (!ws.has(this)) {
throw new Error('this 必须指向 Singer 实例')
}
console.log('是谁在唱歌', this)
}
}
const singer = new Singer()
singer.sing.apply() // Error: this 必须指向 Singer 实例
这里使用 WeakSet 的好处在于添加到其中的对象都是弱引用,那么如果后续代码中将 singer 赋值为 null 了,GC 就可以正确的将其清除。