JS 的大部分时期都只存在一种集合类型,即数组类型。数组在 JS 中的使用正如其他语言的数组一样,但缺少更多类型的集合导致数组也经常被当做队列与栈来使用。
数组只使用了数值型的索引,而如果非数值型的索引是必要的,开发者便会使用非数组的对象。这种技巧引出了非数组对象的定制实现,即 Set 与 Map。
Set 是不包含重复值的列表。你一般不会像对待数组那样来访问 Set 中的某个项;相反更常见的是,只在 Set 中检查某个值是否存在。
Map 是键与相应的值的集合。因此,Map 中的每个项都存储了两块数据,通过指定所需读取的键即可检索对应的值。Map 常被用作缓存,存储数据以便此后快速检索。
由于 Set 与 Map 并不正式存在于 ES5 中,开发者就只能使用非数组的对象。
1.ES5 中的 Set 与 Map
在 ES5 中,开发者使用对象属性模拟 Set 与 Map,例如:
1 | let set = Object.create(null) |
本例中的 set
变量是一个原型为 null
的对象,确保在此对象上没有继承属性。使用对象的属性作为需要检查的唯一值在 ES5 中是很常用的方法。
当一个属性被添加到 set
对象时,它的值也被设为 true
,因此条件判断语句(例如本例中的if语句)就可以简单判断出该值是否存在。
使用对象模拟 Set 与模拟 Map 之间唯一真正的区别是所存储的值。例如,以下例子将对象作为Map使用:
1 | let map = Object.create(null) |
与 Set 不同,Map 多被用来提取数据,而不是仅检查键的存在性。
2.变通方法的问题
尽管在简单情况下将对象作为 Set 与 Map 来使用都是可行的,但一旦接触到对象属性的局限性,此方式就会遇到更多麻烦。
例如,由于对象属性的类型必须为字符串,就必须保证任意两个键不能被转换为相同的字符串。
1 | let map = Object.create(null) |
本例将字符串值 "foo"
赋值到数值类型的键 5
上,而数值类型的键会在内部被转换为字符串,因此 map[5]
与 map["5"]
实际上引用了同一个属性。
当你想将数值与字符串都作为键来使用时,这种内部转换会引起问题。而若使用对象作为键,就会出现另一个问题,例如:
1 | let map = Object.create(null), |
此处的 map[key2]
与 map[key1]
引用了同一个值。
由于对象的属性只能是字符串,而对象默认的字符串类型表达形式又是 "[object Object]"
,因此,key1
与 key2
对象都被转换为同一个字符串 "[object Object]"
。
这种行为导致的错误可能不太显眼,因为貌似合乎逻辑的假设是:键如果使用了不同对象,它们就应当是不同的键。
将对象转换为默认的字符串表现形式,使得对象很难被当做 Map 的键来使用(此问题同样存在于将对象作为Set来使用的尝试上)。
当键的值为假值时,Map也遇到了自身的特殊问题。在需要布尔值的位置(例如 if 语句内),任何假值都会被自动转换为 false
。这种转换单独说来不是问题,只要对如何使用值的问题足够小心。例如:
1 | let map = Object.create(null) |
此例中 map.count
的用法存在歧义,此处的 if
语句是想检查 map.count
属性的存在性,还是想检查非零值?该 if
语句内的代码会被执行是因为 1
是真值。然而若 map.count
的值为 0
,或者该属性不存在,则 if
语句内的代码都将不会被执行。
在大型应用中,这类问题都是难以确认、难以调试的,这也是 ES6 新增 Set 与 Map 类型的首要原因。
JS 存在 in
运算符,若属性存在于对象中,就会返回 true
而无需读取对象的属性值。不过,in
运算符会搜索对象的原型,这使得它只有在处理原型为 null
的对象是才是安全的。但即使原型没问题,许多开发者仍然错误地使用与上例类似的代码,而不是使用 in
运算符。
3.ES6 的 Set
ES6 新增了 Set 类型,这是一种无重复值的有序列表。Set 允许对它包含的数据进行快速访问,从而增加了一个追踪离散值的更有效方式。
3.1 创建 Set 并添加项目
Set 使用 new Set()
来创建,而调用 add()
方法就能向 Set 中添加项目,检查 size
属性还能查看其中包含多少项。
1 | let set = new Set() |
Set 不会使用强制类型转换来判断值是否重复。这意味着 Set 可以同时包含数值 5
与字符串 "5"
,将它们都作为相对独立的项(在 Set 内部的比较使用了 Object.is()
方法,来判断两个值是否相等,唯一的例外是 +0
与 -0
在 Set 中被判断是相等的。)
还可以向 Set 添加多个对象,它们不会被合并为同一项:
1 | let set = new Set(), |
由于 key1
与 key2
并不会被转换为字符串,所以它们在这个 Set 内部被认为是两个不同的项。
如果 add()
方法用相同值进行了多次调用,那么在第一次之后的调用实际上会被忽略:
1 | let set = new Set() |
可以使用数组来初始化一个 Set,并且 Set 构造器会确保不重复地使用这些值。例如:
1 | let set = new Set([1, 2, 3, 4, 5, 5, 5, 5]) |
在此例中,带有重复值的数组被用来初始化这个 Set
。虽然数值 5
在数组中出现了四次,但 Set
中却只有一个 5
。
若要把已存在的代码或 JSON 结构转换为 Set 来使用,这个特性会让转换更轻松。
Set 构造器实际上可以接收任意可迭代对象作为参数。能使用数组是因为它们默认就是可迭代的,Set 与 Map 也是一样。Set 构造器会使用迭代器来提取参数中的值。
Set 构造器只接收可迭代对象作为参数,不接受基本类型的值
使用 has()
方法来测试某个值是否存在于 Set
中:
1 | let set = new Set() |
3.2 移除值
使用 delete()
方法来移除单个值,或调用 clear()
方法来将所有值从 Set
中移除。
1 | let set = new Set() |
在调用 delete()
之后,只有 5
被移走;而执行 clear()
方法后,Set
就被清空了。
这些方法都提供了一个简单的机制来追踪有序的唯一值。不过,在给 Set 添加项之后,需要使用 forEach()
方法来对每项执行操作。
3.3 Set 上的 forEach()
方法
ES5 给数组添加了 forEach()
方法,使得更易处理数组中的每一项,而无需建立 for
循环。Set 类型也添加了相同方法,其工作方式也一样。
forEach()
方法会被传递一个回调函数,该回调函数接收三个参数:
- Set 中下个位置的值;
- 与第一个参数相同的值;
- 目标 Set 自身。
Set 版本的 forEach()
方法与数组版本有个奇怪差异:前者传给回调函数的第一个与第二个参数是相同。虽然开起来是错误,但这种行为却有个正当理由:
具有 forEach()
方法的其他对象(即数组与 Map)都会给回调函数传递三个参数,前两个参数都分别是下个位置的值与键(给数组使用的键是数值索引)。
然而 Set 却没有键。为了让 forEach()
方法的回调函数在不同版本中保持一致,因此将 Set 中的每一项同时认定为键与值,于是 Set 的 forEach()
方法中回调函数前两个参数就始终相同了。
除了参数特点的差异外,在 Set 上使用 forEach()
方法与在数组上基本相同。
1 | let set = new Set([1, 2]) |
此代码在 Set 的每一项上进行迭代,并对传递给 forEach()
的回调函数的值进行了输出。回调函数每次执行时,key
与 value
总是相同的,同时 ownerSet
也始终等于 set
。
此代码输出:
1 | 1 1 |
与使用数组相同,如果想在回调函数中使用 this
,可以给 forEach()
传入一个 this
值作为第二个参数:
1 | let set = new Set([1, 2]) |
本例中 processor.process()
方法在 Set 上调用了 forEach()
,并传递了当前 this
作为回调函数的 this
值。这个传递十分必要,这样 this.output()
就能正确地解析到 processor.output()
方法。
使用箭头函数也能达成同样的效果,而无须传入第二个参数,例如:
1 | let set = new Set([1, 2]) |
3.4 将 Set 转换为数组
将数组转换为 Set 相当容易,因为可以将数组传递给 Set 构造器;而使用扩展运算符也能简单地将 Set 转换回数组。
扩展运算符 ...
能将数组中的项分割并作为函数的分离参数,同样也能将扩展运算符作用于可迭代对象,将它们转换为数组。例如:
1 | let set = new Set([1, 2, 3, 3, 3, 4, 5]), |
此处 Set 清除了数组中的重复值后,又使用扩展运算符将自身的项放到一个新数组中。这些项只是被赋值到新数组中,而并未从 Set 中消失。
当已经存在一个数组,而你想用它创建一个无重复值的新数组时,该方法十分有用,例如:
1 | function elieminateDuplicates(items) { |
在 elieminateDuplicates()
函数中,Set 只是一个临时的中介,以便在创建一个无重复的数组之前将重复值过滤。
3.5 Weak Set
由于 Set 类型存储对象引用的方式,它也可以被称为 Stong Set。
对象存储在 Set 的一个实例中时,实际上相当于把对象存储在变量中。只要对于 Set 实例的引用仍然存在,所存储的对象就无法被垃圾回收机制回收,从而无法释放内存。 例如:
1 | let set = new Set(), |
此例中,将 key
设置为 null
清除了对 key
对象的一个引用,但是另一个引用还存在于 set
内部。可以使用扩展运算符将 Set
转换为数组,然后访问数组的第一项,key
变量就取回了原先的对象。
这种结果在大部分程序中是没问题的,但有时,当其他引用消失之后若 set
内部的引用也能消失,可能会更好。例如,当 JS 代码在网页中运行,同时你想保持与 DOM 元素的联系,在该元素可能被其他脚本移除的情况下,你应当不希望自己的代码保留对该 DOM 元素的最后一个引用(这种情况被成为内存泄漏)。
为了缓解该问题,ES6 也包含了 Weak Set,该类型只允许存储对象弱引用,而不能存储基本类型的值。对象的弱引用在它自己成为该对象的唯一引用时,不会阻止垃圾回收。
Set 构造器只接受可迭代对象作为参数,但是 Set 类型允许存储基本类型的值。
创建 Weak Set
Weak Set 使用 WeakSet
构造器来创建,并包含 add()
方法、has()
方法以及 delete()
方法,以下例子使用了这三个方法:
1 | let set = new WeakSet(), key = {} |
使用 Weak Set 很像在使用正规的 Set。可以在 Weak Set 上添加、移除或检查引用,也可以给构造器传入一个可迭代对象来初始化 Weak Set 的值:
1 | let key1 = {}, key2 = {}, set = new WeakSet([key1, key2]) |
在本例中,一个数组被传递给了 WeakSet
构造器。由于该数组包含了两个对象,这些对象就被添加到了 Weak Set 中。
注意:若数组中包含了非对象的值,就会抛出错误,因为 WeakSet 构造器不接受基本类型的值。
Set 类型之间的关键差异
Weak Set 与正规 Set 之间最大的区别是对象的弱引用。
1 | let set = new WeakSet(), key = {} |
当此代码被执行后,Weak Set 中的 key
引用就不能访问了。不过没有办法核实这一点,因为需要把对于该对象的一个引用传递给 has()
方法,而只要存在其他引用,Weak Set 内部的弱引用就不会消失。
Set 类型可通过 size
属性来检查值的数量,而 WeakSet 类型没有 size
属性。
Weak Set 与正规 Set 还有一些关键的差异,即:
- 对于 WeakSet 的实例,若调用
add()
方法时传入了非对象的参数,就会抛出错误(has()
或delete()
则会在传入了非对象的参数时返回false
); - Weak Set 不可迭代,因此不能被用在
for-of
循环中; - Weak Set 无法暴露出任何迭代器(例如
keys()
与values()
方法),因此没有任何编程手段可用于判断 Weak Set 的内容; - Weak Set 没有
forEach()
方法; - Weak Set 没有
size
属性。
Weak Set 看起来功能有限,而这对于正确管理内存而言是必要的。一般来说,若只想追踪对象的引用,应当使用 Weak Set 而不是正规的 Set。