ES6 的 Set

ES6 新增了 Set 类型,这是一种无重复值的有序列表。Set 允许对它包含的数据进行快速访问,从而增加了一个追踪离散值的更有效方式。

创建 Set 并添加项目

Set 使用 new Set() 来创建,而调用 add() 方法就能向 Set 中添加项目,检查 size 属性还能查看其中包含有多少项:

let set = new Set();
set.add(5);
set.add("5");

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

Set 不会使用强制类型转换来判断值是否重复。这意味着 Set 可以同时包含数值 5 与字符串 "5",将它们都作为相对独立的项(在 Set 内部的比较使用了第四章讨论过的 Object.is() 方法,来判断两个值是否相等,唯一的例外是 +0-0Set 中被判断为是相等的)。你还可以向 Set 添加多个对象,它们不会被合并为同一项:

let set = new Set(),
    key1 = {},
    key2 = {};

set.add(key1);
set.add(key2);

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

由于 key1key2 并不会被转换为字符串,所以它们在这个 Set 内部被认为是两个不同的项(记住:如果它们被转换为字符串,那么都会等于 "[object Object]")。

如果 add() 方法用相同值进行了多次调用,那么在第一次之后的调用实际上会被忽略:

let set = new Set();
set.add(5);
set.add("5");
set.add(5);     // duplicate - this is ignored

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

你可以使用数组来初始化一个 Set,并且 Set 构造器会确保不重复地使用这些值。例如:

let set = new Set([1, 2, 3, 4, 5, 5, 5, 5]);
console.log(set.size);    // 5

在此例中,带有重复值的数组被用来初始化这个 Set。虽然数值 5 在数组中出现了四次,但 Set 中却只有一个 5。若要把已存在的代码或 JSON 结构转换为 Set 来使用,这种特性会让转换更轻松。

Set 构造器实际上可以接收任意可迭代对象作为参数。能使用数组是因为它们默认就是可迭代的,SetMap 也是一样。Set 构造器会使用迭代器来提取参数中的值。(可迭代对象与迭代器详见第八章)

你可以使用 has() 方法来测试某个值是否存在于 Set 中,就像这样:

let set = new Set();
set.add(5);
set.add("5");

console.log(set.has(5));    // true
console.log(set.has(6));    // false

此处的 Set 不包含 6 这个值,因此 set.has(6) 会返回 false

移除值

也可以从 Set 中将值移除。你可以使用 delete() 方法来移除单个值,或调用 clear() 方法来将所有值从 Set 中移除。以下代码展示了二者的作用:

let set = new Set();
set.add(5);
set.add("5");

console.log(set.has(5));    // true

set.delete(5);

console.log(set.has(5));    // false
console.log(set.size);      // 1

set.clear();

console.log(set.has("5"));  // false
console.log(set.size);      // 0

在调用 delete() 之后,只有 5 被移走;而执行 clear() 方法后,set 就被清空了。所有这些方法都提供了一个非常简单的机制来追踪有序的唯一值。不过,在给 Set 添加项之后,要如何对每个项执行一些操作呢?此时 forEcah() 方法就派上用场了。

Set 上的 forEach() 方法

若你曾处理过数组,可能就已经熟悉了 forEach() 方法。ES5 给数组添加了 forEach() 方法,使得更易处理数组中的每一项,而无须建立 for 循环。该方法被开发者普遍使用,于是 Set 类型也添加了相同方法,其工作方式也一样。

forEach() 方法会被传递一个回调函数,该回调接受三个参数:

  1. Set 中下个位置的值;

  2. 与第一个参数相同的值;

  3. 目标 Set 自身。

Set 版本的 forEach() 方法与数组版本有个奇怪差异:前者传给回调函数的第一个与第二个参数是相同的。虽然看起来像是错误,但这种行为却有个正当理由。

具有 forEach() 方法的其他对象(即数组与 Map)都会给回调函数传递三个参数,前两个参数都分别是下个位置的值与键(给数组使用的键是数值索引)。

然而 Set 却没有键。ES6 标准的制定者本可以将 Set 版本的 forEach() 方法的回调函数设定为只接受两个参数,但这会让它不同于另外两个版本的方法。不过他们找到了一种方式让这些回调函数保持参数相同:将 Set 中的每一项同时认定为键与值。于是为了让 SetforEach() 方法与数组及 Map 版本的保持一致,该回调函数的前两个参数就始终相同了。

除了参数特点的差异外,在 Set 上使用 forEach() 方法与在数组上基本相同。这里有些代码展示了该方法如何工作:

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

set.forEach(function(value, key, ownerSet) {
    console.log(key + " " + value);
    console.log(ownerSet === set);
});

此代码在 Set 的每一项上进行迭代,并对传递给 forEach() 的回调函数的值进行了输出。回调函数每次执行时,keyvalue 总是相同的,同时 ownerSet 也始终等于 set。此代码输出:

1 1
true
2 2
true

与使用数组相同,如果想在回调函数中使用 this,你可以给 forEach() 传入一个 this 值作为第二个参数:

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

let processor = {
    output(value) {
        console.log(value);
    },
    process(dataSet) {
        dataSet.forEach(function(value) {
            this.output(value);
        }, this);
    }
};

processor.process(set);

本例中 processor.process() 方法在 Set 上调用了 forEach(),并传递了当前 this 作为回调函数的 this 值。这个传递十分必要,这样 this.output() 就能正确地解析到 processor.output() 方法。此处 forEach() 的回调函数仅使用了第一个参数 value,其余参数则被省略了。你也可以使用箭头函数来达到相同效果,而无须传入第二个参数,就像这样:

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

