
一批价格数据从接口回来,中间夹了几个 NaN。用 Set 去重,长度只掉了一个;换成 indexOf 找 NaN 的下标,返回 -1,好像它压根不在数组里。同一个数组,两段代码给出相反的答案。
差别不在数组,在判据。JavaScript 里做相等判断有三条路:===、Object.is,以及 Set 与 Map 内部用的那一条,规范里叫 SameValueZero。三条路大多数时候答案相同,只在几个特殊值上分叉,NaN 和正负零正好都在里面。
三条判据在同一点上分叉
=== 最常用,它说 NaN 和谁都不同,连自己都不同。Object.is 认为 NaN 与自己相同,另外把 +0 和 -0 判成不同的值。SameValueZero 落在中间:认 NaN 相同,也不区分正负零。数组的 indexOf 走 ===,includes 走 SameValueZero,开头那个反差就是这么来的。
[NaN].indexOf(NaN) // -1,按 === 比
[NaN].includes(NaN) // true,按 SameValueZero 比
NaN 在容器里的两种命运
把这几条判据并排跑一次,输出如下。
== 1. NaN:=== 为假,includes 为真 ==
NaN === NaN : false
[NaN].indexOf(NaN) : -1
[NaN].includes(NaN) : true
[NaN, 1].lastIndexOf(NaN) : -1
new Set([NaN, NaN]).size : 1
new Set([NaN]).has(NaN) : true
Object.is(NaN, NaN) : true
indexOf 和 lastIndexOf 都返回 -1,includes 返回 true。Set 那边更直接:两个 NaN 进去只剩一个,has(NaN) 为真。要回答「数组里有没有这个值」,先想清楚要的是哪一条判据,这两个方法的答案会不一样。
容器用的都是 SameValueZero,跟 includes 一致。new Set([NaN, NaN]).size 是 1,new Map([[NaN, 'x']]).get(NaN) 也能取到值。NaN 在 === 的世界里找不到自己,在容器的世界里反而被当成同一个。
正零、负零,还有绕一圈的 JSON
负数除出来的零和普通零,三条判据给出两个答案。
== 2. 正零与负零:=== 不区分,Object.is 区分 ==
0 === -0 : true
Object.is(0, -0) : false
[0].indexOf(-0) : 0
[0].includes(-0) : true
new Set([0, -0]).size : 1
new Set([0, -0]).has(-0) : true
1/0 与 1/-0 : Infinity / -Infinity
== 3. 类型不同一律不等,Set 与 Map 也不合并 ==
[1].includes('1') : false
[1].indexOf('1') : -1
new Set([1, '1']).size : 2
new Map([[1,'num'],['1','str']]).size : 2
Map.get(1) 与 Map.get('1') : num / str
0 === -0 为真,Object.is 为假,includes 与 indexOf 都按相同处理。数字和字符串之间更彻底,三条判据全都不做转换,1 和 '1' 在 Set 里占两个位置,在 Map 里是两个键。
这一条平时看着不会撞上,数据过一次 JSON 就撞上了。JSON.stringify([0, -0, NaN]) 实测得到 [0,0,null]:负零写成 0,NaN 直接变成 null。序列化把两处边界同时抹平,读回来之后再去比,跟原值已经不是一回事。
对象只比引用,去重要另想办法
== 5. 四种判据逐项对照 ==
判据 NaN,NaN 0,-0 1,'1' 同一引用 结构相同的两个对象
===(indexOf 用的) : false true false true false
Object.is : true false false true false
SameValueZero(includes) : true true false true false
结构完全一样的两个对象,三条判据都给 false,最后那两列也说明了原因:判据在比之前不看内容。这在去重里最容易被忽略。
== 6. 放进实际写法里看 ==
new Set([{id:1},{id:1}]).size : 2 (对象引用不同,去不掉)
new Set([NaN, NaN]).size : 1 (NaN 反而去掉了)
[NaN, 0, -0, 1] 里 indexOf 找不到的个数 : 1
list.includes(-0) : true
JSON.stringify([0, -0, NaN]) : [0,0,null]
new Set([{id: 1}, {id: 1}]) 的长度是 2,两个对象引用不同,去不掉;反倒是 NaN 被干净地合并成一个。要按内容去重,得先把用来判断的那几个字段拼成一个稳定的键,再拿这个键进 Set。键是字符串之后,比较回到值比较,和引用无关。
两种情况下的选择
判断「值是不是相同」,用 includes 或者 Set;判断「是不是同一个引用」,用 indexOf。反过来用会得到看起来很对、边界上错得很难查的结果。
这里给不出「永远用哪一条」的结论。同一份数据在别处被当成引用还是被当成值,决定权在调用方手里,我倾向于在函数入口就把类型收干净,后面就不用再想这件事。唯一想提醒的是别把 indexOf(v) === -1 当成「不存在」的通用判据,碰上 NaN 它会说谎。