Lodash源码阅读-Array模块总结

112 阅读14分钟

Lodash 源码阅读总结

概述

本文是对 Lodash 数组模块源码阅读的系统总结。通过深入学习 Lodash 的 62 个数组处理函数及其内部实现,我获得了对 JavaScript 数组操作、函数式编程、性能优化和代码设计的更深理解。Lodash 的数组模块源码不仅实现了丰富的功能,更体现了优秀的工程实践和设计思想。

学习内容

数组模块功能分类

在学习过程中,我将 Lodash 的数组模块划分为八大类别:

  1. 基础数组访问与操作:包括chunkslice等基础函数
  2. 数组查找与索引:如indexOffindIndexsortedIndex等查找功能
  3. 数组创建与转换:如chunkconcatdrop等数组转换函数
  4. 数组扁平化:包括flattenflattenDeepflattenDepth
  5. 数组填充与修改:如fillpullremove等修改数组的函数
  6. 数组去重:包括uniquniqBysortedUniq等去重函数
  7. 数组集合操作:如differenceintersectionunion等集合运算
  8. 数组异或与压缩:包括xorzipunzip等特殊操作

核心基础函数

在学习过程中,发现了 Lodash 数组模块内部依赖的一系列核心基础函数:

  • 基础查找函数:如baseIndexOfbaseFindIndex
  • 数组切片处理:如baseSlice
  • 集合运算核心:如baseDifferencebaseIntersection
  • 数组扁平化:如baseFlatten
  • 迭代处理:如baseWhilebaseIteratee

学习路径与方法

在研究 Lodash 源码时,我采用了依赖函数深度优先的学习策略。这种方法从底层实现开始,沿着依赖关系逐步向上,直到公共 API 层面。具体步骤为:

  1. 首先学习底层数据结构如 ListCache、MapCache、Stack 和 SetCache
  2. 然后学习基础工具函数如 memoize、eq、baseIteratee 等
  3. 接着学习核心操作函数如 baseFlatten、baseIndexOf、baseIntersection
  4. 最后学习公共 API 函数如 flatten、indexOf、intersection
依赖调用链示例

下面展示几个典型的依赖调用链:

_.intersection
  ↓
baseRest + arrayMap + baseIntersection
  ↓
SetCache + cacheHas + arrayIncludes
  ↓
MapCache + eq
_.uniq
  ↓
baseUniq
  ↓
SetCache + createSet
  ↓
MapCache + Set/nativeCreate
_.findIndex
  ↓
baseFindIndex + baseIteratee
  ↓
baseMatches/baseMatchesProperty/property
依赖函数深度优先学习的优点
  1. 建立完整知识体系:从底层函数开始,构建起完整的技术知识结构,理解代码全貌
  2. 深入理解实现原理:透彻了解每一层的工作原理,而不仅仅是表面 API
  3. 掌握性能优化关键:了解性能优化手段(如缓存、算法选择)如何在不同层次实现
  4. 避免知识断层:不会出现理解高层函数时遇到未知底层函数的情况
  5. 发现复用模式:看到多个高级 API 如何共享底层实现,理解代码组织方式
依赖函数深度优先学习的挑战
  1. 初期学习曲线陡峭:从底层细节开始,难以快速看到整体图景
  2. 时间投入较大:需要学习大量底层函数才能理解一个简单的高层 API
  3. 实用性延迟:不能快速应用所学知识,需要积累到一定程度才能灵活运用
  4. 容易陷入细节:过度关注实现细节,可能忽略 API 设计思想和使用场景
适用场景

依赖函数深度优先的学习方法特别适合:

  • 想深入理解库内部实现的开发者
  • 计划自己实现类似功能的开发者
  • 需要优化性能或定制行为的场景
  • 深入学习 JavaScript 语言特性和设计模式

核心数据结构与缓存机制

Lodash 内部实现了一系列高效的数据结构和缓存机制,这些是其高性能的关键基础:

1. 缓存数据结构体系

Lodash 构建了一个层次清晰的缓存数据结构体系:

  • ListCache:基于数组实现的简单键值对集合,适用于少量数据
  • MapCache:复合型缓存结构,根据键类型选择最佳存储策略
  • SetCache:专门用于存储唯一值的集合结构
  • Stack:自适应栈结构,能根据数据量大小自动切换底层实现

