just-group-by 源码解读:零依赖数组分组工具的使用与实现原理

原创2026-10-14 12:16:241,656 阅读
文章标签:开发工具

just-group-by 源码解读:零依赖数组分组工具的使用与实现原理

just-group-by 是 just 库(package.json)中一个专一而克制的数组工具函数:它只做一件事——接收一个数组和一个分组函数,返回按分组键聚合后的普通对象。本文将以 packages/array-group-by/README.md 为骨架,结合仓库内的源码、类型定义与测试用例,完整讲解它的安装方式、API 行为、边界处理与底层实现原理,读完即可在项目中放心使用,并理解其背后的设计取舍。

安装:两种主流包管理器都支持

just-group-by 以独立的 npm 包形式发布,零运行时依赖(这正是 just 系列"零依赖、只做一件事"理念的体现),安装方式与普通依赖一致:

npm install just-group-by
yarn add just-group-by

从 packages/array-group-by/package.json 可以看到,该包当前版本为 2.2.0,许可证为 MIT,并且同时通过 exports 字段暴露了三种入口:

  • types:指向 ./index.d.ts,供 TypeScript 使用;
  • require:指向 ./index.cjs,供 CommonJS 使用;
  • import:指向 ./index.mjs,供 ESM 使用。

因此无论你的项目使用 import 还是 require,都不需要额外的转译或 polyfill 即可直接使用。

核心 API:一行看懂"从数组返回分组对象"

README 开篇即点明了函数定位:Return a grouped object from array。调用方式如下:

import groupBy from 'just-group-by';

groupBy([6.1, 4.2, 6.3], Math.floor); // { '4': [4.2], '6': [6.1, 6.3] }
groupBy([1,2,3,4,5,6,7,8], function(i) { return i % 2}); // { '0': [2, 4, 6, 8], '1': [1, 3, 5, 7] }

第一个示例中,Math.floor 将 6.1、4.2、6.3 分别映射为 6、4、6,于是数字 4 这一组收集了 [4.2],数字 6 这一组收集了 [6.1, 6.3]。注意返回对象的键最终以字符串形式呈现('4'、'6'),这是 JavaScript 对象属性键的固有特性。

第二个示例用 i % 2 把 1~8 分成"奇数"与"偶数"两组:键 '1' 对应 [1, 3, 5, 7],键 '0' 对应 [2, 4, 6, 8]。这也说明分组函数完全由你定义,返回任何可作属性键的值都能参与分组。

参数说明

参数 类型 必填 说明
arr Array 是 待分组的数组,必须是真实数组(Array.isArray 通过),否则抛出异常
cb / resolver Function 是 分组函数,接收数组中的每个元素作为入参,返回值作为分组键

返回值是一个普通对象:键是分组函数返回的键,值是该键对应的元素组成的数组。传入空数组时返回空对象 {}(可由实现中循环体不执行直接推断)。

源码级实现:短短二十余行的全部逻辑

packages/array-group-by/index.cjs(ESM 版 index.mjs 为同一实现的构建产物)是它的完整实现:

module.exports = groupBy;

function groupBy(arr, cb) {
  if (!Array.isArray(arr)) {
    throw new Error('expected an array for first argument');
  }

  if (typeof cb !== 'function') {
    throw new Error('expected a function for second argument');
  }

  var result = {};
  for (var i = 0; i < arr.length; i++) {
    var item = arr[i];
    var bucketCategory = cb(item);
    var bucket = result[bucketCategory];

    if (!Array.isArray(bucket)) {
      result[bucketCategory] = [item];
    } else {
      result[bucketCategory].push(item);
    }
  }

  return result;
}

实现思路非常直白,可拆解为三步:

  1. 参数校验:先确保第一个参数是数组、第二个参数是函数,任何不满足条件的情况都直接抛错,避免后续产生难以排查的隐性错误;
  2. 遍历分组:对数组中的每个元素调用 cb(item) 得到分组键 bucketCategory,并在结果对象上查找对应"桶";
  3. 入桶:若该键尚无桶(bucket 不是数组),则新建 [item];若桶已存在,则 push 进新元素。

整个过程是 O(n) 的单次遍历,不排序、不去重、不改变原数组,也不做任何缓存或拷贝——这正是"只做一件事"的设计哲学。分组结果中元素的相对顺序保持原数组顺序,这一点在 test/array-group-by/index.cjs 的断言中也有体现。

边界与异常:非数组、非函数一律抛错

README 示例中隐含了严格的参数约束,测试文件则把这些约束完整地固化了下来。test/array-group-by/index.cjs 用 tape 框架验证了所有异常路径:

  • 第一个参数不是数组时抛错:{}、字符串、正则、null、undefined 甚至完全不传参,均抛出 expected an array for first argument;
  • 第二个参数不是函数时抛错:{}、[]、正则、null、undefined 或省略参数,均抛出 expected a function for second argument。

