map、filter、reduce 和 sort:一张报表里最常踩的数组坑
数组方法最容易给人一种“都会用”的错觉:map、filter、reduce、sort 看着都不难,真把一份嵌套数据拍平、分组、聚合、排序到能直接上页面时,细节坑会一个接一个往外冒。
这次报表需求就是个典型练习场。接口给回来的是「仓库 -> 分类 -> 商品列表」三层嵌套,前端要把它变成一张按品类分组、按销量排序的汇总表。写的时候我本来以为只是几行数组方法顺手一拼,结果一下午里把 sort 的默认规则、map 的参数、reduce 的初始值和“原数组到底有没有被改掉”这些老坑几乎踩了个遍。
后面会按这张表的处理顺序往下写,不是单独背 API,而是把数组方法放回真实的数据整理链路里看。
第一步:拿到数据先打印,忘了它是嵌套的
接口拿到手,我随手 console.log(res.data),结构长这样:
1// [{ warehouseName: '华东仓', categories: [{ name: '家电', products: [...] }, ...] }, ...]
三层嵌套。我第一反应是写个 forEach 遍历一下看看有多少条:
1res.data.forEach(function(warehouse) { 2 console.log(warehouse.warehouseName) 3})
forEach 用来遍历,没有返回新数组,不要指望它能返回结果——需要转换数组时,用 map,这个后面立刻就要用上。
forEach 还有两个我这天差点又踩的坑。一是它不能用 break 提前跳出,return 也只是结束当前这次回调、不会中断循环。我一开始想「找到华东仓就停」,写了 forEach 里 return,结果它把整个数组从头跑到尾,白白遍历了没用的几十条。要中途跳出,老老实实用 for 循环,或者用 some(返回 true 就停)。二是 forEach 里面写 await 不会按你想的那样串行等待——我一开始想在遍历仓库时顺手挨个查一下每个仓库的库存详情接口:
1// 看起来像串行,其实几个请求几乎同时发出去了 2list.forEach(async function(item) { 3 await save(item) 4}) 5console.log('done') // 这行根本不等上面跑完就打印了
因为 forEach 压根不理会回调返回的 Promise。真要串行,得用 for...of 配合 await。这个坑组里前几个月刚有人踩过,我这次差点重蹈覆辙,好在扫了一眼旧代码想起来了。
第二步:把三层嵌套摊平成一张表
看完结构,我要做的第一件事是把「仓库 -> 分类 -> 商品」这棵树摊成一份平铺的商品列表,方便后面统一处理。最直接的想法是 map 转换字段:
1var tableData = res.data.map(function(warehouse) { 2 return { 3 warehouseName: warehouse.warehouseName, 4 productCount: warehouse.categories.length 5 } 6})
但这样只是转换了外层,categories 里面的商品还是嵌在里面,没法直接铺成表格行。真正的摊平活儿,map 单独做不了,得配合数组自带的拍平方法——这个我下一节细说。map 应该有返回值,如果只是做副作用,用 forEach 更清楚。我这次差点又犯一个自己 review 时最常打回别人的错——用 map 干 forEach 的活,只改属性不返回新数组,打完才发现返回值压根没接收,赶紧改成了 forEach。
紧接着就是我这次真正踩进去的坑:map 的回调第二个参数是索引,我在给表格加序号列时天天用:
1var rows = tableData.map(function(item, index) { 2 return { seq: index + 1, name: item.name } 3})
问题出在旁边一段处理编号的代码。运营那边给的商品编码是字符串数字,我图省事直接甩给 parseInt 转成数字排序用:
1['1', '2', '3'].map(parseInt) 2// => [1, NaN, NaN]
这个结果第一眼看完全不讲道理,倒回去一步步拆调用才看明白。map 给回调传的是三个参数 (element, index, array),而 parseInt 接收的是 (string, radix) 两个参数——map 才不管你的回调实际能接几个参数,它照样把三个都传过去,多出来的 array 被 parseInt 忽略,但第二个参数 index 结结实实地被当成了 radix(进制)传了进去。于是实际执行的是:
1parseInt('1', 0, arr) // radix 传的是下标 0,不是字符串 '1' 自己的进制 2parseInt('2', 1, arr) // radix 传的是下标 1 3parseInt('3', 2, arr) // radix 传的是下标 2
这里最容易搞反的一点是:那个 0、1、2 是数组的下标,跟字符串 '1'、'2'、'3' 本身是几进制毫无关系。 三行拆开看:parseInt('1', 0),radix 为 0 时 parseInt 按十进制解析(0 等同于「没传」,字符串又没有 0x 前缀,所以走十进制),结果是 1;parseInt('2', 1),radix 为 1 不在 2~36 的合法范围内,直接返回 NaN;parseInt('3', 2),radix 为 2 是合法的二进制,但字符串 '3' 里的字符 3 根本不是二进制允许的 0 或 1,从第一个字符就解析失败,同样是 NaN。三个拼起来正好是 [1, NaN, NaN]。
表格里编号列瞬间一堆 NaN,我盯着控制台愣了几秒才想起这个老坑。想转数字应该用只收一个参数的函数:['1','2','3'].map(Number) 才稳稳得到 [1, 2, 3];或者写一个只透传第一个参数的箭头函数 map(x => parseInt(x, 10)),把 radix 显式钉死成十进制,比记「不能直接传 parseInt」这种忌讳更靠谱。这也是「别把多参数函数直接丢给 map 当回调」的由来,这次算是又亲手验证了一遍。
第三步:真正拍平嵌套数据,用上今年才熟的 flat / flatMap
回到摊平三层嵌套这件正事上。以前遇到这种嵌套,我一般是写个 reduce 手动拼数组:
1var products = res.data.reduce(function(all, warehouse) { 2 warehouse.categories.forEach(function(c) { 3 all = all.concat(c.products) 4 }) 5 return all 6}, [])
能跑,但看着绕。去年 Node 升到 11 之后 Array.prototype.flat 能直接用了,这活一行就能干:
1var nested = res.data.map(function(warehouse) { 2 return warehouse.categories.map(function(c) { return c.products }) 3}) 4// nested 现在是三层:[ [[...], [...]], [[...], [...]], ... ] 5 6var products = nested.flat(2) // 传 2 表示往下拍两层
flat 不传参数默认只拍平一层,嵌套深的话得显式传层数,传 Infinity 能不管多深一次拍到底——但业务数据结构一般是确定的,我更倾向写死具体层数,万一后端哪天多包一层,flat(2) 会提前暴露问题,而 Infinity 只会悄悄糊弄过去。
先 map 转换、再 flat 拍平这个组合太常见,Array.prototype.flatMap 直接把两步合成一步,而且只拍一层、性能比先 map 出一个中间大数组再 flat 更好:
1var products = res.data.flatMap(function(warehouse) { 2 return warehouse.categories.flatMap(function(c) { return c.products }) 3})
外层按仓库 flatMap 一次,里层按分类 flatMap 一次,两层嵌套正好对应两次 flatMap,写出来比嵌套 reduce 清楚不少。我把这段拿去问了带我的前辈,这种新方法能不能放心用,他提醒我一句:flatMap 是原生数组方法,不是语法层面的东西,Babel 只转译语法(箭头函数、解构这些),不会凭空生出一个新的内置方法。要兼容不认这个方法的老环境,得配 core-js 引入 polyfill,光靠 Babel 转译是解决不了的。
第四步:只看「有货」的商品,filter 上场
products 摊平之后有几百条,运营只想看「有货且已上架」的。filter 用来筛选,它不会修改原数组,而是返回新数组:
1var onSaleProducts = products.filter(function(p) { 2 return p.stock > 0 && p.status === 1 3})
按关键词搜索也常用同一招:
1var result = onSaleProducts.filter(function(p) { 2 return p.name.indexOf(keyword) > -1 3})
这里我差点又栽一个坑:filter 按回调返回值的「真假」来留人,回调里一定要返回布尔值。我一开始手滑写了 return p.name,name 是非空字符串永远为真,等于没过滤,页面上「只看有货」勾选框跟没勾一样,对着数据查了好一阵才反应过来是这行的问题。
filter 在「批量移除异常数据」里也特别顺手。这次汇总表里有几条仓库上报的脏数据(商品名是空字符串),与其在原数组上一个个 splice(边删边改索引很容易删错),不如直接留下没问题的:
1onSaleProducts = onSaleProducts.filter(function(p) { 2 return !!p.name 3})
其实这行可以再简写:filter 配 Boolean 能一把过滤掉数组里的假值,arr.filter(Boolean) 能去掉 null、undefined、''、0,清洗接口返回的脏数据时我这次直接就这么用了。
第五步:点开一条商品核对,find 和浅拷贝的老账
汇总表挂出来之后,运营点了一条商品说「这条销量不对,我要看详情核对」。我要根据 id 从摊平的商品列表里找出那一条完整数据:
1var current = onSaleProducts.find(function(p) { 2 return p.id === clickedId 3})
找不到返回 undefined,用之前要处理空值。find 和 filter 的区别我这次又反复用到:find 返回的是「那个元素本身」,filter 返回的是「装着符合条件元素的数组」。要拿一条记录详情用 find,要拿一批用 filter。点开详情弹窗那段代码我是这么写的:
1var target = onSaleProducts.find(item => item.id === clickedId) 2this.detailForm = { ...target } // 注意这里浅拷贝出来一份,别直接编辑列表里的对象
最后这句浅拷贝很关键——find 拿到的是列表里那个对象的引用,直接往上改,列表会跟着变,弹窗还没确认数据就脏了。这个坑我在表单编辑里踩过不止一次,这次手是本能地就把这句带上了。
第六步:全选框和「是否全部达标」的判断
表格上方运营还要一个「本页商品是否全部达标(销量过千)」的提示,还有个全选框。这俩正好是 some 和 every 的活。
some:只要有一个满足就返回 true。
1var hasHotProduct = onSaleProducts.some(function(p) { 2 return p.sales > 1000 3})
every:全部满足才返回 true。
1var allHot = onSaleProducts.every(function(p) { 2 return p.sales > 1000 3})
表格的全选框我基本都这么写:勾选状态是 list.every(i => i.checked),半选(indeterminate)状态是 list.some(i => i.checked) && !全选。两个方法一搭,逻辑特别清楚,还有短路特性——some 遇到 true 立刻停,every 遇到 false 立刻停,拿 some 替代「找到就 break 的 for 循环」完全合理。要记牢空数组的边界:[].some(...) 永远是 false,[].every(...) 永远是 true。这条我这次差点又忘了:本页筛完「有货」之后偶尔会全被过滤空,「是否全部达标」这时候按 every 语义会显示「全部达标」,其实是没数据可判断,我后来在渲染前加了一层空数组判断才没闹笑话。
第七步:按分类聚合销量,reduce 挑大梁
真正的重头戏来了:运营要一张「按分类汇总销量总额」的表。这是 reduce 的主场。
1var total = onSaleProducts.reduce(function(sum, p) { 2 return sum + p.sales * p.price 3}, 0)
按分类分组求和,我最初写的是这样:
1var byCategory = onSaleProducts.reduce(function(map, p) { 2 map[p.categoryName] = (map[p.categoryName] || 0) + p.sales * p.price 3 return map 4}, {})
这次运营还要看某个分类的明细,不只是总额,reduce 稍微改一下就能实现经典的 groupBy 写法——把「按 key 累加数字」换成「按 key 分组、组内放数组」:
1var groupByCategory = onSaleProducts.reduce(function(map, p) { 2 if (!map[p.categoryName]) { 3 map[p.categoryName] = [] 4 } 5 map[p.categoryName].push(p) 6 return map 7}, {}) 8 9// groupByCategory.家电 => [ {...}, {...}, ... ]
reduce 有个我反复叮嘱实习生的坑,这次自己也差点又忘:初始值别省。第二个参数 0 / {} / [] 一定要写,不写时 reduce 会拿数组第一个元素当累加器、从第二个开始遍历,遇到空数组还会直接抛 Reduce of empty array with no initial value。这次本地测试用的假数据凑巧都非空,没发现问题,等接了真实数据、某个冷门分类当天恰好没有上报库存,页面直接白屏。补上 , 0 就好了,从此我写 reduce 第一件事就是先把初始值补上。
1// 危险:空数组会抛错 2[].reduce((a, b) => a + b) 3// 安全 4[].reduce((a, b) => a + b, 0) // => 0
reduce 很强,但不要滥用。我这次为了图一步到位,一度想把「筛选 + 分组 + 求和」全塞进一个 reduce 里,写到一半自己都看不懂那个回调在干嘛,最后拆回「先 filter 再 reduce」两步,反而更清楚。标准就是:如果一段 reduce 让看代码的人需要在脑子里跑一遍才懂,就该拆成 map/filter 的组合。
第八步:分组结果想要一份 Map,顺手学了 Object.fromEntries
按分类分组出来的 groupByCategory 是个普通对象,但我们这次还要一个「按分类频繁增删」的场景——运营会随时勾掉某个分类不看。普通对象当 key-value 容器用起来有点别扭,删 key 要么 delete、要么解构剔除,都不利落。Map 更适合这种频繁增删的场景,has、delete、size 都是原生方法。从 reduce 分组的结果转成 Map 很直接:
1var categoryMap = new Map(Object.entries(groupByCategory)) 2 3categoryMap.has('家电') // true 4categoryMap.delete('家电') // 运营勾掉这个分类,直接删 5categoryMap.size // 剩余分类数,比 Object.keys(obj).length 顺手
反过来,Map 处理完之后如果要传给旧接口(很多地方还是期望一个普通对象),今年新到的 Object.fromEntries 正好补上这一环,跟 Object.entries 刚好是一对反函数:
1var plainObj = Object.fromEntries(categoryMap) 2// 又变回 { 家电: [...], 服饰: [...] } 这样的普通对象
体会下来:需要频繁增删查的中间状态用 Map,最终要交给模板渲染或传给老接口时转回普通对象,来回转换成本很低,没必要在两种数据结构之间纠结。
第九步:销量排序,sort 的老陷阱又来了一次
数据聚合完,最后一步是按销量从高到低排序展示。这是我这次踩得最狠的一个坑,虽然是老坑,换了个马甲照样中招。先把分组结果转回数组,按总销量排:
1var sortedList = Object.entries(groupByCategory).map(function(entry) { 2 return { category: entry[0], totalSales: entry[1].reduce((s, p) => s + p.sales, 0) } 3}) 4 5sortedList.sort(function(a, b) { 6 return a.totalSales - b.totalSales 7})
这段没问题,因为传了比较函数。真正翻车的是旁边另一处:运营又提了个「按商品编号排序」的小需求,编号是字符串形式的数字,我手一滑直接 .sort() 不传参数:
1var codes = ['3', '15', '2', '10'] 2codes.sort() 3// => ['10', '15', '2', '3'] 完全不是数字大小顺序
sort 默认把元素转成字符串再按字符编码比较,'10' 首字符 '1' 比 '2' 小,所以排到了前头。表格上编号列顺序乱七八糟,我对着数据源查了一圈才想起是这个默认排序的老毛病,得自己传比较函数:
1codes.sort(function(a, b) { return Number(a) - Number(b) }) 2// => ['2', '3', '10', '15']
顺手记一组我这次一直在脑子里过的「会不会改原数组」对照表:
- 不改原数组(返回新值):
map、filter、slice、concat、reduce - 会改原数组:
splice、sort、reverse、push、pop、fill
sort 这个尤其阴——它原地排序还返回原数组本身。我排 sortedList 之前留了一份原始顺序给「重置排序」按钮用,写的是 var backup = sortedList; sortedList.sort(...),以为 backup 保住了原始顺序,结果 sort 改的是同一个数组,backup 跟着一起被排乱了。要保留原数组,得先 slice() 复制一份再排:var sorted = sortedList.slice().sort(...)。这个坑和「浅拷贝以为隔离了引用」是同一类错误,换了个场景还是先手滑了一次才想起来。
收尾:拼出最终报表
把前面几步串起来,这张双十一汇总表的核心处理逻辑其实就是几行链式调用:
1var summary = res.data 2 .flatMap(function(warehouse) { // 拍平三层嵌套 3 return warehouse.categories.flatMap(function(c) { return c.products }) 4 }) 5 .filter(function(p) { return p.stock > 0 && p.status === 1 }) // 只看有货上架的 6 .reduce(function(map, p) { // 按分类聚合 7 map[p.categoryName] = (map[p.categoryName] || 0) + p.sales * p.price 8 return map 9 }, {}) 10 11var sortedSummary = Object.entries(summary) 12 .map(function(entry) { return { category: entry[0], total: entry[1] } }) 13 .sort(function(a, b) { return b.total - a.total }) // 销量额从高到低
flatMap 负责「拍平」,filter 负责「留谁」,reduce 负责「怎么聚」,sort 负责「怎么排」,各司其职,比一个大 for 循环里塞满 if 要清楚得多。链式调用要注意性能——每接一个 filter/map 都会完整遍历一次数组、再生成一个中间数组,链得越长临时对象越多。这次几百条数据无所谓,可读性更重要,真到了几万条还要在前端跑这么一长串转换,我会考虑让后端直接返回聚合好的数据。
明天早会前,我把这张表跑通、加上排序和分类筛选交了上去。运营翻了两页说「可以,就是这个」。整个下午折腾下来,sort 默认排序、map 多参数误传、reduce 漏初始值,这几个坑我早就「知道」,却还是被同一批数据从头到尾又验证了一遍。**知道一个坑存在,和在真实数据面前不再踩它,中间还隔着一次亲手写错。**这张表以后应该还会用到 618 大促,下次我打算把这套 flatMap + filter + reduce 的组合直接封成一个工具函数,不给自己留手滑的机会。