虽然数组是js中最常用的一种集合类数据类型,但它存在一些问题,比如说,它自身没有提出重复项的方法。

而说到对象,它有一个缺点是,属性名必须是字符串 或者 symbol 这样的数据类型。但我们有的时候,是需要将对象的键设置为像 数组、对象这样的数据类型。

针对这些问题,ES6 中引入 Set、WeakSet、Map、WeakMap 这四种数据结构。

一、Set

Set 是 ES6 中针对上述问题新增的第一种数据类型,它的组成项中没有重复的项,这样,我们就无需手动去重!

1.1 创建 Set

要新建一个 Set ,可以通过 new Set() 这种实例方法进行创建:

var set = new Set();

console.log(set); // Set{}

创建完成后,我们会发现它是类似对象的数据结构,里面没有任何组成项。

当然,你也可以像创建数组一样,使用new Set() 这个方法传入一个数组作为参数,用于实例化 Set 中的项:

var set = new Set([1, 3, 5]);

console.log(set); // Set {1, 3, 5}
console.log(set.size); // 3

通过上面的代码,我们便能看到 Set 中包含了三项。而要读取 Set 中项的个数,则可以使用 size 属性。

前面说过,Set 一个很重要的特点是不包含重复值,如果你像这样创建 Set,可能得不到你预期的结果:

var set = new Set([1, 3, 5, 3, '5', 1, 1]);

console.log(set); // Set {1, 3, 5, "5"}
console.log(set.size); // 4

你可能会觉得它能像数组一样,输出6个组成项。但由于 Set 不能有重复值的特性,所以代码中的 1 和 3 这些重复值分别只会保留一个。

不过要注意的是,在 Set 中不存在隐式转换,因此数字 5 和 字符串 ‘5’ 是两个不同的项。

1.2 项的操作

我们知道,数组中存在着操作数组项的各种方法,包括新增(push),删除(pop),清空(arr.length = 0)。而 Set 也不另外,它也有几乎类似的操作。

1.2.1 增加项

你可以通过以下方法来新增 Set 项:

var set = new Set([1]);

set.add(3);
set.add('a');
set.add('a');

console.log(set); // Set {1, 3, "a"}
console.log(set.add('b')); // Set {1, 3, "a", "b"}
console.log(set.size); // 4

从上面代码中可以看到,该方法返回 Set 本身。对于重复的项,add() 方法会直接忽略添加。

新增的项,不单单只能是基本的数据类型,它也可以为复杂的数据类型,比如数组,对象:

var set = new Set();

set.add([1, 2]);
set.add([1, 2]);
set.add({});
set.add({});

console.log(set); // Set {[1, 2], [1, 2], {}, {}}
console.log(set.size); // 4

但是,需要注意的是,因为数组或对象这样复杂的数据类型都是它们自身实例出来的 对象 (new Array()、new Object()) ,所以 {}{} 是两个不同的东西。

另外,对于一些特殊值,在ES5中可能都是不对等的,但在 Set 中,则都被认为是同一个东西:

var set = new Set();

set.add(true);
set.add(false);
set.add(+0);
set.add(-0);
set.add(NaN);
set.add(NaN);

console.log(set); // Set {true, false, 0, NaN}
console.log(set.size); // 4

1.2.2 删除项

对于删除 Set 中的组成项,我们则可以通过 delete() 方法:

var set = new Set([1, 3, 5]);

set.delete(3);

console.log(set); // Set {1, 5}
console.log(set.delete(1)); // true
console.log(set.delete(0)); // false
console.log(set.size); // 1

该方法返回一个布尔值,如果删除的项存在,则返回 true,反之则返回 false。

1.2.3 清空项

假如一个 Set 中包含了很多项,我们需要快速清空它包含的项,则需要使用 clear() 方法:

var set = new Set([1, 3, 5]);

set.clear();

console.log(set); // Set {}
console.log(set.size); // 0

1.2.4 判断包含

Set 提供了 has() 方法,它的作用是判断某个项是否存在于 Set 中,这对于我们查询对应的项无疑是非常方便的:

var set = new Set([1, 3, 5, 'a']);

console.log(set.has(3)); // true
console.log(set.has(0)); // false

该方法返回一个布尔值。

