1. 当面试官问出这个问题,他真正想听什么
先还原一下面试现场。你对面坐着一位面试官,抛出一个看似基础的问题:"Set 如何保证元素不重复的?"如果你脱口而出"因为 Set 内部用 === 判断",那这次问答基本就到此为止了——不是因为你答错了,而是因为你只回答到了"表面的一半",而且那一半还是错的。
我在面试候选人的时候,特别喜欢问这个问题。原因很简单:它像一块试金石,能在三五句话里区分出"用过 Set"和"真正理解 Set"的人。用过 Set 的人知道它能去重,写过几行 new Set([1, 1, 2]) 之类的代码;真正理解的人知道它背后有一套完整的"值相等性判定"算法,知道 NaN 可以存进去、知道 -0 和 +0 被认为相等、知道对象是引用比较而非结构比较。
但话说回来,这道题真正想考察的其实是三个层面:
- 基础层面:你有没有用过 Set,知不知道它最基本的去重表现
- 原理层面:你知不知道它用什么算法判重,这个算法和
===、Object.is()有什么区别 - 深度层面:你了不了解 V8 底层是怎么存储这些唯一值的,哈希表冲突怎么处理,
NaN这种特殊值为什么能"自己等于自己"
这篇文章我就从这三个层面一层层拆。你如果正在准备面试,看完之后遇到这个问题基本能从容应对;你如果只是平时写代码用 Set 去重,这篇文章也能帮你搞明白"为什么有时候去重没生效"这种实际问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先拆掉最常见的错误认知:Set 不是用 === 判断重复的
很多人对 Set 的理解来自 MDN 上那句"Set 对象是值的集合,你可以按照插入顺序迭代集合中的元素,Set 中的元素只会出现一次"。然后看到各种文章里写"Set 内部通过 === 来判断元素是否重复",就信了。实际上这是一个流传很广的误解。
2.1 最简单的反例:NaN 去重
NaN 这个值非常特殊。在 JavaScript 中,NaN === NaN 的结果是 false。如果你用 === 来判断重复,那往 Set 里连续 add 两个 NaN,它们永远不会被判定为相等,Set 里就能塞进无数个 NaN。但实际表现是什么?我们直接跑一下:
javascript复制const set = new Set();
set.add(NaN);
set.add(NaN);
console.log(set.size); // 输出 1
结果不是 2,而是 1。这说明 Set 的判重逻辑绝对不可能是单纯的 ===——如果真是那样,两个 NaN 应该被视为不同的元素,size 应该等于 2。
2.2 另一个反例:-0 和 +0
=== 对 -0 === 0 的结果是 true,这一点和 Set 的行为一致。所以 -0 和 0 在 Set 里也会被视为同一个值:
javascript复制const set = new Set();
set.add(-0);
set.add(0);
console.log(set.size); // 输出 1
console.log(set.has(-0)); // 输出 true
console.log(set.has(0)); // 输出 true
有趣的是,当你从 Set 里取到 -0 时,它实际上会以 +0 的形式存在。因为 Set 内部在存储时遵循了"零值归一化"的逻辑,这在后面讲哈希表实现时还会提到。
2.3 对象元素的引用判断
还有一种常见的误解是"Set 可以去重对象"。很多新手拿两个内容完全一样的对象去 add,发现去重失败了:
javascript复制const set = new Set();
set.add({ name: '张三' });
set.add({ name: '张三' });
console.log(set.size); // 输出 2,不是 1
为什么?因为对象是引用类型,{ name: '张三' } 和 { name: '张三' } 是两个不同的对象引用,在 Set 看来就是两个不同的值。这跟 === 的行为是一致的——除非你用同一个变量引用去 add,否则内容再像也不可能被判定为重复。
所以到这里可以得出一个小节:Set 的判重不是简单用 ===,它使用了一个名为 SameValueZero 的算法。
3. SameValueZero:Set 到底用哪套规则判重
既然不是 ===,那 Set 用的是什么?规范里写得非常明确:Set 在 add、has、delete 等方法中,使用的是 SameValueZero 算法来判定两个值是否相等。
3.1 SameValueZero 的判定规则
SameValueZero 的规则其实很简单,可以概括成几句话:
- 两个值的类型不同,直接认为不相等
- 对于普通值(数字、字符串、布尔值等),比较规则基本等同于
===,该不等的还是不等 - 特殊点 1:
NaN等于NaN,这个是和===最大的区别 - 特殊点 2:
+0和-0相等,这个和===一致
用代码来表示 SameValueZero 的核心逻辑,大致长这样:
javascript复制function sameValueZero(x, y) {
if (typeof x === 'number' && typeof y === 'number') {
// 处理 NaN 的情况
if (x !== x && y !== y) return true;
return x === y;
}
return x === y;
}
在 ES2015 的规范里,SameValueZero 还有一个对应的内部操作,我记得在比较时会把 -0 和 +0 统一处理。这也是为什么 Set 里 add 了 -0 之后再读出来会变成 0——它压根没打算区分这两者。
3.2 和 === 的对比
我们来列一个对比表,把 === 和 SameValueZero 放在一起看:
| 比较对象 | === |
SameValueZero(Set 使用) |
|---|---|---|
NaN vs NaN |
false | true |
+0 vs -0 |
true | true |
1 vs 1 |
true | true |
'a' vs 'a' |
true | true |
{} vs {}(不同引用) |
false | false |
null vs undefined |
false | false |
true vs 1 |
false | false |
看到没,唯一的差异就在 NaN 上。这正是 Set 设计的一个细节:按照规范,NaN 应该被视为"等于它自己",这样才能保证 Set 不会出现同一个逻辑值存在多份的情况。
3.3 和 Object.is() 的关系
这里有一个非常容易混淆的点。ES6 里还有 Object.is() 方法,它使用另一种算法叫 SameValue。SameValue 和 SameValueZero 几乎一模一样,唯二的区别就是:
- SameValue:
+0和-0是不相等的 - SameValueZero:
+0和-0是相等的
也就是说:
javascript复制Object.is(NaN, NaN); // true,这一点和 Set 一致
Object.is(0, -0); // false,这一点和 Set 不一致
const set = new Set([0, -0]);
console.log(set.size); // 1,Set 认为它们相等
有个面试追问经常在这里出现:"为什么 Set 不用 Object.is() 而要用 SameValueZero?"我的理解是:在绝大多数业务场景中,+0 和 -0 不应该被区分。比如你用 Set 统计一个数组中出现了哪些数字,[0, -0] 显然应该被认为是同一个值。而且在浮点运算里 -0 的出现往往是某次下溢或者符号位残留的结果,业务上很少有需要把 -0 单独拎出来的场景。所以 SameValueZero 比 SameValue 更贴近"去重"这个语义。
4. V8 引擎底层:从哈希表到哈希冲突
如果面试只追问到这里,其实还没到底。真正的高手面试官会继续问:"那 V8 底层是怎么存储 Set 的元素的?hash 冲突怎么解决?"这一节我们聊这个。
4.1 底层结构不是普通的 Object
在 V8 的早期实现里,Set 确实走过一段用哈希表存储的路线。后来为了优化迭代性能,V8 引入了所谓的 OrderedHashSet(有序哈希集合) 结构。
为什么叫"有序"?因为 Set 是保证插入顺序可迭代的:
javascript复制const set = new Set();
set.add('a');
set.add('b');
set.add('c');
for (const item of set) {
console.log(item);
}
// 输出顺序永远是 a、b、c
哈希表天生是无序的,所以 V8 做了一个折中设计:内部维护一个"哈希表 + 双向链表"复合结构。哈希表负责快速判重和查找,双向链表负责维持插入顺序。每次插入新元素时,既要在哈希表里登记,也要把元素链接到链表尾部。这样无论你调用 has() 还是迭代整个 Set,性能都不错。
4.2 哈希函数怎么处理不同的数据类型
当你要 add 一个值进 Set,V8 需要先计算它的哈希值,然后根据哈希值找到对应的桶(bucket)位置。对不同类型,哈希的取法完全不同:
- 数字:直接基于数字的位运算或某种转换。V8 对有效整数范围(SMI,Small Integer)的数会走快速路径,因为整数的哈希可以直接用值本身做变换,成本极低
- 字符串:计算字符串的哈希码(比如 FNV 或类似的散列算法),每次插入都要重新计算
- 对象:这里有个很关键的机制,V8 会为对象维护一个 随机生成的 hash 值,存在对象的隐藏类(HiddenClass)或属性里。同一个对象无论被放进哪个 Set,它的 hash 值都是一样的,这就保证
set.has(obj)能快速定位到正确的桶
4.3 哈希冲突的线性探测
哈希表不可能完全没有冲突。V8 处理 Set 哈希冲突的方式,主要采用开放定址法中的线性探测策略。简单说就是:如果计算出来的桶位已经被别的元素占了,就顺着往下逐个找空位,直到找到一个空槽放进去。
查找的时候同理:先定位到桶位,如果这个位置存的元素和你要找的元素相等,命中;如果不等,继续往后探测,直到找到匹配元素或者遇到空槽,说明不存在。
举个例子,假设哈希表的桶数组是 [_, _, _, _, _],你 add 一个哈希定位到下标 2 的元素,但下标 2 已经被占了,那就会去查下标 3,如果 3 也被占就查 4,直到有空位。这个插入和查找逻辑,跟 Java 的 HashSet 实现思路非常像,只是语言和内部细节不同。
4.4 扩容和 rehash
当 Set 的元素数量超过哈希表容量的某个阈值(V8 里大概是容量的 75% 左右),就会触发扩容。扩容时分配一个更大的桶数组,然后把所有旧元素重新计算哈希、插入到新数组里。这也是为什么 Set 在元素多到一定程度时会有一个明显的性能抖动——rehash 是不可避免的 O(n) 操作。
不过对日常业务来说,你基本感知不到这个抖动。Set 的 add、has、delete 的平均时间复杂度是 O(1),只有在大规模数据场景下才需要考虑扩容开销。
5. 数组去重:Set 最常见的应用场景拆解
面试问题往往以 Set 为起点,最后都会落到实际场景。面试官最常挂嘴边的就是"那你平时用 Set 做什么?"十个人有九个回答"数组去重"。好,那我们就把数组去重这件事掰开揉碎讲清楚。
5.1 最基础的数组去重
javascript复制const arr = [1, 2, 2, 3, 3, 3, 4];
const unique = [...new Set(arr)];
console.log(unique); // [1, 2, 3, 4]
这段代码是标准答案。new Set(arr) 会遍历数组,逐个 add,依赖 Set 的 SameValueZero 判重自动过滤重复元素,再用展开运算符转回数组。对于基础类型(数字、字符串、布尔值、null、undefined)的数组,这一行就足够。
5.2 一个容易翻车的场景:NaN 在数组去重中反而表现正确
有些老教程会说"NaN 用 Set 去重会出问题"。实际上恰恰相反,Set 对 NaN 的处理是"正确"的:
javascript复制const arr = [NaN, NaN, NaN];
const unique = [...new Set(arr)];
console.log(unique); // [NaN]
如果你用 filter + indexOf 的老办法去重,反而会出问题:
javascript复制const arr = [NaN, NaN, NaN];
const unique = arr.filter((item, index) => arr.indexOf(item) === index);
console.log(unique); // [NaN, NaN, NaN]
因为 indexOf 内部也使用 === 严格相等,NaN === NaN 为 false,所以 indexOf 永远找不到 NaN,自然全被保留下来。这算是一个历史老坑,用 Set 反而轻松避开。
5.3 对象数组去重:Set 不能直接搞定
前面说过,Set 对对象是引用比较。所以在实际开发中,最常见的场景是"对象数组按某个字段去重",直接 new Set 是没用的:
javascript复制const arr = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 1, name: '张三' }
];
const unique = [...new Set(arr)];
console.log(unique.length); // 还是 3
要处理这种场景,业界最常用的方案是借助 Map 或者 reduce 按字段手动去重:
javascript复制const unique = [...new Map(arr.map(item => [item.id, item])).values()];
思路是:把每个对象映射成 [key, value] 对,交给 Map。Map 的键是唯一键,当两个对象的 id 相同,后者会覆盖前者的值,最终取 values 就得到按 id 去重后的数组。这个技巧在面试里也算一个高频考点,它的核心思想其实就是"用 Map 的键去重语义来模拟按字段分组"。
如果你不想用 Map 这种一行流,也可以写得更直白:
javascript复制const map = new Map();
for (const item of arr) {
if (!map.has(item.id)) {
map.set(item.id, item);
}
}
const unique = [...map.values()];
两种写法本质一样。你可能会问,为什么这里不用 Set?因为 Set 只保存"值本身",无法为每个值再绑定一个"去重键"。Map 相当于一个"带键的 Set",键才是判重的依据。
5.4 对象数组去重的误用案例
我还见过有人用 JSON.stringify 序列化对象来做去重:
javascript复制const unique = [...new Set(arr.map(item => JSON.stringify(item)))].map(str => JSON.parse(str));
这种方案在对象字段顺序一致时能工作,但坑非常多。比如两个对象字段顺序不同({id: 1, name: '张三'} 和 {name: '张三', id: 1}),序列化结果就不同,去重失效。如果是嵌套对象且字段顺序不稳定,更是一场灾难。而且序列化之后再反序列化,对象原有的原型链、Date 类型、undefined 值都会丢失。所以除非你能确定对象结构极简单、字段顺序稳定,否则别用这个方案。
6. 面试追问连环炮:WeakSet、性能与底层细节
到这里,主体问题已经答完了,但面试官往往还有连环追问。我挑几个最常被问到的,逐个拆解。
6.1 WeakSet 和 Set 到底有什么区别
WeakSet 经常和 Set 一起被面试官拿出来对比。它的核心特征是:
- 成员只能是对象,不能是基础类型
- 成员是弱引用,不阻止垃圾回收(GC)
- 没有
size属性,不能迭代
为什么 WeakSet 成员必须是对象?因为只有对象才有"可被垃圾回收"这个概念。数字、字符串、布尔值都是值类型,不存在"被回收"一说。所以 WeakSet 的约束本质上是为弱引用机制服务的。
实际应用场景是什么?一个经典案例是标记对象的"已读"状态:
javascript复制const visited = new WeakSet();
function visit(node) {
if (visited.has(node)) return;
visited.add(node);
// 处理这个节点
}
如果用 Set 存储这些节点,即使节点在业务上已经不再被引用,Set 里仍有强引用,节点永远不会被回收,就有内存泄漏风险。改用 WeakSet 后,一旦外部对节点的引用消失,节点随时可以被 GC 掉,WeakSet 里的登记也自动消失,不需要手动 delete。
6.2 Set 和数组的查找性能对比
其实面试还有一个隐藏考点:Set 的 has() 和数组的 indexOf() / includes() 谁快?
数组的查找是线性遍历,时间复杂度 O(n)。Set 的查找走哈希,平均 O(1)。当数据量小时差异不明显,但数据量上万后差距会非常离谱。我在实际项目中做过一次粗测,对一个包含 10 万条字符串的数组,includes() 查找一个"不存在"的元素耗时大约是 Set has() 的几百倍。
所以当你需要频繁判断"某个值是否存在于集合中",并且数据量较大时,优先用 Set 而不是数组。这也算 Set 的另一个高频实战价值。
6.3 扩展运算符之外的转换方法
除了 [...set],把 Set 转回数组还有 Array.from(set),效果等价。区别只在于语义:扩展运算符依赖迭代协议,Array.from 在语义上更明确。两个都可以,看团队规范怎么约定。
另外,new Set(existingSet) 可以用来浅拷贝一个 Set。注意这是浅拷贝,如果 Set 里存的是对象,拷贝后的 Set 仍然指向同一个对象引用。
7. 这套机制的边界陷阱:几个实测下来的反直觉行为
最后分享几个我在实际开发和面试聊天中踩过、见过的边界场景,都挺反直觉,建议你自己跑一遍加深印象。
7.1 修改已加入 Set 的对象属性,会不会影响判重
加入 Set 之后,修改对象的某个属性值,Set 会不会重新判断?
javascript复制const obj = { value: 1 };
const set = new Set();
set.add(obj);
obj.value = 2;
set.add(obj);
console.log(set.size); // 1
答案是:不会重新判断。Set 在 add 时做了一次哈希定位,之后这个对象在 Set 里的"哈希值"已经固定了(存在对象内部的随机 hash 字段)。你修改它的属性,不影响哈希值,也不触发重复判断。所以同一个对象引用,你改成什么样,再 add 进去还是同一个元素。
但如果你把这个对象从 Set 里删除,再改属性,再 add 回去,它会被当作新元素重新插入。知道这个特性,能避免在一些"先修改再重新去重"的业务里产生误解。
7.2 Set 的键等价于值
Set 表面上像是没有键的集合,但实际上它对每个元素都存了一份"键=值"的映射。这个设计上的巧合导致 Set 无法像 Map 那样按任意键去重,除非你把"去重键"包装到值里。
数组去重之所以能用 Set,是因为数组元素本身就是我们要判断去重的对象。一旦需要"按某一隐含字段去重",Set 就失效,必须换成 Map。这个认知我觉得每一个"用 Set 去重"的人都要有。
7.3 delete 之后的重新 add
还有一个边界:delete 一个元素之后,再 add 同一个值,会重新插入到 Set 的末尾,而不是回到原来的位置。因为 Set 的迭代顺序由双向链表的插入顺序决定,删掉之后链表断开了,再插入就是新节点,排到尾部。
javascript复制const set = new Set(['a', 'b', 'c']);
set.delete('a');
set.add('a');
console.log([...set]); // ['b', 'c', 'a']
如果是单纯用 Set 做去重,这个顺序变化通常无所谓。但如果有人依赖 Set 的插入顺序做"最近使用"之类的排序,这个行为就是个大坑。遇到这种需求,还是老老实实写 LRU 缓存或者用 Map 维护顺序更稳妥。
7.4 大数组去重的性能实测感受
最后说一个实操心得。之前我在项目里要对一个百万级的数据数组做去重,一开始用两层循环(O(n²)),跑了几分钟都没出结果。换成 new Set(arr) 之后,整个过程降到百毫秒级。
差别为什么这么大?因为 Set 的哈希表查找是 O(1),一百万次 add 的总复杂度只有 O(n)。而双层循环是每来一个新元素都要和历史元素逐一比较,最坏情况是 O(n²)。理解 Set 底层是哈希表之后,你会很自然地知道什么场景该用它、什么场景不该用它。
比如数据量极小(几十条以内),用 filter + indexOf 甚至双层循环都无所谓。但数据量一旦上来,Set 几乎是最优解。这就是"知其然,也知其所以然"带来的实际收益。
如果你在面试里能讲清楚这几层——SameValueZero 和 === 的区别、NaN 的实验表现、V8 哈希表的存储模型、对象数组去重要换 Map——相信我,对面坐着的面试官大概率不会再纠结这个问题了。最后顺带一提,有些热词里提到的 codex cli binary 报错、SQL 去重查询之类的话题,和 Set 不在一个语境里,别搞混了。