这些数据结构相互配合,构成了 Lodash 高效缓存系统的核心。

ListCache

ListCache 是最基础的缓存结构,使用数组存储键值对:

function ListCache(entries) {
  var index = -1,
    length = entries == null ? 0 : entries.length;

  this.__data__ = [];
  this.size = 0;

  while (++index < length) {
    var entry = entries[index];
    this.set(entry[0], entry[1]);
  }
}

它通过简单的数组遍历查找键,适合小数据量场景(通常少于 200 个元素)。

MapCache

MapCache 是更复杂的缓存结构,根据键的类型分发到不同的存储机制:

function mapCacheClear() {
  this.size = 0;
  this.__data__ = {
    hash: new Hash(), // 存储基本类型键
    map: new (Map || ListCache)(), // 存储引用类型键
    string: new Hash(), // 专门优化字符串键
  };
}

这种设计使得不同类型的键都能获得最佳性能,是 Lodash 缓存系统的核心组件。

SetCache

SetCache 专门用于存储唯一值集合,通过将值作为 MapCache 的键实现:

function SetCache(values) {
  var index = -1,
    length = values == null ? 0 : values.length;

  this.__data__ = new MapCache();
  while (++index < length) {
    this.add(values[index]);
  }
}

它在 Lodash 的交集、差集等集合操作中发挥关键作用。

Stack

Stack 是一个智能的栈结构,能根据数据量自动切换底层实现:

function stackSet(key, value) {
  var data = this.__data__;
  if (data instanceof ListCache) {
    var pairs = data.__data__;
    if (!Map || pairs.length < LARGE_ARRAY_SIZE - 1) {
      pairs.push([key, value]);
      this.size = ++data.size;
      return this;
    }
    data = this.__data__ = new MapCache(pairs);
  }
  data.set(key, value);
  this.size = data.size;
  return this;
}

当数据量小时使用 ListCache,数据量大时自动升级为 MapCache,平衡了性能和内存使用。

2. memoize 函数记忆化

memoize是 Lodash 的函数记忆化实现,它将函数调用结果缓存起来:

function memoize(func, resolver) {
  var memoized = function () {
    var args = arguments,
      key = resolver ? resolver.apply(this, args) : args[0],
      cache = memoized.cache;

    if (cache.has(key)) {
      return cache.get(key);
    }
    var result = func.apply(this, args);
    memoized.cache = cache.set(key, result) || cache;
    return result;
  };
  memoized.cache = new (memoize.Cache || MapCache)();
  return memoized;
}

它的特点包括:

  • 支持自定义缓存键生成函数
  • 缓存可被外部访问和修改
  • 缓存实现可被替换

Lodash 内部还实现了memoizeCapped函数,用于限制缓存大小,防止内存泄漏:

function memoizeCapped(func) {
  var result = memoize(func, function (key) {
    if (cache.size === MAX_MEMOIZE_SIZE) {
      cache.clear();
    }
    return key;
  });

  var cache = result.cache;
  return result;
}
3. 数据结构的应用场景

这些数据结构在 Lodash 中的主要应用:

  • 去重操作:交集intersection、差集difference等使用 SetCache 提高性能
  • 深度比较:比较复杂对象时使用 Stack 检测循环引用
  • 函数记忆化memoize函数依赖 MapCache 存储结果
  • 对象操作:对象遍历、合并等操作中缓存已处理的属性

通过这些精心设计的数据结构,Lodash 在处理复杂数据时能够保持出色的性能,尤其是在大数据量场景下。

技术亮点

1. 位掩码技术

Lodash 在数组模块中使用位掩码优化参数传递,特别是在baseFlatten函数中:

// baseFlatten函数中的位掩码使用示例
var isDeep = bitmask & CLONE_DEEP_FLAG,
  isStrict = bitmask & CLONE_STRICT_FLAG;

这种技术通过一个数字参数传递多个布尔选项,避免了多参数的冗余。

2. 缓存优化

数组模块中多个函数使用缓存避免重复计算,如baseIntersection中:

// 使用Set或对象作为查找缓存
var seen = new SetCache();
// ...
if (!caches[othIndex]) {
  caches[othIndex] = new SetCache();
}

baseIntersection的缓存优化是一个典型例子:

