1. 项目概述:为什么“数组相等”是个技术活?
刚接触JavaScript那会儿,我也觉得判断两个数组是否相等是件再简单不过的事。不就是
arr1 == arr2
或者
arr1 === arr2
吗?结果第一次在项目里这么写,就踩了个大坑。两个看起来一模一样的数组,用
===
比较,返回的永远是
false
。那一刻我才明白,在JavaScript的世界里,数组的“相等”远不止字面意思那么简单。这背后涉及到引用比较、值比较、嵌套结构、特殊值处理等一系列问题,是前端开发中一个高频且容易出错的细节。
无论是表单数据比对、状态管理中的脏检查、单元测试断言,还是实现一个自定义的深比较函数,判断数组相等都是绕不开的基础操作。网上搜一下,你会发现从简单的循环遍历到复杂的递归算法,各种方案五花八门。但哪种方案最可靠?哪种性能最好?在什么场景下该用哪种?这些问题往往没有标准答案,需要根据数组的结构、数据量以及对特殊值(如
NaN
,
undefined
, 稀疏数组,甚至循环引用)的处理要求来综合决定。
这篇文章,我就结合自己多年踩坑和填坑的经验,把JavaScript中判断数组相等的各种方法掰开揉碎了讲清楚。我会从最基础的引用比较讲起,逐步深入到浅比较、深比较,分析每种方法的原理、适用场景和潜在陷阱,并给出可以直接“抄作业”的代码实现和性能优化建议。无论你是正在学习JavaScript的新手,还是想巩固基础的中高级开发者,相信都能从中获得一些实用的启发。
2. 核心概念拆解:从“相等”的定义说起
在深入代码之前,我们必须先统一对“数组相等”这个概念的理解。在不同的上下文和需求下,“相等”的标准可能截然不同。
2.1 引用相等 vs. 值相等
这是最根本的区分,也是新手最容易混淆的地方。
引用相等
指的是两个变量指向内存中的同一个数组对象。在JavaScript中,数组是引用类型,
==
或
===
运算符比较的是引用地址,而不是数组的内容。
const arr1 = [1, 2, 3];
const arr2 = arr1; // arr2 是 arr1 的引用
const arr3 = [1, 2, 3]; // arr3 是一个全新的数组对象
console.log(arr1 === arr2); // true,指向同一个对象
console.log(arr1 === arr3); // false,内容相同,但引用不同
注意 :在绝大多数业务场景下,我们关心的都是值相等,即两个独立的数组是否包含相同的元素。引用相等通常只在你想知道“是不是同一个数组对象”时有用,比如在React的
useEffect依赖数组中,如果你错误地传入了一个新创建但内容相同的数组,可能会导致不必要的重渲染。
值相等 则要求两个数组:
- 长度相同。
- 在相同索引位置上的元素“相等”。
而元素的“相等”又引出了下一个问题:什么是元素的相等?对于基本类型(
number
,
string
,
boolean
,
null
,
undefined
,
symbol
,
bigint
),值相等就是值相同。但对于对象(包括数组、函数、普通对象等),就需要进一步定义是浅比较还是深比较。
2.2 浅比较 vs. 深比较
这是处理嵌套数据结构时的核心概念。
浅比较 只比较数组的第一层元素。如果元素是基本类型,则比较值;如果元素是对象(包括子数组),则比较它们的引用是否相同。
const arrA = [1, { name: 'Alice' }];
const arrB = [1, { name: 'Alice' }];
// 一个简单的浅比较函数(后面会实现)
function shallowEqual(arr1, arr2) {
if (arr1.length !== arr2.length) return false;
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) return false; // 对于对象,这里比较的是引用!
}
return true;
}
console.log(shallowEqual(arrA, arrB)); // false! 因为第二个元素是两个不同的对象
深比较 则会递归地比较数组的所有层级,直到所有叶子节点都是基本类型,并确保它们的值相等。
// 一个理想的深比较结果
deepEqual(arrA, arrB); // 应该返回 true,因为递归比较后,对象的内容是相同的。
选择浅比较还是深比较,完全取决于你的数据结构。如果你的数组里只包含基本类型,或者你明确知道并接受“对象引用相同才视为相等”的规则,那么浅比较就足够了,它的性能也远高于深比较。反之,如果你的数组可能嵌套了复杂的对象或数组,并且你需要比较它们的内容,就必须使用深比较。
2.3 特殊值的处理
一个健壮的相等判断函数必须妥善处理JavaScript中的各种“怪咖”值。
-
NaN:NaN是JavaScript中唯一一个不等于自身的值。NaN === NaN返回false。因此,在比较时,需要单独判断一个值是否是NaN(通常使用Number.isNaN())。 -
+0和-0: 在常规的===比较中,+0 === -0返回true。但在某些严格的数学或规范场景下,它们被视为不同。大多数情况下可以忽略,但需要知晓。 -
null和undefined:null === undefined返回false。你需要决定在你的比较逻辑中,是否要将它们视为相等。通常,null只等于null,undefined只等于undefined。 -
稀疏数组
: 例如
const arr = []; arr[5] = 'foo';,这个数组的length是6,但索引0到4的位置都是“空槽”。遍历时,这些空槽的值是undefined,但它们与直接赋值为undefined在行为上有细微差别(例如在Array.prototype.map中会被跳过)。一个严谨的比较函数可能需要考虑这一点。 - 循环引用 : 这是深比较中最棘手的问题。当一个对象(或数组)通过属性间接或直接地引用自身时,就形成了循环引用。一个不处理循环引用的深比较函数会陷入无限递归,导致栈溢出。
// 循环引用示例
const obj = {};
obj.self = obj;
const arrX = [obj];
const arrY = [obj];
// 深比较 arrX 和 arrY 时,如果不处理循环引用,会在比较 obj.self 时无限递归下去。
理解了这些核心概念,我们才能有的放矢地选择和实现合适的比较方法。
3. 常用方法实现与深度剖析
接下来,我们逐一实现并分析几种常见的数组相等判断方法,从简单到复杂,从片面到全面。
3.1 方法一:JSON.stringify 的“捷径”与巨坑
这是网络上流传最广的“一行代码解决”方案。
function arraysEqualJSON(arr1, arr2) {
return JSON.stringify(arr1) === JSON.stringify(arr2);
}
原理 :将两个数组都序列化成JSON字符串,然后比较字符串是否相等。
优点 :
- 代码极其简洁。
- 对于只包含可序列化值(字符串、数字、布尔、null、普通对象和数组)且顺序一致的数组,它能实现深比较的效果。
致命缺点与坑点 :
-
顺序敏感
:
JSON.stringify对对象的属性顺序不保证稳定(虽然现代引擎通常按定义顺序,但规范未强制)。如果数组元素是对象,且属性顺序不同,即使内容相同,字符串也可能不同。console.log(JSON.stringify({a:1,b:2}) === JSON.stringify({b:2,a:1})); // 可能为 false -
无法处理特殊值
:
-
undefined、函数、Symbol在序列化时会被忽略(在数组中会变成null?不,在数组中直接变成null,在对象中则整个键值对消失)。
console.log(arraysEqualJSON([undefined], [null])); // true! 错误 console.log(arraysEqualJSON([function(){}], [null])); // true! 错误-
NaN和Infinity会被序列化为null。
console.log(arraysEqualJSON([NaN], [null])); // true! 错误- 循环引用会导致序列化报错。
-
- 性能问题 :对于大数组或复杂对象,序列化和字符串比较的开销很大。
实操心得 :这个方法 仅适用于 临时、快速的调试,或者你100%确定数组仅包含基础类型、且不需要考虑
undefined等边界情况。 绝对不要 将其用于生产环境的正式逻辑或测试用例中,它的不可靠性会带来难以排查的Bug。
3.2 方法二:基于循环的浅比较
这是最基础、最可靠的手动实现方法,适用于一维数组或明确只需浅比较的场景。
function arraysShallowEqual(arr1, arr2) {
// 1. 检查是否为同一个引用(可选,但能快速通过)
if (arr1 === arr2) return true;
// 2. 检查长度
if (arr1.length !== arr2.length) return false;
// 3. 遍历比较每个元素
for (let i = 0; i < arr1.length; i++) {
// 使用 Object.is 进行严格比较,它能正确处理 NaN 和 +0/-0
if (!Object.is(arr1[i], arr2[i])) {
return false;
}
}
// 4. 所有元素都通过比较
return true;
}
关键点解析 :
-
Object.is(value1, value2):这是比===更严格的相等比较。它解决了===的两个“缺陷”:-
Object.is(NaN, NaN)返回true。 -
Object.is(+0, -0)返回false。 如果你希望NaN等于自身,并且不区分+0和-0,使用Object.is是最佳选择。如果不需要,用===也可以。
-
-
提前引用检查
:
if (arr1 === arr2) return true;这行是一个有效的优化。如果两个变量指向同一个数组,那么它们的内容必然相同,无需再遍历。 -
稀疏数组
:这个实现能正确处理稀疏数组,因为
for循环会访问到每个索引(包括空槽,其值为undefined),并与另一个数组相同索引的值(也是undefined)用Object.is比较,结果是true。
适用场景 :一维数组,且元素均为基本类型,或你明确希望进行引用比较。
局限性
:对于嵌套对象或数组,它比较的是引用。
[{a:1}]
和
[{a:1}]
会被判定为不相等。
3.3 方法三:递归实现深比较
当需要比较嵌套结构时,我们必须实现一个递归函数。这是一个功能相对完整的深比较实现:
function isDeepEqual(value1, value2) {
// 使用 Object.is 进行基本类型和特殊值(NaN)的比较
if (Object.is(value1, value2)) {
return true;
}
// 判断是否为对象类型(包括数组和普通对象,排除null)
if (
typeof value1 !== 'object' ||
value1 === null ||
typeof value2 !== 'object' ||
value2 === null
) {
return false; // 其中一个是非对象,且Object.is已返回false,则不相等
}
// 处理数组
if (Array.isArray(value1) && Array.isArray(value2)) {
if (value1.length !== value2.length) return false;
for (let i = 0; i < value1.length; i++) {
if (!isDeepEqual(value1[i], value2[i])) return false;
}
return true;
}
// 处理普通对象
// 如果一个是数组一个是对象,则不相等
if (Array.isArray(value1) || Array.isArray(value2)) {
return false;
}
// 获取对象自身的所有键(包括不可枚举和Symbol?这里只使用可枚举属性,通常足够)
const keys1 = Object.keys(value1);
const keys2 = Object.keys(value2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
// 检查 key 是否在 value2 中,并递归比较其值
if (!Object.prototype.hasOwnProperty.call(value2, key)) {
return false;
}
if (!isDeepEqual(value1[key], value2[key])) {
return false;
}
}
return true;
}
// 用于数组的包装函数
function arraysDeepEqual(arr1, arr2) {
return isDeepEqual(arr1, arr2);
}
实现细节与考量 :
-
递归入口
:
isDeepEqual是一个通用函数,可以比较任意两个值。 -
类型检查顺序
:先处理基本类型的快速返回(
Object.is),再处理对象。对于null,typeof null === 'object',所以需要显式排除。 -
数组与对象区分
:使用
Array.isArray()明确区分数组和普通对象。一个数组和一个拥有数字键的普通对象不应该被视为相等。 -
属性比较
:对于对象,我们通常只比较自身可枚举的字符串键属性。这符合大多数场景。如果你需要比较
Symbol键或不可枚举属性,需要调整Object.keys()的部分。 - 循环引用问题 : 上面的实现没有处理循环引用! 这是它的一个重大缺陷。传入循环引用的数据结构会导致无限递归和栈溢出。
3.4 方法四:处理循环引用的健壮深比较
为了解决循环引用,我们需要一个记忆机制来记录已经比较过的对象对,如果再次遇到,则根据情况判断。
function isDeepEqualCyclic(value1, value2, seen = new WeakMap()) {
// 基本类型和特殊值比较
if (Object.is(value1, value2)) {
return true;
}
// 对象类型检查
if (
typeof value1 !== 'object' ||
value1 === null ||
typeof value2 !== 'object' ||
value2 === null
) {
return false;
}
// --- 循环引用检查 ---
// 如果这对对象已经在比较中,则认为它们相等(防止无限递归)
// 使用 WeakMap,键是对象,值是与之比较的另一个对象的 Map
let seenForValue1 = seen.get(value1);
if (seenForValue1) {
// 如果 value1 之前和 value2 比较过,说明遇到了循环引用,且当时认为它们相等(或正在比较中)
// 这里我们返回 true,表示在本次递归路径上,它们被视为相等,以继续其他部分的比较。
// 更严谨的做法是存储比较结果,这里简化处理。
return seenForValue1.get(value2) === true;
}
// 记录当前比较对
if (!seen.has(value1)) {
seen.set(value1, new WeakMap());
}
seen.get(value1).set(value2, true);
// --- 循环引用检查结束 ---
// 数组比较
if (Array.isArray(value1) && Array.isArray(value2)) {
if (value1.length !== value2.length) return false;
for (let i = 0; i < value1.length; i++) {
if (!isDeepEqualCyclic(value1[i], value2[i], seen)) return false;
}
// 比较完成,可以清理这条记录(可选,WeakMap 会自动管理)
// seen.get(value1).delete(value2);
return true;
}
// 混合类型(数组 vs 对象)
if (Array.isArray(value1) || Array.isArray(value2)) {
return false;
}
// 普通对象比较
const keys1 = Reflect.ownKeys(value1); // 获取所有键,包括 Symbol
const keys2 = Reflect.ownKeys(value2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
if (!Reflect.has(value2, key)) {
return false;
}
if (!isDeepEqualCyclic(value1[key], value2[key], seen)) {
return false;
}
}
return true;
}
核心改进 :
-
WeakMap的使用 :WeakMap以对象为键,且是弱引用,不会阻止垃圾回收,适合做这种临时记录。seen这个Map的结构是:seen.set(objA, innerMap),其中innerMap.set(objB, true)表示objA正在与objB进行比较。 -
Reflect.ownKeys():替换Object.keys(),以同时获取字符串键和Symbol键,使比较更加全面。 -
循环引用处理逻辑
:当函数再次遇到一对已经记录在
seen中的对象时,它直接返回true。这意味着,在当前的递归路径上,我们假设这两个对象是相等的,从而跳出无限递归。这是一种保守且合理的策略,因为如果两个结构是循环引用的,并且在其他所有部分都相等,那么它们整体上应该被视为相等。
这个版本是一个生产环境可用的、健壮的深比较函数的基础框架。你可以根据具体需求调整其严格程度(比如是否比较原型链、是否比较不可枚举属性等)。
4. 性能优化与高级技巧
实现功能只是第一步,让代码在真实场景中高效运行同样重要。
4.1 性能优化策略
-
短路优化
:在递归比较中,尽早发现不相等的情况并返回
false,避免不必要的深度遍历。我们的代码中,长度检查、键数量检查、属性存在性检查都是短路优化。 -
类型预判
:在递归前,可以先对常见类型进行快速路径判断。例如,如果两个值都是
Date对象,可以直接比较.getTime();如果都是RegExp对象,可以比较.source和.flags。 - 避免不必要的递归 :对于非常大的对象,递归深度可能导致栈溢出。虽然循环引用处理部分缓解了这个问题,但对于深度嵌套的非循环结构,递归仍有风险。可以考虑使用迭代(栈或队列)的方式实现深比较,但这会大大增加代码复杂度。对于前端绝大多数场景,递归深度是足够的。
-
使用
Map缓存比较结果 :如果在一个生命周期内需要反复比较相同的几组大型对象,可以建立一个缓存(Map),将(value1, value2)的对作为键(需要解决键的生成问题,例如使用WeakMap和唯一标识),存储比较结果。但这通常属于过度优化,仅在性能瓶颈明确时考虑。
4.2 利用现代JavaScript API
-
Array.prototype.every():可以使浅比较的代码更函数式、更简洁,但会失去短路能力(every在遇到false后会停止,所以实际上仍有短路)。function arraysShallowEqualEvery(arr1, arr2) { return arr1.length === arr2.length && arr1.every((val, idx) => Object.is(val, arr2[idx])); } -
第三方库
:在大型项目中,重新造轮子可能引入未知Bug。成熟的工具库提供了经过千锤百炼的相等判断函数。
-
Lodash
:
_.isEqual(value, other)是一个非常强大的深比较函数,处理了几乎所有边界情况,包括循环引用、TypedArrays、Buffer等。性能也经过优化。 -
Underscore
:
_.isEqual()。 -
Node.js 内置
assert:assert.deepStrictEqual(actual, expected),主要用于测试。
-
Lodash
:
实操心得 :我的建议是,在个人学习或小型工具函数中,可以自己实现以加深理解。但在严肃的商业项目中, 优先考虑使用 Lodash 的
_.isEqual。它的可靠性、性能和浏览器兼容性都经过了充分验证,能节省大量开发和测试时间,避免潜在的边界情况Bug。
4.3 在框架与生态中的实践
-
React
: React 的渲染优化依赖于浅比较。
React.memo、PureComponent、useMemo、useCallback的依赖项数组比较,都是使用Object.is进行的浅比较。这就是为什么当依赖项是对象或数组时,你需要保证引用稳定,否则优化会失效。 -
状态管理(如 Redux)
: Redux 要求 reducer 是纯函数,状态更新需要返回新的对象/数组。在
connect或useSelector中,会通过浅比较来判断状态是否变化,从而决定是否重新渲染组件。理解数组/对象的相等性对编写高效的 Redux 应用至关重要。 -
测试框架(如 Jest)
: Jest 的
toEqual()匹配器就是一个深比较匹配器。它比toBe()(引用比较)更常用。了解其原理有助于编写更准确的测试断言。
5. 实战场景与避坑指南
理论说再多,不如看几个真实场景下的应用和容易踩的坑。
5.1 场景一:表单数据脏检查
假设你有一个编辑用户信息的表单,初始数据来自服务器。你想在用户点击保存时,只提交修改过的字段(增量更新)。
const initialData = { name: 'Alice', hobbies: ['reading', 'coding'] };
let currentData = { ...initialData }; // 用户可能修改此对象
// 错误做法:直接比较对象
function hasChangedWrong() {
return currentData !== initialData; // 总是 true,因为 currentData 是新对象
}
// 错误做法:用 JSON.stringify 比较
function hasChangedJSON() {
return JSON.stringify(currentData) !== JSON.stringify(initialData);
}
// 如果用户把 hobbies 数组的顺序从 ['reading', 'coding'] 改成 ['coding', 'reading'],内容没变,但字符串变了,会误判为已修改。
// 推荐做法:深比较(或针对性的浅比较)
import { isEqual } from 'lodash-es'; // 使用 Lodash
function hasChanged() {
return !isEqual(currentData, initialData);
}
// 或者,如果你知道只有第一层或特定字段会变,可以写针对性的比较
function hasChangedSpecific() {
return currentData.name !== initialData.name ||
!arraysShallowEqual(currentData.hobbies, initialData.hobbies);
}
避坑点
:脏检查的关键是定义清楚“变化”的含义。是全量深比较,还是比较特定字段?数组顺序变化是否算变化?这些都需要根据业务逻辑来定。盲目使用
JSON.stringify
或简单的引用比较会导致逻辑错误。
5.2 场景二:React 性能优化
在React函数组件中,
useEffect
、
useMemo
、
useCallback
的依赖数组使用浅比较。
const [list, setList] = useState([{ id: 1, text: 'item' }]);
// 情况1:依赖项是新建的数组,每次渲染都不同,导致 effect 无限执行
useEffect(() => {
console.log('List changed');
}, [list]); // `list` 的引用在每次渲染时是稳定的,没问题
const filteredList = list.filter(item => item.id > 0);
useEffect(() => {
console.log('Filtered list changed (probably wrong)');
}, [filteredList]); // 错误!filter 每次都会返回一个新数组,effect 每次渲染都会执行
// 正确:用 useMemo 缓存结果,保证引用稳定
const memoizedFilteredList = useMemo(() => {
return list.filter(item => item.id > 0);
}, [list]); // 依赖 list,只有当 list 变化时才会重新计算
useEffect(() => {
console.log('Filtered list changed (correct)');
}, [memoizedFilteredList]);
// 情况2:依赖项是对象字面量,每次都是新引用
const config = { threshold: 0.5 };
useEffect(() => {
console.log('Config changed');
}, [config]); // 错误!config 是每次渲染新创建的对象,effect 每次都会执行
// 正确:将 config 移到组件外,或用 useMemo/useState 包裹
const stableConfig = useMemo(() => ({ threshold: 0.5 }), []);
// 或者
const [configState] = useState({ threshold: 0.5 });
避坑点
:牢记React的依赖比较是浅比较。对于对象和数组,确保它们的引用在依赖项不变时是稳定的。善用
useMemo
、
useCallback
、
useState
来管理引用。
5.3 场景三:实现一个自定义的
deepEqual
测试工具
假设你在为一个工具库编写单元测试,但测试框架的断言不够灵活,你需要自己实现一个深比较并输出详细的差异信息。
function deepEqualWithDiff(a, b, path = '') {
if (Object.is(a, b)) {
return { isEqual: true };
}
// 类型不同
if (typeof a !== typeof b) {
return { isEqual: false, path, reason: `类型不同: ${typeof a} vs ${typeof b}` };
}
// 处理 null
if (a === null || b === null) {
return { isEqual: a === b, path, reason: `null 比较不一致` };
}
// 都是对象类型
if (typeof a === 'object') {
// 数组比较
const isArrayA = Array.isArray(a);
const isArrayB = Array.isArray(b);
if (isArrayA !== isArrayB) {
return { isEqual: false, path, reason: `一方是数组,另一方不是` };
}
if (isArrayA && isArrayB) {
if (a.length !== b.length) {
return { isEqual: false, path, reason: `数组长度不同: ${a.length} vs ${b.length}` };
}
for (let i = 0; i < a.length; i++) {
const result = deepEqualWithDiff(a[i], b[i], `${path}[${i}]`);
if (!result.isEqual) {
return result; // 返回第一个不相等处的差异
}
}
return { isEqual: true };
}
// 普通对象比较
const keysA = Object.keys(a);
const keysB = Object.keys(b);
const allKeys = new Set([...keysA, ...keysB]);
for (const key of allKeys) {
const hasA = keysA.includes(key);
const hasB = keysB.includes(key);
if (!hasA || !hasB) {
return { isEqual: false, path: `${path}.${key}`, reason: `属性缺失,一方有,一方无` };
}
const result = deepEqualWithDiff(a[key], b[key], `${path}.${key}`);
if (!result.isEqual) {
return result;
}
}
return { isEqual: true };
}
// 其他基本类型(数字、字符串等)直接不相等
return { isEqual: false, path, reason: `值不同: ${a} vs ${b}` };
}
// 使用示例
const obj1 = { arr: [1, { x: 2 }] };
const obj2 = { arr: [1, { x: 3 }] }; // 内部 x 的值不同
const diff = deepEqualWithDiff(obj1, obj2);
console.log(diff);
// 输出类似: { isEqual: false, path: '.arr[1].x', reason: '值不同: 2 vs 3' }
这个增强版的深比较不仅返回布尔值,还能告诉你第一个差异出现在哪个路径上以及原因,对于调试复杂数据结构的不匹配非常有用。
避坑点 :自己实现深比较用于测试时,要特别注意错误信息的清晰度。一个好的差异报告能极大提升调试效率。同时,要处理好循环引用,避免在测试复杂对象时栈溢出。
判断两个JavaScript数组是否相等,这个看似简单的问题,贯穿了引用与值、浅层与深层、基本类型与复杂对象、性能与准确性等多方面的权衡。从简单的
===
到健壮的、处理循环引用的深比较函数,再到利用
_.isEqual
这样的工业级解决方案,选择哪种方法完全取决于你的具体场景。
对于日常开发,我的经验是: 优先使用稳定可靠的第三方库(如Lodash)来处理复杂的相等判断 ,把精力集中在业务逻辑上。但在面试或需要深入理解语言特性时,能手写实现一个考虑周全的深比较函数,无疑是能力的体现。最重要的是,无论采用哪种方式,都要清楚地知道它的比较规则和边界在哪里,这样才能写出正确、健壮的代码。下次当你再遇到需要比较数组或对象时,不妨先停下来想一想:我需要的,到底是哪一种“相等”?
2101




被折叠的 条评论
为什么被折叠?



