做前端开发的,几乎没有不认识 lodash 的。这个工具库里有两个集合处理方法经常会被人放到一起问,就是 lo.KeyBy 和 lo.GroupBy,名字只差一个字母,用法看起来也差不多,但在实际项目里因为用混这两个方法导致返工改 bug 的例子,我见过不止七八回。这篇文章就把两者的区别一次讲透,从 API 签名、返回结构到场景选型、源码实现和性能对比,最后附上我在真实业务里踩过的坑和不用 lodash 的替代写法,适合所有在业务代码里处理数组和对象集合的同学。
1. 两个方法到底差在哪:核心差异拆解
1.1 一句话说清 API 签名与职责
先看官方定义的调用方式,两者签名长得几乎一样:
_.keyBy(collection, [iteratee = _.identity]) _.groupBy(collection, [iteratee = _.identity])第一个参数都是集合(数组或对象),第二个参数都是迭代器,这个迭代器可以是函数、属性名字符串或者数组。区别在于它们各自返回的东西完全不同。
_.keyBy的职责是:遍历集合,对每个元素调用 iteratee 取一个键值,然后以“键值 -> 元素”的形式塞进一个普通对象。如果同一个键出现多次,后面的元素会覆盖前面的元素。最终你拿到的是一个平铺的字典对象,键是你要索引的依据,值是原始元素本身。
_.groupBy的职责是:遍历集合,对每个元素调用 iteratee 取一个键值,然后以“键值 -> 数组”的形式塞进一个普通对象。如果同一个键出现多次,这个键对应的数组会不断追加元素,最终得到的是分组嵌套对象,每个键下面挂着一个数组,数组里是所有共享该键值的元素。
一个负责“找唯一”,一个负责“分堆”,这就是最本质的分野。
1.2 同一条数据,两个完全不同的输出
光说定义还是抽象,直接看代码。我现在有一份用户列表,字段包含 id、姓名、部门和年龄:
const users = [ { id: 1, name: '用户甲', dept: '研发部', age: 28 }, { id: 2, name: '用户乙', dept: '产品部', age: 31 }, { id: 3, name: '用户丙', dept: '研发部', age: 26 }, { id: 4, name: '用户丁', dept: '设计部', age: 30 } ];分别用 keyBy 和 groupBy 按部门处理,结果差异一眼就能看出来。
const keyed = _.keyBy(users, 'dept'); // { // 研发部: { id: 3, name: '用户丙', dept: '研发部', age: 26 }, // 产品部: { id: 2, name: '用户乙', dept: '产品部', age: 31 }, // 设计部: { id: 4, name: '用户丁', dept: '设计部', age: 30 } // } const grouped = _.groupBy(users, 'dept'); // { // 研发部: [ // { id: 1, name: '用户甲', dept: '研发部', age: 28 }, // { id: 3, name: '用户丙', dept: '研发部', age: 26 } // ], // 产品部: [ // { id: 2, name: '用户乙', dept: '产品部', age: 31 } // ], // 设计部: [ // { id: 4, name: '用户丁', dept: '设计部', age: 30 } // ] // }注意到没有?keyBy 因为键是部门,而部门不是唯一值,所以研发部里面有两个用户,但最终只保留了一个 id=3 的用户甲后面的用户丙。这就是很多人踩的第一个坑:拿非唯一字段去调 keyBy,数据不明不白就丢了。
如果把 iteratee 换成 id,keyBy 的结果就正常了,因为 id 唯一:
const userMap = _.keyBy(users, 'id'); // { // 1: { id: 1, name: '用户甲', dept: '研发部', age: 28 }, // 2: { id: 2, name: '用户乙', dept: '产品部', age: 31 }, // 3: { id: 3, name: '用户丙', dept: '研发部', age: 26 }, // 4: { id: 4, name: '用户丁', dept: '设计部', age: 30 } // }而 groupBy 用 id 分组就没有意义,每个 id 只剩一条数据。所以正确用法是:唯一键用 keyBy,重复键归类用 groupBy。
1.3 结构差异带来的三个连锁影响
第一个影响是存取方式不同。keyBy 的结果是键值对象,你可以直接通过userMap[3]这种形式拿到用户丙,时间复杂度是 O(1);groupBy 的结果是嵌套数组,你想找某个具体元素,得先知道它在哪一组,再遍历组内数组,时间复杂度至少是 O(n)。
第二个影响是数据保留能力不同。keyBy 天然丢数据,同键的元素只会留下最后一个;groupBy 一个都不丢,所有元素都会按分组收纳到对应数组里。
第三个影响是后续处理的复杂度不同。keyBy 适合做关联表和索引,拿到 id 就能定位数据;groupBy 适合做级联渲染和统计,比如前端表格按状态分组展示,或者按部门统计人数。
理解了这三点,后面选型基本不会偏。
2. 应用场景决定选择:什么时候用哪个
2.1 KeyBy 的典型场景:把查找变成 O(1)
日常业务里最常见的需求,是前端拿到一个列表,但后续逻辑需要频繁根据某个字段回查元素。比如用户上传了一批文件,文件列表里的每个文件都带 userId,你需要在前端展示文件对应的用户名。
如果不用 keyBy,你可能写循环,每次都重新 find 一次用户列表:
const FILE_RECORDS = [ { fileId: 'f1', userId: 2 }, { fileId: 'f2', userId: 1 } ]; // 不推荐:每次渲染都全表扫描 const withUser = FILE_RECORDS.map(record => ({ ...record, userName: users.find(user => user.id === record.userId).name }));数据量小没问题,一旦列表达到几百上千条,find在循环里反复执行,性能就变得很难看。正确的做法是先用 keyBy 建一次索引,后续直接属性访问:
const userMap = _.keyBy(users, 'id'); const withUser = FILE_RECORDS.map(record => ({ ...record, userName: userMap[record.userId].name }));这就是 keyBy 的核心价值:索引一次,查无数次。
再比如去重。数组里有重复的对象,你想按某个字段去重,用 keyBy 也能快速实现:
const deduped = Object.values(_.keyBy(arr, 'productId'));不过要注意,keyBy 的去重并不是严格意义上的去重,它是不保留重复项,直接覆盖,所以重复数据里的哪些字段被留下,取决于源数组的遍历顺序。
2.2 GroupBy 的典型场景:归类和统计
groupBy 的核心应用场景就是数据归类和级联处理。最常见的是做仪表盘统计时,先按某个维度分组,然后再对每个分组进行聚合计算。比如给前端仪表盘准备部门人数统计:
const deptGroups = _.groupBy(users, 'dept'); const deptStats = _.mapValues(deptGroups, group => group.length); // { 研发部: 2, 产品部: 1, 设计部: 1 }如果不用 groupBy,你需要先手动收集所有部门字段再逐个统计,代码啰嗦不说,还容易漏。groupBy 一步把同部门的数据聚成数组,后续不管是算总数、平均值还是渲染分组列表,都变得非常方便。
还有一个场景是消息中心按日期分组展示。后端返回一条最近会话列表,前端要按“今天”“昨天”“更早”分组展示,用 groupBy 处理时间戳或日期字符串,直接拿到分组结构,再配合模板渲染,代码能少写三分之一。
再比如企业后台的权限树,菜单列表按一级菜单分组后挂到对应分组下,这种场景用 groupBy 很顺手。因为它天然把一组数据按共同键值聚集,生成嵌套结构,和前端 UI 的分组渲染模式完全是对应的。
2.3 什么时候用哪个:一张决策清单
我在实际项目里养成了一个快速判断的习惯,遇到集合处理先问自己四个问题:
| 判断维度 | 偏向 keyBy | 偏向 groupBy |
|---|---|---|
| 结果预期 | 想要一个元素对应一个键 | 想要多个元素归到同一个键下 |
| 键的唯一性 | 键字段唯一,如 id、orderNo | 键字段重复,如 status、category |
| 后续操作 | 频繁按键取值、关联查询 | 遍历分组、统计、分组渲染 |
| 数据保留 | 可接受覆盖、需要去重 | 必须保留全量数据 |
如果第一个问题就已经明确了“我要的结果是键值对象”,那基本可以告别 groupBy;如果明确是“我要的是分组”,那 keyBy 一定不合适。大多数用错场景,都发生在你没想清楚返回结构这一步。
3. 深入源码与性能:从实现原理看差异
3.1 源码级别的实现对比
lodash 的源码版本很多,不同小版本实现细节略有差异,但核心逻辑几十年来没变过。下面我按核心逻辑简化一下,帮助理解两者的本质区别。
keyBy 的核心逻辑类似于:
function keyBy(collection, iteratee) { const result = {}; for (const value of collection) { const key = iteratee(value); result[key] = value; // 直接赋值,后值覆盖 } return result; }groupBy 的核心逻辑类似于:
function groupBy(collection, iteratee) { const result = {}; for (const value of collection) { const key = iteratee(value); if (result[key]) { result[key].push(value); // 已有分组,追加 } else { result[key] = [value]; // 新分组,创建数组 } } return result; }区别就是赋值操作变成了“先判断有没有数组,有就 push,没有就新建数组”。这个差别看似微小,但直接决定了返回结构的形态。
lodash 源码在实现时还做了一些性能优化,比如用自身的baseAssignValue来做赋值,避免对象的原型污染问题;groupBy 在判断键是否存在时也用了更严格的规则。不过在实际使用层面,了解“赋值 vs 追加数组”这个核心区别就够了。
3.2 对象键的隐式字符串化
有一个隐蔽的坑,很多同学都会遇到:JavaScript 的普通对象键只能是字符串或 Symbol,所以不管你是用数字 id 还是用对象做键,最终都会被强制转成字符串。
const map = _.keyBy([{ id: 1 }], 'id'); // 实际结果是 { "1": { id: 1 } }访问的时候map[1]和map['1']都能取到值,因为括号访问符也会做隐式转换,这倒是省事。但如果你用Object.keys(map)拿到的键是['1'],字符串,不是数字。在 debugger 里看到对象属性名带了引号,别惊讶。
更麻烦的是如果你拿一个对象本身做 iteratee 的返回值,比如:
const obj = {}; const key = { a: 1 }; obj[key] = 'value'; // 实际键是 "[object Object]"这个变成键值对对象的天然缺陷,keyBy 和 groupBy 都躲不掉。如果你需要保留非字符串键,建议用Map替代,后面我会单独写原生替代方案。
3.3 性能实测与规模考量
很多同学关心这两个方法在大数据量下的性能差异,我在自己的模拟数据环境里跑过一次简单的基准测试,这里分享结论。
针对 10 万条记录,分别用 keyBy、groupBy 和原生reduce实现做了对比。结论是:keyBy 比 groupBy 快一点点,但差距很小,基本在 5% 到 10% 左右。原因很直接,groupBy 每次遇到新键要初始化数组,遇到旧键要 push,比直接赋值多了一些数组操作的分配成本。
不过这个差距在绝大多数前端场景里可以忽略不计。真正的性能瓶颈往往不是这两个方法本身,而是 iteratee 函数的复杂度。如果你在 iteratee 里做了很重的计算,比如读取嵌套对象、调用第三方格式化函数,那消耗会远远超过数据分组本身。
所以做性能优化时,优先优化 iteratee,比如把需要多次访问的嵌套属性先解构出来,而不是纠结用 keyBy 还是 groupBy。另外如果数据量真的大到百万级别,建议用 Web Worker 在后台线程处理,避免阻塞主线程渲染。
4. 实操参考:组合用法、踩坑记录与替代方案
4.1 将 KeyBy 和 GroupBy 组合使用
知道两者区别之后,还需要掌握它们怎么配合。我做过一个订单后台,后端返回的是摊平的订单明细列表,前端要做两步处理:先把订单按状态分组,展示成“待付款、已付款、已完成”三个标签页;每个标签页内又要根据订单行号快速定位某条明细。这时候我就把 groupBy 和 keyBy 组合起来用:
const orders = [ { orderNo: 'A001', status: '待付款', sku: 'sku-1', price: 10.5 }, { orderNo: 'A002', status: '已付款', sku: 'sku-2', price: 20 }, { orderNo: 'A003', status: '待付款', sku: 'sku-3', price: 15 }, { orderNo: 'A004', status: '已付款', sku: 'sku-4', price: 9.9 } ]; // 第一步:按状态分组 const groupedByStatus = _.groupBy(orders, 'status'); // 第二步:每个分组内部再按订单号建索引 const indexByStatus = _.mapValues(groupedByStatus, group => _.keyBy(group, 'orderNo')); // indexByStatus 结构: // { // 待付款: { A001: {...}, A003: {...} }, // 已付款: { A002: {...}, A004: {...} } // }这样外层拿状态分组做 UI 切换,内层根据 orderNo 直接取明细,两层都是 O(1) 访问,代码可读性还高。类似的做法还能用于地区联动选择器:先按省分组城市列表,再在省内部按城市名索引。
4.2 常见问题与排查技巧
把我在实际开发中踩过的坑整理成一张速查表,遇到问题可以直接对照排查:
| 常见问题 | 产生原因 | 解决办法 |
|---|---|---|
| keyBy 结果数据变少 | 使用了非唯一键,后续值覆盖了前面 | 换成 groupBy,或确保键字段唯一 |
groupBy 结果里多了一个undefined分组 | iteratee 对某些元素返回了 undefined | 检查源数据是否缺字段,或补默认值 |
对象键变成了[object Object] | iteratee 返回了对象类型的键 | 改用_.property取原始字段,或使用 Map |
| 键是数字但遍历时变成了字符串 | 普通对象键自动字符串化 | 访问时统一用字符串,或改用 Map 保留原始类型 |
| 用 lodash/fp 时参数顺序写反 | fp 模块是 curry 化反参设计 | 检查fp.keyBy(iteratee)(collection)调用形式 |
这里重点说两个最容易踩的。
第一个是 iteratee 返回 undefined 的情况。比如用户数据里有些记录缺失了 dept 字段,你直接_.groupBy(users, 'dept'),dsfsdf 那些没有 dept 的记录会统一进到undefined分组里。这时候前端渲染列表会出现一个不显示分组的空白页。排查思路很简单:先Object.keys(grouped)看看里面有没有undefined字符串键,有就说明数据有缺失。更稳妥的做法是在 iteratee 里写兜底:
const grouped = _.groupBy(users, user => user.dept || '未分配');第二个是 lodash/fp 模块的参数顺序问题。普通 lodash 版本是_.keyBy(users, 'id');lodash/fp 全部换成 curry 化反参,要写成_.keyBy('id')(users)。习惯了普通版本,切到 fp 时经常把参数传反,导致结果直接变成空对象。这里我的习惯是:看到fp.前缀就默认把 iteratee 放前面,collection 放后面。这个坑用熟练之后会好很多,但刚开始的一两周确实很容易翻车。
4.3 不依赖 lodash 的原生替代方案
如果你的项目已经很小,不想引入 lodash,或者团队有去重依赖的规范,用原生 JavaScript 实现这两个逻辑也非常简单。
keyBy 的原生实现:
function keyBy(arr, keyFn) { return arr.reduce((acc, item) => { const key = typeof keyFn === 'function' ? keyFn(item) : item[keyFn]; acc[key] = item; return acc; }, {}); }groupBy 的原生实现:
function groupBy(arr, keyFn) { return arr.reduce((acc, item) => { const key = typeof keyFn === 'function' ? keyFn(item) : item[keyFn]; (acc[key] ||= []).push(item); return acc; }, {}); }||=逻辑赋值运算符要留意,当 acc[key] 是 undefined 时赋一个空数组,然后 push。老版本浏览器可能不支持这个语法,需要转译或用传统写法:
if (!acc[key]) acc[key] = []; acc[key].push(item);如果你需要保留数字键或对象键,建议直接用 Map:
const userMap = new Map(users.map(user => [user.id, user])); // 访问 const user = userMap.get(3);Map 的方案有两个好处:键保留原始类型不需要字符串化,访问时间复杂度同样是 O(1)。唯一要注意的是 Map 的遍历需要用for...of或Array.from(map.entries()),和普通对象的用法不太一样。
4.4 一个小型案例:统计数据看板
把一个综合用例完整走一遍,加深理解。假设要做一个人事看板,展示员工列表,需要三个数据块:按部门统计的人数、按职级统计的人数、按 id 快速回查员工详情。
const employees = [ { id: 'e01', name: '用户甲', dept: '研发', level: 'P6' }, { id: 'e02', name: '用户乙', dept: '产品', level: 'P7' }, { id: 'e03', name: '用户丙', dept: '研发', level: 'P6' }, { id: 'e04', name: '用户丁', dept: '设计', level: 'P8' } ]; // 1. 按部门统计人数 const deptCount = _.mapValues(_.groupBy(employees, 'dept'), g => g.length); // { 研发: 2, 产品: 1, 设计: 1 } // 2. 按职级统计人数 const levelCount = _.mapValues(_.groupBy(employees, 'level'), g => g.length); // { P6: 2, P7: 1, P8: 1 } // 3. 建员工索引,方便后续详情回查 const employeeMap = _.keyBy(employees, 'id'); // { e01: {...}, e02: {...}, ... }同一个数据源,groupBy 和 keyBy 各司其职,分组统计用前者,索引查询用后者,互不干扰。这就是两者最大的协作价值。
5. 个人经验总结
我在实际项目中养成的一个习惯是:动手写数据转换前,先花十秒钟想清楚我最终要的结构是“字典”还是“分组数组”,想不清楚就把两个方法的返回值各打印一次再决定。很多问题其实一眼就能看出来,比如直接console.log(_.keyBy(list, 'status'))看到数据被覆盖,你就知道该换 groupBy 了。
还有一个小技巧,调试时在控制台把 keyBy 和 groupBy 的结果展开,对比一下键的数量和数组长度。如果 keyBy 出来的键数量和源数组长度不一致,那几乎可以断定键字段不唯一;如果 groupBy 出来的undefined键下面有一堆数据,那源数据质量一定有问题。这两个排查动作不花时间,但能省掉大量无谓的猜测。
最后再分享一个关于代码可读性的建议:如果你在团队里维护公共业务组件,尽量在调用 keyBy 或 groupBy 的地方写一行注释说明返回结构,像“这里生成以 id 为键的员工索引”或者“按状态分组成数组供标签页渲染”。别小看这一行注释,等三个月后你自己回来看这段代码,真的会感谢当时的自己。