ES6之Set和Map

一、Set

Set是ES6新增的一类数据结构。从概念上来说,Set源于数学中的“集合”,不过为了便于使用,它没有遵循“无序性”这一特性。Set原生提供了Iterator遍历器,可以按加入集合的先后顺序依次遍历Set中的每个成员,也就是说Set是有序的。

下面来详细探讨Set的语法特性。

1. Set对象的构造

从实现上来说,Set与数组Array十分接近。两者的区别在于,数组可以存储相同元素,而Set的成员都是唯一的(这一点正是“集合”最重要的特征之一)。

Set最基本的构造方法是使用Set构造函数,然后用Set的原型方法add向其添加成员:

let set = new Set();

set.add(1);
set.add(2);

不过这样构造Set看起来并不便捷。Set允许你用一个数组作为参数来构造一个Set对象,构造函数会自动去掉数组中的重复元素:

let set = new Set([1, 2, 2, 3, 3, 4]);

for(let item of set){
  console.log(item);
}  //依次输出:1 2 3 4

可以看到,以一个数组作为参数去构造Set对象时,重复元素被自动移除,因此在使用for … of循环依次输出Set的每个成员时,得到的是1 2 3 4。

有趣的是,在数组的构造函数上存在一个静态方法Array.from(),它可以将一个可遍历(即实现了Iterator接口)的结构转化为数组,而Set恰好就是一个可遍历结构。因此,对于上面的代码,你可以将set对象重新转为为数组:

let arr = Array.from(set);

arr //[1, 2, 3, 4]

实际上我们发现了一个非常简便的数组去重方法:

let arr = [1, 2, 2, 3, 3, 4];

//对数组进行去重
arr = Array.from(new Set(arr)); //[1, 2, 3, 4]
//或
arr = [...new Set(arr)]

先用数组去构造一个临时的Set对象,再将该集合重新转为数组,即可完成去重。类似的方法还可以用于字符串去重:

[...new Set("ababc")].join(""); //"abc"

实际上,Set的构造方式十分灵活,只要传入一个具有Iterator接口的对象即可。关于遍历器Iterator,请参考前文ES6之遍历器Iterator。因此下面的语句都可以构造一个Set对象:

new Set([1, 2, 3, 3, 4]);  //数组
new Set(arguments);  //函数的arguments参数
new Set(document.querySelectorAll(".className")); //NodeList集合
new Set('ababc');  //字符串
...

如果你为自定义对象实现了遍历器接口,那么该对象同样可以用于Set构造函数:

let author = {
  name: '夕山雨',
  age: 24,
  stature: '179',
  weight: 65,

  [Symbol.iterator](){
    let sort = ['name', 'stature', 'weight', 'age'];
    let index = 0;
    let _this = this;
    return {
      next(){
        return index < 4 ? 
        		{value: _this[sort[index++]], done: false}:
        		{value: undefined, done: true}
      }
    }
  }
}

> new Set(author);  //依次调用遍历器的next,将属性值输出到Set中
< Set(4) {"夕山雨", "179", 65, 24}

Set判断两个成员相等的依据类似于===,但是有一点差别,使用三个等号判断NaN时,会发现NaN === NaN返回false(即两者不相等,NaN在js中表示“不是数字”,运算法则认为两个不是数字的变量不能判定为相等)。但是Set认为两者是相等的,因此不会向集合中插入重复的NaN。另外,Set构造函数会保留undefined和null元素,但是为移除空元素,如:

new Set([undefined, null, , 123]);
//Set(3) {undefined, null, 123},数组的第三个元素为空,因此被移除了

2. Set的操作方法

在介绍Set的操作方法前,简要介绍Set的两个实例属性:

  1. Set.prototype.constructor:构造函数,默认就是Set函数。
  2. Set.prototype.size:返回Set实例的成员总数。
let set = new Set([1, 2, 2, 3]);

set.constructor;  //值为set的构造函数
set.size;  //值为3,即set成员数量

两个属性较为简单,这里不再详述。

Set总共有四个操作方法,用于操作对象自身:

  1. Set.prototype.add(value): 向集合中添加成员,返回Set本身
  2. Set.prototype.delete(value):删除某个成员,返回Boolean值,表示是否删除成功
  3. Set.prototype.has(value):判断Set中是否包含某个值
  4. Set.prototype.clear(): 清空Set,没有返回值

下面的代码可以展示这四个方法的用法:

let set = new Set();

set.add(1).add(2).add(2).add(3);  //{1, 2, 3}
set.delete(2);  //{1, 3}
set.has(2);  //false
set.has(1);  //true
set.clear(); //{}

3. Set的遍历方法

Set原生提供了四种遍历方法可以遍历所有成员,分别是:

  1. Set.prototype.keys():返回键值的遍历器
  2. Set.prototype.values():返回键值的遍历器
  3. Set.prototype.entries():返回键值对的遍历器
  4. Set.prototype.forEach():使用回调函数遍历每个成员

我们通常认为Set结构是没有键的,因此这里keys()和values()的返回值都是成员遍历器(从这里可以看出Set与Array的另一个差别,Array的keys方法返回值遍历的是元素的索引值,即会依次输出索引‘0’,‘1’,‘2’等)。entries()是keys()和values()的组合,而forEach()则类似于数组的forEach方法。举例如下:

let set = new Set([{}, 'blue', 123]);
for (let item of set.keys()) {
  console.log(item);
}
// {}
// blue
// 123

for (let item of set.values()) {
  console.log(item);
}
// {}
// blue
// 123

for (let item of set.entries()) {
  console.log(item);
}
// [{}, {}]
// ['blue', 'blue']
// [123, 123]

set.forEach((value, key) => console.log(key + ' : ' + value));
// {} : {}
// 'blue' : 'blue'
// 123 : 123

对于Set结构没有键这个问题,我更加倾向于另一种理解,那就是Set的成员就是以自身为键(在ES5中,显然这样认为是错的,因为ES5的对象只能以字符串作为键名,这无法解释上面输出key时可以输出对象的行为。但是在ES6中,与Set一同出现的Map结构,就允许以任意数据结构为键,因此我们有理由相信,Set就是一种特殊结构的Map),于是我们可以这样理解Set结构:

let obj = {};
let set = new Set([obj]);

set //{obj: obj},成员和键都是对象obj自身

不过这两种理解对我们使用Set几乎没有影响。

二、WeakSet

WeakSet从字面意思来看就是“弱Set”,它源于Set,具有一些特殊的行为,用于某些特殊的场景。

WeakSet与Set有两点不同。

首先,WeakSet只能用于保存对象,不能保存其他类型的数据。这里说的其他类型是指:undefined、null、Number、String、Symbol、Boolean。除了这些数据,所有直接或间接继承自Object的都算是对象,如Array、RegExp、NodeList、Date、Set、Map等。

其次,WeakSet保存的是对对象的弱引用,不计入垃圾回收机制。如何理解呢?这要先从垃圾回收机制说起。理论上,垃圾回收机制不会回收被变量引用的内存(因为它是可访问的),只有没有变量引用该内存时才会对其进行回收。
在这里插入图片描述
比如:

let a = {};
b = a;

let ws = new WeakSet();
ws.add(a);

此时a、b和ws都保持对这个空对象的引用,他们的内存结构如下:
在这里插入图片描述
对我们来说,a、b和ws都有对该内存的引用,但是由于ws保存的是弱引用,因此对于垃圾回收机制来说,只有a和b保存了对该内存的引用(ws保存的是弱引用,因此用虚线区分)。如果我们像下面一样释放了a和b对该内存的引用:

a = null;
b = null;

那么该内存将被垃圾回收机制释放,等垃圾回收机制运行完毕后,WeakSet中对该内存的引用也会自动失效:
在这里插入图片描述
这就是弱引用的含义,一旦某块内存的引用只剩下弱引用,那么垃圾回收机制就会回收该内存。

由于WeakSet保存的成员不能保证总是存在,为了防止多次遍历的结果不一致,所以ES6规定,WeakSet不具有size属性,也不能遍历。

让我们来归纳一下,WeakSet只能保存对象,而且对所保存的对象是弱引用,并且无法遍历所保存的对象。也就是说,如果你没有对WeakSet某个成员的引用,那你就无法访问它,可如果我们有这个引用,为什么还要通过WeakSet来访问该对象呢?这样一想,WeakSet似乎毫无价值,所以WeakSet到底有什么用呢?

答案就是,维护一组临时对象集合,不过不干涉这组对象的生命周期。举个典型的例子:

let ws = new WeakSet();
class People{
  constructor(){
    //所有用该构造函数构造出的People实例,都临时保存在ws中
    ws.add(this); 
  }

