为什么要有 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 不相等,{}{} 不相等(引用不同),NaNNaN 相等 …… 唯一的区别是,它认为 +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 的元素,只能一个一个迭代。迭代的方式主要有 forEachfor……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……offorEach

// 遍历 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 直接访问它保存的私有变量,只有通过内部暴露出来的 getNamegetAge 方法,才能访问私有变量。

此外,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 可能会导致意想不到的结果。