怎样手写深比较 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。
实现与测试
下面的实现按“原始值 → 原型 → 特殊对象 → 循环引用 → 键”的顺序判断,重点看每一步排除了哪类情况。
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 怎样取舍
先看三种比较方式在这两个值上的差别。
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 有什么区别?》。