Lodash 源码阅读-equalArrays
功能概述
equalArrays
是 Lodash 库中的一个内部工具函数,专门用于深度比较两个数组是否相等。它是 baseIsEqualDeep
函数的数组专用版本,支持部分深度比较和无序比较,能够处理循环引用等复杂情况。这个函数是 Lodash 中 _.isEqual
方法的核心组件之一,为 Lodash 提供了强大而灵活的深度相等性检查能力。
前置学习
依赖函数
- arraySome:用于检查数组中是否有元素满足条件
- cacheHas:检查缓存中是否存在特定键
- SetCache:一个用于存储唯一值的缓存结构
- Stack:用于跟踪遍历过的对象,处理循环引用
技术知识
- 位掩码(Bitmask) :使用二进制位表示多个布尔标志
COMPARE_PARTIAL_FLAG
(值为 1):表示进行部分比较COMPARE_UNORDERED_FLAG
(值为 2):表示进行无序比较
- 循环引用检测:处理对象间的循环引用问题
- 深度比较:递归比较嵌套数据结构
- 自定义比较器:支持用户自定义的比较逻辑
源码实现
javascript
/**
* A specialized version of `baseIsEqualDeep` for arrays with support for
* partial deep comparisons.
*
* @private
* @param {Array} array The array to compare.
* @param {Array} other The other array to compare.
* @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details.
* @param {Function} customizer The function to customize comparisons.
* @param {Function} equalFunc The function to determine equivalents of values.
* @param {Object} stack Tracks traversed `array` and `other` objects.
* @returns {boolean} Returns `true` if the arrays are equivalent, else `false`.
*/
function equalArrays(array, other, bitmask, customizer, equalFunc, stack) {
var isPartial = bitmask & COMPARE_PARTIAL_FLAG,
arrLength = array.length,
othLength = other.length;
if (arrLength != othLength && !(isPartial && othLength > arrLength)) {
return false;
}
// Check that cyclic values are equal.
var arrStacked = stack.get(array);
var othStacked = stack.get(other);
if (arrStacked && othStacked) {
return arrStacked == other && othStacked == array;
}
var index = -1,
result = true,
seen = bitmask & COMPARE_UNORDERED_FLAG ? new SetCache() : undefined;
stack.set(array, other);
stack.set(other, array);
// Ignore non-index properties.
while (++index < arrLength) {
var arrValue = array[index],
othValue = other[index];
if (customizer) {
var compared = isPartial
? customizer(othValue, arrValue, index, other, array, stack)
: customizer(arrValue, othValue, index, array, other, stack);
}
if (compared !== undefined) {
if (compared) {
continue;
}
result = false;
break;
}
// Recursively compare arrays (susceptible to call stack limits).
if (seen) {
if (
!arraySome(other, function (othValue, othIndex) {
if (
!cacheHas(seen, othIndex) &&
(arrValue === othValue ||
equalFunc(arrValue, othValue, bitmask, customizer, stack))
) {
return seen.push(othIndex);
}
})
) {
result = false;
break;
}
} else if (
!(
arrValue === othValue ||
equalFunc(arrValue, othValue, bitmask, customizer, stack)
)
) {
result = false;
break;
}
}
stack["delete"](array);
stack["delete"](other);
return result;
}
实现思路
equalArrays
函数的实现思路可以分为以下几个关键步骤:
-
长度检查 :首先比较两个数组的长度,如果长度不同且不是部分比较模式(或部分比较模式下第二个数组不长于第一个数组),则直接返回
false
。 -
循环引用检查 :使用
stack
对象检查是否存在循环引用,如果两个数组已经在比较栈中,则直接比较它们的引用关系。 -
设置比较标记:将两个数组相互添加到比较栈中,标记它们正在被比较,以处理可能的循环引用。
-
元素比较:
- 如果提供了自定义比较器
customizer
,则使用它进行比较 - 如果是无序比较模式,使用
SetCache
和arraySome
检查每个元素是否在另一个数组中有匹配项 - 如果是有序比较模式,直接按索引比较对应元素
- 如果提供了自定义比较器
-
清理和返回:从比较栈中移除两个数组,并返回比较结果。
这种实现方式既灵活又强大,能够处理各种复杂的数组比较场景,包括嵌套数组、循环引用和自定义比较逻辑。
源码解析
参数解析
javascript
function equalArrays(array, other, bitmask, customizer, equalFunc, stack) {
函数接收六个参数:
array
:要比较的第一个数组other
:要比较的第二个数组bitmask
:位掩码标志,控制比较的行为customizer
:自定义比较函数,允许用户定制比较逻辑equalFunc
:用于确定值是否相等的函数,通常是baseIsEqual
stack
:跟踪已遍历对象的栈,用于处理循环引用
位掩码处理
javascript
var isPartial = bitmask & COMPARE_PARTIAL_FLAG,
arrLength = array.length,
othLength = other.length;
这里使用位运算从 bitmask
中提取标志:
isPartial
:如果设置了COMPARE_PARTIAL_FLAG
(值为 1),则为true
,表示进行部分比较- 同时获取两个数组的长度,用于后续比较
长度检查
javascript
if (arrLength != othLength && !(isPartial && othLength > arrLength)) {
return false;
}
这个条件检查两种情况:
- 如果两个数组长度不同,且不是部分比较模式,则返回
false
- 如果是部分比较模式,但第二个数组长度小于第一个数组,也返回
false
这意味着在部分比较模式下,第二个数组可以比第一个数组长,但反之则不行。
循环引用检查
javascript
var arrStacked = stack.get(array);
var othStacked = stack.get(other);
if (arrStacked && othStacked) {
return arrStacked == other && othStacked == array;
}
这段代码检查两个数组是否已经在比较栈中(即它们是否已经被比较过):
- 如果两个数组都已经在栈中,则检查它们是否互相引用对方
- 这是处理循环引用的关键部分,防止无限递归
循环引用详解
循环引用是指数据结构中的元素引用了自身或其祖先元素,形成一个闭环。在 JavaScript 中,这种情况会导致常规的深度比较算法陷入无限递归,最终导致栈溢出错误。equalArrays
函数通过巧妙的设计解决了这个问题。
循环引用检测机制
循环引用检测的核心思想是使用一个Stack
数据结构来跟踪当前正在比较的对象对。stack
对象维护了两个重要的映射:
array -> other
: 记录第一个数组映射到第二个数组other -> array
: 记录第二个数组映射到第一个数组
当函数递归比较数组元素时,如果遇到已经在比较过程中的数组,就会触发循环引用检查。
关键的判断条件是:
javascript
arrStacked == other && othStacked == array;
这个条件检查:
- 当前的
other
是否就是之前为array
记录的值 - 当前的
array
是否就是之前为other
记录的值
只有两个条件同时满足,才能确认这是一个结构相同的循环引用模式。
具体例子
例子 1:数组直接自引用
考虑两个简单的自引用数组:
javascript
var arr1 = [];
arr1.push(arr1); // arr1 = [arr1]
var arr2 = [];
arr2.push(arr2); // arr2 = [arr2]
_.isEqual(arr1, arr2); // 返回 true
当equalArrays
比较这两个数组时:
-
初始阶段:
- 将
arr1
和arr2
添加到栈中:stack.set(arr1, arr2)
和stack.set(arr2, arr1)
- 将
-
元素比较:
- 比较
arr1[0]
和arr2[0]
- 因为
arr1[0] === arr1
且arr2[0] === arr2
,所以会递归调用equalFunc
- 比较
-
循环引用检测:
- 当再次比较
arr1
和arr2
时,发现它们已经在栈中 - 获取
arrStacked = stack.get(arr1) = arr2
- 获取
othStacked = stack.get(arr2) = arr1
- 检查
arr2 === arr2[0]
和arr1 === arr1[0]
,两者都为true
- 确认这是结构相同的循环引用,返回
true
- 当再次比较
例子 2:嵌套数组循环引用
复杂一点的例子:
javascript
var arr1 = [1, 2];
var subarr1 = [3, arr1];
arr1.push(subarr1); // arr1 = [1, 2, [3, arr1]]
var arr2 = [1, 2];
var subarr2 = [3, arr2];
arr2.push(subarr2); // arr2 = [1, 2, [3, arr2]]
_.isEqual(arr1, arr2); // 返回 true
比较过程:
-
比较头两个元素(1 和 2):完全相等
-
比较第三个元素(子数组):
- 比较
subarr1[0]
和subarr2[0]
:两者都是 3,相等 - 比较
subarr1[1]
和subarr2[1]
:递归调用equalFunc
比较arr1
和arr2
- 比较
-
递归比较时:
- 发现
arr1
和arr2
已经在栈中 - 检查
stack.get(arr1) === arr2
和stack.get(arr2) === arr1
- 两者都为
true
,确认循环引用结构相同,返回true
- 发现
例子 3:不同结构的循环引用
javascript
var arr1 = [];
arr1.push(arr1); // arr1 = [arr1]
var arr2 = [[]];
arr2[0].push(arr2); // arr2 = [[arr2]]
_.isEqual(arr1, arr2); // 返回 false
比较过程:
- 比较
arr1[0]
和arr2[0]
:arr1[0] === arr1
,但arr2[0]
是一个独立的数组,不等于arr2
- 需要递归比较
arr1
和arr2[0]
- 在递归比较过程中:
- 虽然两者都有循环引用,但结构不同
- 无法满足循环引用检测条件
- 最终返回
false
为什么设计成双向检查?
双向检查(arrStacked == other && othStacked == array
)是为了确保循环引用的结构完全一致。单向检查可能会误判一些情况,例如:
javascript
var a = [];
var b = [a];
a.push(b); // a引用b,b引用a
var c = [];
var d = [c];
c.push(d); // c引用d,d引用c
尽管a
和c
都参与了循环引用,但它们的结构实际上是一致的。双向检查能够识别出这种对称性,确保结构相同的循环引用被正确地判定为相等。
Stack 的作用
Stack
数据结构不仅用于循环引用检测,还承担了几个重要作用:
- 防止无限递归:避免陷入无限深的比较循环
- 识别等价结构:确定不同引用但结构相同的循环引用
- 提高性能:已比较过的对象对可以直接返回结果,无需重复比较
这种设计使得 Lodash 能够安全、高效地比较包含循环引用的复杂数据结构,是深度相等比较功能的关键部分。
初始化比较状态
javascript
var index = -1,
result = true,
seen = bitmask & COMPARE_UNORDERED_FLAG ? new SetCache() : undefined;
stack.set(array, other);
stack.set(other, array);
这里初始化了比较所需的变量:
index
:当前比较的索引,初始为 -1result
:比较结果,初始为true
seen
:如果是无序比较模式(设置了COMPARE_UNORDERED_FLAG
,值为 2),则创建一个SetCache
实例用于跟踪已比较的元素
同时,将两个数组互相添加到比较栈中,标记它们正在被比较。
元素比较循环
javascript
while (++index < arrLength) {
var arrValue = array[index],
othValue = other[index];
这个循环遍历数组的每个元素,逐一进行比较。
自定义比较器处理
javascript
if (customizer) {
var compared = isPartial
? customizer(othValue, arrValue, index, other, array, stack)
: customizer(arrValue, othValue, index, array, other, stack);
}
if (compared !== undefined) {
if (compared) {
continue;
}
result = false;
break;
}
如果提供了自定义比较器:
- 根据是否是部分比较模式,调用比较器时参数顺序会有所不同
- 如果比较器返回了明确的结果(不是
undefined
):- 如果结果为真值,继续比较下一个元素
- 如果结果为假值,设置
result
为false
并跳出循环
为什么要改变参数顺序?
参数顺序的改变反映了比较的方向或重点:
-
完整比较模式 (
isPartial = false
):- 我们将
array
作为主要对象,other
作为比较对象 - 判断的是两个数组是否完全相等(双向比较)
- 因此参数顺序是
customizer(arrValue, othValue, ...)
- 我们将
-
部分比较模式 (
isPartial = true
):- 我们将
other
作为主要对象,array
作为要检查的对象 - 判断的是
array
是否是other
的子集(单向比较) - 因此参数顺序是
customizer(othValue, arrValue, ...)
- 我们将
这种设计使得自定义比较器在不同的比较模式下能保持一致的语义,即第一个参数总是"主要对象"的元素,第二个参数总是"比较对象"的元素。
无序比较模式
javascript
if (seen) {
if (!arraySome(other, function(othValue, othIndex) {
if (!cacheHas(seen, othIndex) &&
(arrValue === othValue || equalFunc(arrValue, othValue, bitmask, customizer, stack))) {
return seen.push(othIndex);
}
})) {
result = false;
break;
}
}
如果是无序比较模式(seen
存在):
- 使用
arraySome
在第二个数组中查找与当前元素相等的元素 - 如果找到匹配项,将其索引添加到
seen
缓存中,表示该元素已被匹配 - 如果没有找到匹配项,设置
result
为false
并跳出循环
这允许数组元素的顺序不同,只要所有元素都能找到对应的匹配项即可。
有序比较模式
javascript
else if (!(
arrValue === othValue ||
equalFunc(arrValue, othValue, bitmask, customizer, stack)
)) {
result = false;
break;
}
如果是有序比较模式(seen
不存在):
- 直接比较对应索引位置的元素是否相等
- 首先尝试使用严格相等(
===
)进行比较 - 如果不相等,则使用
equalFunc
(通常是baseIsEqual
)进行深度比较 - 如果两种比较都失败,设置
result
为false
并跳出循环
清理和返回结果
javascript
stack["delete"](array);
stack["delete"](other);
return result;
最后,从比较栈中移除两个数组,并返回比较结果。这一步很重要,它确保了栈的正确状态,防止内存泄漏和错误的循环引用检测。
总结
equalArrays
是 Lodash 中一个强大而复杂的内部工具函数,专门用于深度比较数组是否相等。它支持多种比较模式,能够处理循环引用、嵌套结构和自定义比较逻辑,是 _.isEqual
方法的核心组件之一。
这个函数的实现体现了几个重要的编程原则和技术:
- 位掩码技术:使用二进制位表示多个布尔标志,提高参数传递的效率
- 循环引用处理:使用栈跟踪已比较的对象,防止无限递归
- 短路求值:一旦发现不相等的元素,立即返回结果,避免不必要的比较
- 灵活的比较策略:支持有序比较、无序比较和部分比较等多种模式
- 可扩展性:通过自定义比较器支持用户定制的比较逻辑
理解 equalArrays
的工作原理,有助于我们更好地使用 Lodash 的 _.isEqual
方法,并在自己的代码中实现更高效、更可靠的深度比较逻辑。