Lodash 源码解读与原理分析 - Lodash FP 版本架构与构建详情

44 阅读10分钟

一、先明确 Lodash FP 的核心目标与整体架构

1.1 核心目标

把原生 Lodash 从 命令式、data-first(数据在前) 改造成 函数式、iteratee-first(迭代器在前) 的风格,同时满足:

  • 自动柯里化:传部分参数返回新函数
  • 不可变操作:不修改原数据,返回新数据
  • Ramda 兼容性:支持 Ramda 风格的 API 与用法
  • 通用性:一套规则适配所有 Lodash 函数,无需硬编码

1.2 整体分层架构与数据流

Lodash FP 采用四层架构,数据流单向流转,规则与逻辑完全分离:

层次核心文件核心职责通俗定位
配置层_mapping.js定义所有转换规则(别名、参数、迭代器、特性)转换说明书:规定「哪些函数要怎么改」
核心层_baseConvert.js实现通用转换逻辑(柯里化、参数重排、不可变包装)流水线工人:照着说明书把原生函数改成 FP 版本
入口层_convertBrowser.js适配浏览器环境,自动转换全局 Lodash 实例环境适配器:让浏览器能直接用 FP 版本
打包层lib/fp/build-dist.jsWebpack 打包,生成 UMD 格式产物打包工具:产出最终可直接使用的 JS 文件

核心数据流

_mapping.js(规则) → _baseConvert.js(转换) → _convertBrowser.js(适配) → build-dist.js(打包) → lodash.fp.js(产物)

二、配置层 _mapping.js:转换规则的完整定义

_mapping.js 是所有转换的依据,所有规则都围绕 Lodash FP 的核心目标设计,我们按「规则类型 + 作用 + 实现」的逻辑拆解。

2.1 别名映射规则:解决「命名统一 + Ramda 兼容」

为什么定义?

  1. Lodash 内部有别名(比如 each 其实是 forEach);
  2. 要兼容 Ramda 生态(比如 Ramda 的 pipe 对应 Lodash 的 flow);
  3. 统一内部处理逻辑:不管用户写别名还是原名,内部都按真实函数名处理。

怎么定义?

// 1. aliasToReal:别名 → 真实函数名(手动维护)
exports.aliasToReal = {
  // Lodash 内部别名
  'each': 'forEach', 'first': 'head', 'entries': 'toPairs',
  // Ramda 兼容别名
  'all': 'every', 'always': 'constant', 'pipe': 'flow', 'compose': 'flowRight'
};

// 2. realToAlias:真实函数名 → 所有别名(自动生成,避免手动维护)
exports.realToAlias = (function() {
  const result = {};
  for (const alias in exports.aliasToReal) {
    const realName = exports.aliasToReal[alias];
    (result[realName] || (result[realName] = [])).push(alias);
  }
  return result;
})();

架构中的作用

  • 核心层提供「别名映射表」:_baseConvert.js 转换时,先通过这个表找到函数的真实名,再应用后续规则。

2.2 参数规则:解决「参数重排 + 自动柯里化」

这是实现 iteratee-first 的关键,分 3 个子规则,规则之间相互配合

2.2.1 aryMethod:定义函数的参数个数

**为什么定义?**柯里化的前提是「知道函数该接收几个参数」—— 比如 filter 是 2 个参数,柯里化后传 1 个参数(迭代器)就返回新函数,等传第 2 个参数(数据)时才执行。

怎么定义?

exports.aryMethod = {
  '1': ['flow', 'floor', 'ceil', 'uniqueId'], // 1个参数的函数
  '2': ['filter', 'map', 'forEach', 'includes'], // 2个参数的函数
  '3': ['reduce', 'set', 'zipWith', 'getOr'], // 3个参数的函数
  '4': ['fill', 'setWith'] // 4个参数的函数
};

2.2.2 aryRearg:默认参数重排规则

**为什么定义?**统一规定「不同参数个数的函数,参数顺序怎么调整」,核心是把 data-first 改成 iteratee-first