  run(){
    //如果ws中没有保存run的调用者this,那它就不是People实例
    if(!ws.has(this){
      throw new Error("只能在People实例上调用run方法!");
    }) else {
      ...
  }
}

我们用WeakSet类型的变量ws保存了所有由People类构造出的实例。然后每次调用run方法时,我们都先检查this(也就是run的调用者)是否存在与ws中,如果不存在,那么就意味着此时run不是被People实例所调用的,将立即抛出错误。

显然如果用Set替换WeakSet,也可以实现该功能。但是两者有一个重大区别,那就是在Set中保存实例,会影响该实例的释放,而WeakSet不会。

具体来说,假如你现在销毁了某个People实例,但是忘记从Set中清除该实例,Set对该实例的引用会导致其内存无法释放,从而造成内存泄漏,即Set影响到了实例的销毁。想要保证内存正常释放,你必须在销毁实例时从Set中清除该实例,如果项目较为复杂,这会带来很大的隐患。

但如果你使用WeakSet来保存这组实例,就不会造成内存泄漏。因为WeakSet保存的是对这组实例的弱引用,一旦实例被销毁,垃圾回收机制就会忽略WeakSet对它的弱引用而直接释放内存。

三、Map

1. Map的基本原理

ES6在Object的基础上提供了Map结构,两者的差别在于,Object只允许以字符串作为键,而Map则允许任意的数据类型作为键。

只能以字符串作为键给Object带来了很多限制,比如你希望为一组DOM节点对象分别保存一组参数,于是你打算这样写:

let div1 = document.getElementById("div1"),
	div2 = document.getElementById("div2"),
	div3 = document.getElementById("div3"),

let data = {
  [div1]: { ... },
  [div2]: { ... },
  [div3]: { ... }
}

你可能认为自己以DOM节点对象为主键为data对象添加了三个属性,但实际上data上只有一个实例属性,即:

> data
< {"[object HTMLDivElement]": {...}}
//DOM对象被转成了字符串"[object HTMLDivElement]"

由于Object不支持字符串以外的数据作为主键,因此你的三个“对象键”都被浏览器默认转化为字符串:“[object HTMLDivElement]”。

这完全不是我们想要的。为了实现我们的目标,必须改变一下数据结构:

let data = [
  {target: div1, params: { ... }},
  {target: div2, params: { ... }},
  {target: div3, params: { ... }}
]

我们人为地把一个简单的对象处理成嵌套结构来解决上述问题,这完全没有优雅可言。而Map就是来帮助我们解决这个问题的。

对于上面的例子,可以写成下面的形式:

let data = new Map();
data.set(div1, { ... });
data.set(div2, { ... });
data.set(div3, { ... });

//或者简写为
let data = new Map([
  [div1, { ... }],
  [div2, { ... }],
  [div3, { ... }]
])

此时我们才是真正以div1、div2和div3这三个对象作为键。如果你想获取对象div1对应的参数,可以用data.get(div1)。

Map把Object的“字符串 – 值”的结构升级为“值 – 值”的更加完善的Hash结构,你现在可以用任意的数据结构作为键来使用了。

注意,在使用对象作为键时,只有对象地址完全一致才被认为是同一个键。如:

let a = {},
    b = {};
let map = new Map([
  [a, 1],
  [b, 2]
]);

map.get(a) // 1
map.get(b) // 2
map.get({}) // undefined

虽然a和b的值都是{},但是因为{} !== {},所以在map中,a和b代表的是两个键。这点与Object是不同的:

let a = 'name',
    b = 'name'
let obj = {
    [a]: 1,
    [b]: 2
}

> obj
< {name: 2}

因为’name’ === ‘name’,所以最终obj只有一个实例属性’name’。

2. Map的操作方法

与Set一样,Map也有一个size属性,返回Map中的成员数量,不再详述。

Map的操作方法与Set也是大致相同的,不同的是,Map没有add方法,取而代之的是set和get两个存取方法。下面是Map支持的5个操作方法:

  1. Set.prototype.set(key, value): 添加或更新键值对
  2. Set.prototype.get(key): 获取某个键的值
  3. Set.prototype.delete(key):删除某个成员,返回Boolean值,表示是否删除成功
  4. Set.prototype.has(key):判断Map是否存在某个key
  5. Set.prototype.clear(): 清空Map,没有返回值

set和get的用法前面例子中已经提到了,delete和has分别删除某个键和判断某个键是否存在,clear则是清空Map,这几个方法都较为简单,不再详述。

3. Map的遍历方法

Map同样有四个遍历方法:

  1. Set.prototype.keys():返回键名的遍历器
  2. Set.prototype.values():返回键值的遍历器
  3. Set.prototype.entries():返回键值对的遍历器
  4. Set.prototype.forEach():使用回调函数遍历每个成员

不同于Set,Map的每个成员都是键值对的组合,因此它的keys方法返回键名,values方法返回键值,entries返回键值对,forEach把键值对传入回调函数进行调用,用法参考Set,不再赘述。

4. Map与其他数据结构的转换

(1) Map转为数组

每个Map对象都可以转为一个二维数组:

const myMap = new Map()
  .set(true, 7)
  .set({foo: 3}, ['abc']);
[...myMap]
// [ [ true, 7 ], [ { foo: 3 }, [ 'abc' ] ] ]
(2) 数组转为Map

将一个二维数组传入Map构造函数即可创建一个Map对象:

new Map([
  [true, 7],
  [{foo: 3}, ['abc']]
])
// Map {
//   true => 7,
//   Object {foo: 3} => ['abc']
// }
(3) Map转为对象

想要正确转为对应的对象,Map的键必须都是字符串,此时可以手动处理:

const map = const myMap = new Map()
  .set('yes', true)
  .set('no', false);

const obj = {};
for(let [k, v] of map){
  obj[k] = v
}
// { yes: true, no: false }
(4) 对象转为Map

这个很简单,遍历对象属性即可:

const obj = { name: "123" }

const map = new Map();
for(let k of Object.keys(obj)){
  map.set(k, obj[k])
}
(5) Map转为JSON

根据Map的结构,可以转化为对象JSON,也可以转化为数组JSON。如果Map的键全部都是字符串,可以按转化为对象的方式转为对象JSON;如果存在非字符串键,则只能按照转为数组的方式转为数组字符串。

具体转换方式分别参考Map转为对象和Map转为数组。

(6) JSON转为Map

一般情况下,经过这种转换,会得到一个所有键都是字符串的Map对象,类似于将对象转换为Map。但是如果该JSON是类似二维数组的形式,可以传入Map构造函数,直接创建一个标准的Map对象。

具体转换方式参考上述实现。

四、WeakMap

与WeakSet类似,Map也有对应的WeakMap。WeakMap与Map的差别也类似于WeakSet和Set,它们有以下两点差别。

首先,WeakMap只能以对象作为键,不能以其他数据结构作为键。其次,WeakMap对键的引用也是弱引用,不会计入垃圾回收机制。因此不用担心因为使用了某个对象作为WeakMap的键而导致该对象无法释放。

同WeakSet,WeakMap也不允许遍历成员或键,但相对于WeakSet来说,它的用途会更广泛一些。因为WeakMap本身是键值对的结构,所以只要提供键,就可以访问它对应的值。

比如一个很常见的例子是,我们以DOM节点为键,保存与该DOM节点相关的数据。假如使用Map来实现,那么在释放该DOM节点时,必须同时从Map中移除这个键,否则垃圾回收机制就无法回收该节点,这样会导致内存泄漏,WeakMap则不会有这个问题。看下面的例子:

let btn = document.getElementById("btn");
//这样写在释放btn时,必须手动从map中移除btn
// let map = new Map().set(btn, {clickCount: 0})
//这样写就可以安心地释放btn,WeakMap不会导致内存泄漏
let wm = new WeakMap().set(btn, {clickCount: 0});

btn.addEventListener('click', function(){
  if(wm.has(this)){
    let param = wm.get(btn);
    param.clickCount++;
  }
})

这样就可以在wm中记录节点btn被点击的次数,并且当该节点被释放时,不需要手动从WeakMap中手动去除btn这个键,因为WeakMap不计入垃圾回收机制。

当你需要为一组对象保存参数,但不想因为保存参数时引用了这些对象导致内存泄漏,就可以使用WeakMap了。

总结

Set:保存一组互异的值,成员是有序的,可遍历
WeakSet:源自Set,只能保存对象,不计入垃圾回收,不可遍历
Map:将Object的“字符串–值”结构升级为“值–值”,成员是有序的,可遍历
WeakMap:源自Map,键必须是对象,不计入垃圾回收,不可遍历

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值