☰
JavaScript比较深度解析:==、===、Object.is与深比较
2026/10/14 7:20:25 网站建设 项目流程

开头先说点实际的。我见过太多开发者在 JavaScript 比较这个看似基础的问题上翻车,从刚入门的新人到写了好几年业务的老手都有。有人因为==和===的差异在表单校验里放过了非法值,有人因为对象比较的引用陷阱导致组件无限渲染,还有人为了深比较两个数据结构,直接用JSON.stringify硬比,结果被字段顺序折腾得死去活来。这个主题值得花时间彻底讲透,因为比较逻辑不仅是语言语法的一部分,更是日常开发中对数据正确性和程序行为判断的底层支撑。这篇博文适合所有写 JavaScript 的开发者,不管你是刚接触前端还是已经在写框架源码,理解比较的完整语义都能帮你少走弯路。

1. 宽松相等与严格相等:真正搞清楚 == 和 === 的底层逻辑

1.1 为什么 JavaScript 里有两个相等运算符

很多语言里判断相等就是==,但 JavaScript 很特殊,它同时提供了==(宽松相等)和===(严格相等)两套机制。这不是设计冗余,而是为了在不同场景下表达不同的语义。===的规则简单直接:类型不同直接不相等,类型相同再比较值;而==则走了一套隐式类型转换流程,先把两个操作数转成相同的类型,再按转换后的结果比较。

我在早期写代码的时候也习惯性地认为“为了保证安全,一律用===就好”,直到别人问了我一个问题:为什么null == undefined返回true,但null === undefined返回false?我当时答不上来。后来认真看了规范才明白,==的转换规则里专门有一条:如果一个是null,另一个是undefined,直接返回true,这是语言设计上特意留给空值判断的便利通道。

所以关键不是背结论,而是理解这套转换发生的顺序和条件。用===确实能规避绝大多数坑,但这不意味着==就没有存在的意义。在一些特定场景下,==反而能写出更简洁且符合直觉的代码,只是你必须清楚它每一步在做什么。

1.2 == 的隐式转换规则拆解

来看==到底怎么比较的。按照 ECMAScript 规范,比较过程大致是:

  • 如果两个操作数类型相同,直接按===的规则比较。
  • 如果一个是null,一个是undefined,返回true。
  • 如果一个是数字,一个是字符串,把字符串转成数字再比较。
  • 如果一个是布尔值,先把它转成数字(true转 1,false转 0),再比较。
  • 如果一个是对象,另一个是数字或字符串,会调用对象的ToPrimitive转换,得到原始值后再比较。

这最后一条是所有“魔法”的根源。比如[1] == 1为什么是true?因为数组[1]会被转成字符串"1",然后"1"和数字1比较时再把"1"转成数字1,所以相等。我见过有人拿这个写代码来炫技,比如[ ] == false返回true,[0] == false也返回true,背后全是同一套转换路径。

console.log([] == ![]); // 看这个结果先想想为什么

拆开分析就很清楚:![]是布尔值false,布尔值false先转数字 0,左边空数组转为字符串"",字符串""转为数字 0,两边都是 0,所以返回true。整个过程每一步都有规范依据,不是随机行为。

注意:==的转换规则里,布尔值会先转数字,再和另一侧比较。这意味着"false" == false是false,因为字符串"false"先转数字(结果是NaN),而布尔值false先转数字 0,NaN不等于 0。这种反直觉的结论,只有画完整条转换链才能看懂。

1.3 什么时候可以放心用 ==

既然==这么容易踩坑,为什么还要用它?我的实践经验是,在判断null和undefined时,==非常好用。你不能保证后端返回的某个字段是null还是undefined,但两者的语义对于业务来说往往相同:没有值。用value == null可以同时覆盖两种空值情况,而如果用===就需要写成value === null || value === undefined。

比较稳妥的判断逻辑是:绝大多数情况用===,只有在明确判断空值时才用value == null。这个写法在很多流行库的源码里都出现过,因为它简洁、清晰、语义精确。但前提是你自己清楚它在做什么,而不是因为看着顺眼就写上去。

我在代码评审中经常遇到一种情况,开发者写了if (res.data && res.data.status == 1),看到==就皱眉头。其实这里是安全的,因为res.data已经确认不是空值,而状态码 1 是数字,任何字符串"1"会被转为数字 1,所以这个写法反而能兼容接口返回字符串类型的状态码。比较规则本身没有对错,重要的是你是否有意识地控制比较的条件和方向。

