看到这一节的标题,你可能会有个疑问:数组和对象方法,前面课程里不是早就用过了吗?确实,能用和用得好是两回事。我在带项目和面试的过程中发现一个挺普遍的现象:很多开发者学完 ES6+,let/const、箭头函数、模板字符串都切换得很痛快,可是真正一碰到数组和对象,还是习惯性回到 for 循环加临时变量的老写法。代码能跑,但读起来总有种"上个时代"的感觉。
这次要讲的内容,就是把 ES6+ 在数组和对象上的高级方法串起来:map、filter、reduce、展开运算符、解构、Object.entries、可选链、空值合并,再到去重、分组、排序这些高频业务场景。学完这一节,你能把一段十几行的 for 循环压成一行链式调用,能在嵌套对象里安全取值,能随手写出团队需要的通用数据处理逻辑。如果你是跟着这个系列一路学过来的,这一节就是把前面语法点汇总落地的关键一课;如果你已经写了一阵子 JS 但总觉得代码不太利索,这节课同样值得完整过一遍。每个小节我都会给到可以直接抄的写法,最后还会把官方文档不太会写明的坑位单独拎出来讲。
1. 为什么说数组和对象方法才是 ES6+ 的分水岭
1.1 从"命令式"到"声明式":代码变短只是表象
很多人初次接触 map、filter、reduce 时的第一反应是"这不过是把 for 循环换个写法,省几行字而已"。这个理解不能说错,但会大大低估这些方法带来的思维方式转变。
for 循环是典型的命令式写法,意思是你要事无巨细地告诉计算机每一步该做什么:先建一个空数组,再拿一个索引变量 i,判断 i 有没有到数组长度,每次循环自增一次,再把符合条件的元素 push 进结果。这套流程本身没有错,但它把"我要过滤出成年人"这个真实意图,淹没在了一堆索引、边界、临时变量的细节里。
map、filter、reduce 这类方法是声明式的:你只需要描述"我要得到什么",具体的遍历过程由引擎帮你完成。就好比你去餐厅点菜,命令式是你自己跑进后厨,指挥厨师怎么切菜、什么时候放盐、开多大火;声明式是你直接说"我要一份土豆炖牛肉",剩下的事,交给厨师的厨艺和经验。
这种思维差异在需求变更时体现得最明显。比如你写了一个循环去筛选年龄大于等于 25 的用户,过两天产品说要再筛出名字里带"张"的人,命令式写法你得在循环体里加一层 if 嵌套;声明式写法只是往 filter 回调里多补一个条件。代码结构没有被打乱,改动面积很小,这就是可维护性上的实质收益。代码短只是水面上的冰山一角,真正值钱的是它把"做什么"和"怎么做"解耦了。
1.2 一张表先看全:数组和对象核心方法速查与选型
在逐个拆解之前,先把这一节要讲的核心方法整体过一遍。我自己很喜欢用一张速查表来记忆:看到方法名,先反应它干什么、返回什么、会不会改原数组。
| 方法 | 用途 | 返回值 | 是否改变原数组 |
|---|---|---|---|
| map | 对每个元素做映射 | 等长新数组 | 否 |
| filter | 按条件筛选出元素 | 筛选后的新数组 | 否 |
| reduce | 把数组归并成任意值 | 任意类型 | 否 |
| forEach | 遍历数组并执行副作用 | undefined | 否 |
| some | 有任一元素满足条件 | 布尔值 | 否 |
| every | 所有元素满足条件 | 布尔值 | 否 |
| find | 返回第一个满足条件的元素 | 元素或 undefined | 否 |
| findIndex | 返回第一个满足条件的下标 | 下标或 -1 | 否 |
| includes | 判断是否包含某个值 | 布尔值 | 否 |
| flat / flatMap | 数组扁平化 | 新数组 | 否 |
| Object.keys / values / entries | 取对象的键、值、键值对数组 | 数组 | - |
| Object.fromEntries | 键值对数组转对象 | 对象 | - |
这张表里需要额外注意最后两行属于对象方法,但它们返回和处理的核心对象,本身就是数据集合。另外提醒一个反直觉的坑:sort、reverse、splice、push 这类方法会直接修改原数组。你在写状态管理、不可变更新逻辑时如果用了它们,原数组悄悄变了,界面上可能会出现非常难排查的"怪问题"。
把这张表放在心里,后面的内容就不会迷失。接下来先拆数组三件套。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数组高级方法逐个拆解:map / filter / reduce 与展开运算符的实操边界
2.1 map / filter / reduce 三件套:什么时候用哪个,别再靠猜
先看 map。它做的事情是一对一的映射:数组有 N 个元素,map 之后就一定还是 N 个元素,每个元素都被回调函数处理过一遍。最常见的场景是提取字段、格式化数据、给对象追加字段。
js复制const users = [
{ id: 1, name: '张三', age: 23 },
{ id: 2, name: '李四', age: 29 }
];
// 提取所有人名
const names = users.map(user => user.name);
console.log(names); // ['张三', '李四']
// 给每个用户追加一个 level 字段
const usersWithLevel = users.map(user => ({
...user,
level: user.age >= 25 ? 'senior' : 'junior'
}));
map 的回调里能拿到三个参数:当前元素、当前下标、原数组。第三个参数 arr 平时用得少,但用 map 去给另一个数组"配对"时会很关键,比如两个数组按下标合并,这时候就可以用 arr 拿到另一个数组的引用。
再看 filter。它做的事情是按条件筛选:回调返回 true 就保留,返回 false 就丢弃。它返回的数组长度可能变短,但原数组结构不变。
js复制const adults = users.filter(user => user.age >= 18);
filter 有两个很典型的错误写法。第一个是画蛇添足,写成 filter(user => user.age >= 18 === true),完全没有必要。第二个更隐蔽,想用 filter 取数据、又想在里面修改数据,比如:
js复制// 错误示例
const result = users.filter(user => {
if (user.age >= 25) {
return user.name;
}
});
这里回调返回的是名字字符串,不是布尔值。字符串 '张三' 是非空的 truthy 值,会被当作 true,所以结果看起来"好像对",但一旦某个人名是空字符串,这个人就会被错误地过滤掉。filter 的回调只该回答一个问题:这个元素要不要留下,不要顺手在里面做别的计算。
最后是 reduce,它是三件套里最强大的一个,因为它可以把一个数组归并成任何形状:数字、字符串、对象、另一个数组,都可以。只要你想把一个数组"变成一个整体",reduce 就是最趁手的工具。
js复制const totalAge = users.reduce((sum, user) => sum + user.age, 0);
console.log(totalAge); // 52
// 把数组转成以 id 为 key 的对象
const usersById = users.reduce((acc, user) => {
acc[user.id] = user;
return acc;
}, {});
console.log(usersById[1]); // { id: 1, name: '张三', age: 23 }
reduce 回调的第一个参数是累计值 accumulator,第二个是当前元素。第二个参数传给 reduce 的是初始值,这一点非常关键:如果不传初始值,acc 会默认是数组的第一个元素,current 从第二个元素开始。如果数组是空的,reduce 会直接抛错:Reduce of empty array with no initial value。所以在写 reduce 的时候,我习惯性永远写上初始值,不管数组会不会为空,这一行代码的防御性差不了多少。
reduce 还有一个高频失误点,是箭头函数缩略写法引发的。如果你写成 (acc, user) => { acc[user.id] = user; },这里用了大括号却忘了 return,reduce 下一轮收到的 acc 就变成了 undefined,后面直接报错。用大括号,就一定要记得把累加结果返回出去。
三件套的选型思路,我用一句话概括:一对一变换用 map;筛选子集用 filter;要把整个数组变成一个新结构,比如统计总和、分组、转对象,用 reduce。如果你发现自己在 map 的回调里写 push,那大概率该换成 filter 或 reduce 了。
2.2 展开运算符和剩余参数:数组复制的深浅坑位
展开运算符 ... 是 ES6+ 里"存在感"极强的一个语法。它最简单的用法是复制数组和合并数组:
js复制const arr1 = [1, 2, 3];
const arr2 = [4, 5];
const merged = [...arr1, ...arr2]; // [1, 2, 3, 4, 5]
注意这里有个热词场景:"js 怎么用扩展运算符把一个数组里面的值都添加到另外一个数组"。答案就在上面,[...target, ...source] 或 target.push(...source) 都可以。前者是返回新数组,后者是修改原数组,按需选择。
展开运算符最大的坑是浅拷贝。它只复制第一层引用,如果数组里装的是对象,复制出来的新数组里,对象还是同一个引用。
js复制const arr = [{ a: 1 }, { a: 2 }];
const copy = [...arr];
copy[0].a = 999;
console.log(arr[0].a); // 999,原数组也变了
如果你想得到一份真正独立的副本,需要深拷贝,这个我在第五部分展开讲。但日常业务里,很多场景其实并不需要深拷贝,你只是想"往数组头部加一个元素、尾部加一个元素、或者替换其中某项",这时候展开运算符配合不可变更新的写法,比直接 push、splice 安全得多。
展开运算符还能直接把数组元素展开成函数参数,这在处理 Math.max、Math.min 这类接口时非常好用:
js复制const nums = [3, 1, 4, 1, 5];
console.log(Math.max(...nums)); // 5
在 React 或小程序的状态管理里,有一套非常经典的不可变更新写法:
js复制setState(prev => [...prev, newItem]); // 尾部追加
setState(prev => [newItem, ...prev]); // 头部插入
setState(prev => prev.map(item => item.id === newItem.id ? newItem : item)); // 替换某项
这套写法的核心思路是:永远返回一个新数组,旧引用保持不变,让 UI 框架能通过引用比较判断出"状态变了"。直接 prev.push(newItem) 虽然也能让数据变化,但引用没变,很多框架检测不到更新,界面就不会刷新。这个坑我见过太多人踩,特别是从直接操作 DOM 的老写法转过来的人。
剩余参数和展开运算符长得像,但作用相反:展开运算符是把数组拆开,剩余参数是把剩下的元素收集成一个数组。
js复制const [first, ...rest] = [10, 20, 30, 40];
console.log(first); // 10
console.log(rest); // [20, 30, 40]
这个写法在"取出第一个作为表头、其余做列表"的场景非常常见。同理,对象解构里 const { name, ...other } = obj 可以把多余的字段一次性收集出来,配合表单提交时剔除个别字段很顺手。
顺带说一句 Array.from。它是 ES6 提供的方法,专门把类数组对象、Set、字符串这些"长得像数组但没数组方法"的东西,转成真正的数组:
js复制const set = new Set([1, 2, 3]);
const arrFromSet = Array.from(set); // [1, 2, 3]
类数组常见于函数内部的 arguments、DOM 查询结果 NodeList。在旧代码里你可能会看到 [].slice.call(nodeList) 这种把类数组转数组的写法,在 ES6+ 时代直接 Array.from(nodeList) 就干净多了。
3. 对象操作的现代姿势:entries、keys、values 与可选链的组合拳
3.1 遍历对象:keys / values / entries 与 fromEntries 的正反转换
JavaScript 里对象遍历是个经典老大难。老写法里用的 for...in 有一个非常坑的行为:它不仅遍历对象自身的可枚举属性,还会把原型链上可枚举的属性一起遍历出来。如果你的对象原型被扩展过,或者用过某些会挂到原型上的库,for...in 的结果里就会出现一堆你根本不认识的字段。
ES6+ 之后处理对象遍历的正统姿势是先把对象"数组化",再交给数组方法来处理。Object.keys、Object.values、Object.entries 三个方法分别返回键名数组、键值数组、键值对数组。
js复制const product = { name: '键盘', price: 199, stock: 50 };
Object.keys(product); // ['name', 'price', 'stock']
Object.values(product); // ['键盘', 199, 50]
Object.entries(product);
// [['name', '键盘'], ['price', 199], ['stock', 50]]
拿到键值对数组之后,就可以用上前面讲过的 filter、map、reduce 了。举个真实场景:清洗对象里值为空字符串或 null 的字段。
js复制const raw = { name: '你好', desc: '', price: 0, note: null };
const cleaned = Object.fromEntries(
Object.entries(raw).filter(([key, value]) => value !== '' && value !== null)
);
// { name: '你好', price: 0 }
这里 Object.entries 把对象变成了 [key, value] 对组成的数组,filter 过滤掉不需要的,最后 Object.fromEntries 再把数组转回对象。fromEntries 是 ES2019 出来的方法,正好是 entries 的逆向操作,这一正一反组合,几乎是所有"对象字段清洗"需求的标准答案。
注意上面这个例子里 price: 0 是保留的,因为 0 是合法值,filter 条件里只排除了空字符串和 null。如果你把规则改成 filter(([key, value]) => Boolean(value)),price: 0 就会被误伤,因为 0 也是 falsy。这种细节在业务里非常容易出 bug,取舍规则要提前想清楚。
对象转查询字符串也是一个很常见的场景,用 entries 加 URLSearchParams 就能搞定:
js复制const params = new URLSearchParams(Object.entries(product));
console.log(params.toString()); // name=%E9%94%AE%E7%9B%98&price=199&stock=50
再说一个非常高频的考题:如何判断对象为空?最直观的做法就是用 keys:
js复制function isEmptyObject(obj) {
if (obj === null || obj === undefined) {
return false;
}
return Object.keys(obj).length === 0;
}
这里有个细节要提醒:Object.keys(null) 会直接抛错,所以参数进来先判空。isEmptyObject 的判断语义看你业务怎么定,如果 null 和 undefined 也视为"空",那可以返回 true,自己按需调整。
3.2 可选链和空值合并:嵌套对象取值不再提心吊胆
对象操作里最让人头疼的,是从后端接口返回的嵌套数据里取深层字段。老写法是逐层判断:
js复制// 旧写法
const nickname = res && res.data && res.data.user && res.data.user.profile
? res.data.user.profile.nickname
: '未设置';
这种写法又长又丑,漏掉一层就白屏。ES2020 推出的可选链运算符 ?. 解决了这个问题。它的作用是:访问属性之前,如果前面的值是 null 或 undefined,整个表达式短路,返回 undefined,而不是报错。
js复制const nickname = res?.data?.user?.profile?.nickname ?? '未设置';
这一行就把上面的十几行判断替代了,而且逻辑清晰。可选链有三种用法:属性访问 obj?.name、函数调用 obj.method?.()、数组下标 arr?.[0]。函数调用的场景,在判断第三方 SDK 有没有暴露某个方法时很有用:
js复制const result = sdk?.init?.({ debug: true });
// 如果 sdk.init 不是函数,不会抛错,result 为 undefined
和可选链经常一起出现的是空值合并运算符 ??。它跟 || 有本质区别:|| 在左边值是假值(0、''、false、NaN、null、undefined)时都会取右边的值;?? 只在左边是 null 或 undefined 时取右边的值。这个区别在数字 0 和空字符串的场景里非常关键:
js复制const count = 0;
console.log(count || 10); // 10,这是错的
console.log(count ?? 10); // 0,这才是对的
比如接口返回用户积分,0 分是合法值,用 || 去兜底默认值就会把 0 错变成 10,用 ?? 就不会。这里有一个很隐蔽的语法坑:?? 不能直接和 ||、&& 混用而不加括号,比如 a ?? b || c 会直接报语法错误,因为 JS 觉得运算优先级不明确。如果确实要混用,必须用括号明确,比如 (a ?? b) || c。
可选链加空值合并的组合,我愿称之为"安全取值三连"。配合解构赋值还能更进一步:
js复制const { profile = {}, nickname = profile.name ?? '匿名' } = res?.data?.user ?? {};
这里 res?.data?.user 如果取不到,右边的空对象兜底,解构出来的 profile 也给了空对象默认值,后面再用 profile.name 去读取就不会踩 undefined 的雷。这套组合在写前端页面时利用率极高,建议有条件就无脑用。
4. 对象数组高频实战:去重、分组、排序、检索一次讲透
4.1 对象数组去重:Set、Map、reduce 三种方案的取舍
数组去重在业务里出现的频率,高到值得单独开一节来讲。先看最简单的情况:元素是原始值的数组去重。
js复制const names = ['张三', '李四', '张三', '王五'];
const uniqueNames = [...new Set(names)]; // ['张三', '李四', '王五']
利用 Set 天然不存重复值,再用展开运算符转回数组,一句话搞定。字符串数组取交集也可以用类似思路:
js复制const listA = ['apple', 'banana', 'cherry'];
const listB = ['banana', 'cherry', 'date'];
const setB = new Set(listB);
const intersect = listA.filter(item => setB.has(item)); // ['banana', 'cherry']
先包装成 Set,再配合 filter 和 has 做判断,避免内层循环,时间复杂度友好很多。
但对象数组去重就完全是另一回事了。同一份用户数据从接口里重复拉取、或者列表数据里出现了 id 相同的不同对象,你直接用 new Set(arr) 去重是无效的,因为 Set 对对象的比较是引用比较,两个内容相同的对象只要不是同一引用,Set 就认为它们不重复。
对象数组去重,核心是"按字段去重",最常见的方案有以下三种。
方案一,用 Map 按字段去重,同时尽量保留首次出现的数据:
js复制const list = [
{ id: 1, name: 'A' },
{ id: 2, name: 'B' },
{ id: 1, name: 'A' }
];
const seen = new Map();
const unique = list.filter(item => !seen.has(item.id) && seen.set(item.id, true));
// [{ id: 1, name: 'A' }, { id: 2, name: 'B' }]
这里利用了逻辑与的短路特性:seen.has(item.id) 为 true 时整个表达式直接返回 false,不会执行后一半;为 false 时执行 seen.set(...),set 方法返回 Map 自身,恒为 truthy。虽然这个写法看起来很紧凑,但它能成立的前提是"看到了一个 id 就一定把它标记掉"。
方案二,用 reduce,理解起来最直观:
js复制const unique = list.reduce((acc, item) => {
if (!acc.some(exist => exist.id === item.id)) {
acc.push(item);
}
return acc;
}, []);
这种写法适合团队里新人比较多、追求可读性的场景。代价是 acc.some 每次都要遍历当前结果集,数据量过万之后会明显变慢。
方案三,用 Map 的键覆盖特性,性能最好:
js复制const map = new Map(list.map(item => [item.id, item]));
const unique = [...map.values()];
思路是先构造一个以 id 为 key 的 Map,后出现的同 id 对象会覆盖前面的,最后取 values 展开成数组。这个方案的时间复杂度是最优的,但它保留的是"最后一次出现"的数据,和前两个方案保留"第一次出现"的行为不同。如果业务要求以第一次为准,就不能直接套用,要么先 list.reverse() 再操作,要么再额外加判断。
三种方案没有绝对好坏,按数据规模和业务语义选。我个人的习惯是:数据量几百条以内用方案二,优先保证别人能读懂;数据量过千且对性能有要求,用方案三;方案一这种"精简写法",适合在你自己维护、且团队整体水平都跟得上的代码库里用。
4.2 分组、排序与检索:把后端数据整理成 UI 想要的形状
前端做得多了会发现,接口返回的数据很少能直接拿来渲染,大部分时间我们都在做"数据整形"。分组就是最常见的整形操作。比如把一批订单按状态分组:
js复制const orders = [
{ no: '1001', status: 'pending', amount: 100 },
{ no: '1002', status: 'done', amount: 200 },
{ no: '1003', status: 'pending', amount: 300 }
];
const grouped = orders.reduce((acc, order) => {
(acc[order.status] ||= []).push(order);
return acc;
}, {});
// { pending: [{ no: '1001', ... }, { no: '1003', ... }], done: [{ no: '1002', ... }] }
||= 是 ES2021 的逻辑空值赋值运算符,意思是:如果 acc[order.status] 当前没有值,就赋一个空数组,然后把当前订单推进去。这比 acc[order.status] = acc[order.status] || [] 简洁,也比先判断再赋值的写法紧凑。
排序是对象数组里第二个高频操作。这里有个让无数人踩过的坑:sort() 不传比较函数时,所有元素会被转成字符串,按 Unicode 码点排序。也就是说 [10, 9, 100].sort() 会得到 [10, 100, 9],因为字符串比较里 '100' 排在 '9' 前面。所以数字排序必须传比较函数:
js复制orders.sort((a, b) => a.amount - b.amount); // 金额升序
orders.sort((a, b) => b.amount - a.amount); // 金额降序
字符串排序建议用 localeCompare,尤其在中文场景下。直接用 a.name > b.name ? 1 : -1 是按 Unicode 码点排序,结果对中文用户来说往往不符合直觉;localeCompare 会带上语言环境的排序规则,中文按拼音顺序排,体验好得多。
js复制users.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
多字段排序也很常见,比如先按状态排、状态一致再按金额排:
js复制orders.sort((a, b) => {
if (a.status !== b.status) {
return a.status.localeCompare(b.status);
}
return a.amount - b.amount;
});
注意一个细节:sort 是原地排序,它会直接修改原数组。如果你不想破坏原来的顺序,需要先 [...orders].sort(...)。
最后是检索。find 返回第一个满足条件的元素,findIndex 返回下标,some 和 every 返回布尔值。
js复制const order = orders.find(item => item.no === '1002');
const hasPending = orders.some(item => item.status === 'pending');
const allDone = orders.every(item => item.status === 'done');
find 找不到元素时返回 undefined,直接取它的属性会报错。所以更稳的姿势是配合可选链和空值合并:
js复制const amount = orders.find(item => item.no === '1005')?.amount ?? 0;
"find 加可选链加空值合并"这个三连在业务代码里出现频率极高,可以理解为"取到了就用,取不到就给默认值",又安全又简洁。
5. 网上教程很少系统讲的坑与性能思考
5.1 map 和 forEach 的选择:不是所有遍历都该用 map
map 和 forEach 的官方定义其实很清楚:map 返回新数组,forEach 只执行回调不返回。但到了实际项目里,我见过太多"用 map 干 forEach 的活"的代码,比如:
js复制// 错误示例
users.map(user => {
console.log(user.name);
doSomething(user);
});
这段代码最后有没有人接收 map 的返回值?没有。那 map 就白白创建了一个没有任何人用的新数组,既浪费内存,又让读代码的人困惑:作者到底是要变更后的新数组,还是只是想遍历一遍?
判断标准其实很简单:如果你不需要返回值,只是想遍历数组然后产生副作用,比如打日志、发请求、更新外部状态,用 forEach。如果你需要"每项都变成一个东西",最后要拿这个新数组去渲染或继续处理,用 map。同理,要筛选用 filter,要归并用 reduce。把遍历方法和目的对齐,代码的意图会清晰很多。
性能方面说点实在的。数据量在几千以内,for 循环、forEach、map 的差异几乎可以忽略,这时候优先选可读性最好的写法。真正遇到大数据量卡顿,光换遍历方法也没用,更可能的问题是你在循环里做了太多计算,或触发了太多渲染。我在项目里的习惯是,先写出意图明确的代码,等真到了性能测试阶段发现瓶颈,再用 for 循环或缓存长度这类手段做定向优化。为了那几毫秒,把代码写得所有人看不懂,才是最大的浪费。
5.2 不可变更新、深拷贝与链式调用的边界
前面提到状态更新时用 [...arr, newItem] 而不是 push,这里把它展开成一个完整的
