业务中处理数据结构常用的JS方法
接一个后台列表页,接口丢回来一坨数据,产品的需求是:先按状态过滤掉已作废的,再把 user_name 改成 userName 交给表格组件,右上角还要显示一个合计数字,点某一行的时候要根据 id 把那条记录捞出来。我刚工作那会儿的写法是一路 for 循环加临时变量,写到第三个需求的时候自己都读不下去了。
后来把这几件事拆给 filter、map、reduce、find,代码从二十多行缩到五行,更重要的是每一行在干什么一眼就能看出来。这篇不打算逐个抄 API 签名,重点放在:遇到一个具体需求该挑哪个方法、几个方法怎么串起来、以及哪些写法看着优雅其实藏着性能坑。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
filter、find、map、reduce、some、every各自负责的那一件事- 拿到需求怎么在三十秒内选出该用哪个方法
- 业务里高频出现的不可变更新套路,新增、删除、改某一项、动态键
reduce里用展开运算符累加为什么会退化成 O(n²)- 链式调用遍历多遍,到底要不要合并成一次
reduce - 2018 之后新增的
flatMap、Object.fromEntries、Object.groupBy能省掉哪些手写代码
数组方法的完整清单(包括哪些会改原数组、各自的返回值边界)我另开了一篇 JavaScript数组方法总结 讲,这篇只聊业务里怎么用。
# 一、四个方法,各管一件事
先把职责划清楚,后面选型就不用犹豫了。
# 1.1 filter 负责筛
filter 可以筛除数组和类似结构中不满足条件的元素,并返回满足条件的元素组成的数组。
const integers = [1, 2, 3, 4, 6, 7];
const evenIntegers = integers.filter(i => i % 2 === 0);
// evenIntegers 的值为 [2, 4, 6]
它的返回值一定是数组,最坏情况是空数组,不会是 undefined。这一点很关键,业务里拿到 filter 的结果可以直接 .length 或者直接丢给 v-for,不用先判空。
# 1.2 find 负责找一个
find 返回数组或类似结构中满足条件的第一个元素。
const posts = [
{id: 1, title: 'Title 1'},
{id: 2, title: 'Title 2'}
];
// 找出 id 为 1 的 post
const title = posts.find(p => p.id === 1).title;
这里有个坑要注意,find 找不到的时候返回 undefined,上面这行 .title 就会直接抛 Cannot read properties of undefined。这是我在列表页点击详情那一步最常见的线上报错来源,尤其是列表已经刷新过、用户点的是一条已经被删掉的记录。稳妥的写法是加可选链:
const title = posts.find(p => p.id === 1)?.title ?? '';
?. 和 ?? 是 ES2020 才有的,2018 年那会儿只能写 const post = posts.find(...); const title = post ? post.title : ''。现在直接用新语法就行。
# 1.3 map 负责整批换一遍
map 的作用在于处理流式数据,比如数组。可以把它想象成所有元素都要经过的一个转换器。
const integers = [1, 2, 3, 4, 6, 7];
const twoXIntegers = integers.map(i => i * 2);
// twoXIntegers 现在是 [2, 4, 6, 8, 12, 14],而 integers 不发生变化
map 的返回数组长度和原数组严格一致,这是它和 filter 最根本的差别。想边转换边过滤,正规做法是 filter 之后再 map,别指望在 map 里 return 一个 undefined 就能把那一项去掉,那样只会得到一个塞满 undefined 的等长数组。
# 1.4 reduce 负责合成一个
当你想要把多个数据揉进一个结果里时,就该用 reducer 了。
const posts = [
{id: 1, upVotes: 2},
{id: 2, upVotes: 89},
{id: 3, upVotes: 1}
];
const totalUpvotes = posts.reduce((totalUpvotes, currentPost) =>
totalUpvotes + currentPost.upVotes, // reducer 函数
0 // 初始化投票数为 0
);
console.log(totalUpvotes) // 输出投票总数:92
传给 reduce 的回调函数除了累加值和当前值,还可以再接两个参数。第三个参数是每个元素在原数据结构中的位置,比如数组下标。第四个参数是调用 reduce 方法的数据集合,比如例子中的 posts。
第二个参数那个初始值别省。数组为空且没给初始值的时候,reduce 会直接抛 TypeError: Reduce of empty array with no initial value。接口返回空列表在业务里太常见了,这个错我踩过不止一次。
# 1.5 some 和 every 负责回答是非题
some 找到数组中符合条件的一项就不会再往下找,跟 find 一样只关心第一个命中的。
[1, 2, 3, 4, 5].some(v => v > 4) // true,有某一项满足条件即为真
every 则是数组中每个元素都为真才会返回真。
[1, 2, 3, 4, 5].every(v => v > 1) // false,每一项都大于 1 才会返回 true
这两个都是短路的,some 一旦拿到 true 就停,every 一旦拿到 false 就停。所以「判断列表里有没有勾选项」这种需求,写 list.some(i => i.checked) 比 list.filter(i => i.checked).length > 0 更省,后者会把整个数组走完还额外造一个新数组。
有个反直觉的地方:空数组调用 every 返回 true,调用 some 返回 false。逻辑上讲得通,空集合里「所有元素都满足条件」这句话是空真,但表单校验里如果用户一项都没填,every 直接放行,这个我踩过。
# 二、拿到需求先问三个问题
选型其实不复杂,问三句就够了。
第一,我要的结果是什么形状?一个布尔值、一个元素、一个新数组,还是一个别的东西。
第二,结果的长度和原数组一样吗?
第三,我需要提前退出吗?
对着下面这张表落座就行:
| 我想要的结果 | 用它 | 找不到 / 空数组时 |
|---|---|---|
| 满足条件的全部元素 | filter |
返回 [] |
| 满足条件的第一个元素 | find |
返回 undefined |
| 满足条件的第一个下标 | findIndex |
返回 -1 |
| 长度不变、内容换一遍 | map |
返回等长数组 |
| 有没有任意一个满足 | some |
空数组返回 false |
| 是不是全部都满足 | every |
空数组返回 true |
| 一个汇总值、一个对象、一个 Map | reduce |
空数组且无初始值会抛错 |
| 只想执行副作用,不要返回值 | forEach |
无法中途 break |
最后一行值得多说一句。forEach 里 return 只能结束当前这次回调,跳不出整个循环,break 更是直接语法错误。真需要提前跳出的场景,要么用 some 当伪 break,要么老老实实写 for...of。为了跳出循环去 throw 一个异常再 catch,这种写法我见过,但不建议。
# 三、业务里高频的不可变更新套路
React 的 setState、Redux 的 reducer、Vue 3 的 ref 替换,都要求你造一个新对象而不是原地改。下面这几段是我几乎每个项目都会写一遍的模板。
# 3.1 向对象数组添加新元素
const books = [];
const newBook = {title: 'Alice in wonderland', id: 1};
const updatedBooks = [...books, newBook];
// updatedBooks 的值为 [{title: 'Alice in wonderland', id: 1}]
[...books, newBook] 而不是 books.push(newBook),差别在于前者产生新引用,React 的浅比较能看出变化,后者改的还是同一个数组,组件不会重渲染。
# 3.2 为一个数组创建视图
如果需要实现用户从购物车中删除物品,但是又不想破坏原来的购物车列表,可以用 filter。
const myId = 6;
const userIds = [1, 5, 7, 3, 6];
const allButMe = userIds.filter(id => id !== myId);
// allButMe is [1, 5, 7, 3]
这里其实是「删除」的不可变实现。用 splice 也能删,但 splice 改的是原数组,而且要先 indexOf 找下标,两步操作中间数组一变就容易出错。
# 3.3 向数组中新增元素
const books = ['Positioning by Trout', 'War by Green'];
const newBooks = [...books, 'HWFIF by Carnegie'];
// newBooks 现在是 ['Positioning by Trout', 'War by Green', 'HWFIF by Carnegie']
# 3.4 为对象新增一组键值对
const user = {name: 'Shivek Khurana'};
const updatedUser = {...user, age: 23};
// updatedUser 的值为:{name: 'Shivek Khurana', age: 23}
对象展开运算符是 ES2018 才进标准的,数组展开是 ES2015。2018 年写这段的时候还得靠 babel 转译,现在所有现代浏览器和 Node 18 以上都直接支持。
# 3.5 使用变量作为键名为对象添加键值对
const dynamicKey = 'wearsSpectacles';
const user = {name: 'Shivek Khurana'};
const updatedUser = {...user, [dynamicKey]: true};
// updatedUser is {name: 'Shivek Khurana', wearsSpectacles: true}
方括号里放变量叫计算属性名。表单那种字段名由配置决定的场景全靠它,写 { [field.key]: value } 就能动态拼出来。