1.3 Set 中的循环操作

读取 Set 中的每一项,最常见的方法是使用 for of

for (var key of set) {
console.log(key);
}
// 1
// 3

当然,你也可以使用ES5中处理数组的 forEach 方法:

var arr = [1, 3];

arr.forEach(function(value, key) {
console.log(key, value);
});
// 0 1
// 1 3

var set = new Set([1, 3, 5]);

set.forEach(function(value, index) {
console.log(index, value);
});
// 1 1
// 3 3
// 5 5

不过要注意,forEach 对于数组的操作,输出的 key 、val是“键值对”(索引、项的值)。但是 Set 则每次依次都是输出 相同的项。

另外,Set 还提供了其他三个遍历方法,它们分别是:

  • keys() : 返回键名的遍历器
  • values() : 返回键值的遍历器
  • entries() : 返回键值对的遍历器

大体用法如下:

var set = new Set([1, 3]);

for (var key of set.keys()) {
console.log(key);
}
// 1
// 3

for (var key of set.values()) {
console.log(key);
}
// 1
// 3

for (var key of set.entries()) {
console.log(key);
}
// [1, 1]
// [3, 3]

目前为止,Set 中好像没有直接更新项的操作方法。所以,如果你要更新 Set 中的项,必须先把它们转换为数组,再转回 Set 。

1.4 Set 与 数组的互转

Set 和 数组有很多类似的特性,它们各自也有一些不同的方法。我们可以借助它们之间的方法和特性,来完成一些高效的操作。

比如,我们需要翻转 Set 中项的顺序时,可以先把它转换为数组,然后利用数组自身的 reverse() 方法即可:

var set = new Set([1, 3, 5, 'a']);

var arr = [...set]; // 转换为数组

console.log(arr, arr instanceof Array); // [1, 3, 5, "a"] true
console.log(new Set(arr.reverse())); // Set {"a", 5, 3, 1}

又比如,当数组需要去重时,我们可以先将数组转换为 Set,然后再转回 数组。这样就避免了在数组中又循环,又判断。

var arr = [1, 3, 5, 'a', 3, 5, 'a'];

var set = new Set(arr); // 转换为 Set

console.log(set, typeof set); // Set {1, 3, 5, "a"} object
console.log([...set]); // [1, 3, 5, "a"]

二、WeakSet

WeakSet 是相对 Set 来说,从字面上理解是 弱的 Set 数据结构。它的用法与 Set 类似,不过也有几个不同的地方。

首先来说说 Set 的问题,假设有如下代码:

var set = new Set(),
obj = {};

set.add(obj);

console.log(set.size); // 1

obj = null; // 清除引用

console.log([...set][0]); // {}

由上面的代码可以看到,虽然我们原始值设置为 null,以为这样就清除了原始引用,但最后还是打印出了其对应的值,这显然不是我们想要的目的。

类似地,有的时候我们会对一些DOM原始进行事件绑定,绑定完后就在内存中存在着一个引用关系。而如果事后,这些DOM可能会被移除,但引用关系仍然存在,这就造成了内存泄露,这当然不是你想看到的。

为了解决相关问题,ES6 中引入了 WeakSet,它只会以对象的形式进行弱引用,而这种弱引用不会影响垃圾回收机制。

然后是 WeakSet 有着和 Set 相同的方法,比如创建(new WeakSet)、新增项(add)、删除项(delete)、包含项判断(has),但是没有 清空项(clear)的操作:

var weakSet = new WeakSet(),
obj = {},
arr = [1, 3];

weakSet.add(obj);
weakSet.add(arr);
console.log(weakSet); // WeakSet {{}, [1, 3]}

weakSet.delete(obj);
console.log(weakSet); // WeakSet {[1, 3]}

console.log(weakSet.has(obj)); // false
console.log(weakSet.has(arr)); // true

需要注意的是,WeakSet 创建函数的参数,只能是对象,而不能传递 基本数据类型,否者会报错:

var arr = [1, 3],
weakSet = new WeakSet(arr); // Invalid value used in weak set

这里数组中的项是 1、3,它们都是基本数据类型。

你可以把它改为如下形式:

var arr = [[1, 3], {}],
weakSet = new WeakSet(arr);

