数组去重写 [...new Set(arr)],这个大家都会。但再往下问一层就容易卡壳:为什么 Set 里 NaN 只会存一个,而 === 明明说 NaN !== NaN?给 DOM 节点挂一份额外数据,用 WeakMap 和用普通对象到底差在哪?Map 和普通对象都是键值对,什么时候该换成 Map?
这篇把这四个结构一次讲清楚,每个都给出判等规则、方法清单、转换手法和适用场景,最后补一节这几年新增的能力。文中的结论我在 Node 22.23.1 上逐条验证过。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
Set的判等规则 SameValueZero,以及它和严格相等的两处差别Set的实例方法清单,交集并集差集的手写实现WeakSet和WeakMap的弱引用到底弱在哪,为什么它们不能遍历Map的键为什么是跟内存地址绑定的,以及和 Object 的性能取舍- Map 与 Array、Object、JSON 之间的六种转换写法
- 这几年新增的集合运算方法、分组方法和 WeakRef
Set 和 Map 主要的应用场景在于数据重组和数据储存。Set 是一种叫做集合的数据结构,Map 是一种叫做字典的数据结构。
# 一、集合(Set)
ES6 新增的一种新的数据结构,类似于数组,但成员是唯一且无序的,没有重复的值。
Set 本身是一种构造函数,用来生成 Set 数据结构:
new Set([iterable])
举个例子:
const s = new Set();
[1, 2, 3, 4, 3, 2, 1].forEach(x => s.add(x));
for (let i of s) {
console.log(i) // 1 2 3 4
}
// 去重数组的重复对象
let arr = [1, 2, 3, 2, 1, 1];
console.log([... new Set(arr)]) // [1, 2, 3]
这里有个坑要注意,原文这两段代码是没有分号的,直接跑会报错。原因是自动分号插入不会在行尾是 ) 而下一行以 [ 开头时补分号,const s = new Set() 换行接 [1, 2, 3...] 会被解析成 new Set()[1, 2, 3, 4, 3, 2, 1],也就是取属性而不是新建数组。这类问题在不写分号的代码风格里挺常见,行首是 [、(、`、+、- 这五种字符时都要当心。上面的代码我补上了分号。
Set 对象允许你储存任何类型的唯一值,无论是原始值或者是对象引用。
# 1.1 判等规则 SameValueZero
向 Set 加入值的时候不会发生类型转换,所以 5 和 "5" 是两个不同的值。Set 内部判断两个值是否不同,使用的算法叫做「SameValueZero」,它类似于精确相等运算符(===),主要的区别是 Set 认为 NaN 等于自身,而精确相等运算符认为 NaN 不等于自身。
let set = new Set();
let a = NaN;
let b = NaN;
set.add(a);
set.add(b);
set // Set {NaN}
let set1 = new Set()
set1.add(5)
set1.add('5')
console.log([...set1]) // [5, "5"]
把这条规则说完整一点,JavaScript 里一共有四套判等:
| 算法 | 对应写法 | NaN 与 NaN | +0 与 -0 |
|---|---|---|---|
| 宽松相等 | == |
不等 | 相等 |
| 严格相等 | === |
不等 | 相等 |
| SameValueZero | Set / Map / includes |
相等 | 相等 |
| SameValue | Object.is |
相等 | 不等 |
Set、Map、Array.prototype.includes 走的都是 SameValueZero,所以 [NaN].includes(NaN) 是 true 而 [NaN].indexOf(NaN) 是 -1。这个差别偶尔会咬人。
对象引用不参与值比较,两个内容一样的对象在 Set 里是两个成员。想按内容去重,得先序列化成字符串再去重,或者自己写一轮比较。
# 1.2 实例属性
constructor:构造函数size:元素数量
let set = new Set([1, 2, 3, 2, 1])
console.log(set.length) // undefined
console.log(set.size) // 3
注意是 size 不是 length。这个设计上和 Map 保持一致,也和数组区分开了,因为 Set 没有下标的概念。
# 1.3 操作方法
add(value):新增,相当于 array 里的 pushdelete(value):存在即删除集合中 valuehas(value):判断集合中是否存在 valueclear():清空集合
let set = new Set()
set.add(1).add(2).add(1)
set.has(1) // true
set.has(3) // false
set.delete(1)
set.has(1) // false
add 返回的是 Set 本身,所以可以链式调用;delete 返回布尔值表示有没有删掉东西。这两个返回值的差别记一下,写链式的时候容易搞混。
Array.from 方法可以将 Set 结构转为数组:
const items = new Set([1, 2, 3, 2])
const array = Array.from(items)
console.log(array) // [1, 2, 3]
// 或
const arr = [...items]
console.log(arr) // [1, 2, 3]
has 是 Set 最有价值的地方。数组的 includes 是线性查找,数据量一大就慢;Set 的 has 是基于哈希的,接近常数时间。判重、白名单校验、已访问节点标记这类场景,用 Set 替掉数组能带来数量级的差别。
# 1.4 遍历方法
遍历顺序为插入顺序,这一点是规范保证的,不是实现细节。
keys():返回一个包含集合中所有键的迭代器values():返回一个包含集合中所有值的迭代器entries():返回一个包含 Set 对象中所有元素的键值对迭代器forEach(callbackFn, thisArg):用于对集合成员执行 callbackFn 操作,如果提供了 thisArg 参数,回调中的 this 会是这个参数,没有返回值
let set = new Set([1, 2, 3])
console.log(set.keys()) // SetIterator {1, 2, 3}
console.log(set.values()) // SetIterator {1, 2, 3}
console.log(set.entries()) // SetIterator {1, 2, 3}
for (let item of set.keys()) {
console.log(item);
} // 1 2 3
for (let item of set.entries()) {
console.log(item);
} // [1, 1] [2, 2] [3, 3]
set.forEach((value, key) => {
console.log(key + ' : ' + value)
}) // 1 : 1 2 : 2 3 : 3
Set 里 keys() 和 values() 返回的是一样的东西,因为集合只有值没有键。entries() 给出的是 [value, value] 这种成对形式,纯粹是为了和 Map 的接口保持一致,实际很少用。
Set 可默认遍历,默认迭代器生成函数是 values() 方法:
Set.prototype[Symbol.iterator] === Set.prototype.values // true
我在 Node 22 上确认过这一行返回 true。这就是 [...set] 和 for...of 能直接用在 Set 上的原因,展开运算符找的就是 Symbol.iterator。
Set 本身没有 map 和 filter,但转成数组之后就有了:
let set = new Set([1, 2, 3])
set = new Set([...set].map(item => item * 2))
console.log([...set]) // [2, 4, 6]
set = new Set([...set].filter(item => (item >= 4)))
console.log([...set]) //[4, 6]
因此,Set 很容易实现交集(Intersect)、并集(Union)、差集(Difference):
let set1 = new Set([1, 2, 3])
let set2 = new Set([4, 3, 2])
let intersect = new Set([...set1].filter(value => set2.has(value)))
let union = new Set([...set1, ...set2])
let difference = new Set([...set1].filter(value => !set2.has(value)))
console.log(intersect) // Set {2, 3}
console.log(union) // Set {1, 2, 3, 4}
console.log(difference) // Set {1}
这三行是 2019 年的标准写法,现在有原生方法了,第七节会讲。
# 二、WeakSet
WeakSet 对象允许你将弱引用对象储存在一个集合中。
WeakSet 与 Set 的区别:
- WeakSet 只能储存对象引用,不能存放原始值,而 Set 两者都可以
- WeakSet 对象中储存的对象值都是被弱引用的,即垃圾回收机制不考虑 WeakSet 对该对象的引用。如果没有其他的变量或属性引用这个对象值,则这个对象将会被垃圾回收掉(不考虑该对象还存在于 WeakSet 中)。所以 WeakSet 对象里有多少个成员元素,取决于垃圾回收机制有没有运行,运行前后成员个数可能不一致,遍历结束之后有的成员可能取不到了。ES6 规定 WeakSet 不可遍历,也没有办法拿到它包含的所有元素
那为什么规范干脆禁掉遍历,而不是让你自己承担风险呢?
因为一旦允许遍历,垃圾回收的时机就变成了可观测的行为。同一段代码在不同引擎、不同内存压力下遍历出的结果会不一样,这在语言层面是不可接受的。禁掉遍历,弱引用就成了一个纯粹的内部实现细节。同理,WeakSet 也没有 size 属性。
属性:
constructor:构造函数,任何一个具有 Iterable 接口的对象,都可以作参数
const arr = [[1, 2], [3, 4]]
const weakset = new WeakSet(arr)
console.log(weakset)