先记住这个答案
=== 不做类型转换,但认为 NaN 不等于自身,且把 +0 和 -0 看作相等。Object.is 认为 NaN 等于 NaN,并区分 +0 与 -0。Set、Map 的键用 SameValueZero:接近 Object.is,唯一关键差别是把 +0 和 -0 合并。三者比较对象时都只看身份,不递归比较属性内容。
- 先问要按值、身份还是键去重
- NaN 与正负零是三规则分水岭
- 对象比较只看身份,不做深比较
真正分叉的边界只有几处
日常比较里,=== 已经覆盖大多数场景:类型不同直接 false,类型相同再按原始值或对象身份判断。它没有 == 那种类型转换,所以 1 === '1' 是 false。问题在于两个角落:NaN 被规定为不等于任何值,包括它自己;而 +0 和 -0 在数学意味上常被当作同一个零,=== 也沿用了这个直觉。
Object.is(NaN, NaN) 返回 true,Object.is(0, -0) 返回 false。这不是一种全面“更严格”的比较:它在 NaN 上判为相同,在正负零上判为不同。对其他值,结果与严格相等一致。正负零可在部分数值计算中产生不同结果,例如倒数分别为正无穷与负无穷,因此是否保留这种区别应由业务数据的含义决定。
SameValueZero 主要出现在 Set、Map 的键比较,以及数组 includes 这类“是否已经存在”的判断里。它的效果接近 Object.is,会让重复 NaN 只保留一个,但它刻意把 +0 和 -0 视为同一个键。原因很简单:集合去重通常关心业务意义上的同一个数,而不是零的符号。
console.log(NaN === NaN, Object.is(NaN, NaN));
console.log(0 === -0, Object.is(0, -0));
console.log(new Set([NaN, NaN, 0, -0]).size);
console.log(Object.is({ n: 1 }, { n: 1 }));查看输出与解释
false true
true false
2
false前两行显示 NaN 与正负零在 === 和 Object.is 下结论相反;Set 用 SameValueZero,所以 NaN 去重、正负零合并,只剩两个键;两个新对象属性相同也不是同一个身份。
对象内容相同不等于同一个对象
无论 ===、Object.is 还是 SameValueZero,遇到对象、数组、函数、Map、Set 实例时都不会展开属性逐项比较。{ n: 1 } 和另一个 { n: 1 } 在内存里是两个身份,所以结果一定是 false。能把它们判等,除非你手里保存的是同一个引用,例如 const a = { n: 1 }; const b = a。
这解释了为什么深比较要单独写:递归遍历字段、处理循环引用、区分数组顺序和日期时间,都不是这三种规则承诺的能力。把 Object.is(a, b) 当成“结构相同”会让缓存失效、依赖判断和状态更新逻辑一起失真。反过来,如果你确实只想知道引用有没有换,Object.is 足够清晰,也比递归遍历便宜得多。
不可变更新利用的正是身份规则。你保留旧对象,创建一个新对象表示变化,UI 层就能用引用是否相同快速判断“这一块可能变了”。但它只回答“引用换没换”,不回答“业务语义变没变”;如果每次渲染都新建一个内容相同的配置对象,身份比较会认为发生了变化。
业务里要先定义“相同”的含义
先规定无效数值与正负零在业务中的含义,再选择比较规则。例如坐标计算产生 NaN 时,通常应先校验输入并明确报错或缺失策略;如果允许将 NaN 作为集合元素,Set 会把多个 NaN 合并。需要保留每次失败的独立记录时,应给记录建立独立标识,而不是期待更换这三种比较规则就能区分不同的 NaN。
Map 使用 SameValueZero:NaN 可以命中此前的 NaN 键,0 与 -0 会命中同一个键。对象键按身份匹配,两个内容相同的新对象不会自动命中。若要按对象内容缓存,需要设计覆盖必要字段、稳定且无歧义的缓存键;简单 JSON 序列化还需考虑字段顺序、不可序列化数据和碰撞边界。
不可变更新通过创建新引用表达状态变化,但新引用也可能装着相同内容。React 比较部分状态和依赖值时使用 Object.is,不会替应用递归检查对象字段。因此应从正确的数据所有权和依赖关系出发,减少无意义的新引用,而不是把身份比较误当成内容比较或缓存命中保证。
- 数值数据
- 先约定 NaN、缺失值和正负零各自的业务含义。相等规则只负责比较,不能代替校验,也无法恢复已经在计算或序列化中丢失的信息。
- 缓存与去重
- 对象身份与业务键是不同的索引依据。需要跨对象实例命中时,应使用经过设计的稳定键,并明确更新、失效和碰撞处理规则。
框架里的 Object.is 只是身份与边界值判断
在 React 里看到 Object.is,不要把它读成“框架替你做了深比较”。例如 useState 的更新函数若返回与当前值 Object.is 相同的结果,通常可以 bail out;但你传入一个新建的 { n: 1 },即使字段一样,Object.is 仍是 false。effect 依赖数组也类似,它逐项用相同值规则看引用或原始值是否变化,对象字面量每轮新建就会被视为变化。
这也不是“只要相同就永远不再执行任何组件代码”的保证。调度、并发渲染、父组件更新、上下文变化都可能让函数组件仍然被调用,只是某些 state 更新或 effect 重跑被避免。工程上更稳的写法是减少无意义的新引用:把稳定配置提到组件外,用 useMemo 或 useCallback 固定身份,或者在状态层保存可比较的原始值。
当你必须比较结构,请明确引入深比较并控制成本,而不是期待 Object.is 自动理解数据。选择规则前先写一句话:相同是按身份、按 SameValueZero 键,还是按归一化后的字段。写下来之后,===、Object.is、Set/Map 和深比较各自的位置就清楚了。
容易答错的地方
- 把 Object.is 说成更严格的 ===
- 二者没有高低级,只是边界不同:=== 合并正负零、否定 NaN 自等;Object.is 识别 NaN 自等并区分正负零。
- 以为 Set 会分开保存 0 和 -0
- Set 与 Map 键用 SameValueZero,NaN 会去重,+0 和 -0 也会合并成一个键,所以示例 size 是 2。
- 用 Object.is 判断两个对象内容一致
- 对象比较只看身份;属性全同的新对象仍不等。需要内容一致就做深比较或先建立稳定引用。排查“Object.is”时还要核对输入、版本和执行顺序,并用反例确认修复后的边界。
面试官还会怎么问?
什么时候应该优先用 Object.is?
需要把 NaN 当作可匹配值,或必须区分 +0 与 -0 时使用;普通等值判断仍多用 ===。
Array.prototype.includes 用哪种规则?
它按 SameValueZero 查找,所以 [NaN].includes(NaN) 为 true,[0].includes(-0) 也为 true。
为什么 Map 用对象作键经常查不到?
因为键按身份而不是字段内容比较。两个看起来相同的对象不是同一键,应改用稳定字符串键或复用同一引用。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。