前端知库

怎样手写深比较 isEqual?

先处理原始值和同一引用,再按类型逐层比较:Date 比时间戳,RegExp 比 source 和 flags,数组和普通对象比自有可枚举键的数量和每个键的值,用 WeakMap 记录正在比较的对象对来处理循环引用。NaN 通常当作相等,+0 和 -0 是否相等要按需求明确取舍。

参考答案

简答

表单提交前想判断用户有没有改过数据,直接 oldData === newData 不行,两个内容一样的对象引用不同,结果总是 false。这时需要深比较:一层层往下比,所有值都相同才算相等。

写法是递归。原始值用 === 比较;两边都是对象时,先看原型是否相同,排除数组和普通对象混比,再比较自有可枚举键的数量,最后逐个键递归比较值。Date 要比时间戳,RegExp 要比 source 和 flags,它们的数据不在可枚举属性里。

有几处要明确取舍。NaN === NaN 是 false,但深比较一般希望两个 NaN 相等;+0 === -0 是 true,Object.is 却把它们区分开,按业务选一种并写清楚。键数要比较,否则 { a: 1 } 和 { a: 1, b: undefined } 会被误判相等。

还要处理循环引用。对象的属性指回自己时,递归会无限进行,所以用 WeakMap 记下正在比较的对象对,再次遇到同一对就直接返回 true。

实现与测试

下面的实现按“原始值 → 原型 → 特殊对象 → 循环引用 → 键”的顺序判断,重点看每一步排除了哪类情况。

JavaScript
function isEqual(a, b, seen = new WeakMap()) {
  // NaN 和自己相等;+0 和 -0 也当作相等,和 === 保持一致
  if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true;
  if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
    return false;
  }
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;

  if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
  if (a instanceof RegExp) return a.source === b.source && a.flags === b.flags;

  // 已经在比较这一对,说明遇到了循环引用,先假定相等,结论由其他字段决定
  if (seen.get(a) === b) return true;
  seen.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.hasOwn(b, key) || !isEqual(a[key], b[key], seen)) return false;
  }
  return true;
}

console.log(isEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] })); // true
console.log(isEqual(NaN, NaN)); // true
console.log(isEqual(0, -0)); // true
console.log(isEqual({ a: 1 }, { a: 1, b: undefined })); // false
console.log(isEqual([1, 2], { 0: 1, 1: 2 })); // false
console.log(isEqual(new Date(0), new Date(0))); // true
console.log(isEqual(/a/g, /a/i)); // false

const x = { name: 'x' };
x.self = x;
const y = { name: 'x' };
y.self = y;
console.log(isEqual(x, y)); // true

为什么 { a: 1 } 和 { a: 1, b: undefined } 不相等?如果只遍历 a 的键,b 多出来的 b 属性根本不会被检查;就算遍历 b 的键,a.b 读出来也是 undefined,会被误判相等。所以先比较键的数量,再用 Object.hasOwn 确认键真的存在。

为什么 [1, 2] 和 { 0: 1, 1: 2 } 不相等?它们的键和值完全一样,Object.keys 都是 ['0', '1'],区别只在原型:一个是 Array.prototype,一个是 Object.prototype。比较原型这一步顺便保证了后面 a instanceof Date 成立时 b 也是 Date。

为什么 Date 和 RegExp 要单独处理?Object.keys(new Date(0)) 是空数组,时间值存在对象内部,不是可枚举属性,不单独处理的话任意两个日期都会被判为相等。Date 用 getTime() 取时间戳,用 Object.is 比较是为了让两个无效日期(时间戳都是 NaN)也算相等。RegExp 的 flags 返回所有标志组成的字符串,和 source 一起就能确定一个正则。

NaN 和 +0、-0 怎样取舍

先看三种比较方式在这两个值上的差别。

JavaScript
console.log(NaN === NaN); // false
console.log(Object.is(NaN, NaN)); // true
console.log(0 === -0); // true
console.log(Object.is(0, -0)); // false
console.log(new Date(0) === new Date(0)); // false

MDN 说 Object.is 和 === 的唯一区别,就在于对 NaN 和正负零的处理。深比较关心“内容是否一样”,两个 NaN 通常应该相等,所以实现里单独判断了 Number.isNaN。至于 +0 和 -0,大部分业务数据里它们没有区别,上面的实现跟随 === 当作相等。如果需要区分(比如数值计算里关心符号),把第一行改成 Object.is(a, b) 即可,NaN 也会一起被当作相等。

循环引用怎样处理

x.self 指向 x 自己。不做处理的话,比较 x.self 和 y.self 又回到比较 x 和 y,递归停不下来,最后栈溢出。

seen 记录“a 正在和哪个 b 比较”。第二次遇到 x 和 y 这一对时直接返回 true,意思是“这一对是否相等,由外层还在进行的比较决定”。外层会继续比较 name 等其他字段,任何一个不同都会返回 false。用 WeakMap 是因为键是对象,比较结束后不会阻止这些对象被回收。

常见追问

为什么不用 JSON.stringify 比较?

JSON.stringify 结果依赖键的顺序,{ a: 1, b: 2 } 和 { b: 2, a: 1 } 会被判为不同;它还会丢掉 undefined 和函数,把 NaN 变成 null,遇到循环引用直接抛错。只在数据结构简单、顺序固定时才能凑合用。

Map、Set 和 Symbol 键要怎样处理?

上面的实现没有覆盖它们。Map 要比较 size,再逐个键取值递归比较;Set 的元素如果是对象,需要两两匹配,成本更高。Object.keys 不包含 Symbol 键,需要的话改用 Reflect.ownKeys。面试时说明这些边界,并说出怎样扩展就可以。

深比较和深拷贝的实现有什么共同点?

都要递归遍历、按类型分支、用 WeakMap 处理循环引用。深拷贝的写法可以看《怎样手写深拷贝?和 structuredClone 有什么区别?》。