数组方法

按返回结果与是否修改原数组选择 JavaScript 数组方法,并避开排序、稀疏数组、浅拷贝和异步回调中的常见错误。

难度 入门 时长 标准深度约 11分钟
版本 Node 24
what

数组方法把遍历、筛选、查找、归约和重排这些常见循环写成有明确返回约定的操作。

trap

方法名看似相近,副作用却不同:sort()splice() 会修改原数组,toSorted()toSpliced() 则返回浅拷贝。

fix

先决定需要的结果形状,再确认方法是否修改原数组、怎样处理空数组与空槽,以及回调是否真的会被等待。

是什么,为什么存在

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()

order-summary.js
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 数组。

copying-methods.js
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() 分别选择首个与最后一个匹配项。

search-and-sort.js
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, Zoe

some() 检查到第二项时已经得到 true,所以没有继续调用回调。原始 scores 顺序保持不变,因为排序使用的是 toSorted(),不是 sort()

当业务需要全部匹配项时才使用 filter()。用 filter(...)[0] 寻找第一项会遍历更多元素并分配新数组,find() 更准确地表达需求;需要索引时则使用 findIndex()findLastIndex()

空槽不是 undefined 元素

数组可以有索引范围,却缺少某个索引属性,这就是 稀疏数组(sparse array) 。不同方法对空槽的处理并不一致,所以不能只看 length 推断回调次数。

sparse-arrays.js
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 -1

map() 没有为原数组的空槽调用回调,并在结果中保留了对应空槽。find() 会访问每个索引,把空槽当作 undefined 传给回调;includes(undefined) 也把空槽视作 undefined,而 indexOf(undefined) 会跳过空槽。

如果业务数据需要一个明确的缺失值,应建立稠密数组,例如用 Array.from({ length: 3 }, () => undefined)。这样每个索引都真实存在,方法之间的差异不会悄悄改变结果。

陷阱

修复方法: 不允许修改输入时使用 toSorted()。数值升序传入 (a, b) => a - b,对象排序则明确写出主键和次键;不要用只返回 truefalse 的比较函数。

修复方法: 只复制确实需要更新的层级,例如 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 表示索引存在且其值为 undefined1 in valuesObject.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() 建立新数组,再为真正变化的元素创建新对象。

深拷贝不是数组方法自动提供的保证。克隆策略要根据值的类型、原型、循环引用以及是否允许传输 DateMap、二进制数据等对象来决定;不能用 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() 可读取字符串、SetMap、迭代器和带 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;字符串不可变,带只读长度或受限属性的对象也可能抛错。借用方法前要检查对象的属性约束。

类型化数组拥有一组相似的方法,却有固定长度、特定数值元素类型和不同的构造规则。不要只因名称相同就假定 ArrayTypedArray 的所有边界行为一致;处理二进制数据时,应按类型化数组的契约检查。

循环更清楚的场景

数组方法适合结果能由一个方法名准确描述的工作。需要提前 break、顺序等待异步操作、同时维护多个相关累加器,或明确区分空槽与 undefined 时,循环往往能把控制流写得更直接。

for...of 逐项读取值,适合顺序 await,但读取稀疏数组时会为其空槽产生 undefined。需要知道某个索引是否真实存在时,可以遍历索引并使用 Object.hasOwn() 检查。

不要为了缩短行数把可读循环改写成嵌套的 reduce()。方法选择的标准是返回契约与副作用是否清楚,而不是回调数量或链式调用长度。

为中间结果命名

短链可以把数据流写得很清楚,长链却会隐藏每一步的输入假设。只要某个阶段有业务含义,或需要单独记录、测试和复用,就把它保存为有意义的常量。

命名中间结果不改变数组方法的语义,也不会自动解决复制成本。它的价值在于给审查者一个边界,让空输入、排序稳定性和元素共享等条件能逐段验证。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?