怎么定义?

exports.aryRearg = {
  '2': [1, 0],    // 2参数函数:参数反转 → 迭代器在前,数据在后
  '3': [2, 0, 1], // 3参数函数:第三个参数移到首位 → 迭代器在前
  '4': [3, 2, 0, 1] // 4参数函数:第四个参数移到首位
};

例子:原生 _.filter(data, iteratee) 是 2 参数函数,按 [1,0] 反转后,变成 fp.filter(iteratee, data)

2.2.3 methodRearg:特殊函数的自定义重排规则

**为什么定义?**有些函数的参数逻辑特殊,不能用默认规则 —— 比如 getOr 的参数是 (data, path, defaultValue),默认的 3 参数规则不适用,需要自定义。

怎么定义?

exports.methodRearg = {
  'getOr': [2, 1, 0], // getOr → (defaultValue, path, data)
  'isMatchWith': [2, 1, 0],
  'zipWith': [1, 2, 0]
};

架构中的作用

  • 核心层提供「参数处理依据」:_baseConvert.js 先通过 aryMethod 确定参数个数,再通过 aryRearg/methodRearg 调整参数顺序,最后基于参数个数做柯里化。

2.3 迭代器规则:解决「简化迭代器参数」

为什么定义?

原生 Lodash 的迭代器会传冗余参数 —— 比如 map 的迭代器默认传 (val, idx, arr),但函数式编程中 90% 的场景只需要 val,简化后更易用。

怎么定义?

// 1. iterateeAry:定义迭代器接收的参数个数
exports.iterateeAry = {
  'map': 1, 'filter': 1, 'forEach': 1, // 迭代器只接收 val
  'reduce': 2, 'reduceRight': 2 // 迭代器接收 acc 和 val
};

// 2. iterateeRearg:特殊迭代器的参数重排
exports.iterateeRearg = {
  'mapKeys': [1], // mapKeys 的迭代器只接收 key
  'reduceRight': [1, 0] // reduceRight 迭代器参数反转
};

架构中的作用

  • 核心层提供「迭代器简化规则」:_baseConvert.js 基于这个规则,把迭代器的参数个数限制到指定数量。

2.4 特性规则:解决「不可变 + 特殊行为」

2.4.1 mutate:标记「修改原数据的函数」

**为什么定义?**函数式编程要求「纯函数」,不能修改原数据。需要标记这些函数,让核心层对它们做「克隆后修改」的包装。

怎么定义?

exports.mutate = {
  'array': { 'fill': true, 'pull': true, 'reverse': true },
  'object': { 'assign': true, 'merge': true, 'defaults': true },
  'set': { 'set': true, 'unset': true, 'update': true }
};

2.4.2 skipFixed/skipRearg:标记「不适用默认规则的函数」

**为什么定义?**有些函数的逻辑特殊,不能限制参数个数或重排参数 —— 比如 flow 可以传任意多个函数,add 的参数顺序没必要反转。

怎么定义?

// skipFixed:不限制参数个数
exports.skipFixed = { 'flow': true, 'mixin': true, 'runInContext': true };
// skipRearg:不重排参数顺序
exports.skipRearg = { 'add': true, 'eq': true, 'merge': true };

架构中的作用

  • 核心层提供「特殊函数处理依据」:_baseConvert.js 会跳过这些函数的默认规则,避免转换出错。

三、核心层 _baseConvert.js:通用转换逻辑的实现

_baseConvert.js 的核心是 baseConvert 函数,它的作用是:读取 _mapping.js 的规则,对单个原生 Lodash 函数执行「标准化转换流程」

我们以「转换 filter 函数」为例,结合架构数据流,拆解完整的转换步骤。

3.1 转换的前置准备:初始化配置

首先定义转换的「开关」,可以通过 options 自定义(比如关闭柯里化):

