just-group-by 源码解读:零依赖数组分组工具的使用与实现原理
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;
}
实现思路非常直白,可拆解为三步:
- 参数校验:先确保第一个参数是数组、第二个参数是函数,任何不满足条件的情况都直接抛错,避免后续产生难以排查的隐性错误;
- 遍历分组:对数组中的每个元素调用
cb(item)得到分组键bucketCategory,并在结果对象上查找对应"桶"; - 入桶:若该键尚无桶(
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 键、返回字符串/数字联合键等)均通过类型检查。
运行验证:测试即文档
仓库为该模块提供了两套互补的验证手段:
- 运行时测试: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]}); - 类型测试: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 都是一个值得收藏的参考样本。