Set是ES6新增的一类数据结构。从概念上来说,Set源于数学中的“集合”,不过为了便于使用,它没有遵循“无序性”这一特性。Set原生提供了Iterator遍历器,可以按加入集合的先后顺序依次遍历Set中的每个成员,也就是说Set是有序的。
下面来详细探讨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},数组的第三个元素为空,因此被移除了
在介绍Set的操作方法前,简要介绍Set的两个实例属性:
let set = new Set([1, 2, 2, 3]);
set.constructor; //值为set的构造函数
set.size; //值为3,即set成员数量
两个属性较为简单,这里不再详述。
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(); //{}
Set原生提供了四种遍历方法可以遍历所有成员,分别是:
我们通常认为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从字面意思来看就是“弱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对它的弱引用而直接释放内存。
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();
Map.set(div1, { ... });
Map.set(div2, { ... });
Map.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’。
与Set一样,Map也有一个size属性,返回Map中的成员数量,不再详述。
Map的操作方法与Set也是大致相同的,不同的是,Map没有add方法,取而代之的是set和get两个存取方法。下面是Map支持的5个操作方法:
set和get的用法前面例子中已经提到了,delete和has分别删除某个键和判断某个键是否存在,clear则是清空Map,这几个方法都较为简单,不再详述。
Map同样有四个遍历方法:
不同于Set,Map的每个成员都是键值对的组合,因此它的keys方法返回键名,values方法返回键值,entries返回键值对,forEach把键值对传入回调函数进行调用,用法参考Set,不再赘述。
每个Map对象都可以转为一个二维数组:
const myMap = new Map()
.set(true, 7)
.set({foo: 3}, ['abc']);
[...myMap]
// [ [ true, 7 ], [ { foo: 3 }, [ 'abc' ] ] ]
将一个二维数组传入Map构造函数即可创建一个Map对象:
new Map([
[true, 7],
[{foo: 3}, ['abc']]
])
// Map {
// true => 7,
// Object {foo: 3} => ['abc']
// }
想要正确转为对应的对象,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 }
这个很简单,遍历对象属性即可:
const obj = { name: "123" }
const map = new Map();
for(let k of Object.keys(obj)){
map.set(k, obj[k])
}
根据Map的结构,可以转化为对象JSON,也可以转化为数组JSON。如果Map的键全部都是字符串,可以按转化为对象的方式转为对象JSON;如果存在非字符串键,则只能按照转为数组的方式转为数组字符串。
具体转换方式分别参考Map转为对象和Map转为数组。
一般情况下,经过这种转换,会得到一个所有键都是字符串的Map对象,类似于将对象转换为Map。但是如果该JSON是类似二维数组的形式,可以传入Map构造函数,直接创建一个标准的Map对象。
具体转换方式参考上述实现。
与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,键必须是对象,不计入垃圾回收,不可遍历