function baseConvert(util, name, func, options) {
  // 配置开关:默认开启所有核心特性
  const config = {
    curry: options.curry ?? true,    // 自动柯里化
    rearg: options.rearg ?? true,    // 参数重排
    immutable: options.immutable ?? true, // 不可变包装
    cap: options.cap ?? true         // 限制迭代器参数个数
  };

  // 核心转换函数:wrap 是真正处理函数的地方
  return wrap(name, func, util.placeholder);
}

3.2 核心转换流程:wrap 函数的 6 个步骤

wrap 函数是转换的核心,所有规则都会在这里落地,步骤是固定的,适用于所有 Lodash 函数

步骤 1:解析真实函数名(应用别名规则)

function wrap(name, func, placeholder) {
  // 从 aliasToReal 找到真实函数名:比如 name 是 'each' → 真实名是 'forEach'
  const realName = mapping.aliasToReal[name] || name;
  let wrapped = func; // 初始化 wrapped 为原生函数

步骤 2:不可变包装(应用 mutate 规则)

如果函数在 mutate 列表中(比如 set),就用 wrapImmutable 包装,实现「先克隆、再修改」:

  if (config.immutable) {
    // 判断函数类型:数组/对象/set
    if (mapping.mutate.array[realName]) {
      wrapped = wrapImmutable(func, cloneArray); // 克隆数组
    } else if (mapping.mutate.object[realName]) {
      wrapped = wrapImmutable(func, createCloner(func)); // 克隆对象
    }
  }

  // wrapImmutable 的核心逻辑
  function wrapImmutable(func, cloner) {
    return function(...args) {
      const clonedData = cloner(args[0]); // 克隆原数据
      args[0] = clonedData; // 替换成克隆后的数据
      func(...args); // 修改克隆后的数据(原数据不变)
      return clonedData; // 返回新数据
    };
  }

对于 filter:它不在 mutate 列表中,跳过这一步。

步骤 3:参数个数限制(应用 aryMethod 规则)

aryMethod 找到函数的参数个数,固定参数个数(比如 filter 是 2 个参数):

  // 遍历 aryMethod,找到当前函数的参数个数
  for (const aryKey of Object.keys(mapping.aryMethod)) {
    if (mapping.aryMethod[aryKey].includes(realName)) {
      // castFixed:固定参数个数 → filter 只能接收 2 个参数
      wrapped = castFixed(realName, wrapped, aryKey);
      break;
    }
  }

  // castFixed 核心逻辑
  function castFixed(name, func, n) {
    if (config.fixed && !mapping.skipFixed[name]) {
      return _.ary(func, n); // 用 Lodash 内置的 ary 函数限制参数个数
    }
    return func;
  }

步骤 4:参数顺序重排(应用 aryRearg/methodRearg 规则)

按规则调整参数顺序,实现 iteratee-first

  // castRearg:重排参数 → filter 按 [1,0] 反转
  wrapped = castRearg(realName, wrapped, aryKey);

  // castRearg 核心逻辑
  function castRearg(name, func, n) {
    if (config.rearg && !mapping.skipRearg[name]) {
      // 优先用自定义规则 methodRearg,没有就用默认规则 aryRearg
      const rule = mapping.methodRearg[name] || mapping.aryRearg[n];
      return _.rearg(func, rule); // 用 Lodash 内置的 rearg 重排参数
    }
    return func;
  }

对于 filter:原生 (data, iteratee) → 重排后 (iteratee, data)

步骤 5:迭代器参数简化(应用 iterateeAry 规则)

限制迭代器的参数个数,简化用户的使用成本:

  // castCap:限制迭代器参数个数 → filter 的迭代器只接收 1 个参数(val)
  wrapped = castCap(realName, wrapped);

  // castCap 核心逻辑
  function castCap(name, func) {
    if (config.cap) {
      const iterateeN = mapping.iterateeAry[name];
      if (iterateeN) {
        return overArg(func, (iter) => _.ary(iter, iterateeN));
      }
    }
    return func;
  }

对于 filter:迭代器从接收 (val, idx, arr) 变成只接收 (val)

步骤 6:自动柯里化(应用 aryMethod 规则)

基于参数个数做柯里化,让函数支持部分应用:

  // castCurry:柯里化 → filter 是 2 参数函数,传 1 个参数返回新函数
  wrapped = castCurry(realName, wrapped, aryKey);

  // castCurry 核心逻辑
  function castCurry(name, func, n) {
    if (config.curry && n > 1) {
      return _.curry(func, n); // 用 Lodash 内置的 curry 函数柯里化
    }
    return func;
  }

  // 给转换后的函数加属性:支持占位符和自定义转换
  wrapped.placeholder = placeholder;
  wrapped.convert = createConverter(realName, func);

  return wrapped;
}