2. Object.is 与更精确的比较语义

2.1 Object.is 解决了什么痛点

===虽然严格,但还是有两个特例:NaN === NaN返回false,+0 === -0返回true。这两个行为本身符合 IEEE 754 浮点数标准的语义,但在实际开发中经常造成困惑。你想判断一个计算结果是不是NaN,不能写result === NaN,必须用Number.isNaN(result)或者result !== result这种绕弯子的方式。而Object.is的出现,提供了一套更符合直觉的比较语义:Object.is(NaN, NaN)返回true,Object.is(+0, -0)返回false。

我在处理数值计算的边界情况时,比如订单金额、坐标数据这类和浮点数精度强相关的业务,Object.is能让代码的意图表达得更明确。===会把+0和-0视为同一个值,这在大多数业务场景下没有影响,但在某些数据展示场景下,正负零变化可能意味着符号信息的丢失,就需要依靠Object.is来区分。

console.log(Object.is(NaN, NaN)); // true console.log(Object.is(0, -0)); // false console.log(Object.is(undefined, undefined)); // true

2.2 手写一个 Object.is 来彻底理解原理

理解一个 API 的最快方式是自己把它实现出来。Object.is的规范实现并不复杂,核心逻辑分三步:检查类型是否一致,检查是否都是NaN,检查符号位是否一致。