// 伪代码简化
function baseIntersection(arrays) {
  var caches = Array(otherLength);
  // ...

  // 对第一个数组的每个元素
  for (var i = 0; i < length; i++) {
    var value = array[0][i];

    // 检查该元素是否在所有其他数组中存在
    if (includes && !caches[0].has(value)) {
      // 只有第一次遇到该元素时才检查
      caches[0].add(value);

      // 检查其他所有数组
      for (var j = 1; j < arrays.length; j++) {
        // 如果当前数组还没有缓存,创建缓存
        if (!caches[j]) caches[j] = new SetCache(arrays[j]);

        // 如果当前元素不在此数组中,跳到下一个元素
        if (!caches[j].has(value)) continue nextValue;
      }

      // 元素存在于所有数组中,添加到结果
      result.push(value);
    }
  }
}

这种优化将时间复杂度从 O(n²)降低到 O(n),处理大数组时效果显著。

3. 参数处理与收集

Lodash 数组模块展示了灵活的参数处理能力,如baseRest的应用:

// baseRest 用于收集不定数量的参数,是许多接受多个数组参数函数的基础
var intersection = baseRest(function (arrays) {
  // ...实现代码
});

intersection函数通过baseRest能够优雅地处理任意数量的数组参数:

var intersection = baseRest(function (arrays) {
  var mapped = arrayMap(arrays, castArrayLikeObject);
  return mapped.length && mapped[0] === arrays[0]
    ? baseIntersection(mapped)
    : [];
});

4. 高度模块化设计

Lodash 将复杂操作分解为多个基础函数,形成清晰的调用层次。这种设计提高了代码复用性和可维护性。

// 示例:difference 函数的依赖链
// 公共API
_.difference(array, [values]);

// 内部实现层次
baseDifference < -baseIndexOf < -strictIndexOf;

这种模块化设计使得代码更易于维护和测试。例如,_.difference_.differenceBy_.differenceWith三个公共 API 都复用了同一个baseDifference核心实现。

5. 边界情况处理

Lodash 数组模块在处理边界情况方面非常严谨,例如对 NaN 的特殊处理:

// baseIndexOf 通过特殊技巧处理 NaN 的比较
function baseIndexOf(array, value, fromIndex) {
  // 使用 value === value 判断是否为 NaN
  return value === value
    ? strictIndexOf(array, value, fromIndex)
    : baseFindIndex(array, baseIsNaN, fromIndex);
}

这段代码解决了 JavaScript 中 NaN !== NaN 的问题,使得 _.indexOf 可以正确查找数组中的 NaN 值,而原生 indexOf 则不能。

[1, NaN, 2, 3].indexOf(NaN); // -1 (原生方法找不到NaN)
_.indexOf([1, NaN, 2, 3], NaN); // 1 (Lodash方法能找到NaN)

6. 性能优化策略

Lodash 数组模块采用了多种性能优化策略,包括缓存、循环优化和优先使用原生方法等。一个重要的例子是有序数组的二分查找优化:

// baseSortedIndex 使用二分查找提高性能
function baseSortedIndex(array, value, retHighest) {
  var low = 0,
      high = array == null ? low : array.length;

  // 二分查找核心算法
  while (low < high) {
    var mid = (low + high) >>> 1,
        computed = array[mid];

    if (computed !== null && /* ... 比较逻辑 ... */) {
      low = mid + 1;
    } else {
      high = mid;
    }
  }
  return high;
}

这种二分查找算法将有序数组的查找时间复杂度从 O(n) 降低到 O(log n)。

设计模式与最佳实践

1. 函数组合模式

Lodash 数组模块大量使用函数组合来构建复杂功能,将小功能函数组合成更强大的操作。

// 示例:xor 函数的实现组合了 flatten 和 uniq
// baseXor 的最后一步组合了 baseFlatten 和 baseUniq
return baseUniq(baseFlatten(result, 1), iteratee, comparator);

这种函数组合模式使代码更具声明性和可读性。复杂操作被分解为更小的步骤,每个步骤都由专门的函数处理。

2. 策略模式

Lodash 数组模块根据不同条件选择不同的算法或处理策略:

// 示例:索引查找根据值类型选择不同策略
// baseIndexOf 函数根据值是否为 NaN 选择不同的查找策略
function baseIndexOf(array, value, fromIndex) {
  return value === value
    ? strictIndexOf(array, value, fromIndex)
    : baseFindIndex(array, baseIsNaN, fromIndex);
}

