数组方法把遍历、筛选、查找、归约和重排这些常见循环写成有明确返回约定的操作。
方法名看似相近,副作用却不同:sort() 和 splice() 会修改原数组,toSorted() 和 toSpliced() 则返回浅拷贝。
先决定需要的结果形状,再确认方法是否修改原数组、怎样处理空数组与空槽,以及回调是否真的会被等待。
是什么,为什么存在
JavaScript 数组是按整数索引组织值的对象,length 记录其索引范围。数组方法是 Array.prototype 上的一组操作,用来表达「逐项转换」「保留满足条件的项」「寻找一个匹配项」「汇总为一个结果」或「改变元素顺序」等意图。
这些方法解决的是重复循环代码的表达问题。手写 for 循环当然能完成同样的工作,但读者还要从循环变量、条件和累加语句中推断目的;map()、filter()、find() 和 reduce() 会直接写出结果的形状。
数组方法不会统一选择可变或不可变语义。push()、splice()、sort() 和 reverse() 修改接收者,而 map()、filter()、slice() 以及较新的 toSorted() 等方法创建新数组。选错这一层语义,代码即使输出正确,也可能破坏调用方仍在使用的数组。
你会在接口数据整理、界面列表更新、校验、搜索和聚合中遇到这些方法。学习重点不是背完整的方法清单,而是根据所需结果、是否允许副作用以及边界输入来选择。
工作原理
最实用的分类方式是看方法返回什么,以及原数组是否改变。下面的表列出常用方法的主要约定;「新数组」只表示外层数组是新的,并不代表元素对象被深拷贝。
| 目的 | 常用方法 | 结果 | 修改原数组 |
|---|---|---|---|
| 逐项执行副作用 | forEach() | undefined | 否 |
| 逐项转换或筛选 | map()、filter()、flatMap() | 新数组 | 否 |
| 查找元素或索引 | find()、findLast()、findIndex() | 元素、索引或未找到标记 | 否 |
| 判断条件 | some()、every()、includes() | 布尔值 | 否 |
| 汇总 | reduce()、reduceRight() | 任意累加结果 | 由回调决定 |
| 截取或拼接 | slice()、concat()、flat() | 新数组 | 否 |
| 原地增删或重排 | push()、pop()、splice()、sort()、reverse() | 因方法而异 | 是 |
| 复制后增删或重排 | toSpliced()、toSorted()、toReversed()、with() | 新数组 | 否 |
map()、filter()、find()、some() 和 every() 等迭代方法接收回调。回调通常能取得当前值、索引和原数组;箭头函数只用到值时,可以把其余参数省略。reduce() 的回调则接收累加器、当前值、索引和原数组。
返回值决定回调的含义。map() 保存每次回调的返回值,filter() 根据返回值的真假保留原元素,find() 返回首个匹配元素,some() 与 every() 在结果已经确定时短路。forEach() 忽略回调返回值,因此不适合构造结果或提前结束遍历。
复制型方法产生 浅拷贝(shallow copy) 。外层数组的标识发生变化,但其中的对象、数组和函数仍是原引用。修改新数组的长度或槽位不会影响旧数组;通过共享元素修改嵌套对象,则两边都能观察到。
排序还需要一个 比较函数(comparator) 。比较函数返回负数时 a 排在 b 前,返回正数时 a 排在 b 后,返回 0 时二者保持相等次序。只返回布尔值的比较函数无法同时正确表达这三种结果。
示例
从记录得到新结果
先从最常见的无副作用管道开始。筛选已付款订单、格式化标签和求和是三个不同结果,因此分别使用 filter()、map() 与 reduce();条件判断则交给能短路的 some() 和 every()。
const orders = [
{ id: 'A-101', status: 'paid', total: 48 },
{ id: 'A-102', status: 'pending', total: 125 },
{ id: 'A-103', status: 'paid', total: 80 },
];
const paidOrders = orders.filter((order) => order.status === 'paid');
const labels = paidOrders.map(
(order) => `${order.id}: EUR ${order.total}`,
);
const paidTotal = paidOrders.reduce(
(sum, order) => sum + order.total,
0,
);
console.log(labels);
console.log(paidTotal);
console.log(orders.some((order) => order.total >= 100));
console.log(orders.every((order) => order.id.startsWith('A-')));[ 'A-101: EUR 48', 'A-103: EUR 80' ]
128
true
true三个数组方法都没有改变 orders。这里给 reduce() 传入 0,所以即使没有已付款订单,结果仍是数字 0,累加器类型也从第一次调用起就很明确。
这条链没有强行把所有工作塞进一次 reduce()。当中间结果 paidOrders 有清楚的业务含义,保留它通常比把筛选、格式化和求和揉进一个复杂回调更容易检查。
复制式更新与共享元素
需要新排序结果或新列表状态时,可以使用复制型方法。toSorted()、with() 与 toSpliced() 不修改 cart,但它们不会克隆商品对象或对象里的 tags 数组。
const cart = [
{ sku: 'tea', qty: 1, tags: ['drink'] },
{ sku: 'mug', qty: 2, tags: ['ceramic'] },
{ sku: 'book', qty: 1, tags: ['paper'] },
];
const ranked = cart.toSorted((a, b) => b.qty - a.qty);
const updated = cart.with(0, { ...cart[0], qty: 3 });
const removed = cart.toSpliced(1, 1);
const snapshot = cart.slice();
snapshot[0].tags.push('featured');
const show = (items) => items.map(
({ sku, qty }) => `${sku}:${qty}`,
).join(', ');
console.log(show(ranked));
console.log(show(updated));
console.log(show(removed));
console.log(show(cart));
console.log(cart[0].tags.join(', '));mug:2, tea:1, book:1
tea:3, mug:2, book:1
tea:1, book:1
tea:1, mug:2, book:1
drink, featured前四行说明外层数组可以独立增删、替换和排序。最后一行暴露了浅拷贝边界:snapshot[0] 与 cart[0] 是同一个对象,其中的 tags 也是同一个数组。
with() 只替换指定索引上的元素。示例同时用对象展开创建了新的首项,所以更新 qty 不会修改旧对象;若直接把 cart[0] 放回去,嵌套引用仍会共享。
排序、查找与短路
数值排序需要明确的比较函数。多个排序键可以通过 || 连接:只有分数相等时才比较名称;find() 和 findLast() 分别选择首个与最后一个匹配项。
const scores = [
{ name: 'Lin', score: 9 },
{ name: 'Amir', score: 12 },
{ name: 'Bea', score: 12 },
{ name: 'Zoe', score: 4 },
];
const leaderboard = scores.toSorted(
(a, b) => b.score - a.score || a.name.localeCompare(b.name, 'en'),
);
const firstPassing = scores.find((entry) => entry.score >= 10);
const lastPassing = scores.findLast((entry) => entry.score >= 10);
let checks = 0;
const hasWinner = scores.some((entry) => {
checks += 1;
return entry.score === 12;
});
console.log(leaderboard.map(({ name }) => name).join(' > '));
console.log(firstPassing.name, lastPassing.name);
console.log(hasWinner, checks);
console.log(scores.map(({ name }) => name).join(', '));Amir > Bea > Lin > Zoe
Amir Bea
true 2
Lin, Amir, Bea, Zoesome() 检查到第二项时已经得到 true,所以没有继续调用回调。原始 scores 顺序保持不变,因为排序使用的是 toSorted(),不是 sort()。
当业务需要全部匹配项时才使用 filter()。用 filter(...)[0] 寻找第一项会遍历更多元素并分配新数组,find() 更准确地表达需求;需要索引时则使用 findIndex() 或 findLastIndex()。
空槽不是 undefined 元素
数组可以有索引范围,却缺少某个索引属性,这就是 稀疏数组(sparse array) 。不同方法对空槽的处理并不一致,所以不能只看 length 推断回调次数。
const readings = [18, , 21];
const mapVisits = [];
const adjusted = readings.map((value, index) => {
mapVisits.push(index);
return value + 1;
});
const findVisits = [];
readings.find((value, index) => {
findVisits.push(`${index}:${String(value)}`);
return false;
});
console.log(readings.length, 1 in readings);
console.log(mapVisits.join(','));
console.log(adjusted.length, 1 in adjusted);
console.log(findVisits.join('|'));
console.log(readings.includes(undefined), readings.indexOf(undefined));3 false
0,2
3 false
0:18|1:undefined|2:21
true -1map() 没有为原数组的空槽调用回调,并在结果中保留了对应空槽。find() 会访问每个索引,把空槽当作 undefined 传给回调;includes(undefined) 也把空槽视作 undefined,而 indexOf(undefined) 会跳过空槽。
如果业务数据需要一个明确的缺失值,应建立稠密数组,例如用 Array.from({ length: 3 }, () => undefined)。这样每个索引都真实存在,方法之间的差异不会悄悄改变结果。
陷阱
修复方法: 不允许修改输入时使用 toSorted()。数值升序传入 (a, b) => a - b,对象排序则明确写出主键和次键;不要用只返回 true 或 false 的比较函数。
修复方法: 只复制确实需要更新的层级,例如 items.with(i, { ...items[i], done: true })。如果数据结构存在更深的可变对象,先写清所有权边界,再决定逐层复制还是采用专门的克隆方案。
修复方法: 为求和传 0,为字符串拼接传 '',为列表构建传 []。按动态键分组时优先考虑 Map;如果确实需要普通对象,要处理输入键与对象原型之间的冲突。
修复方法: 把缺失规则写成谓词。只删除空值时使用 value != null;如果还要删除空字符串,应显式加入 value !== '',让读者看见业务规则。
修复方法: 需要并行并等待全部结果时,用 await Promise.all(items.map(async ...))。需要顺序执行或限制并发时,用 for...of 配合 await,或实现明确的有界 worker 方案。
修复方法: 需要独立对象时使用 Array.from({ length: 3 }, () => ({ pending: true }))。fill() 适合数字、字符串等原始值,或确实要共享同一个引用的场景。
回调的遍历约定
多数迭代方法在第一次调用回调前确定要处理的长度范围。遍历开始后追加到数组尾部的元素通常不会进入本轮回调;删除或改写尚未访问的槽位,则可能影响之后读到的值。依赖这种行为的代码很难审查,回调中应避免改变正在遍历的数组。
回调的第三个参数是调用该方法的数组,而不是正在构造的结果数组。map() 回调无法通过这个参数取得尚未完成的映射结果;若后续计算需要前一步结果,应拆成两个命名阶段或使用明确的累加器。
除 reduce() 与 reduceRight() 外,常见的回调型数组方法还接受可选的 thisArg。普通函数会按该值设置 this,箭头函数则保留词法 this,所以传入 thisArg 对箭头函数没有作用。直接捕获需要的值通常比依赖动态 this 更清楚。
some()、every()、find() 与 findIndex() 会在答案确定后停止。forEach() 没有标准的提前终止机制;需要 break、顺序 await 或复杂控制流时,for...of 往往更合适。
稀疏数组的方法差异
空槽表示某个索引属性不存在,而显式的 undefined 表示索引存在且其值为 undefined。1 in values 和 Object.hasOwn(values, 1) 可以区分二者;读取 values[1] 时,两者看起来都得到 undefined。
map()、forEach()、filter()、some() 和 every() 不会为空槽调用回调。map() 会在结果中保留空槽,而 filter() 只把通过谓词的已有元素放进结果,因此结果是稠密的。flat() 在展平层级中也会移除空槽。
find()、findIndex()、findLast() 与 findLastIndex() 会访问范围内的每个索引,空槽作为 undefined 参与回调。includes() 同样把空槽视作 undefined,但 indexOf() 和 lastIndexOf() 跳过空槽。这些差异正是稀疏输入必须单独测试的原因。
稀疏数组常由 new Array(length)、删除索引或带连续逗号的字面量产生。需要逐项初始化时,使用 Array.from({ length }, (_, index) => makeValue(index));对空数组调用 map() 不会创建元素,因为根本没有已有槽位可供回调。
归约与累加器所有权
reduce() 不是专门的求和函数。它把一系列元素折叠成一个值,这个值可以是数字、字符串、数组、Map 或业务对象;初始值同时定义了空输入的结果和累加器的起始类型。
省略初始值时,方法会寻找第一个已有元素作为累加器。如果数组没有已有元素,就会抛出 TypeError;只有一个已有元素时,回调一次也不执行。这些规则在稀疏数组上尤其不直观,因此应用代码通常应传入初始值。
累加器可以原地更新,也可以每轮返回新对象。[...accumulator, item] 会在每次回调时复制已有内容;向本次归约专用的数组执行 push() 则复用同一个累加器。选择哪种写法取决于累加器是否只属于这次调用,不应机械地把「不可变」套到临时内部状态上。
按外部字符串分组时,Map 能直接表达任意键,并避开普通对象继承属性的问题。如果下游接口必须接收对象,可以在边界处转换;不要让生成代码未经检查地用输入字符串写入 {}。
复制方法与元素标识
slice()、concat()、展开语法、Array.from()、toSorted()、toReversed() 和 toSpliced() 都能创建新的外层数组。map() 与 filter() 也返回新数组,但保留哪些元素、是否调用转换回调由各自语义决定。
外层复制足以隔离排序、增删和索引替换。它不能隔离元素对象上的赋值,所以状态更新常写成两层操作:先用 map() 或 with() 建立新数组,再为真正变化的元素创建新对象。
深拷贝不是数组方法自动提供的保证。克隆策略要根据值的类型、原型、循环引用以及是否允许传输 Date、Map、二进制数据等对象来决定;不能用 JSON 往返作为所有 JavaScript 值的通用答案。
方法链也不自动等于更安全的代码。每一步都应有清楚的输入和输出契约;当链中出现副作用、异步边界或难以命名的累加器时,把步骤拆开通常更容易验证。
索引、范围与增删
数组范围方法通常采用起始索引包含、结束索引不包含的约定。slice(start, end) 读取这个范围并返回新数组,splice(start, deleteCount, ...items) 则从原数组删除或插入元素;二者名称相近,第二个参数的含义却完全不同。
| 操作 | 方法 | 返回值 | 原数组变化 |
|---|---|---|---|
| 读取一个索引 | at(index) | 元素或 undefined | 无 |
| 复制一段范围 | slice(start, end) | 删除前的浅拷贝 | 无 |
| 原地删除或插入 | splice(start, deleteCount, ...items) | 被删除元素组成的数组 | 有 |
| 在副本中删除或插入 | toSpliced(start, deleteCount, ...items) | 修改后的新数组 | 无 |
| 替换副本中的一个索引 | with(index, value) | 修改后的新数组 | 无 |
| 在尾部添加或删除 | push(...items)、pop() | 新长度或被删除元素 | 有 |
| 在头部添加或删除 | unshift(...items)、shift() | 新长度或被删除元素 | 有 |
at() 与 with() 接受负索引,-1 表示最后一项。普通的 array[-1] 不是倒数索引,而是名为 "-1" 的对象属性;它不参与常规数组迭代,也不会改变 length。
slice() 会把负边界换算成相对数组末尾的位置。slice(-2) 复制最后两项,而 slice(1, -1) 从索引 1 复制到最后一项之前。它不删除原数组中的任何元素。
splice() 的第二个参数是删除数量,不是结束索引。items.splice(2, 1) 从索引 2 删除一项,items.splice(2, 0, value) 则在该处插入且不删除;生成代码把它误写成 slice() 风格边界时,往往会多删元素。
push() 与 unshift() 返回新长度,pop() 与 shift() 返回被移除的元素;空数组上的后两者返回 undefined。不要根据「方法修改了数组」推断其返回值也是该数组,链式调用前应先检查返回约定。
创建数组与识别数组
Array 的静态方法处理的是创建和类型识别,而不是某个已有数组的实例状态。它们常用于把迭代器、类数组对象和异步数据源放进统一的数组管道。
| 需求 | 静态方法 | 主要结果 |
|---|---|---|
| 识别真正的数组 | Array.isArray(value) | 布尔值 |
| 从可迭代或类数组对象创建 | Array.from(source, mapFn?) | 新数组 |
| 按参数原样创建 | Array.of(...items) | 新数组 |
| 从异步或同步来源创建并等待值 | Array.fromAsync(source, mapFn?) | 新数组的 Promise |
Array.isArray() 比 value instanceof Array 更适合做数组检查。来自另一个浏览器 realm 的数组拥有不同的 Array 构造函数,可能无法通过当前 realm 的 instanceof,但 Array.isArray() 仍能识别其数组内部标记。
Array.from() 可读取字符串、Set、Map、迭代器和带 length 的类数组对象。可选映射函数在构建期间执行,避免先创建数组再调用 map();它仍然是逐项转换,不表示深拷贝。
Array.of(3) 创建 [3],而 new Array(3) 创建长度为 3 的稀疏数组。只有一个数值参数时,这个差异最危险;数组字面量通常更直接,Array.of() 则适合参数数量由调用方决定的工厂。
Array.fromAsync() 返回 Promise,并处理异步可迭代、同步可迭代或类数组来源。它会等待来源产生的值以及映射函数的结果,但不等同于用 Promise.all() 对现成数组启动无界并发;选择时要先明确来源的迭代与容量语义。
泛型方法与类数组对象
许多 Array.prototype 方法是泛型的:它们读取 length 和整数键,并不要求接收者真的是数组。例如,DOM 集合与 arguments 有时可以通过 Array.prototype 上的方法处理,但先用 Array.from() 转换通常更容易理解和传递。
泛型不表示所有接收者都可安全修改。push()、splice() 等方法需要写入索引和 length;字符串不可变,带只读长度或受限属性的对象也可能抛错。借用方法前要检查对象的属性约束。
类型化数组拥有一组相似的方法,却有固定长度、特定数值元素类型和不同的构造规则。不要只因名称相同就假定 Array 与 TypedArray 的所有边界行为一致;处理二进制数据时,应按类型化数组的契约检查。
循环更清楚的场景
数组方法适合结果能由一个方法名准确描述的工作。需要提前 break、顺序等待异步操作、同时维护多个相关累加器,或明确区分空槽与 undefined 时,循环往往能把控制流写得更直接。
for...of 逐项读取值,适合顺序 await,但读取稀疏数组时会为其空槽产生 undefined。需要知道某个索引是否真实存在时,可以遍历索引并使用 Object.hasOwn() 检查。
不要为了缩短行数把可读循环改写成嵌套的 reduce()。方法选择的标准是返回契约与副作用是否清楚,而不是回调数量或链式调用长度。
为中间结果命名
短链可以把数据流写得很清楚,长链却会隐藏每一步的输入假设。只要某个阶段有业务含义,或需要单独记录、测试和复用,就把它保存为有意义的常量。
命名中间结果不改变数组方法的语义,也不会自动解决复制成本。它的价值在于给审查者一个边界,让空输入、排序稳定性和元素共享等条件能逐段验证。
延伸阅读
4个问题 · 1 道输出预测题 · 1 道找错题