对应地,packages/array-group-by/index.d.ts 在类型层也做了同样严格的约束:

export default function groupBy<T, G extends keyof any = keyof any>(
  arr: readonly T[],
  resolver: (arg: T) => G
): Record<G, T[]>;

解读这个签名:

  • arr 接受 readonly T[],即只读数组类型也可以传入;
  • resolver 接收元素 T 并返回分组键 G;
  • G extends keyof any 表示分组键必须是 string | number | symbol 中的一种(keyof any 即这三者的联合),保证结果能安全地作为对象属性键;
  • 返回值类型为 Record<G, T[]>,即"键为 G、值为 T 数组"的记录,让调用方在编译期就能获得精确的分组结果类型。

packages/array-group-by/index.tests.ts 通过 // @ts-expect-error 模式验证了这些约束:传入 {}、字符串、正则、null、undefined 作为第一个参数,或传入非函数作为第二个参数,都会被 TypeScript 正确拦截并报错;而合法的调用(包括按元素首字母分组、按数组长度分组、返回 Symbol 键、返回字符串/数字联合键等)均通过类型检查。

运行验证:测试即文档

仓库为该模块提供了两套互补的验证手段:

  1. 运行时测试:test/array-group-by/index.cjs 基于 tape(见 test/util/test.js 的封装)断言了三个关键行为——非数组参数抛错、非函数参数抛错、分组结果与 README 示例完全一致(deepEqual 校验 {'4': [4.2], '6': [6.1, 6.3]} 与 {'0': [2, 4, 6, 8], '1': [1, 3, 5, 7]});
  2. 类型测试:packages/array-group-by/index.tests.ts 由仓库根目录 package.json 中的 test-types(tsc)脚本执行,确保 .d.ts 的泛型推导在合法/非法调用上均表现正确。

这意味着 README 中的每一行示例都不是"纸上谈兵",而是被测试用例逐字验证过的可运行行为。

双格式构建:一份源码,ESM 与 CJS 兼得

值得注意的一点是:index.cjs 才是该模块的源文件,index.mjs 是由 Rollup 构建生成的。每个包目录下的 rollup.config.js 统一复用根目录的 config/createRollupConfig.js:

module.exports = function createRollupConfig(path) {
  return {
    input: resolve(path, './index.cjs'),
    output: {
      file: resolve(path, './index.mjs'),
      format: 'esm',
    },
    plugins: [cjs()],
  };
};

构建流程为:以 index.cjs 为输入,通过 @rollup/plugin-commonjs 转换后输出 format: 'esm' 的 index.mjs。因此无论你使用 ESM 的 import groupBy from 'just-group-by',还是 CommonJS 的 const groupBy = require('just-group-by'),最终拿到的都是同一份逻辑,无需自行转译。

从 packages/array-group-by/CHANGELOG.md 可以看到该模块的演进轨迹:2.2.0 将 Node 模块从 .js 重命名为 .cjs,2.1.2 改进了类型定义,2.1.1 调整了 exports 顺序——双格式支持与类型质量一直是维护重点。

使用建议与适用场景

just-group-by 适合任何需要"把数组按某个维度归类"的场景,例如:

  • 按首字母归类单词列表:groupBy(['a', 'b', 'c', 'aa', 'bb', 'cc'], str => str.charAt(0));
  • 按属性归类对象集合(自行编写访问属性的 resolver);
  • 按数值区间、按奇偶性、按类别枚举等任意自定义维度。

它不提供排序、嵌套分组或多键分组等"高级能力"——这正是刻意为之:需要更重能力时你会知道该选别的方案,而当你只需要"分组"这一件事时,这个零依赖、约二十行、行为完全透明的函数就是最轻的选择。

小结

  • 安装:npm install just-group-by 或 yarn add just-group-by,零依赖、ESM/CJS 双格式开箱即用;
  • 用法:groupBy(array, resolver) 返回 Record<分组键, 元素数组>,分组键必须是 string | number | symbol;
  • 约束:第一个参数必须是数组、第二个参数必须是函数,否则立即抛错,TypeScript 类型与运行时检查双重把关;
  • 实现:单次遍历 + 对象桶收集,O(n) 时间、不改动原数组,行为被 test/array-group-by/index.cjs 完整覆盖。

无论你是想在业务代码中快速归类数据,还是想研读一个"小而美"的工具函数如何做到职责单一、边界严谨,just-group-by 都是一个值得收藏的参考样本。

登录后查看全文
just