策略模式使得 Lodash 能够根据具体情况选择最优的实现,从而提高性能和效率。

3. 工厂函数

Lodash 数组模块使用工厂函数根据输入创建特定功能的函数。baseIteratee 是一个典型例子:

// baseIteratee 根据输入类型生成不同的迭代函数
function baseIteratee(value) {
  if (typeof value == "function") {
    // 输入是函数,直接返回
    return value;
  }
  if (isObject(value)) {
    // 输入是对象,生成匹配器函数
    return isArray(value)
      ? baseMatchesProperty(value[0], value[1])
      : baseMatches(value);
  }
  // 输入是字符串/属性路径,生成属性访问函数
  return property(value);
}

这个工厂函数使得数组模块中的 _.uniqBy, _.differenceBy 等函数能够接受函数、对象或字符串作为处理元素的方式:

const users = [
  { id: 1, name: "Alice", active: true },
  { id: 2, name: "Bob", active: false },
  { id: 1, name: "Alice", active: true },
];

// 使用属性字符串
_.uniqBy(users, "id");

// 使用函数
_.sortedUniqBy([1.1, 1.2, 2.3, 2.4], Math.floor);

技术收获

1. JavaScript 深层理解

值比较机制

Lodash 数组模块中的 eq 函数实现了 SameValueZero 比较算法,处理了 JavaScript 比较中的特殊情况:

// 示例:比较 NaN 和 +/-0
console.log(+0 === -0); // true (=== 认为相等)
console.log(Object.is(+0, -0)); // false (Object.is 区分)
console.log(NaN === NaN); // false (=== 无法识别 NaN)
console.log(Object.is(NaN, NaN)); // true (Object.is 识别 NaN)

// Lodash 的 eq 行为
function eq(value, other) {
  return value === other || (value !== value && other !== other);
}
console.log(eq(NaN, NaN)); // true
数组操作与引用

理解数组操作中的浅拷贝与结构变换:

// 示例:浅拷贝与 flatten 的区别
const original = [1, [2, 3]];

// 浅拷贝:嵌套数组仍然是引用
const shallowCopy = original.slice();
shallowCopy[1][0] = 99;
console.log(original[1][0]); // 99 (原数组受影响)

// flatten:创建新数组,改变了结构
const flattened = _.flatten(original);
console.log(flattened); // [1, 2, 3] (新的一维数组)
// 修改 flattened 不会影响 original
flattened[1] = 88;
console.log(original[1][0]); // 仍然是 99

2. 算法与数据结构实践

哈希表在交集计算中的优化

intersection 函数使用哈希表优化查找性能:

// 核心思路:将一个数组放入哈希表,然后遍历另一个数组检查元素是否存在
function simplifiedIntersection(array1, array2) {
  const cache = {};
  array1.forEach((value) => {
    cache[value] = true;
  });

  return array2.filter((value) => cache[value]);
}
console.log(simplifiedIntersection([1, 2, 3], [2, 3, 4])); // [2, 3]
二分查找在有序数组操作中的应用

Lodash 的有序数组操作函数利用二分查找提高性能:

// 示例:使用 sortedIndex 找到值在有序数组中的插入位置
const sortedArray = [10, 20, 30, 40, 50];
const insertPos = _.sortedIndex(sortedArray, 35);
console.log(insertPos); // 3 (值 35 应插入在索引 3 处)

// 内部实现基于二分查找
function simplifiedSortedIndex(array, value) {
  let low = 0,
    high = array.length;
  while (low < high) {
    const mid = (low + high) >>> 1;
    if (array[mid] < value) low = mid + 1;
    else high = mid;
  }
  return low;
}

3. 函数式编程技巧

高阶函数的广泛应用

Lodash 数组模块中的许多函数接受函数作为参数,用于自定义行为:

// 示例:使用 _.findIndex 查找第一个偶数
const numbers = [1, 3, 4, 7, 8];
const firstEvenIndex = _.findIndex(numbers, function (n) {
  return n % 2 === 0;
});
console.log(firstEvenIndex); // 2 (索引为 2 的元素 4 是第一个偶数)