3.3 转换后的效果:filter 函数的前后对比

特性原生 LodashLodash FP(转换后)依赖的规则
参数顺序_.filter(data, iter)fp.filter(iter, data)aryRearg: [1,0]
柯里化需手动 _.curry(_.filter)自动柯里化:fp.filter(iter)(data)aryMethod: '2'
迭代器接收 val, idx, arr只接收 valiterateeAry: 1
不可变无(不修改原数据)无(无需包装)mutatefilter
别名each 别名fp.each 等价于 fp.forEachaliasToReal: each→forEach

3.4 架构中的作用

_baseConvert.js配置层和入口层的桥梁:它读取配置层的规则,输出转换后的 FP 函数,再交给入口层做环境适配。

四、入口层与打包层:从转换到可用产物

4.1 入口层 _convertBrowser.js:浏览器环境适配

_convertBrowser.js 是对 baseConvert 的简单包装,核心作用是 自动转换浏览器全局的 Lodash 实例

const baseConvert = require('./_baseConvert');

function browserConvert(lodash, options) {
  // 转换整个 Lodash 库:把所有原生函数都变成 FP 版本
  return baseConvert(lodash, lodash, options);
}

// 自动转换全局 _ 对象:浏览器引入 Lodash 后,直接用 _ 就是 FP 版本
if (typeof _ === 'function' && typeof _.runInContext === 'function') {
  _ = browserConvert(_.runInContext());
}

module.exports = browserConvert;

4.2 打包层 build-dist.js:生成最终产物

打包层基于 Webpack,把转换后的代码打包成 UMD 格式(支持浏览器、Node.js、模块化引入),核心流程:

  1. 打包 _mapping.js 生成 mapping.fp.js(规则文件);
  2. 打包 _convertBrowser.js 生成 lodash.fp.js(核心产物);
  3. 压缩 lodash.fp.js 生成 lodash.fp.min.js(生产环境用)。

打包配置核心代码

const webpack = require('webpack');
const fpConfig = {
  entry: './fp/_convertBrowser.js',
  output: {
    path: './dist',
    filename: 'lodash.fp.js',
    library: 'fp',
    libraryTarget: 'umd' // 支持所有模块系统
  }
};

// 执行打包
async.series([
  (cb) => webpack(fpConfig, cb),
  (cb) => file.min('./dist/lodash.fp.js', cb)
]);

五、全局逻辑串联总结

  1. 配置层 _mapping.js 定义「转换说明书」:规定每个函数的参数、迭代器、不可变等规则;
  2. 核心层 _baseConvert.js 是「流水线工人」:照着说明书,对每个原生函数执行「找真实名→不可变包装→参数处理→迭代器简化→柯里化」的固定流程;
  3. 入口层 _convertBrowser.js 是「环境适配器」:把转换后的函数适配到浏览器环境;
  4. 打包层 是「打包工具」:把所有代码打包成 UMD 格式的产物,供开发者直接使用。

整个流程的核心是 「规则与逻辑分离」—— 新增或修改函数的转换方式,只需要改 _mapping.js 的规则,不需要动核心转换逻辑,这也是 Lodash FP 扩展性强的根本原因。