function myObjectIs(a, b) { if (a === b) { // 处理 +0 和 -0 的情况 return a !== 0 || 1 / a === 1 / b; } // 处理 NaN return a !== a && b !== b; }

第一次看到a !== 0 || 1 / a === 1 / b这段的人可能会懵。其实它利用了1 / +0 === Infinity而1 / -0 === -Infinity的特性,通过判断倒数是否相等来区分正负零。这个实现非常巧妙,也说明一个问题:如果你想深入理解语言特性,读规范文本和引擎源码是绕不开的路。

注意:Object.is做的是 SameValue 比较,它和===、==都不同,是 ES6 中引入的独立语义。在Object.defineProperty的Object.defineProperties内部判断属性值是否变化时,就使用了这种比较语义,你不可能通过Object.defineProperty把属性值从NaN改成另一个NaN并触发钩子,引擎认为它们是同一个值。

2.3 三类比较的适用场景对照

比较方式处理类型转换处理 NaN区分 +0/-0典型场景
==会不等不区分判断 null/undefined 空值
===不会不等不区分日常绝大多数相等判断
Object.is不会相等区分数值边界语义、属性描述符逻辑

我不建议在日常业务代码里把Object.is当成===的替代品到处使用。它的性能表现和可读性与===相比没有优势,而且大多数开发者对它的了解不够深入,反而会困惑你为什么要这么写。但当你需要精确的 SameValue 语义时,比如处理NaN缓存命中、实现不可变数据比较,它就是最合适的工具。

3. 对象比较与深拷贝:引用陷阱和值比较的完整方案

3.1 一切对象比较的本质

JavaScript 的对象(包括数组、函数)属于引用类型。你用==或===比较两个对象,比较的是它们的引用地址,而不是它们的内容。这一点是很多隐蔽 bug 的来源。比如你写了一个配置合并的逻辑,把默认配置和用户配置做合并,然后顺手判断一下合并前后的对象是否相等来决定要不要触发更新,结果发现永远相等或永远不相等,原因就是你在拿两个不同的对象引用做比较。

我可以很肯定地说,在真实项目里,90% 以上涉及对象比较的需求,实际想要的是值语义,也就是两个对象结构、属性、属性值是否完全一致,而不是它们是不是同一个引用。可惜 JavaScript 原生不提供这种比较能力,需要自己实现或借助工具库。

这里有一个很关键的概念区分:浅比较和深比较。浅比较只比较对象的第一层属性,适合层级不深、结构扁平的场景;深比较会递归遍历所有属性,适合嵌套结构复杂的数据。性能差异巨大,实际选型时要慎重。

const a = { name: "demo", items: [1, 2] }; const b = { name: "demo", items: [1, 2] }; console.log(a === b); // false,引用不同 console.log(a == b); // false,引用不同,== 和 === 对对象没有区别

3.2 数组相等更隐蔽:底层是字符串转换

数组的比较比普通对象更容易让人困惑。因为数组重写了toString方法,当数组参与==运算且另一侧是原始类型时,数组会先转成字符串再比较。所以[1, 2] == "1,2"是true。这个特性导致很多人误以为数组可以用==做内容比较,其实只是巧合。

最典型的翻车场景是我见过有人写:

const selected = [1, 2, 3]; if (selected == [1, 2, 3]) { // 这永远不会进入 }

两个数组都是在比较结束时转成字符串?不对。==比较两个对象时,不执行ToPrimitive,而是直接比较引用。只有当一侧是原始类型时,另一侧的对象才转原始值。所以这里两边的数组都不会转换,引用不同直接false。

数组内容比较的正确方式是先比较长度,再逐项比较,或者借助every方法手动实现,再或者用工具库里的isEqual。如果你非要偷懒用JSON.stringify,那必须提前接受它的三个坑:属性顺序敏感、函数和undefined被丢弃、NaN和Infinity被转为null。

3.3 手写一个可靠的深比较函数

与其遇到问题就去安装一个工具库,不如先自己实现一遍深比较,理解了细节之后,你选型工具库也会更有主见。一个能覆盖大多数业务场景的深比较函数,需要考虑的数据类型包括普通对象、数组、Date、RegExp、Map、Set、基本类型,以及循环引用。

function deepEqual(a, b, visited) { if (a === b) return true; if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) { return false; } // 日期比较 if (a instanceof Date && b instanceof Date) { return a.getTime() === b.getTime(); } // 正则比较 if (a instanceof RegExp && b instanceof RegExp) { return a.source === b.source && a.flags === b.flags; } visited = visited || new Map(); if (visited.has(a) && visited.get(a) === b) return true; visited.set(a, b); const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { if (!Object.prototype.hasOwnProperty.call(b, key)) return false; if (!deepEqual(a[key], b[key], visited)) return false; } // 额外处理 Map 和 Set 可以在原型判断分支中补充 return true; }

这个函数里用visited来记录已经比较过的对象引用对,避免循环引用导致栈溢出。这是深比较实现里最容易被新手忽略的部分。我实际测过,一个互相引用的对象图,如果没有循环检测,递归调用会直接让浏览器选项卡崩溃。

真实业务中,我倾向于推荐直接使用成熟工具库的深比较实现,因为它们在性能、边界情况覆盖和类型支持上都经过大量测试。但如果你想搞懂原理或者需要定制化比较逻辑,自己实现一遍绝对是最快的学习路径。

3.4 JSON.stringify 深比较的缺陷实例

JSON.stringify(a) === JSON.stringify(b)这种写法在网上流传很广,但它有致命缺陷。我实际踩过的坑是国际化文案的配置比较:对象里的属性值是undefined时,序列化会直接丢掉这个键;函数值会被完全忽略;Date对象转成了字符串;属性排列顺序不一致会导致比较失败。还有更隐蔽的,当值可能是NaN时,序列化后变成了null,两个值原本非常明确地不相等,却被判断为相等。

const obj1 = { a: undefined, b: 1 }; const obj2 = { b: 1 }; console.log(JSON.stringify(obj1) === JSON.stringify(obj2)); // true,undefined 属性被丢弃

有些场景下这些差异不影响业务,但有些场景下就是事故。我在一个状态同步模块中就是因为它,导致本应触发更新的状态没有触发更新,白白排查了一个下午。所以我的建议是:不要在需要严格语义的比较场景里用JSON.stringify走捷径,除非你完全确认数据里不会出现函数、undefined、NaN、Date等类型,并且属性顺序稳定可控。

4. 工程实践中的比较策略与常见坑位盘点

4.1 组件更新判断中的浅比较

现代前端框架比如 React 和 Vue 内部都使用了比较策略来决定是否重新渲染。官方把这种策略设计成浅比较,也就是只对比 props 和 state 的第一层引用。这样一来,如果你在父组件里构造了一个新的对象字面量传给子组件,即使对象内容和上一次完全一样,子组件也会收到一个全新的引用,从而触发更新。

这既是性能优化的选择,也是开发者容易踩坑的地方。我遇到过这样的情况:一个子组件接收一个配置对象config,父组件每次渲染都是展开运算创建一个新对象,子组件内部用useMemo依赖config,结果useMemo每帧都重新计算,完全没有起到缓存作用。解决办法是提升状态到更稳定的位置,或者对 props 做记忆化处理,比如用useMemo把对象包起来。

从源码角度理解,React 的Object.is比较在这里发挥了重要作用。React 对 Fiber 节点的 props 比较用的就是Object.is语义,这也是为什么 React 已经内置了对NaN和+0/-0特殊处理的原因。

4.2 状态管理里面的比较陷阱

状态管理库内部几乎都依赖相等比较来判断状态是否变化。例如某状态库要求开发者提供纯函数更新状态,更新后它会用比较语义判断新旧状态是否相同,如果相同则不触发订阅通知。如果你在更新函数里直接修改了原对象并返回它,比较结果是同一个引用,状态就会被判定为没有变化。

很多初学者在这里栽跟头。正确做法是返回一个全新的对象或数组,让比较逻辑能够识别到变更。但这里面有一个微妙的点:如果你的数据处理流程很复杂,比如多个中间函数对对象做链式操作,最后返回的结果可能在某些路径下仍然是原引用。这时候我就会写一个小的断言工具,在开发环境里判断返回值是否还是原引用,通过这种方式把问题暴露在早期。

4.3 比较运算符的性能参考

操作复杂度性能特征
引用比较a === bO(1)最快,引擎层直接比较指针
基本类型比较O(1)立即完成,无额外开销
浅比较O(n),n 为顶层属性数少一层循环,性能优秀
JSON.stringify深比较O(n),n 为全部节点数序列化+字符串比较,内存开销大
递归深比较O(n),n 为全部节点数函数调用多,但可控性强

在大数据场景下,比如上万条记录的表格数据做排序筛选,深比较的性能会是明显的瓶颈。我做过一个数据面板项目,每秒钟要接收数百条来自前端的实时数据,必须判断新数据是否和旧数据相同,如果相同就跳过更新。最开始用了通用深比较工具,结果 CPU 占用率居高不下,后来优化成先比较版本号和 key 集合长度,再逐层比较,性能提升非常明显。

4.4 常见判断问题速查表

问题现象原因解决方案
0 == ""为 true空字符串和 0 相等字符串转数字用===
null >= 0为 true空值大于等于 0关系运算符强制转换先判断空值再比较
两个内容相同的对象不相等对象引用比较对象是引用类型深比较
NaN不等于自己缓存失效NaN比较语义用Object.is或Number.isNaN
+0 === -0为 true无法区分符号零IEEE 754 语义Object.is
["a"] == "a"为 true数组参与比较乱转ToPrimitive转字符串显式转类型

4.5 如何在工程中规范比较逻辑

在团队协作中,比较逻辑的混乱往往来自于上下文中对数据类型和取值范围的假设不一致。一个字段可能是null、undefined、空字符串、0 四种情况,不同的人对它们的比较姿势完全不同,这就会引出隐性 bug。

我习惯在项目里建立几条硬性规范:

第一,默认使用===,禁止使用隐式转换来碰运气。代码评审时看到==需要开发者在旁边注释说明为什么这里必须用它。

第二,判断空值统一使用value == null,避免value === null || value === undefined的冗长写法。

第三,对象比较默认选择浅比较 + 显式说明。如果确实需要深比较,封装成一个独立的工具函数,所有业务代码调用统一入口,禁止各自实现。

4.6 我在实际踩坑后的经验总结

我在一个真实项目里遇到过这样一个问题:一个列表选择组件,里面的已选项通过一个 Set 来管理,判断某个值是否选中时用了selected.has(item)。由于Set内部使用 SameValueZero 比较,而 item 是对象,所以两个内容相同但引用不同的对象永远匹配不上。后来改成用某些唯一标识字段做比较,比如item.id,问题随即消失。这个例子让我深刻意识到,比较策略的选型必须紧贴数据形态。

之后我给自己定下一条铁律:在写任何比较逻辑前,先问自己三件事。第一,比较的双方是什么类型?基本类型还是引用类型?第二,我想得到的语义是引用相等还是值相等?第三,如果发生类型转换,它会不会改变我的判断结果?每次过一遍这三件事,基本能把九成的 bug 挡在门外。

5. 比较逻辑的扩展应用:源码阅读中的比较思维

5.1 从比较角度看框架设计意图

读源码的时候,很多人只关注算法流程,忽略了比较逻辑背后的设计意图。实际上比较语义的选择往往暴露了框架作者对数据变更频率、性能瓶颈和 API 易用性的权衡。

拿 React 的useEffect依赖比较来说,React 会用Object.is逐个比较依赖项。这意味着你在依赖数组里写一个内联对象,每次渲染都会得到新引用,效果就会每次执行。这是框架开发者故意留给你的性能钩子:如果你想控制效果执行频率,就把对象记忆化或抽成稳定引用。

在 Vue 3 的源码中,响应式系统的trigger逻辑大量使用了比较来判断是否真的需要通知依赖更新。比较结果决定了一个小小的shouldTrigger分支,直接影响整个系统的运行效率。你能通过这些细节感觉到,比较运算在框架里不是可有可无的边角逻辑,而是核心性能路径上的重要一环。

5.2 比较逻辑在数据结构算法中的核心地位

排序、查找、去重,这些常见算法的底层操作都是比较。在自定义排序逻辑时,比较函数返回值的正负号决定了元素的先后顺序。很多人在迁移到不同语言时会在比较函数上踩坑,因为 JavaScript 比较函数的返回值不限于-1或1,任何负数都代表小于,任何正数都代表大于,0 代表相等。

我在实现一个多字段排序的功能时,会特意把比较函数拆成多个独立的比较块,每个块负责一个字段,最后通过||的短路逻辑把多个比较结果串起来。这种方式的好处是易读、易扩展,新增排序维度的时候只需要追加一个比较块。

function compare(a, b) { if (a.priority !== b.priority) { return a.priority - b.priority; } if (a.updateTime !== b.updateTime) { return a.updateTime - b.updateTime; } return a.name.localeCompare(b.name); }

去重操作的原理本质也是比较。用Set去重时,基本类型走的是 SameValueZero 语义,对象类型依然走引用比较。如果你要对对象数组按某个字段去重,就不能只依赖Set,必须用一个 Map 或者 Reduce 手动根据字段建立索引。理解了比较语义之后,这些数据结构的边界行为就一点都不神秘了。

5.3 从比较视角看待类型系统的演化

TypeScript 的流行并没有取消 JavaScript 比较运算的复杂性,它只是通过类型系统把更多错误拦截在编译期。但在运行时,比较运算的语义仍然完全遵循 ECMAScript 规范。类型标注可以减少一部分因为假设失误导致的比较错误,但无法改变==的隐式转换规则,也无力控制对象引用的比较语义。

在写 TypeScript 时,我依然会遇到类型收窄和比较逻辑配合的场景。比如if (value !== null)之后,TypeScript 能自动把value的类型收窄到非空类型,但如果你写成if (value),只能排除null和undefined,并不能排除空字符串""和 0。这个差异影响很大,尤其在处理用户输入和接口字段时。

这是一个非常实用的观察:类型系统和比较逻辑是互补关系,而不是替代关系。类型系统负责在编译期约束可能性空间,比较逻辑负责在运行时判断具体值。两者配合好,才能写出同时具备安全性和表达力的代码。

5.4 自定义比较方法的实现从入门到进阶

在业务中我们经常要自定义相等语义。比如两个订单对象只要订单号相同,就算作同一个订单;两个用户对象只要邮箱一致,就算同一个用户。这种业务语义的等价关系,显然不能靠语言内置的引用比较或结构深比较来完成,需要自己定义。

实现自定义比较时,我强烈建议把所有规则收敛到一个模块里,用统一的函数对外暴露。这样当业务规则变化时,只需要修改一处,不至于在多个文件里翻找分散的比较逻辑。同时,自定义比较函数一般不会永久适合所有场景,它依赖业务上下文的约定,因此函数注释里应该写清楚等价条件的定义依据。

function isSameOrder(a, b) { return a.orderNo === b.orderNo && a.sourceChannel === b.sourceChannel; }

在实际开发中,这个模式广泛用于表格行选中、缓存更新判断、依赖触发条件等场景。它是比深比较更贴近业务的一层抽象,也是每个资深前端都应该掌握的技能。

写在最后的一个分享

这些年写下来,我最大的体会是:比较逻辑看起来简单,但它衡量的是你对语言底层语义和业务需求的把控能力。不要试图背下所有边界值的运算结果,那是投机取巧,没法让你应对新的场景。真正有效的方法是养成一个习惯:在遇到相等判断时,先停下来想清楚这里的操作数是基本类型还是引用类型,比较的意图是值相等还是引用相等,允许不允许隐式类型转换。每次花几秒钟把这三件事想清楚,能节省你无数小时的调试时间。

如果你也想深入这个方向,给你一个建议:自己实现一个覆盖常见类型的深比较函数,用各种奇怪的边界值去测试它,直到你能解释每一个分支为什么存在。这个过程会让你对比较的理解上一个台阶,而且以后看框架源码的时候,你会更加游刃有余。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询