1. 开篇:先讲一个我踩过的坑
做前端开发这几年,Lodash 里的_.keyBy和_.groupBy是我用得最多的两个集合处理方法,但最开始我真没把它们当两类东西看。有一回我在一个订单状态统计模块里,想按订单状态把列表分一下组,顺手就写了_.keyBy(orders, 'status')。结果页面渲染出来,每个状态只显示了一条订单,其他订单静默消失了。我查了整整一个下午,最后才发现问题出在keyBy的键冲突覆盖行为上——同一状态的多条订单,后一条会把前一条整个顶掉。
这件事之后我认真捋了一遍keyBy和groupBy的差异,发现其实不只是“返回对象还是数组”这么简单。它们在返回值结构、键冲突处理、迭代逻辑、适用场景上都有本质区别。这篇博文就想把这些区别讲透,尤其是源码层面的实现差异和我在真实项目里踩过的坑。不管你是刚接触 Lodash 的新人,还是用了很久但没系统对比过这两个方法的老手,我相信都能从中得到点东西。
先给个一句话结论:_.keyBy是“把列表变成以指定字段为键的索引表”,键必须唯一,值是单条记录;_.groupBy是“按指定字段把元素分到不同桶里”,每个桶是一个数组,会保留所有同键记录。一个解决“快速查找”,一个解决“归类聚合”,方向完全不一样。
2. 本质区别:keyBy 查字典,groupBy 分桶
2.1 两个高频业务场景,正好对应两种需求
我先把两个方法落回到真实业务里,这样更容易理解它们为什么存在。
第一个场景:服务端返回了一个用户列表,前端需要根据用户 ID 快速找到某一条用户信息,比如在订单列表里渲染下单人姓名。最直观的做法是users.find(u => u.id === targetId),但如果订单有好几百条、用户也有几千个,在循环里反复 find 就是 O(n*m),非常浪费。更合理的思路是先遍历一遍用户列表,生成一张“以 id 为键”的映射表,之后每次查找都变成 O(1) 的取值操作。这就是_.keyBy的典型用途。
第二个场景:后台管理系统里有一堆工单记录,需要按“待处理 / 处理中 / 已完成”三个状态分组展示,每一组底下显示该状态的工单列表,同时统计数量。这个需求天然要求“同一个状态的所有记录都保留下来”,而不是只留一条。这种按某个相同属性把元素聚合成一组数组的操作,就是_.groupBy的强项。
两个场景抽象一下:前者希望“拿到一条记录”,后者希望“拿到一组记录”。这个核心诉求的不同,决定了该用哪个方法。
2.2 一段代码看懂两者返回结果
先看一段最基础的代码:
import _ from 'lodash'; const users = [ { id: 1, name: '张三', dept: '前端' }, { id: 2, name: '李四', dept: '后端' }, { id: 3, name: '王五', dept: '前端' }, ]; // keyBy 以 id 为键,将数组折叠成“索引表” const userMap = _.keyBy(users, 'id'); // 结果结构: // { // 1: { id: 1, name: '张三', dept: '前端' }, // 2: { id: 2, name: '李四', dept: '后端' }, // 3: { id: 3, name: '王五', dept: '前端' } // } // groupBy 以 dept 为键,将数组按部门“分桶” const deptGroups = _.groupBy(users, 'dept'); // 结果结构: // { // 前端: [ // { id: 1, name: '张三', dept: '前端' }, // { id: 3, name: '王五', dept: '前端' } // ], // 后端: [ // { id: 2, name: '李四', dept: '后端' } // ] // }注意一个细节:userMap的键虽然是数字1、2、3,但在 JS 对象里它其实被转成了字符串'1'、'2'、'3',不过访问时写成userMap[1]和userMap['1']效果一样,所以实际使用中不太会感知到差别。
console.log(userMap[1].name); // '张三' console.log(deptGroups['前端'].length); // 2一句话总结:keyBy的结果里每个键对应一个元素;groupBy的结果里每个键对应一个元素数组。这个结构差异是所有其他差异的根源。
3. 表面相似,内在迥异:四个维度的对比
3.1 返回值结构不同
_.keyBy(collection, iteratee)返回普通对象,形如:
{ key1: item1, key2: item2, // ... }_.groupBy(collection, iteratee)返回普通对象,形如:
{ key1: [item1, item2], key2: [item3], // ... }很多同学一开始以为groupBy返回的是二维数组,比如[[key, items]],或者以为keyBy的 value 是数组,这都属于把结果结构搞混了。记住一句话:keyBy的值是“记录”,groupBy的值是“记录数组”。
3.2 键冲突处理不同:这是最关键的差异
这个差异是我开头那个坑的根源,也是两者行为上最本质的分水岭。
看这个例子:
const list = [ { id: 1, type: 'a', value: 'x' }, { id: 2, type: 'a', value: 'y' }, ]; const keyed = _.keyBy(list, 'type'); // { a: { id: 2, type: 'a', value: 'y' } } const grouped = _.groupBy(list, 'type'); // { a: [ { id: 1, type: 'a', value: 'x' }, { id: 2, type: 'a', value: 'y' } ] }keyBy遇到相同键时,后面的值会把前面的覆盖掉,对象里最终只有一条记录。groupBy遇到相同键时,会把新值追加到这个键对应的数组末尾,所有记录都保留。
这种“静默覆盖”特别危险。因为 Lodash 不会为重复键抛异常,代码也不会红,但数据已经悄悄丢了。我在实际项目里见过不止一次:有人拿keyBy去处理“同一分类下有多个商品”的场景,结果每个分类只剩最后一个商品,前端页面怎么看怎么不对。
反过来,如果你用groupBy去建 id 索引表,每取一条数据还要多写一层[0]或者?.at(-1),反而把简单事情搞复杂了。所以选型之前,一定要问自己一句:这个键是唯一的吗?我到底需要保留一条记录,还是一组记录?
3.3 迭代器(iteratee)参数差异
这两个方法接收的第二个参数是相同的类型,可以是字符串属性路径、数组形式的属性路径、或者函数。
// 字符串路径 _.keyBy(users, 'id'); _.groupBy(users, 'dept.name'); // 数组路径 _.keyBy(users, ['profile', 'age']); // 函数 _.groupBy(users, item => item.dept + '-' + item.city);需要留意的是,如果传函数,Lodash 调用它时会传入三个参数:当前元素value、当前索引或键index/key、原集合collection。大多数场景下你只会用到第一个参数,但不小心用到了第二个参数时容易绕晕。比如:
_.groupBy(users, (item, index) => index % 2 === 0 ? 'even' : 'odd'); // 按数组下标奇偶分组,key 是 'even' / 'odd'这里第二个参数是下标,不是对象里的字段值。初学者常以为 iteratee 只会收到一个参数,结果写出来的分组逻辑莫名其妙。
此外,不管是keyBy还是groupBy,如果你传的 iteratee 返回undefined,那么结果的键就是字符串'undefined'。这个现象很容易被忽略,后面我会专门讲。
3.4 性能和内存表现差异
从复杂度上说,两者都是 O(n) 遍历,没有本质差别。但从微操层面看,groupBy每遇到一个新键要创建数组,遇到重复键要 push 元素,分配和扩容的开销略高于keyBy的纯赋值。
不过在实际业务规模下,这种性能差异基本可以忽略。我在一个模拟项目里用 10 万条订单数据分别跑过这两个方法,keyBy大概 12ms,groupBy大概 18ms,差距在个位数毫秒级。真正影响性能的不是方法本身,而是 iteratee 里的计算。如果你在 iteratee 里做正则匹配、JSON 解析、字符串拼接大对象,那不管用哪个方法都会变慢。
// 慢的原因不在 groupBy,而在 iteratee 里做了重活 _.groupBy(list, item => { const parsed = JSON.parse(item.payload); // 不要在这里这么干 return parsed.category; });如果数据量真的到了几十万、上百万级别,我建议改用原生Map配合for循环,或者用 Web Worker 分片处理,而不是在 Lodash 的通用迭代器上纠结那几毫秒。
4. 源码级拆解:它们其实是同一个工厂造出来的
4.1 createAggregator 是什么
这两个方法表面上看起来完全独立,实际上在 Lodash 内部,它们都出自同一个辅助函数:createAggregator。我以 Lodash 4.17.21 为例,源码里是这样定义的:
var keyBy = createAggregator(function(result, value, key) { baseAssignValue(result, key, value); }); var groupBy = createAggregator(function(result, value, key) { if (hasOwnProperty.call(result, key)) { result[key].push(value); } else { baseAssignValue(result, key, [value]); } });你可以看到,两者的差异只在于传给createAggregator的那个“聚合回调”不同。keyBy的回调是直接把value赋给result[key];groupBy的回调是先判断key是否已经存在,存在就把valuepush 进已有数组,不存在就创建一个[value]数组。
这就是为什么名字像、参数也像,行为却不一样的根本原因:它们共享同一套迭代框架,但因为聚合逻辑不同,输出完全不同。
4.2 createAggregator 的迭代逻辑
为了更直观,我把createAggregator的核心循环简化出来,它大概长这样:
function createAggregator(setter, initializer) { return function(collection, iteratee) { const result = initializer ? initializer() : {}; iteratee = getIteratee(iteratee, 3); if (Array.isArray(collection)) { let index = -1; const length = collection.length; while (++index < length) { const value = collection[index]; setter(result, value, iteratee(value, index, collection)); } } else { // 对普通对象,用 baseForOwn 遍历自身可枚举属性 for (const key in collection) { if (Object.prototype.hasOwnProperty.call(collection, key)) { const value = collection[key]; setter(result, value, iteratee(value, key, collection)); } } } return result; }; }这里有个点值得注意:当 collection 本身是一个普通对象时,keyBy和groupBy也能用,直接对对象的键值进行重新聚合。比如:
_.groupBy({ a: { type: 1 }, b: { type: 2 } }, 'type'); // { 1: [{ type: 1 }], 2: [{ type: 2 }] }这意味着你不一定非要传数组,传对象也会被遍历处理。这也是 Lodash 集合类方法的一个通用特性。
4.3 为什么这样设计是合理的
有人可能会想:既然 keyBy 本质上也能用 groupBy 实现,那为什么不直接用_.mapValues(_.groupBy(list, key), group => group[0])来模拟 keyBy?这个想法没错,功能上确实等价,但实现上会多做一层数组创建和取值,而且要额外处理空分组。Lodash 把 keyBy 单独提出来,就是要用最直接的“覆盖赋值”表达“取最后一条”的语义。
反过来,groupBy 又为什么不用 keyBy 去模拟分组?因为 keyBy 天然丢掉重复键,逻辑上就无法保留同一个键下的多条记录,用它是模拟不出来 groupBy 的。
所以你可以把createAggregator理解成一个通用的分组框架,setter 决定聚合行为。这个设计给了 Lodash 很强的扩展性,其他聚合方法比如_.countBy、_.partition其实也走了同一条路线:
var countBy = createAggregator(function(result, value, key) { if (hasOwnProperty.call(result, key)) { ++result[key]; } else { baseAssignValue(result, key, 1); } });这个设计思路对我平时的代码组织也很有启发:与其每个业务场景写一套独立的 reduce 逻辑,不如抽象一个公共遍历器,再把“每次拿到元素的处理动作”作为回调传进去。维护成本会大大降低。
4.4 一个安全细节:baseAssignValue 的用意
在源码里,赋值不是直接写result[key] = value,而是用了baseAssignValue。这个封装的目的是防止__proto__这类特殊键造成原型污染。
_.keyBy(users, () => '__proto__'); // 如果直接赋值,result['__proto__'] = value 会影响整个对象的原型链 // baseAssignValue 内部做了处理,避免污染普通业务中很少会有人用__proto__做键,但这个细节说明 Lodash 在处理不可信数据时的安全性考虑。如果你自己在写类 keyBy 的工具函数,也建议加上类似的防护,尤其是数据来源是用户输入或第三方接口时。
5. 实操示例:从需求出发选对方法
5.1 按状态分组做统计
统计类需求基本都用 groupBy,因为它能保留所有记录,方便后续length计数或遍历渲染。
const orders = [ { id: 'o1', status: 'pending', total: 100 }, { id: 'o2', status: 'paid', total: 200 }, { id: 'o3', status: 'pending', total: 300 }, { id: 'o4', status: 'cancelled', total: 50 }, ]; const groupedByStatus = _.groupBy(orders, 'status'); // { // pending: [order1, order3], // paid: [order2], // cancelled: [order4] // } const statusCount = _.mapValues(groupedByStatus, items => items.length); // { pending: 2, paid: 1, cancelled: 1 }这里我用了_.mapValues配合 groupBy 做计数,比单独用_.countBy更灵活,因为 groupBy 之后你还能拿到完整的原始记录,而不是只有数字。
5.2 用 keyBy 模拟关联表
接口分成两个接口返回的场景太常见了:一个订单列表,一个用户列表。前端需要把用户名拼到订单上。用 keyBy 建立索引表是最优雅的做法:
const users = [ { id: 1, name: '张三' }, { id: 2, name: '李四' }, ]; const orders = [ { orderId: 'A01', userId: 1, product: '键盘' }, { orderId: 'A02', userId: 2, product: '鼠标' }, { orderId: 'A03', userId: 1, product: '显示器' }, ]; const userMap = _.keyBy(users, 'id'); const enrichedOrders = orders.map(order => ({ ...order, userName: userMap[order.userId]?.name || '未知用户', }));不用 keyBy 的话,常规写法是双层循环或者每次 find,数据量一旦上去就能明显感觉到卡顿。用 keyBy 先把用户列表打散成映射表,关联时就是 O(1) 查找,整体复杂度从 O(n*m) 降到 O(n+m)。
5.3 复合键和动态 iteratee
有些字段无法直接作为唯一键,比如“同一天里同一用户可能有多条记录,但同一用户同一天的记录只允许一条”,这时可以用函数拼接复合键。
const records = [ { userId: 1, date: '2026-01-05', score: 80 }, { userId: 1, date: '2026-01-05', score: 90 }, // 重复,会被 keyBy 覆盖 { userId: 1, date: '2026-01-06', score: 70 }, ]; const map = _.keyBy(records, item => `${item.userId}_${item.date}`); // { // '1_2026-01-05': { userId: 1, date: '2026-01-05', score: 90 }, // '1_2026-01-06': { userId: 1, date: '2026-01-06', score: 70 } // }同样,groupBy 也可以用函数做更自由的分组。比如按月份分组:
const logs = [ { time: '2026-01-05 10:00:00', level: 'info' }, { time: '2026-01-18 11:00:00', level: 'error' }, { time: '2026-02-03 09:30:00', level: 'info' }, ]; const monthlyLogs = _.groupBy(logs, item => item.time.slice(0, 7)); // { '2026-01': [两条], '2026-02': [一条] }这里的关键点是:iteratee 返回什么,结果对象的键就是什么。返回字符串就用字符串做键,返回数字就用数字做键,返回布尔值就用布尔值做键。一旦返回undefined,键都会统一变成'undefined'字符串。
5.4 原生 reduce 等价写法
如果你不想引入 Lodash,或者需要在某些轻量项目里手写类似逻辑,用原生 reduce 也能做到:
// keyBy 的 reduce 版本 const userMap = users.reduce((acc, user) => { acc[user.id] = user; return acc; }, {}); // groupBy 的 reduce 版本 const grouped = orders.reduce((acc, order) => { if (acc[order.status]) { acc[order.status].push(order); } else { acc[order.status] = [order]; } return acc; }, {});我在很多代码评审里看到有人写出“用 reduce 模拟 keyBy”的代码,其中不少还带着|| []加 push 的复杂逻辑,那其实就是 groupBy 的手写版本,反而把事情搞复杂了。了解底层等价逻辑是好事,但能用现成且语义明确的方法,就没必要自己造一个有歧义的轮子。
6. 常见问题与避坑指南
6.1 空数组和空对象的结果
这个比较直白:
_.keyBy([], 'id'); // {} _.groupBy([], 'type'); // {}两者都返回空对象,不存在把空数组处理成null的问题。但注意,空对象的原型是Object.prototype,如果后面做了Object.keys(result).length判断,结果会是 0,这点没问题。不过千万别用if (result)去判断是否为空,因为空对象是 truthy,永远为 true。
6.2 对象或数组作为 iteratee 返回值的坑
这点我反复强调都不为过。普通对象的键只能是字符串或 Symbol,当你让 iteratee 返回一个对象时,JavaScript 会把它转成字符串'[object Object]'。看这个例子:
const list = [ { name: '张三', dept: '前端' }, { name: '李四', dept: '后端' }, ]; // 错误示范:期望按 dept 分组,结果 key 被压平了 const bad = _.groupBy(list, item => ({ dept: item.dept })); // { '[object Object]': [张三, 李四] } // 所有元素都被分到同一个桶里 // 正确做法:返回字符串键 const good = _.groupBy(list, item => item.dept);同理,如果 iteratee 返回数组,也会被转成'array1,array2'之类的逗号拼接字符串,照样是个大坑。复合键的正确姿势是返回拼接字符串,而不是返回数组或对象。
6.3 数字键、中文键和输出顺序
JS 对象在遍历键时有一个特殊规则:整数索引键(比如'1'、'2'、'10')会按数字从小到大排序,而字符串键(比如中文、字母组合)会按插入顺序排序。这意味着,如果你用 keyBy 生成{ 2: ..., 3: ..., 10: ... },遍历时顺序会变成2, 3, 10,不会给你2, 10, 3的顺序。
const result = _.keyBy(users, 'id'); // 假设 id 分别有 2、10、3,Object.keys 返回的顺序是 ['2','3','10'] console.log(Object.keys(result)); // ['2', '3', '10']如果业务渲染对键顺序有严格要求,我建议你把键转成固定宽度字符串,比如item.id.toString().padStart(5, '0'),或者干脆用原生Map作为替代,Map 完全按插入顺序保存键,不会有这种整数键重排的怪问题。
6.4 键冲突导致的数据覆盖问题
keyBy 最大的坑就是静默覆盖。我自己的排查经验是:如果某个页面出现“数据变少、看起来像去重了”的现象,第一个怀疑对象就是 keyBy。为了提前发现隐患,可以在调用前校验重复项:
const ids = list.map(item => item.id); const hasDuplicate = new Set(ids).size !== ids.length; if (hasDuplicate) { console.warn('调用 keyBy 前发现重复 id,存在键覆盖风险'); }如果项目里有很多这种高风险调用,可以封装一个安全 keyBy:
function safeKeyBy(list, keyFn) { const result = {}; for (const item of list) { const key = typeof keyFn === 'function' ? keyFn(item) : item[keyFn]; if (result[key] !== undefined) { throw new Error(`重复键: ${key}`); } result[key] = item; } return result; }当然,这只是一个思路,实际要不要抛异常取决于业务容忍度。有些场景本来就需要“后面的覆盖前面的”,那就没必要强制校验。
6.5 什么时候可以不依赖 Lodash
如果你只是用keyBy做 id 映射,现代 JavaScript 的原生Map其实是更好的选择:
// 原生 Map const userMap = new Map(users.map(user => [user.id, user])); console.log(userMap.get(1)); // { id: 1, name: '张三' }Map 的好处是键可以是任意类型,不会出现'[object Object]'这种字符串强转,也不会受整数键重排规则影响。但 Map 没有 Lodash 那种字符串路径 iteratee 的便利写法,需要手动指定映射方式。
如果你的项目已经全局引入了 Lodash,或者代码库里到处是_.chain、_.filter、_.map穿插使用的写法,那保持一致用_.keyBy也没有问题。核心原则是:选一个方案后全项目风格统一,别在一套代码里混着用原生 Map 和 Lodash keyBy,维护起来会很乱。
7. 选择指南与我的实操心得
7.1 快速判断该用哪个
- 需要“按 id / code / 唯一编号”快速取某个对象 →
keyBy - 需要把相同属性的数据归到同一个数组里 →
groupBy - 同一个键可能出现多次,而且每次都要保留 →
groupBy - 同一个键理论上只出现一次,丢了会出错 →
keyBy,但建议加防重复校验 - 只需统计每组数量,不需要原始记录 →
countBy可能更省事,底层同样是 createAggregator - 要按多个维度层级分组,比如先按年份再按月份 →
groupBy返回后配合_.mapValues继续分组 - 需要把下拉选项去重成字典,而且只想要最后一项 →
keyBy
我一般会先在注释里写清楚“我这块数据的业务键是否唯一”,这比记方法文档重要得多。
7.2 代码 Review 时我会特别盯这几点
我在给团队做代码评审时,看到keyBy就会重点确认三件事:第一,选用的字段在数据里是否真的唯一;第二,如果有重复,业务上是取第一条还是最后一条,能不能接受静默覆盖;第三,如果只是取第一条,用_.uniqBy或_.uniq是不是语义上更清晰,因为它明确表达了“去重”意图,而不是靠“覆盖”副作用去模拟去重。
看到groupBy我会确认:后续消费这个对象时,有没有用Object.values把它转回数组,会不会因为对象键顺序影响渲染顺序;以及空分组会不会导致页面展示异常,比如某状态今天没有数据,groupBy 结果里就没有这个 key,前端模板里可能直接undefined.length报错。
// 页面模板里容易踩的雷 groupedByStatus.status?.length // 如果某状态没有数据,这里就是 undefined稳妥做法是先给默认值:
const pendingList = groupedByStatus['pending'] || [];7.3 最后分享一个小技巧
我后来在上一个模拟项目里写了一个小而实用的组合:用 groupBy 做二次聚合,再用 keyBy 建立分组后的索引。
// 先按状态分组 const grouped = _.groupBy(orders, 'status'); // 再为每个分组生成该分组的统计索引 const summary = _.mapValues(grouped, (items) => { const total = _.sumBy(items, 'total'); const count = items.length; return { count, total }; }); // 再用 keyBy 到外部服务传入的配置表里取分组展示文案 const statusTextMap = _.keyBy(statusConfigList, 'value');这套组合在后台报表类页面里很常用:既拿到了分组明细,又快速取到了状态名称、排序权重等配置信息。可以这么说,只要你能准确区分“查字典”和“分桶”,并且养成在调用 keyBy 前先确认键唯一性的习惯,这两个方法基本不会给你制造意外。