为什么要有 Set 和 Map ?
有了对象,为什么还需要 Set 集合与 Map 映射呢?因为使用对象有时候会带来一些问题:
1)比如执行 if(obj.num)
,预期行为是只要存在 num
就能通过 if 判断,但如果 obj.num = 0
,那么也无法继续执行,即使此时 num
确实是存在的。
2)另外,如果使用对象,那么 key 必须是 Symbol 或者字符串,非字符串类型的 key 也会通过 toString()
被转换成字符串,这意味着 obj[5]
与 obj['5']
没有区别,尽管我们本意是想创建两个不同的 key;甚至,当key 是对象的时候,不管我们操作的是 obj[{a:1}]
还是 obj[{b:2}]
,实际操作的都是 obj['[object Object]']
,这是因为对象会被转换成字符串 '[object Object]'
,这些都是与我们的预期不符合的。
因此,ES6 新增了 Set 和 Map。
Set
Set 是一个不包含重复元素的集合,元素可以是任意数据类型
1)创建
调用 new Set()
可以创建一个空的 Set 集合,之后通过 add()
添加元素
let set = new Set()
set.add('one')
set.add('two')
set.size // 2
另外,也可以在创建 Set 集合的时候进行初始化。只需要传入一个可迭代对象 —— 比如数组,数组中包含 Set 集合的元素
let set = new Set([1,2,2,3,4])
set.size // 4
2)如何确保元素不重复:零值相等算法
Set 中不能包含重复的元素,这点是怎么做到的呢?实际上,每次添加元素的时候,都会调用 “零值相等算法” 判断新增元素是否和已有元素重复,若不重复则可以加入。
“零值相等算法” 和 Object.is()
所使用的 “同值相等算法” 很像,所以它认为 '5'
和 5
不相等,{}
和 {}
不相等(引用不同),NaN
和 NaN
相等 …… 唯一的区别是,它认为 +0 和 -0 是相等的,这意味着一个 Set 无法同时存在 +0 和 -0。
let set = new Set()
set.add('5'); set.add(5)
console.log(set) // Set{'5',5}
set.add({}); set.add({})
console.log(set) // Set{'5',5,{...},{...}}
set.add(NaN); set.add(NaN)
console.log(set) // Set{'5',5,{...},{...},NaN}
set.add(+0); set.add(-0)
console.log(set) // Set{'5',5,{...},{...},NaN,+0}
3)相关操作
可以用 size
获取 Set 的元素个数,用 has()
检测是否存在某个元素,用 delete()
移除指定元素,用 clear()
清空整个 Set 集合。
因为初始化 Set 的时候可以传入一个数组,而对 Set 使用 ...
展开运算符,又可以将其转化为数组,所以 Set 和数组是可以互相转化的。这有什么用呢?由于 Set 不能包含重复元素,所以其实可以利用这一点来实现数组去重:
let arr = [1,2,1,3,2,4,5,4]
let _arr = [...new Set(arr)]
console.log(_arr) [1,2,3,4,5]
但仍然需要注意的是,由于 Set 采用的是零值相等算法,所以如果数组中有 +0 和 -0,即使这是两个不同的数字,也会被认为是同一个,去重后只剩下 0。
4)迭代
Set 并没有 key,无法像数组、对象或者 Map 那样通过指定 key 访问对应的 value。要访问 Set 的元素,只能一个一个迭代。迭代的方式主要有 forEach
和 for……of
。
let set = new Set(['a','b'])
// 方式一
set.forEach((value,key) => {
console.log(key,value)
})
// 方式二
for(let i of set){
console.log(i)
}
// 方式三
for(let i of set[Symbol.iterator]()){
console.log(i)
}
// 方式四
for(let value of set.values()){
console.log(i)
}
// 方式五
for(let key of set.keys()){
console.log(i)
}
// 方式六
for(let i of set.entries()){
console.log(i)
}
虽然可以遍历出 key,但这里的 key 其实是 value。
WeakSet
如果 Set 中的元素为对象,那么这个对象使用的是强引用,即使在 Set 外面通过 obj = null
释放了对象,由于 Set 内还有对该对象的强引用,也会导致该对象无法被回收。
而如果使用的是 WeakSet,那么集合中的元素为对象时,这个对象使用的是弱引用,它并不会妨碍垃圾回收机制 —— 只要在 WeakSet 外面释放了对象,那么对象就一定会被回收,因此不存在内存泄露的问题。
此外,WeakSet 还有一些特点:
- 不可以存储原始值,否则报错
- 不可迭代,所以不能使用
forEach()
,clear()
- 不支持
size
属性 - 不暴露诸如
keys()
,values()
等迭代器
应用场景
WeakSet 的一个应用场景是给 DOM 节点打上 tag,或者说进行分类。比如现在有一些按钮是禁用的,那么我们可以把它们集中归类到一个 Set 中:
let btns = document.querySelectorAll('.button_disable')
let disabledBtns = new Set(btns)
但是,假如这些按钮节点一段时间后会从 DOM 树中移除,那么由于 Set 中还有它们的强引用,就会导致它们占用的内存没有被释放,出现内存泄露问题。
而如果改用 WeakSet,那么只要按钮 DOM 被移除,就不存在任何强引用了,它们的内存自然就会被释放,WeakSet 中保存的只是弱引用,不会妨碍 GC 机制。
Map
Map 是一个包含多组键值对的映射,并且键名和键值支持所有的数据类型。
1)创建和读写
调用 new Map()
可以创建一个空的 Map 映射,之后通过 map.set(key,value)
添加键值对,map.get(key)
访问指定键名的键值。
let map = new Map()
let obj = {}
// 写
map.set('name','Jack')
map.set(obj,'I am object') // Map 的键名可以是对象
// 读
map.get('name') // 'Jack'
map.get(obj) // 'I am object'
map.get('unexisted key') // 访问不存在的键,返回 undefined
另外,也可以在创建 Map 的时候进行初始化。只需要传入一个数组,传入的数组的每个元素也是一个数组,这些数组中存放着键值对:
let map = new Map([
['name','Jack'],
['age',12]
])
map.get('name') // 'Jack'
map.get('age') // 12
2)相关操作
除了 get(key)
和 set(key,value)
方法之外,Map 还有 has(key)
,delete(key)
,clear()
,size
(返回键值对对数)等方法和属性。
3)迭代
虽然 Map 有 key,但是不能像对象或者数组那样使用 for……in
迭代。通常的迭代方式是使用 for……of
和 forEach
。
// 遍历 key 和 value
map.forEach((value,key) => {
console.log(key,value) // name Jack,age 12
})
// 遍历 key 和 value
for(let pair of map.entries()){
console.log(pair) // ['name','Jack'],['age',12]
}
// 遍历 key
for(let key of map.keys()){
console.log(key) // name,age
}
// 遍历 value
for(let value of map.values()){
console.log(value) // 'Jack',12
}
PS:map.entries()
等同于 map[Symbol.iterator]()
,会返回 map 的迭代器。另外,直接 for(let pair of map)
也是可以的。
WeakMap
类似的,Map 也有弱引用版本 —— WeakMap。WeakMap 最大的特点在于,它的键名必须是对象,且保存着对象的弱引用。也就是说,它不会妨碍垃圾回收机制回收该对象。
比如说下面这段代码:
let obj = {}
new Map().set(obj,1)
obj = null
如果使用的是 map,那么 map 内外各有一个关于 obj
的强引用,即使执行了 obj = null
,由于 map 内部仍然有对于 obj
的强引用,也会导致 obj
没有被回收;但是,如果使用的是 weakmap,那么执行了 obj = null
之后,obj
就被回收了(而且键值对也会被移除),因为 map 对于 obj
的引用是一个弱引用,不会妨碍垃圾回收机制的判定。
应用场景
(1)DOM 对象关联元数据
有时候,我们确实需要用到 DOM 对象的引用去关联一些元数据,但是又不想后期费心地去释放这个对象的引用。那么这个时候使用 WeakMap 就非常合适了。比如下面这个例子:
const btn = document.querySelector('button')
new WeakMap().set(btn,{disable: true})
如果使用的是 map,那么当我们手动移除按钮 DOM 节点的时候,还得回过头来把 map 中对于 btn
的强引用也删除,否则无法真的释放 DOM 节点的内存;但是如果使用的是 weakmap,那么只要手动移除 DOM 节点就够了,weakmap 中对于 btn
的引用根本不需要去管它,因为它是一个弱引用,不会妨碍垃圾回收的判定。
(2)实现私有变量
此外,WeakMap 也可以用来实现实例的私有变量。这里的私有变量应该具备两个特点:
- 无法从外部直接访问和修改,只能通过暴露的方法进行访问
- 一旦实例销毁,则它的私有变量也一并销毁,从而确保信息的私有性
实现的代码如下:
let Person = (function(){
let privateData = new WeakMap()
function Person(name,age){
privateData.set(this,{name:name,age:age})
}
Person.prototype.getName = function(){
return privateData.get(this).name
}
Person.prototype.getAge = function(){
return privateData.get(this).age
}
return Person
})()
let p = new Person('Jack',12)
p.getName() // 'Jack'
p.getAge() // 12
在上面的这段代码中,我们用一个 WeakMap 维护多个映射关系,其中,key 为实例,value 为该实例的私有变量。每次创建新实例的时候,都会往 WeakMap 中添加新的“映射条目”。
1)由于我们使用的是 WeakMap,所以只要在外围移除实例的强引用,就一定可以确保实例被回收,同时,与之关联的私有变量也会被销毁。
2)WeakMap 和 Person 构造函数一起被封装到了一个闭包函数中,因此我们无法在外面通过 WeakMap 直接访问它保存的私有变量,只有通过内部暴露出来的 getName
和 getAge
方法,才能访问私有变量。
此外,WeakMap 还有一些特点:
- 不支持
size
属性 - 不支持迭代 —— 由于弱引用的 key-value 随时会被销毁,所以没有必要提供可以迭代的能力
- 不支持
forEach()
和clear()
—— 因为不支持迭代,所以也不支持这两个方法
WeakRef
WeakSet 中的元素和 WeakMap 中的 key 都保持着对象的弱引用, 那么有没有一种更加直接的方式可以创建某个对象的弱引用呢?那就是使用 WeakRef。
new WeakRef(obj)
返回一个 WeakRef 实例对象,对象中包含对于传入 obj
的弱引用:
let obj = {a:1}
let wr = new WeakRef(obj)
通过 deref()
方法,可以获取弱引用指向的实际对象(强引用):
console.log(wr.deref() === obj) // true
可以为同一个对象创建多个弱引用,这些弱引用的行为保持一致,不会在调用 deref()
的时候有任何差异化表现:
let wr2 = new WeakRef(obj)
console.log(wr.deref() === wr2.deref()) // true
应该尽量避免使用 WeakRef。因为不同浏览器引擎采用的 GC 机制不同,可能很复杂且不可预测,依赖于 GC 去清除 WeakRef 可能会导致意想不到的结果。