console.log(weakSet); // WeakSet {[1, 3], {}}

而对于 WeakSet 中项的操作,新增项函数也不能传递 基本数据类型,但 删除项 和 包含项判断 却没有这个要求:

var arr = [[1, 3], {}],
weakSet = new WeakSet(arr);

console.log(weakSet); // WeakSet {[1, 3], {}}
console.log(weakSet.has(1)); // false
console.log(weakSet.delete(1)); // false

weakSet.add(1); // 报错: Invalid value used in weak set

另外,WeakSet 还有一些其他与 Set 的不同地方:

  • WeakSet 没有 size 属性,所以不能获取其中项的个数
  • WeakSet 中的项不能使用 for-of 循环,没有 forEach() 方法,所以不能读取到对应的项

总之一句话,要处理一些弱引用关系时,可以尝试着使用 WeakSet 。

三、Map

前面说了很多关于 Set 的内容,但是你会发现,虽然我们可以对 Set 进行各种操作,但是我们无法改变其中的项,换言之,即不能更新 Set 中现有项的内容。

于是,ES6 中又引入了 Map。

3.1 创建 Map

通过实例化方法 new Map() 来创建 Map:

var map = new Map();

console.log(map); // Map {}

你也可以把一个数组作为参数传入到 new Map() 中,以下代码的结果同等上面的:

var map = new Map([]); // 传入一个空数组

console.log(map); // Map {}

但是,如果传入的数组中的项是非对象,则会抛出错误:

var map = new Map([{}, 3]);

console.log(map); // 错误:Iterator value 3 is not an entry object

其实,说到底,Map 中就是一个键值对的集合。

如果 new Map() 传入的参数为数组,且数组项的值仍然为数组,当这个值(假设为 arrItem)的长度只有一项时,则 Map 项只有键,没有值(为 undefined)。当 arrItem 的长度为两项时,则 Map 项既有键,也有值。当 arrItem 的长度多余两项时,则 Map 项也是既有键,也有值,它们的值分别是 arrItem 中的第一项和第二项。有点晕?没关系,用代码来验证下:

var map1 = new Map([[1], [2]]),
map2 = new Map([['name', 'yix'], ['age', 28], {}]),
map3 = new Map([['name', 'yix', 'job', 'web'], ['age', 28]]);

console.log(map1); // Map {1 => undefined, 2 => undefined}
console.log(map2); // Map {"name" => "yix", "age" => 28, undefined => undefined}
console.log(map3); // Map {"name" => "yix", "age" => 28}

3.2 项的操作

类似 Set,Map 中也有很多关于项操作的方法,比如说,项的新增、项的删除、清空以及判断包含等。

3.2.1 增加项

前面我们知道,Set 是通过 add() 方法来新增项的,而 Map 中的操作则有点差异,它是利用 set(key, value) 的方法新增项的,该方法同时设置了项的值,并且,由于该方法执行后是返回自身,所以我们可以链式调用:

var map = new Map();

map.set('name', 'yix')
.set('age', 28)
.set('job', 'web');

console.log(map); // Map {"name" => "yix", "age" => 28, "job" => "web"}
console.log(map.size); // 3

上面的代码新增了三项,然后通过 size 属性获取了该 Map 的长度。

在过去,对象的键只能是字符串,很容易引发同属性名的键值覆盖的情况。但如今 Map 中可以把对象作为键值,所以,对于值相等的对象,不会导致覆盖,当然你也可以用 symbol :

var map = new Map(),
key1 = {},
key2 = {};

map.set(key1, 'a');
map.set(key2, 'b');

console.log(map.get(key1)); // "a"
console.log(map.get(key2)); // "b"

3.2.2 读取项

Map 中还提供了 get() 方法,用于读取 Map 中包含的额项,但如果读取项的键不存在,则该方法返回 undefined

var map = new Map();

map.set('name', 'yix')
.set('age', 28);

console.log(map.get('name')); // "yix"
console.log(map.get('age')); // 28
console.log(map.get('job')); // undefined

3.2.2 删除项

删除项的操作和 Set 相同,该方法返回一个布尔值,若删除的项存在则返回 true,反之则返回 false:

var map = new Map([['name', 'yix'], ['age', 28]]);

