Lodash 源码阅读-baseIntersection
概述
baseIntersection 是 Lodash 内部的核心函数,用于计算多个数组的交集。它支持自定义迭代器和比较器,能高效处理大型数组,是 _.intersection、_.intersectionBy 和 _.intersectionWith 等公共方法的基础实现。
前置学习
依赖函数
- arrayIncludes:检查数组中是否包含指定值,使用 SameValueZero 算法比较
- arrayIncludesWith:使用自定义比较器检查数组中是否包含某值
- arrayMap:对数组中的每个元素应用迭代器函数
- baseUnary:将函数转换为只接收一个参数的函数
- SetCache:专门用于存储唯一值的缓存结构
- cacheHas:检查 SetCache 中是否存在某个值
- nativeMin:原生 Math.min 方法的引用
技术知识
- 数组交集算法:高效计算多个数组交集的算法策略
- 大数据集优化:大型数组的性能优化方法和缓存使用
- 标签跳转:JavaScript 中的标签式跳转语法(
outer:、continue outer) - 短路计算:利用逻辑操作符的短路特性优化条件判断
- 特殊值处理:处理 JavaScript 中的特殊值(如 -0 和 +0)
源码实现
function baseIntersection(arrays, iteratee, comparator) {
var includes = comparator ? arrayIncludesWith : arrayIncludes,
length = arrays[0].length,
othLength = arrays.length,
othIndex = othLength,
caches = Array(othLength),
maxLength = Infinity,
result = [];
while (othIndex--) {
var array = arrays[othIndex];
if (othIndex && iteratee) {
array = arrayMap(array, baseUnary(iteratee));
}
maxLength = nativeMin(array.length, maxLength);
caches[othIndex] =
!comparator && (iteratee || (length >= 120 && array.length >= 120))
? new SetCache(othIndex && array)
: undefined;
}
array = arrays[0];
var index = -1,
seen = caches[0];
outer: while (++index < length && result.length < maxLength) {
var value = array[index],
computed = iteratee ? iteratee(value) : value;
value = comparator || value !== 0 ? value : 0;
if (
!(seen
? cacheHas(seen, computed)
: includes(result, computed, comparator))
) {
othIndex = othLength;
while (--othIndex) {
var cache = caches[othIndex];
if (
!(cache
? cacheHas(cache, computed)
: includes(arrays[othIndex], computed, comparator))
) {
continue outer;
}
}
if (seen) {
seen.push(computed);
}
result.push(value);
}
}
return result;
}
实现思路
baseIntersection 的核心思路是将第一个数组作为基准,然后检查其中的每个元素是否存在于所有其他数组中。为了提高性能,函数根据不同情况选择性地使用缓存:
- 首先确定使用哪种包含检查方法(普通或带比较器)
- 对于大型数组(≥120 元素)或使用迭代器的情况,创建缓存加速查找
- 遍历第一个数组的每个元素,检查它是否存在于所有其他数组中
- 跳过重复元素,确保结果没有重复值
- 当元素在所有数组中都存在时,将其添加到结果中
整个算法通过缓存、短路计算和标签跳转等方式实现了高效的交集计算。
源码解析
初始变量设置与功能选择
var includes = comparator ? arrayIncludesWith : arrayIncludes,
length = arrays[0].length,
othLength = arrays.length,
othIndex = othLength,
caches = Array(othLength),
maxLength = Infinity,
result = [];
这段代码设置了函数运行所需的基础变量:
-
动态选择检查函数
includes变量通过三元表达式根据是否有比较器决定使用哪个函数:- 有比较器时用
arrayIncludesWith,可以用自定义逻辑比较元素 - 无比较器时用
arrayIncludes,使用内置的 SameValueZero 算法
这种设计允许函数灵活处理不同的比较需求,同时避免了运行时的条件判断。
- 有比较器时用
-
数组维度信息
length:第一个数组的长度,用于外层循环限制othLength:数组总数,表示要计算交集的数组个数othIndex:初始值等于数组总数,用于倒序遍历
-
结果与缓存准备
caches:与输入数组等长的数组,存储可能的缓存maxLength:结果可能的最大长度,初始为Infinityresult:存储最终交集的空数组
数组预处理和缓存创建
while (othIndex--) {
var array = arrays[othIndex];
if (othIndex && iteratee) {
array = arrayMap(array, baseUnary(iteratee));
}
maxLength = nativeMin(array.length, maxLength);
caches[othIndex] =
!comparator && (iteratee || (length >= 120 && array.length >= 120))
? new SetCache(othIndex && array)
: undefined;
}
array = arrays[0];
这个循环从后向前处理所有输入数组,为主算法做准备:
-
迭代器应用
if (othIndex && iteratee)确保只对除第一个外的其他数组应用迭代器。为什么第一个数组不预先应用迭代器?因为在主循环中,我们会按需处理第一个数组的元素,而其他数组需要提前处理以便缓存。
// 例如,对于 Math.floor 迭代器和数组 [[1.1, 2.2], [1.9, 2.9]] // 第二个数组会变成 [1, 2] // 而第一个数组保持原样,在主循环中处理每个元素 -
最短长度追踪
maxLength = nativeMin(array.length, maxLength)记录所有数组中的最短长度。这是一个重要优化,因为交集的长度不可能超过任何一个数组的长度,所以我们可以限制结果大小。
-
缓存创建策略
缓存创建条件是复杂而精确的:!comparator && (iteratee || (length >= 120 && array.length >= 120))这个条件可以分解为:
- 没有比较器(缓存对自定义比较不友好)
- 且满足以下任一条件:
- 有迭代器(避免重复计算转换值)
- 或者两个数组都足够大(≥120 元素)
缓存条件的设计体现了几个性能考量:
- 比较器冲突:当使用自定义比较器时,缓存的基于哈希的查找可能与比较器的逻辑不一致
- 迭代计算成本:有迭代器时缓存可以避免重复计算
- 大数组临界点:120 是经验值,表示何时缓存的好处超过其创建成本
- 双数组大小要求:要求两个数组都大,因为如果只有一个大,性能瓶颈仍在较小的数组
othIndex && array确保不为第一个数组创建缓存(因为othIndex为 0 时,表达式求值为 0)。 -
基准数组设置
循环结束后,array = arrays[0]将基准设为第一个数组,这是交集计算的起点。
缓存初始化的隐藏细节
var index = -1,
seen = caches[0];
这里有个容易被忽视的细节:seen = caches[0] 获取的是第一个数组的缓存,它的实际值需要仔细分析:
-
当
othIndex为 0 处理第一个数组时:othIndex && array短路为 0- 创建的是
new SetCache(0) - SetCache 构造函数接收 0 作为参数:
function SetCache(values) { var index = -1, length = values == null ? 0 : values.length; // values 是 0,所以 length 为 0 this.__data__ = new MapCache(); while (++index < length) { // 循环不会执行 this.add(values[index]); } } -
因此
seen初始状态只有两种可能:undefined:不满足缓存创建条件- 空的 SetCache 实例:满足条件但内部没有元素
这种设计确保了初始检查必定失败(空缓存中找不到任何元素),然后随着我们找到交集元素,通过 seen.push(computed) 逐步填充缓存。
交集计算核心循环
outer: while (++index < length && result.length < maxLength) {
var value = array[index],
computed = iteratee ? iteratee(value) : value;
value = comparator || value !== 0 ? value : 0;
if (
!(seen ? cacheHas(seen, computed) : includes(result, computed, comparator))
) {
othIndex = othLength;
while (--othIndex) {
var cache = caches[othIndex];
if (
!(cache
? cacheHas(cache, computed)
: includes(arrays[othIndex], computed, comparator))
) {
continue outer;
}
}
if (seen) {
seen.push(computed);
}
result.push(value);
}
}
这是函数的核心部分,实现交集计算的主要逻辑:
-
循环控制
outer:标签和++index < length && result.length < maxLength条件共同控制循环:- 不超出第一个数组边界
- 不超过可能的最大结果数量
- 标签允许内层循环使用
continue outer直接跳到外层循环的下一次迭代
-
值处理与转换
var value = array[index], computed = iteratee ? iteratee(value) : value; value = comparator || value !== 0 ? value : 0;这段代码处理当前元素,包括:
- 获取原始值
value - 如果有迭代器,计算其转换值
computed - 特殊处理数值 0:将 -0 和 +0 统一为 0(除非有比较器)
对 0 的特殊处理是为了符合 JavaScript 中 SameValueZero 算法的行为,确保 -0 和 +0 被视为相等。
- 获取原始值
-
重复值跳过
if ( !(seen ? cacheHas(seen, computed) : includes(result, computed, comparator)) ) { // 处理元素... }这个条件检查确保不处理重复元素:
- 如果有缓存,检查元素是否已在缓存中
- 否则,检查结果数组是否已包含此元素
- 取反操作
!表示"如果元素不在结果中"才继续处理
例如,对于输入
[[1, 1, 2], [1, 2]],如果不跳过重复值,结果可能是[1, 1, 2],而正确结果应是[1, 2]。 -
交集判断
othIndex = othLength; while (--othIndex) { var cache = caches[othIndex]; if ( !(cache ? cacheHas(cache, computed) : includes(arrays[othIndex], computed, comparator)) ) { continue outer; } }这段代码是算法的核心,检查元素是否存在于所有其他数组中:
- 遍历除第一个以外的所有数组(从后向前)
- 对每个数组,用缓存或直接查找检查元素是否存在
- 如果任何一个数组不包含该元素,立即使用
continue outer跳到下一个元素
这是一个短路优化:一旦确定元素不可能是交集的一部分,就立即停止当前元素的处理。
-
结果收集
if (seen) { seen.push(computed); } result.push(value);当元素通过了所有检查,意味着它存在于所有数组中:
- 如果有缓存,将转换后的值添加到缓存中(标记为已处理)
- 将原始值(或标准化的 0)添加到结果数组
算法复杂度分析
函数的时间复杂度受到缓存使用的显著影响:
-
没有缓存时:O(n × m × k)
- n: 第一个数组长度
- m: 平均每个数组长度
- k: 数组个数
每个元素都需要在每个数组中线性查找。
-
使用缓存时:O(n × k)
- 利用 SetCache 将查找复杂度从 O(m) 降为接近 O(1)
- 对大型数组(≥120 元素)的优化非常显著
这解释了为什么函数对数组大小设置 120 的阈值:在此阈值下,缓存的创建和维护成本与其带来的性能提升达到平衡点。
具体案例分析
让我们通过一个简单例子看函数如何工作:
// 输入
var arrays = [
[1, 2, 3, 4],
[3, 4, 5, 6],
];
// 执行过程
// 1. 预处理:maxLength = 4, 数组不够大,不创建缓存
// 2. 遍历第一个数组元素:
// - 元素 1:在第二个数组中不存在,跳过
// - 元素 2:在第二个数组中不存在,跳过
// - 元素 3:在第二个数组中存在,添加到结果
// - 元素 4:在第二个数组中存在,添加到结果
// 3. 返回结果 [3, 4]
再看一个使用迭代器的例子:
// 输入
baseIntersection(
[
[2.1, 1.2],
[2.3, 3.4, 4.2],
],
Math.floor
);
// 执行过程
// 1. 预处理:第二个数组应用 Math.floor 变为 [2, 3, 4]
// 2. 遍历第一个数组元素:
// - 元素 2.1:computed = Math.floor(2.1) = 2
// 在处理后的第二个数组中存在,添加到结果
// - 元素 1.2:computed = Math.floor(1.2) = 1
// 在处理后的第二个数组中不存在,跳过
// 3. 返回结果 [2.1]
总结
baseIntersection 函数展示了 Lodash 在处理数组操作时的精湛设计和性能优化思路:
-
算法策略
- 基于第一个数组遍历,减少不必要的比较
- 短路逻辑避免多余计算
- 标签跳转实现高效的循环控制
-
性能优化
- 根据数组大小和操作类型动态决定是否使用缓存
- 针对迭代器场景特别优化,避免重复计算
- 维护最短长度限制,减少不必要的迭代
-
健壮性设计
- 正确处理特殊值(如 -0 和 +0)
- 支持自定义比较器和迭代器
- 避免结果中的重复值
这个函数是一个很好的例子,展示了如何在 JavaScript 中实现高效的集合操作算法,以及如何在算法设计中权衡空间和时间复杂度。通过研究这样的代码,我们可以学习到许多实用的性能优化技巧和边界情况处理方法。