先记住这个答案
Set 判断两个值是否重复用的是 SameValueZero 规则。它和 === 几乎一样:不做类型转换,对象按引用比较。唯一的区别是 NaN 被当成等于 NaN,所以 new Set([NaN, NaN]) 只有一个成员,而 NaN === NaN 是 false。+0 和 -0 被当成同一个值,而且存进 Set 的 -0 会被统一成 +0。所以数组去重可以直接写 [...new Set(arr)],但对象数组里两个字段一样的对象不会被去掉,要按字段去重得自己选一个键。
NaN能去重,+0和-0算同一个值- 不做类型转换,1 和
"1"是两个成员 - 对象按引用比较,内容相同也不算重复
- 对象数组按字段去重,用
Map以id为键
用特殊值和对象验证规则
把 NaN、正负零、数字和字符串、同一个对象和字段相同的新对象放在一起去重,看最后剩几个。
const item = { id: 7 }
const unique = [...new Set([NaN, NaN, -0, +0, 1, '1', item, item, { id: 7 }])]
console.log(unique.length) // NaN、0、1、'1'、item、新对象
console.log(new Set([1, '1']).size) // 不做类型转换
console.log(new Set([item, { id: 7 }]).size) // 对象按引用比较
const zero = [...new Set([-0])][0]
console.log(Object.is(zero, -0), Object.is(zero, +0)) // -0 被统一成 +0查看输出与解释
6
2
2
false true9 个值去重后剩 6 个:两个 NaN 合成一个,-0 和 +0 合成一个,同一个 item 引用合成一个。数字 1 和字符串 '1' 类型不同,都保留。最后那个 { id: 7 } 虽然和 item 字段一样,但它是新创建的对象,引用不同,所以也保留。
最后一行说明即使只往 Set 里放一个 -0,取出来的也是 +0。规范规定 add 时会把 -0 转成 +0。
对象数组怎么按字段去重
接口返回的用户列表里同一个用户出现了两次,两条记录是不同的对象,Set 去不掉。这时要选一个能代表「同一个用户」的字段,通常是 id。
- 用
Map以id为键 - 写成
[...new Map(users.map(u => [u.id, u])).values()]。Map的键是id,同一个id后面的记录会覆盖前面的值,所以最后保留的是每个id最后出现的那条。如果要保留第一条,就在循环里先判断map.has(u.id),存在就跳过。 有人会用JSON.stringify把对象转成字符串再放进Set去重,但字段顺序不同就会被当成两个值,值里有undefined或函数还会丢失,不是可靠的做法。
容易答错的地方
- 说
Set用===判断重复 - 如果用的是
===,那么new Set([NaN, NaN])就会有两个成员,因为NaN === NaN是false。实际只有一个,所以准确的说法是 SameValueZero。 - 说
Set用Object.is判断重复 Object.is(+0, -0)是false,如果Set用它,+0和-0会是两个成员。实际上它们被当成同一个,这正是 SameValueZero 名字里 Zero 的意思。
面试官还会怎么问?
去重之后的顺序是什么?
保留每个值第一次出现的位置。[...new Set([3, 1, 3, 2])] 得到 [3, 1, 2],不会自动排序,要排序就再调用 sort。
对象放进 Set 后改了字段,还是同一个成员吗?
是。Set 记住的是引用,改字段不改引用,set.has(obj) 仍然是 true。也正因为这样,不能指望 Set 按字段内容帮你去重。
数组 includes 和 Set.has 判断相等的规则一样吗?
一样,都是 SameValueZero,都能找到 NaN。indexOf 不一样,它用 ===,所以 [NaN].indexOf(NaN) 返回 -1。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。