// 示例:使用 _.takeWhile 提取开头的奇数
const leadingOdds = _.takeWhile(numbers, (n) => n % 2 !== 0);
console.log(leadingOdds); // [1, 3]
函数组合思想

Lodash 数组模块的内部实现体现了函数组合思想:

// 示例:pullAllBy 函数组合了移除逻辑和自定义比较逻辑
var array = [{ x: 1 }, { x: 2 }, { x: 3 }, { x: 1 }];
_.pullAllBy(array, [{ x: 1 }, { x: 3 }], "x");
console.log(array); // => [{ 'x': 2 }]
// 内部组合了遍历、比较和移除操作

4. 代码质量与可维护性

命名规范与语义清晰

Lodash 数组模块的函数和变量命名表达了其意图和作用范围:

// 示例:清晰的命名
baseIndexOf; // 基础实现,用于内部
strictIndexOf; // 表明使用严格比较
isArrayLike; // 类型检查函数
castArray; // 类型转换函数
单一职责原则与抽象分层

Lodash 将复杂功能分解为多个单一职责的函数,并通过分层调用组织起来:

// 示例:indexOf 的分层实现
// _.indexOf (公共 API, 处理参数)
// -> baseIndexOf (处理 NaN vs 普通值)
//    -> strictIndexOf (执行严格比较查找)
//    -> baseFindIndex + baseIsNaN (执行 NaN 查找)

对现代 JavaScript 的思考

通过对比 Lodash 数组模块与现代 JavaScript 原生功能:

  1. 原生方法 vs 工具库:现代 JavaScript 已内置许多数组方法,但 Lodash 在一致性和扩展性上仍有优势
  2. 性能考量:Lodash 在特定场景下(如大数据处理)有针对性优化
  3. 未来发展:随着 JavaScript 语言发展,两者差距逐渐缩小

具体功能对比案例

数组去重

Lodash 方式:

_.uniq([1, 2, 1, 4, 1, 3]); // [1, 2, 4, 3]
// 支持对象数组
_.uniqBy([{ id: 1 }, { id: 2 }, { id: 1 }], "id"); // [{id:1}, {id:2}]

ES6 方式:

[...new Set([1, 2, 1, 4, 1, 3])]; // [1, 2, 4, 3]
// 对象数组需要自己实现
Array.from(new Map(array.map((item) => [item.id, item])).values());
数组扁平化

Lodash 方式:

_.flatten([1, [2, [3, [4]], 5]]); // [1, 2, [3, [4]], 5]
_.flattenDeep([1, [2, [3, [4]], 5]]); // [1, 2, 3, 4, 5]
_.flattenDepth([1, [2, [3, [4]], 5]], 2); // [1, 2, 3, [4], 5]

ES6 方式:

[1, [2, [3, [4]], 5]].flat(); // [1, 2, [3, [4]], 5]
[1, [2, [3, [4]], 5]].flat(Infinity); // [1, 2, 3, 4, 5]
[1, [2, [3, [4]], 5]].flat(2); // [1, 2, 3, [4], 5]

实践应用

通过 Lodash 数组模块源码学习,获得了以下实践指导:

  1. 模块化设计实践:将复杂功能分解为可复用的小函数
  2. 边界情况处理:全面考虑各种输入情况,提高代码健壮性
  3. 性能优化技巧:利用缓存、算法优化提升性能
  4. API 设计思想:保持一致性和直观性

具体项目应用场景

  1. 数据处理:处理 API 返回的复杂嵌套数据

    // 提取所有用户的技能列表并去重
    const allSkills = _.uniq(_.flattenDeep(users.map((user) => user.skills)));
    
  2. 高效数据集处理

    // 高效查找两个大型数组的交集
    const commonItems = _.intersection(array1, array2);
    
  3. 自定义数据转换

    // 按照指定属性分组
    const groupedData = _.chunk(sortedData, 3);
    

总结

通过系统学习 Lodash 数组模块的源码,我不仅掌握了丰富的数组操作函数,更深入理解了 JavaScript 语言特性、函数式编程范式和优秀代码设计的精髓。这些知识将直接应用于我的日常开发工作,提升代码质量和性能。

Lodash 数组模块的设计思想、优化技巧和编码实践值得反复学习和应用,它们展示了如何在实际项目中平衡功能、性能和可维护性。