前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

业务中处理数据结构常用的JS方法

首页2018-08-12 19:11:43Front-End
JavaScriptAPI数组方法

接一个后台列表页,接口丢回来一坨数据,产品的需求是:先按状态过滤掉已作废的,再把 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 } 就能动态拼出来。

# 3.6 修改数组中满足条件的元素对象

fe
  • 一、四个方法,各管一件事
    • 1.1 filter 负责筛
    • 1.2 find 负责找一个
    • 1.3 map 负责整批换一遍
    • 1.4 reduce 负责合成一个
    • 1.5 some 和 every 负责回答是非题
  • 二、拿到需求先问三个问题
  • 三、业务里高频的不可变更新套路
    • 3.1 向对象数组添加新元素
    • 3.2 为一个数组创建视图
    • 3.3 向数组中新增元素
    • 3.4 为对象新增一组键值对
    • 3.5 使用变量作为键名为对象添加键值对
    • 3.6 修改数组中满足条件的元素对象
    • 3.7 找出数组中满足条件的元素
    • 3.8 获取数组中某一对象的下标
    • 3.9 删除目标对象的一组属性
    • 3.10 将对象转化成请求串
  • 四、reduce 里用展开运算符,小心 O(n²)
  • 五、链式调用遍历几遍,要不要合并
  • 六、2018 之后,这些活不用自己写了
  • 总结
  • 参考

← 小程序之自定义组件在小程序中集成redux/immutable/thunk第三方库 →