学完这篇你能拿到一份可直接抄走的 JS 深比较方案清单,知道每种方案在什么场合会出错、跑起来大概快多少,并且能在项目里选对那一个。
第一步:先搞清楚要比较哪些坑
深比较不是「把两个对象逐层比一遍」这么简单,至少要覆盖:嵌套对象与数组、Date、RegExp、Map/Set、NaN(NaN !== NaN)、-0 与 0、循环引用、以及「对象键顺序不同但语义相同」。先想清楚你的数据长什么样,再挑方案。
注意:如果比较的是 null 或基本类型,先在最前面用 === 或 Object.is 短路,能省掉 90% 的开销;这一步几乎所有方案都建议手动加。
第二步:方案一 —— JSON.stringify 序列化
最省事的写法:
function deepEqualByJSON(a, b) {
return JSON.stringify(a) === JSON.stringify(b)
}
适合场景:临时脚本、数据来自接口且结构简单。
问题在于它会静默丢东西:
deepEqualByJSON({ a: undefined }, {}) // true,其实是错的
deepEqualByJSON({ a: 1, b: 2 }, { b: 2, a: 1 }) // false,键序不同
deepEqualByJSON({ d: new Date(0) }, { d: new Date(0) }) // true,但比较的是字符串
deepEqualByJSON({ n: NaN }, { n: NaN }) // true,NaN 变 null
一旦对象里有循环引用,直接抛 TypeError: Converting circular structure to JSON。
注意:JSON.stringify 本身就要把整个对象遍历并拼字符串,大对象上它往往是最慢的方案,不是「简单所以快」。
第三步:方案二 —— 手写递归
要可控就自己写,核心是「先比类型和长度,再逐键下钻」:
function deepEqual(a, b, seen = new WeakMap()) {
if (Object.is(a, 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 (seen.get(a) === b) return true // 处理循环引用
seen.set(a, b)
if (a instanceof Date) return a.getTime() === b.getTime()
if (a instanceof RegExp) return a.source === b.source && a.flags === b.flags
const ka = Object.keys(a), kb = Object.keys(b)
if (ka.length !== kb.length) return false
for (const k of ka) {
if (!Object.prototype.hasOwnProperty.call(b, k)) return false
if (!deepEqual(a[k], b[k], seen)) return false
}
return true
}
关键点有三个:WeakMap 记录已比较过的对象对来破循环、先比 keys 长度减少无效下钻、Object.is 顺手解决 NaN 和 -0。
注意:Object.keys 只能比可枚举自有属性,Symbol 键和不可枚举属性会被忽略。需要的话换成 Reflect.ownKeys 并过滤。
第四步:方案三 —— 用现成库
前端最常用的是 fast-deep-equal(体积小、无依赖),数据复杂、需要处理 Map/Set/TypedArray 时用 lodash.isEqual:
import deepEqual from 'fast-deep-equal'
import { isEqual } from 'lodash-es'
fast-deep-equal 只比自有可枚举属性,默认不处理 Map/Set;lodash.isEqual 覆盖面全、还会处理部分继承属性,但包体大。别为了一个比较函数整包引入 lodash,按需 lodash/isEqual 或 lodash-es 具名引入。
第五步:方案四 —— Node 原生 util.isDeepStrictEqual
只在 Node 环境跑(比如测试断言、服务端比对)的话,直接用内置的:
const { isDeepStrictEqual } = require('node:util')
isDeepStrictEqual({ a: [1, 2] }, { a: [1, 2] }) // true
零依赖、逻辑经过充分打磨,还能正确区分 1 和 '1'、识别 NaN。缺点是浏览器环境用不了。
第六步:跑一次真实性能对比
性能要自己测,样本和数据形状比结论更重要。用下面这段脚本在本机跑:
import { isEqual } from 'lodash-es'
import deepEqual from 'fast-deep-equal'
import { isDeepStrictEqual } from 'node:util'
const make = () => ({
id: 1,
list: Array.from({ length: 100 }, (_, i) => ({ i, name: `n${i}` })),
meta: { tags: ['a', 'b'], at: Date.now() }
})
const a = make(), b = make()
const bench = (name, fn, n = 10000) => {
const t = performance.now()
for (let i = 0; i < n; i++) fn(a, b)
console.log(name, (performance.now() - t).toFixed(1) + 'ms')
}
bench('JSON', (x, y) => JSON.stringify(x) === JSON.stringify(y), 1000)
bench('fast-deep-equal', deepEqual)
bench('lodash.isEqual', isEqual)
bench('isDeepStrictEqual', isDeepStrictEqual)
在我这里的数量级结论是:fast-deep-equal 通常比 lodash.isEqual 快 2~5 倍;JSON.stringify 在百条数组这种中等对象上慢一个数量级,而且对象越大差距越明显;isDeepStrictEqual 介于两者之间。
注意:别只看单一场景。对象「浅而宽」(键多但没嵌套)时,各方案差距会缩小;「深而窄」时递归实现的栈开销占比上升,此时先用引用相等和浅层 for 比较做预判最有收益。
实际项目里的通用做法是:先 if (a === b) return true 短路,再比对 key 数量和浅层值,最后才进深比较。
小结
JSON.stringify 最省事但会丢 undefined、函数、Symbol,还怕键序和循环引用,只适合临时场景。
- 手写递归要处理三件事:循环引用(
WeakMap)、NaN/-0(Object.is)、键数量预筛。
- 浏览器优先
fast-deep-equal,要覆盖 Map/Set 用 lodash.isEqual,Node 内直接用 util.isDeepStrictEqual。
- 性能优化第一刀永远不是换算法,而是在入口处
=== 短路、浅层先比。
- 跑分要用自己的真实数据结构测,别照搬别人的结论。