console.log(map); // {"name" => "yix", "age" => 28}

map.delete('name');
console.log(map); // Map {"age" => 28}

map.delete('age');
console.log(map); // Map {}

console.log(map.delete('age')); // false

由于前面删除过 age 项,所以最后一行的删除返回 false。

3.2.3 清空项

清空项的操作也和 Set 相同,它对于迅速清空一个包含多项的 Set 尤为方便,该方法没有返回值:

var map = new Map([['name', 'yix'], ['age', 28]]);

console.log(map); // {"name" => "yix", "age" => 28}

map.clear();
console.log(map); // Map {}

3.2.4 判断包含

判断包含项的操作和 Set 相同,如果对应的项存在,将返回 true,反之则返回 false:

var map = new Map([['name', 'yix'], ['age', 28]]);

console.log(map.has('name')); // true

map.delete('name');

console.log(map.has('name')); // false

3.3 Map 中的循环操作

要循环操作 Set 中的每一项,我们可以通过 forEach() 方法,以下代码首先读取了 Set 中的每一项,然后对每一项的值加上 ‘$$$’ 三个美元符号:

var map = new Map([['name', 'yix'], ['age', 28]]);

map.forEach(function(value, key) {
console.log(key, value);
});
// "name" "yix"
// "age" 28


map.forEach(function(value, key) {
map.set(key, value + '$$$');
});

console.log(map); // Map {"name" => "yix$$$", "age" => "28$$$"}

3.4 Map 与 数组的互转

为了方便数据处理,我们通常会将 Map 和 数组两者相互转化,可以通过以下方式:

var map1 = new Map([['name', 'yix'], ['age', 28]]);

var arr1 = [...map1]; // Map 转 数组

console.log(arr1); // [['name', 'yix'], ['age', 28]]

var map2 = new Map([['name', 'yix'], ['web', 'job']]); // 数组 转 Map

console.log(map2); // Map {"name" => "yix", "web" => "job"}

另外,Map 还提供了其他三个遍历方法,它们分别是:

  • keys() : 返回键名的遍历器
  • values() : 返回键值的遍历器
  • entries() : 返回键值对的遍历器

结合数组转换,我们可以轻松的获取到 Map 中项的键、值:

var map = new Map([['name', 'yix'], ['age', 28]]);

console.log([...map.keys()]); // ["name", "age"]
console.log([...map.values()]); // ["yix", 28]
console.log([...map.entries()]); // [['name', 'yix'], ['age', 28]]

四、WeakMap

ES6 中引入 WeakMap 的原因和 WeakSet 相同,WeakMap 是相对 Map 来说,它的用法和 Map 类似,但 WeakMap 中的键必须是 对象,并且这种对象的引用是弱引用,它不受垃圾回收机制影响。当引用关系不存在时,则 Map 会移除对应的键值对。

WeakMap 有着和 Map 相同的方法,比如创建(new WeakMap)、新增项(set)、删除项(delete)、包含项判断(has),但是没有 清空项(clear)的操作:

var weakMap = new WeakMap(),
obj = {},
arr = [1, 3];

weakMap.set(obj);
weakMap.set(arr, 'ccc');
console.log(weakMap); // WeakMap {{} => undefined, [1, 3] => "ccc"}

weakMap.delete(obj);
console.log(weakMap); // WeakMap {[1, 3] => "ccc"}

console.log(weakMap.has(obj)); // false
console.log(weakMap.has(arr)); // true

另外,WeakMap 还有一些其他与 Map 的不同地方:

  • WeakMap 没有 size 属性,所以不能获取其中项的个数
  • WeakMap 中的项不能使用 forEach() 方法,所以不能读取和操作对应的项

这种弱引用的关系,一般主要用于DOM事件的绑定引用。比如页面有一个按钮 btn,每点击一次,其对应的计数加 1,但当该 btn 元素被移除时,则 btn 元素对应的事件引用也被解除,这样就避免了内存泄漏。也无需手动将 btn 对应的事件设置为 null :

var btn = document.querySelector('.btn');
var weakMap = new WeakMap();

weakMap.set(btn, {count: 0});

btn.addEventListener('click', function() {
var clickTimes = weakMap.get(btn);
clickTimes.count += 1;
}, false);