☰
30-seconds-of-code:JavaScript 对象比较完全指南——从引用相等、JSON 序列化到递归深度相等
2026/10/4 1:42:20 网站建设 项目流程
  • 教程
  • 文档

【免费下载链接】30-seconds-of-code

Coding articles to level up your development skills

项目地址:https://gitcode.com/gh_mirrors/30/30-seconds-of-code
点击查看免费下载

导读

在 JavaScript 中,"两个对象是否相等"远没有初看起来那么简单:即使两个对象拥有完全相同的属性和值,==与===也会返回false;用JSON.stringify()比较序列化字符串又暗藏多个陷阱;真正可靠的方案通常需要借助递归实现深度相等比较。本文基于 30-seconds-of-code 仓库中的 object-comparison.md 展开,逐层讲解引用比较、序列化比较与深度相等三种方案的原理、代码实现与边界情况,并关联仓库内js/comparison系列的其他文档与源码级佐证,帮助你写出可复用的对象比较工具。

为什么两个"内容相同"的对象不相等

JavaScript 中对象与数组都是引用类型(reference type),其相等性判断基于引用(reference)而非内容。这意味着只要两个对象在内存中不是同一个实例,无论其属性如何相似,==与===都会判定它们不相等:

const a = { name: 'John', age: 26 }; const b = { name: 'John', age: 26 }; a === b; // false

这与原始值(primitive)的按值比较截然不同。仓库中的姊妹篇 equality.md 详细解释了双等号(==,宽松相等)与三等号(===,严格相等)的差异:==会先进行类型强制转换(type coercion)再比较值,而===同时比较类型与值,行为更可预测,因此日常开发中应优先使用===。

此外还存在第三个选择Object.is(),它与===行为基本一致,唯一的例外是NaN与+0/-0的处理(详见 object-is-triple-equals.md):

{} === {}; // false Object.is({}, {}); // false,对象依旧按引用比较 +0 === -0; // true Object.is(+0, -0); // false NaN === NaN; // false Object.is(NaN, NaN); // true

不过要注意,Object.is()同样只解决"引用是否相同"的问题,无法比较两个内容相同但引用不同的对象。这正是下面两种方案试图解决的问题。

JSON.stringify() 比较法及其陷阱

JSON.stringify()经常被当作对象比较的"快捷方案":把两个对象序列化成字符串后,再比较字符串是否相等。

const equals = (a, b) => JSON.stringify(a) === JSON.stringify(b); const a = { name: 'John', age: 26 }; const b = { name: 'John', age: 26 }; equals(a, b); // true const c = { name: 'John' }; const d = { name: 'John', age: undefined }; equals(c, d); // true,但按语义应该为 false

上面的例子揭示了该类方案最常见的陷阱:相似但不相等的值可能序列化出完全相同的字符串。undefined属性在序列化时会被直接丢弃,导致{ name: 'John' }与{ name: 'John', age: undefined }的序列化结果一致。除此以外,JSON.stringify()比较法还存在其他已知限制:

  • 键的顺序:序列化结果依赖键的插入顺序(数字键另有排序规则),属性顺序不同但内容相同的对象会得到不同字符串;
  • 函数、Symbol 等特殊值:会被序列化流程跳过或处理为特殊值,导致误判;
  • 循环引用:遇到自引用对象会抛出TypeError: Converting circular structure to JSON(仓库中的 deep-clone-structured-clone.md 同样提到了这一点)。

因此,JSON.stringify()只适合少数"恰好没有上述边界情况"的场景,不能作为通用对象比较方案。真正的解决方案是下面这种递归深度相等比较。

深度相等比较:手写递归 equals()

深度相等比较(deep equality)通过递归逐层比较两个对象每个嵌套层级的值,能够覆盖空值、特殊类型、多层级嵌套等大多数场景。这是社区中各类 shallow/deep equality 工具函数普遍采用的思路。

逐步拆解实现逻辑

实现equals(a, b)需要依次处理以下场景(步骤顺序即代码顺序):

第一步:恒等判断(identical)先检查a === b。这一步同时覆盖了原始值的相等判断,以及"两个引用指向同一个对象"的情况。

if (a === b) return true;

第二步:Date 对象特殊处理如果双方都是Date实例,用Date.prototype.getTime()比较其时间戳(毫秒值)。两个表示同一时刻但不同实例的Date对象,在===下不相等,但时间值相等时语义上应当相等。

if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();

第三步:空值与原始值如果任意一方是null/undefined(即 falsy),或者双方都不是对象(原始值),则回退到严格比较a === b。这样能正确处理原始值相等、以及null/undefined与对象混合的情况。

if (!a || !b || (typeof a !== 'object' && typeof b !== 'object')) return a === b;

第四步:原型检查如果两者原型(prototype)不同,直接返回false。这一步防止把具有相同形状但来源不同的对象误判为相等。

if (a.prototype !== b.prototype) return false;

第五步:键数量与递归使用Object.keys()获取双方的可枚举键,先比较键的数量是否一致;再用Array.prototype.every()遍历a的每个键,确认该键在b中存在,并递归调用equals()比较对应值。

const keys = Object.keys(a); if (keys.length !== Object.keys(b).length) return false; return keys.every(k => equals(a[k], b[k]));

完整实现与运行验证

将上述五步组合起来,就得到了一份完整的深度相等比较函数:

const equals = (a, b) => { if (a === b) return true; if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime(); if (!a || !b || (typeof a !== 'object' && typeof b !== 'object')) return a === b; if (a.prototype !== b.prototype) return false; const keys = Object.keys(a); if (keys.length !== Object.keys(b).length) return false; return keys.every(k => equals(a[k], b[k])); }; const a = { name: 'John', age: 26 }; const b = { name: 'John', age: 26 }; equals(a, b); // true,内容相同 const c = { name: 'John' }; const d = { name: 'John', age: undefined }; equals(c, d); // false,键数量不同,正确区分了 JSON.stringify 会误判的场景 const e = [1, 2, 3]; const f = { 0: 1, 1: 2, 2: 3 }; equals(e, f); // true,数组与数字键对象在此实现下被视为相等

从源码验证边界行为

最后这个例子值得单独说明:equals([1, 2, 3], { 0: 1, 1: 2, 2: 3 })返回true。从该实现看,数组与数字索引对象拥有相同的键集合(['0', '1', '2'])且对应值相等,Object.keys()无法区分二者,因此被判定为相等。这正是"从代码结构可以推断"的行为特征:该实现更关注键与值的结构一致性,而非容器类型本身。如果你的业务要求严格区分数组与普通对象,可以在递归前增加Array.isArray()的类型一致性检查。

另一个值得留意的边界是原型链上的属性:Object.keys()只返回对象自身的可枚举键,因此该实现只比较自有属性,继承属性不会参与比较。这与仓库中 object-has-value-or-key.md 提到的"键检查只针对自身可枚举属性"的约定一致。

补充方案:单向匹配与对称匹配

深度相等是"双向、全量"的比较,但很多业务场景只需要部分匹配(例如表单校验、配置核对)。仓库中的 match-object-properties.md 提供了另一组工具函数,可作为深度相等的补充:

单向匹配:判断第一个对象的键是否为第二个对象键的"超集",且对应值相等。只遍历source的键,配合Object.prototype.hasOwnProperty()与===:

const matches = (obj, source) => Object.keys(source).every( key => obj.hasOwnProperty(key) && obj[key] === source[key] ); matches( { age: 25, hair: 'long', beard: true }, { hair: 'long', beard: true } ); // true

对称匹配:双向比较两个对象的键集合完全一致且值相等,利用Set汇总双方键并校验集合大小:

const matchesSymmetric = (a, b) => { const keysA = Object.keys(a), keysB = Object.keys(b); const keys = new Set([...Object.keys(a), ...Object.keys(b)]); if (keys.size !== keysA.length || keys.size !== keysB.length) return false; return [...keys].every( key => a.hasOwnProperty(key) && b.hasOwnProperty(key) && a[key] === b[key] ); }; matchesSymmetric( { hair: 'long', beard: true }, { age: 25, hair: 'long', beard: true } ); // false,键集合大小不一致

值得注意的是,matches/matchesSymmetric默认用===做值比较(即浅层值比较),而equals()用递归做深层值比较;两者在嵌套对象场景下行为不同,应根据数据深度选择。此外matchesWith/matchesSymmetricWith还支持传入自定义比较函数,用于正则、忽略大小写等特殊匹配规则。

关联:比较与克隆、数组比较

理解对象比较,还需要把它放入整个js/comparison系列中看待。仓库的 comparison.yaml 将该主题组织为一条完整的知识链,包含js/s/equality、js/s/object-comparison、js/s/array-comparison、js/s/date-comparison、js/s/object-is-triple-equals、js/s/match-object-properties、js/s/arrays-have-same-contents等条目。

其中与对象比较最密切的两个关联主题是:

  • 克隆:对象按引用传递的机制决定了"复制"需要克隆。浅克隆只复制外层,嵌套对象仍是引用;深克隆(递归或structuredClone())才能得到完全独立的副本。参见 shallow-deep-clone-object.md 与 deep-clone-structured-clone.md。需要注意的是,克隆与比较是两个方向的问题——深度相等比较是判断"两个对象内容是否一致",而深克隆是"生成一个内容一致但独立的对象",二者常配合使用(例如先克隆再比较,或在不可变数据流中校验结果)。
  • 数组比较:数组是对象的一种特殊形态,arrays-have-same-contents.md 讨论了"忽略顺序、只看元素与频次"的比较策略,它使用Array.prototype.every()与Set统计唯一值,思路与equals()的递归结构互相印证——都是"先核对规模(长度/键数量),再逐项确认"。

总结与选型建议

回顾三种方案,可以给出如下的选型建议:

方案适用场景主要局限
==/===/Object.is()引用相等、原始值比较无法比较两个内容相同但引用不同的对象
JSON.stringify()比较无undefined、无函数、无循环引用的扁平对象序列化会丢弃/改写特殊值,键顺序影响结果
递归深度相等equals()嵌套对象、Date、多层级结构的通用比较需要自行维护边界情况,且对数组与数字键对象不区分

实际工程中,推荐优先采用递归深度相等这类显式实现(或社区成熟的 deep-equal 类库),并在其中显式处理业务相关的边界类型(Date、Map、Set、RegExp、循环引用等)。如果比较只是"验证某个对象是否包含另一对象的属性子集",matches系列是更轻量的选择。将 object-comparison.md 与js/comparison系列的其他文档结合阅读,你就能系统掌握 JavaScript 值比较的全貌。

  • 教程
  • 文档

【免费下载链接】30-seconds-of-code

Coding articles to level up your development skills

项目地址:https://gitcode.com/gh_mirrors/30/30-seconds-of-code
点击查看免费下载
上一篇:pi-mono的可扩展性设计:应对业务增长的架构考量
下一篇:ALVR有线串流实战指南:突破Wi-Fi限制,实现稳定低延迟VR体验

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询