let processor = {
    output(value) {
        console.log(value);
    },
    process(dataSet) {
        dataSet.forEach((value) => this.output(value));
    }
};

processor.process(set);

本例中的箭头函数读取了包含它的 process() 函数的 this 值,因此就能正确地将 this.output() 解析为调用 processor.output()

要记住,虽然 Set 能非常好地追踪值,并且 forEach() 可以让你按顺序处理每一项,但是却无法像数组那样用索引来直接访问某个值。如果你想这么做,最好的选择是将 Set 转换为数组。

将 Set 转换为数组

将数组转换为 Set 相当容易,因为可以将数组传递给 Set 构造器;而使用扩展运算符也能简单地将 Set 转换回数组。第三章介绍的扩展运算符(…​),能将数组中的项分割开并作为函数的分离参数。你同样能将扩展运算符用于可迭代对象(例如 Set),将它们转换为数组。例如:

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

console.log(array);             // [1,2,3,4,5]

此处的 Set 在初始化时载入了一个包含重复值的数组。Set 清除了重复值之后,又使用了扩展运算符将自身的项放到一个新数组中。而这个 Set 仍然包含在创建时所接收的项(1、2、3、4 与 5),这些项只是被复制到了新数组中,而并未从 Set 中消失。

当已经存在一个数组,而你想用它创建一个无重复值的新数组时,该方法十分有用。例如:

function eliminateDuplicates(items) {
    return [...new Set(items)];
}

let numbers = [1, 2, 3, 3, 3, 4, 5],
    noDuplicates = eliminateDuplicates(numbers);

console.log(noDuplicates);      // [1,2,3,4,5]

eliminateDuplicates() 函数中,Set 只是一个临时的中介物,以便在创建一个无重复的数组之前将重复值过滤掉。

Weak Set

由于 Set 类型存储对象引用的方式,它也可以被称为 Strong Set。对象存储在 Set 的一个实例中时,实际上相当于把对象存储在变量中。只要对于 Set 实例的引用仍然存在,所存储的对象就无法被垃圾回收机制回收,从而无法释放内存。例如:

let set = new Set(),
    key = {};

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

// eliminate original reference
key = null;

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

// get the original reference back
key = [...set][0];

在本例中,将 key 设置为 null 清除了对 key 对象的一个引用,但是另一个引用还存于 set 内部。你仍然可以使用扩展运算符将 Set 转换为数组,然后访问数组的第一项,key 变量就取回了原先的对象。这种结果在大部分程序中是没问题的,但有时,当其他引用消失之后若 Set 内部的引用也能消失,那就更好。例如,当 JS 代码在网页中运行,同时你想保持与 DOM 元素的联系,在该元素可能被其他脚本移除的情况下,你应当不希望自己的代码保留对该 DOM 元素的最后一个引用(这种情况被称为内存泄漏)。

为了缓解这个问题,ES6 也包含了 Weak Set,该类型只允许存储对象弱引用,而不能存储基本类型的值。对象的弱引用在它自己成为该对象的唯一引用时,不会阻止垃圾回收。

创建 Weak Set

Weak Set 使用 WeakSet 构造器来创建,并包含 add() 方法、has() 方法以及 delete() 方法。以下例子使用了这三个方法:

let set = new WeakSet(),
    key = {};

// add the object to the set
set.add(key);

console.log(set.has(key));      // true

set.delete(key);

console.log(set.has(key));      // false

使用 Weak Set 很像在使用正规的 Set。你可以在 Weak Set 上添加、移除或检查引用,也可以给构造器传入一个可迭代对象来初始化 Weak Set 的值:

let key1 = {},
    key2 = {},
    set = new WeakSet([key1, key2]);

console.log(set.has(key1));     // true
console.log(set.has(key2));     // true

在本例中,一个数组被传给了 WeakSet 构造器。由于该数组包含了两个对象,这些对象就被添加到了 Weak Set 中。要记住若数组中包含了非对象的值,就会抛出错误,因为 WeakSet 构造器不接受基本类型的值。

Set 类型之间的关键差异

Weak Set 与正规 Set 之间最大的区别是对象的弱引用。此处有个例子说明了这种差异:

let set = new WeakSet(),
    key = {};

// add the object to the set
set.add(key);

console.log(set.has(key));      // true

// remove the last strong reference to key, also removes from weak set
key = null;

当此代码被执行后,Weak Set 中的 key 引用就不能再访问了。核实这一点是不可能的,因为需要把对于该对象的一个引用传递给 has() 方法(而只要存在其他引用,Weak Set 内部的弱引用就不会消失)。这会使得难以对 Weak Set 的引用特征进行测试,但 JS 引擎已经正确地将引用移除了,这一点你可以信任。

这些例子演示了 Weak Set 与正规 Set 的一些共有特征,但是它们还有一些关键的差异,即:

  1. 对于 WeakSet 的实例,若调用 add() 方法时传入了非对象的参数,就会抛出错误(has()delete() 则会在传入了非对象的参数时返回 false);

  2. Weak Set 不可迭代,因此不能被用在 for-of 循环中;

  3. Weak Set 无法暴露出任何迭代器(例如 keys()values() 方法),因此没有任何编程手段可用于判断 Weak Set 的内容;

  4. Weak Set 没有 forEach() 方法;

  5. Weak Set 没有 size 属性。

Weak Set 看起来功能有限,而这对于正确管理内存而言是必要的。一般来说,若只想追踪对象的引用,应当使用 Weak Set 而不是正规 Set

Set 给了你处理值列表的新方式,不过若需要给这些值添加额外信息,它就没用了。这就是 ES6 还添加了 Map 类型的原因。