JavaScript对象比较全解析:==与===、深比较函数及类型转换陷阱

发布时间:2026/10/14 7:20:21
JavaScript对象比较全解析:==与===、深比较函数及类型转换陷阱
写 JavaScript 写了这么多年每隔一阵就会有人问我同一个问题两个对象到底怎么比这个问题看起来简单里面的坑却深到能把老手都绊一跤。不用找多复杂的场景就一句{} {}答案是false——两个看起来一模一样的空对象用严格相等比较直接不等。很多刚接触的人当场就懵了接着就会冒出灵魂三问那到底怎么判断两个对象内容一样和到底有什么区别为什么明明看起来一样却不相等这个问题恰恰也是 JavaScript 比较机制里最核心的一块。无论是写业务逻辑、做状态管理、写单元测试还是实现数据缓存、做表单差异检测你几乎绕不开对象内容的比较判断。把 JavaScript 比较搞清楚不只是背几个面试题那么简单它直接影响你写的代码能不能正常工作、性能稳不稳定。这篇文章我不打算走教科书路线就从实际运行结果出发把比较机制一层层拆开再带着你手写一个能用于真实的深度比较函数最后聊聊那些真正让人头疼的边界情况和排错经历。那我们先从一个最反直觉的现象说起。1. 一个看似简单的问题为什么{} ! {}1.1 先看看这些让你怀疑人生的结果老实说JavaScript 的比较规则是各种主流语言里最绕的。先抛一段代码你自己在控制台跑一下试试console.log({} {}); // false console.log({ a: 1 } { a: 1 }); // false console.log([] []); // false console.log(new Date() new Date()); // false console.log(0 -0); // true console.log(NaN NaN); // false前四行全是false这直接挑战了“两个东西长得一样就该相等”的直觉。后两行更是离谱0和-0明明一个是正零一个是负零它告诉你相等NaN连自己都不等于自己它也理直气壮。这些结果不是 bug是 JavaScript 设计时就定下的规则。理解这一切之前先建立一个关键概念值相等与引用相等。基本类型数字、字符串、布尔值、null、undefined、Symbol、BigInt比较的是“值本身”你有一个 1我也有一个 1这两个 1 是一样的。但对象包括数组、函数、日期等不一样对象是一块内存区域的引用{}每次执行都会在内存里新开一块区域{} {}表面看上去是两个内容一样的对象其实问的是“这两个引用指针指向的是不是同一块内存”。答案当然是否定的它们各是各的。1.2 那和存在的意义是什么JavaScript 早期只提供了后来又补了。看名字就明白了是抽象相等是严格相等。严格相等要求类型和值都相同所以它不会做任何类型转换而会在类型不一致的时候偷偷做转换这也是大量坑的根源。拿一个最常见的例子来说console.log(1 1); // true console.log(1 1); // false1是数字1是字符串用比较时引擎会把字符串转成数字再比所以结果是true。用不转换类型都不一样直接false。按照业内普遍的约定业务代码里应该一律使用只在极少数刻意要利用隐式转换的场合才用。我不知道你第一次踩到的坑是什么感受反正我当年因为0 false这种问题排查了半天后来就形成了习惯一律严格相等省得给自己留雷。注意null undefined在抽象相等规则下是true但null undefined是false。这是一对常被混淆的特例很多人在判断一个值“是否为 null 或 undefined”时习惯写value null这是少有的推荐使用场景。2. 比较运算的类型转换到底在做什么2.1 ToPrimitive 与 ToNumber隐式转换的幕后黑手的隐式转换不是随便乱转的它遵循一套固定的步骤核心是两个内部操作ToNumber和ToPrimitive。当你写123 123时引擎会把字符串123转成数字123再比较。这个“转数字”的动作就是ToNumber。如果字符串里不是合法的数字比如abc转出来的结果是NaN比较结果自然就是false。ToPrimitive则是把一个对象转成原始值的方法。它会按顺序调用对象的Symbol.toPrimitive方法、toString()、valueOf()。对普通对象来说toString()返回的是[object Object]这种字符串对数组来说toString()会把数组转成逗号拼接的字符串比如[1,2]变成1,2。这个机制解释了很多反直觉的现象。比如console.log([] 0); // true console.log([0] 0); // true console.log([1] 1); // true[]先通过ToPrimitive变成空字符串字符串再通过ToNumber变成0所以[] 0就是true。[1]变成1再变成数字1。这种规则在业务代码里基本属于灾难但它确实存在而且被大量老代码和面试题拿来用。2.2的抽象比较算法完整规则规范里把比较拆成了对应类型组合的规则表。我把它整理成一张常用的表格你以后再遇到奇怪的比较可以直接查左侧类型右侧类型转换规则nullundefined直接相等双双相等数字字符串字符串转数字布尔值任意布尔值先转数字true→1, false→0对象数字/字符串/布尔值对象通过 ToPrimitive 转原始值对象对象引用相等不做转换SymbolSymbol严格相等不自动转字符串这套规则最大的问题在于布尔值的转换。0 false的求值过程是false先转成数字0然后0也要转成数字0最终0 0所以答案是true。你嘴上念的是“字符串0等于布尔值false”实际上引擎比的是“数字0等于数字0”完全不是人类直觉。2.3 一个容易翻车的实战场景我在一个表单校验场景里踩过类似的坑。某同事写了一段代码判断用户输入是否为非空数字if (input.value true) { ... }他希望input.value为1时进入分支实际上1 true同样成立因为true转成 11转成 1看起来没问题。直到某一天用户输入了00 false是true于是“0”也被判为真值校验逻辑彻底错了。这种代码如果一直用就不会有任何问题。所以我在项目里对新人只有一个要求默认全用遇到必须写明注释说明为什么故意用抽象相等。这一条规则经过多年验证能挡住 90% 的类型转换坑。3. 手写一个深度比较函数从需求到实现3.1 先确定边界你到底在比较什么既然没法比对象内容业务里又经常需要“两个对象内容是否一样”的判断于是就有了深度比较的需求。比如表单提交时判断用户有没有改过数据比较接口返回的数据和本地缓存是否一致单元测试里断言一个对象是否等于期望值手写一个深度比较函数前必须先想清楚需求边界。我自己的实现要支持这些场景支持Object、Array、Date、RegExp、Map、Set支持循环引用不能因为a.self a这种结构死循环支持NaN相等即NaN和NaN应该视为相等null、undefined、函数这类特殊值要直接处理3.2 第一步基础类型直接用 Object.is 收口比较两个值最先分叉的就是“是不是能直接判等”的基础类型。这里我建议直接用Object.is()而不是原因就是 NaN 和正负零的问题。看下面这组对比Object.is(NaN, NaN); // true Object.is(0, -0); // falseNaN NaN是false但深比较通常希望NaN和NaN视为相等毕竟它们都是“非数字”这个状态0 -0是true但Object.is会区分正负零这在某些场景下更精确。我选择Object.is作为基础类型的判等函数避免自己写一堆特判。3.3 第二步引用类型分叉处理两个值都不是基本类型就进入深度比较流程。先看类型是否一致如果一个是数组一个是普通对象直接认为不等。然后针对特殊对象做差异化处理const getType (value) Object.prototype.toString.call(value); function isDeepEqual(a, b) { if (Object.is(a, b)) return true; if (typeof a ! object || typeof b ! object || a null || b null) return false; const typeA getType(a); const typeB getType(b); if (typeA ! typeB) return false; // Date 类型比较时间戳 if (typeA [object Date]) { return a.getTime() b.getTime(); } // RegExp 类型比较字符串源 if (typeA [object RegExp]) { return a.toString() b.toString(); } // Map/Set/ArrayBuffer 等继续展开... }这里用Object.prototype.toString.call()拿到的[object Date]这种标签是判断对象内置类型最稳的办法。有人喜欢用constructor.name但那个字段可以被改写不靠谱。3.4 第三步循环引用用 WeakMap 记录深度比较最麻烦的是循环引用。假设有这样的结构const objA { name: demo }; objA.self objA; const objB { name: demo }; objB.self objB;两个对象内容上完全一致但如果你在两个对象之间互相递归比对会永远绕不出来。解决方案是维护一个“已访问配对集合”用WeakMap存储左边存到右边的映射一旦发现同一对已经比较过就跳过并视为相等。这一步很关键少了它函数就直接爆栈。代码里是在每次递归进来时先检查当前a是否之前已经跟某个对象配对过如果配对的正是b说明这条分支是安全的不会死循环。3.5 完整可运行代码我把完整实现放出来这是一份能用在大多数业务场景的版本function isDeepEqual(a, b, seen new WeakMap()) { // 1. 基础类型直接交给 Object.is if (Object.is(a, b)) return true; // 2. 只要有一方不是对象或者一方是 null直接不等 if (typeof a ! object || typeof b ! object || a null || b null) { return false; } // 3. 内置类型标签不一致直接不等 const tagA Object.prototype.toString.call(a); const tagB Object.prototype.toString.call(b); if (tagA ! tagB) return false; // 4. 循环引用检测 if (seen.has(a)) { return seen.get(a) b; } seen.set(a, b); // 5. 特殊类型分别处理 if (tagA [object Date]) { return a.getTime() b.getTime(); } if (tagA [object RegExp]) { return a.toString() b.toString(); } if (tagA [object Map]) { if (a.size ! b.size) return false; for (const [key, value] of a) { if (!b.has(key) || !isDeepEqual(value, b.get(key), seen)) { return false; } } return true; } if (tagA [object Set]) { if (a.size ! b.size) return false; for (const value of a) { let matched false; for (const other of b) { if (isDeepEqual(value, other, seen)) { matched true; break; } } if (!matched) return false; } return true; } // 6. 数组和普通对象比较多键和键对应的值 const keysA Reflect.ownKeys(a); const keysB Reflect.ownKeys(b); if (keysA.length ! keysB.length) return false; for (const key of keysA) { if (!Object.prototype.hasOwnProperty.call(b, key)) { return false; } if (!isDeepEqual(a[key], b[key], seen)) { return false; } } return true; }测试几个场景const objA { a: [1, 2, { b: NaN }], date: new Date(2024-01-01) }; const objB { a: [1, 2, { b: NaN }], date: new Date(2024-01-01) }; console.log(isDeepEqual(objA, objB)); // true const objC { a: [1, 2, { b: NaN }] }; objC.self objC; const objD { a: [1, 2, { b: NaN }] }; objD.self objD; console.log(isDeepEqual(objC, objD)); // true循环引用不死循环 const one new Map([[k, { v: 1 }]]); const two new Map([[k, { v: 1 }]]); console.log(isDeepEqual(one, two)); // true这套实现兼顾了正确性和可读性日常业务完全够用。4. 性能与陷阱深度比较的真实成本4.1 为什么深比较这么慢深度比较的速度取决于两个维度对象规模与对象深度。一次深比较要遍历树上的每个节点时间复杂度和对象里的字段数量成正比。如果一个对象有几百个字段字段里又套着数组、套着对象那一次比较就要访问上千个引用。更麻烦的是空间开销。递归本身会占用调用栈如果用我前面那版带WeakMap的实现每次递归都要在映射表里登记一对对象引用。对象深度特别大的时候比如一个深度一万层左右的结构普通的递归写法直接就抛RangeError: Maximum call stack size exceeded。这不是深比较函数写错了是调用栈上限被顶穿了。有个实用的经验值深度几十层、字段几十个的对象毫秒级就能比较完深度成百上千层、总字段数上万的对象建议先想想有没有必要做“全量深比较”能不能换个策略。4.2 什么场景别用深比较我见过不少项目把深比较当万能药。列表数据一来先深比较判断是否变化再决定要不要重新渲染。这个思路本身没错问题出在比较的对象太大太频繁。举一个真实调优案例某后台管理系统的表格页面接口每次返回上千条记录每条记录几十个字段前端在useEffect里用深比较判断“数据是否变化”结果每次页面刷新都要卡顿 200 毫秒以上。排查的时候我一看代码就明白了你拿一个 O(n) 的深比较函数去比较一个 O(n) 的大数组最后算下来是 O(n^2) 级别的开销。上千条记录乘以每条几十个字段等于每次要执行几万次递归能不卡吗。那这种情况下怎么优化呢三个方向第一先浅比较再深比较。先用上一次的快照和当前数据做一个比较如果引用没变直接认为没变化跳过深比较。大部分场景下接口返回的新数据都是全新的引用浅比较就能拦住一大批。第二比较之前先算签名或哈希。给数据生成一个轻量摘要比如把关键字段拼接成字符串再对比或者用一个序列化库生成确定性字符串。当然这么做要拿捏好“确定性”这个点字段顺序变化会影响结果。第三定制比较范围。业务里往往只关心几个字段的变化没必要全量对比。手动写死要比较的字段名单速度快到可以忽略不计。我在那个表格项目里就只比较了列表的id集合和每条数据的status、time渲染性能问题直接消失。4.3 用缓存减少重复比较深度比较的另一个常见浪费是“重复比较同一个值”。很多状态管理实现里每次 action 派发后都会判断旧的 state 和新的 state 是否一致。如果 state 是一个嵌套很深的对象同一块子树其实经常被反复比较。比较好的做法是把比较结果缓存起来用 WeakMap 或者 Map 做记忆化。不过这个优化要谨慎因为缓存命中率和对象的不可变性强相关。业务里如果对象频繁被修改缓存反而增加了内存占用。我的经验是先测量再优化。用performance.now()跑一段比较逻辑看看真的慢不慢别上来就折腾缓存。注意深比较不是银弹。不要为了“结构严谨”而滥用深比较。优先用浅比较、引用比较解决问题确实需要值比较时再请出深比较。5. 实战中那些让人头疼的边界情况5.1 NaN、±0、Symbol基本类型的细节控前面说过Object.is(NaN, NaN)是true但你可能不知道Symbol比较同样有坑。Symbol(a) Symbol(a)是false因为每个 Symbol 都是唯一的。那么两个对象里各有一个Symbol(a)深比较应该把它们当作相等吗我的答案是不相等。因为 Symbol 的设计初衷就是唯一标识符如果创建时是单独调用的它们本质就是两个不同的东西。但反过来如果你用一个共享的 Symbol 变量赋值给两个属性const shared Symbol(shared); const a { key: shared }; const b { key: shared };这时两边指向同一个 Symbol 引用Object.is会给出true深比较自然也是相等的。这个逻辑其实和对象比较是一致的引用相同则相同。至于-0和0如果业务逻辑里真的有精度需求比如金融计算必须区分这两个值。这时候Object.is是兄弟反而是坑。如果你用写深比较0和-0会被判为相等这在普通业务里无所谓但在某些对正负零敏感的算法里会出问题。5.2 原型链和不可枚举属性深比较里另一个容易忽略的是Object.keys()和Reflect.ownKeys()的行为差异。普通深比较只要比较可枚举自有属性就够了业务里确实很少关心不可枚举属性。但如果你在做工具库或者要比较 JSON 反序列化出的数据不可枚举属性就不该被无视。看一个反例const a { name: demo }; Object.defineProperty(a, internal, { value: 42, enumerable: false, writable: false, configurable: false, }); const b { name: demo }; Object.defineProperty(b, internal, { value: 42, enumerable: false, writable: false, configurable: false, });用普通的Object.keys()比较a和b会被认为相等但实际上它们都有一个不可枚举的internal属性内容也一样。严格来说这不算错误但你得知道自己的比较范围。原型链方面我遇到过一个真实问题。某团队从一个第三方库拿对象库在原型上挂了方法对象自身没有这些属性。如果深比较里只比较自有属性两个对象可能内容一样但原型对象不同深比较根本不会发现。这种情况下如果你要做一个“结构性相等”的通用函数建议把原型也纳入比较范围。我的实现里没有直接比较原型因为业务里绝大多数场景只需要自有属性如果哪天需要了可以在函数入口处加一行if (Object.getPrototypeOf(a) ! Object.getPrototypeOf(b)) return false;5.3 跨 realm 对象不一样的“同一个对象”跨 realm 是深比较里最隐蔽的坑。如果你在页面里嵌了 iframeiframe 里的Array和主页面里的Array是两个不同的构造函数。用Array.isArray()可以正确判断但用instanceof Array就会失灵因为iframe里的数组的Array.prototype是 iframe 自己的原型链。更麻烦的是Object.prototype.toString.call()在这种场景下依然管用它返回的[object Array]不依赖具体构造函数所以我的实现里用的是toString标签来判断类型天然免疫了一部分跨 realm 问题。但Date、RegExp这种类的判断也是一样的思路只要用Object.prototype.toString.call(value)拿标签基本不会翻车。注意一点Symbol.toStringTag可以被自定义对象改写。碰见带有Symbol.toStringTag属性的对象Object.prototype.toString.call()会返回被改写后的标签这种“伪装”行为在写防御性代码时要留意。5.4 一次真实的排错实录最后分享一个让我印象深刻的排查经历。某次项目里两个对象用我的深比较函数一直返回false但肉眼看起来内容一模一样。控制台输出都是一样的字段打印 JSON 也一致。我先怀疑是字段顺序问题然后用Object.keys()打印了双方的 keys发现顺序也一样。再逐一比对字段发现某个字段的值一个是1另一个是1——一个是数字一个是从后端拿到的字符串。控制台里打印出来长得完全一样颜色不同而已控制台会区分数字和字符串的颜色不仔细看根本发现不了。从那以后我在深比较函数里加上了类型先行的判断if (typeof a ! typeof b) return false;别看这只是个小改动它让函数第一时间拦截类型不一致的情况避免进入深递归之后才发现问题。另一条经验是比较之前先JSON.stringify两个对象打一眼能快速排除大部分低级问题。当然JSON.stringify对循环引用会抛错对undefined、函数也会直接忽略这只能作为辅助手段。这个案例也再次印证了前面说的用而不是做基础比较能让你在深比较里少一个潜在的类型转换陷阱。我用这套深比较函数跑了将近一年覆盖了几个业务模块的状态判断和数据校验整体非常稳。唯一一次翻车是在一次手滑把WeakMap的参数顺序写反了导致循环引用检测失效排查了好一阵子才定位到。后来我加了注释把seen参数的含义和用法固定下来。如果你也要在生产代码里用类似的实现强烈建议你针对循环引用专门写一个测试用例——这是深比较函数最容易在重构时被搞坏的地方。