多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Lodash keyBy 与 groupBy 深度对比:返回结构、覆盖机制与源码实现

Lodash keyBy 与 groupBy 深度对比:返回结构、覆盖机制与源码实现 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大概 12msgroupBy大概 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(nm)。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、3Object.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存在键覆盖风险); }如果项目里有很多这种高风险调用可以封装一个安全 keyByfunction 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 前先确认键唯一性的习惯这两个方法基本不会给你制造意外。
返回列表