# 数组方法

Source: https://codewiki.com/zh/javascript/array-methods/

> - **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()`。

<!-- quick -->

```js
// file: 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-')));
```

```text
[ 'A-101: EUR 48', 'A-103: EUR 80' ]
128
true
true
```

<!-- /quick -->

三个数组方法都没有改变 `orders`。这里给 `reduce()` 传入 `0`，所以即使没有已付款订单，结果仍是数字 `0`，累加器类型也从第一次调用起就很明确。

这条链没有强行把所有工作塞进一次 `reduce()`。当中间结果 `paidOrders` 有清楚的业务含义，保留它通常比把筛选、格式化和求和揉进一个复杂回调更容易检查。

### 复制式更新与共享元素

需要新排序结果或新列表状态时，可以使用复制型方法。`toSorted()`、`with()` 与 `toSpliced()` 不修改 `cart`，但它们不会克隆商品对象或对象里的 `tags` 数组。

```js
// file: 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(', '));
```

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

```js
// file: 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(', '));
```

```text
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` 推断回调次数。

```js
// file: 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));
```

```text
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)`。这样每个索引都真实存在，方法之间的差异不会悄悄改变结果。

## 陷阱

> **陷阱:** `sort()` 会原地修改数组，而且省略比较函数时按字符串形式比较元素。数字数组 `[2, 10, 1]` 的默认顺序不会是数值升序。

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

> **陷阱:** `slice()`、展开语法、`concat()` 和复制型数组方法只创建浅拷贝。随后修改某个元素对象时，旧数组中的同一个对象也会改变。

**修复方法：** 只复制确实需要更新的层级，例如 `items.with(i, { ...items[i], done: true })`。如果数据结构存在更深的可变对象，先写清所有权边界，再决定逐层复制还是采用专门的克隆方案。

> **陷阱:** 在可能为空的数组上省略 `reduce()` 初始值会抛出 `TypeError`。非空时，首个已有元素会成为初始累加器，回调从下一个已有元素开始，这也可能让累加器类型不稳定。

**修复方法：** 为求和传 `0`，为字符串拼接传 `''`，为列表构建传 `[]`。按动态键分组时优先考虑 `Map`；如果确实需要普通对象，要处理输入键与对象原型之间的冲突。

> **陷阱:** `filter(Boolean)` 不只删除 `null` 和 `undefined`，还会删除合法的 `0`、`false`、空字符串和 `NaN`。生成的数据清洗代码很容易因此静默丢值。

**修复方法：** 把缺失规则写成谓词。只删除空值时使用 `value != null`；如果还要删除空字符串，应显式加入 `value !== ''`，让读者看见业务规则。

> **陷阱:** `forEach(async (item) => ...)` 不会等待回调返回的 Promise。外层代码会先继续执行，回调中的拒绝也不会由 `forEach()` 汇总。

**修复方法：** 需要并行并等待全部结果时，用 `await Promise.all(items.map(async ...))`。需要顺序执行或限制并发时，用 `for...of` 配合 `await`，或实现明确的有界 worker 方案。

> **陷阱:** `new Array(3).fill({ pending: true })` 把同一个对象引用放进三个槽位。更新其中一项，会让三项看起来一起变化。

**修复方法：** 需要独立对象时使用 `Array.from({ length: 3 }, () => ({ pending: true }))`。`fill()` 适合数字、字符串等原始值，或确实要共享同一个引用的场景。

<!-- deep -->

## 回调的遍历约定

多数迭代方法在第一次调用回调前确定要处理的长度范围。遍历开始后追加到数组尾部的元素通常不会进入本轮回调；删除或改写尚未访问的槽位，则可能影响之后读到的值。依赖这种行为的代码很难审查，回调中应避免改变正在遍历的数组。

回调的第三个参数是调用该方法的数组，而不是正在构造的结果数组。`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()`。方法选择的标准是返回契约与副作用是否清楚，而不是回调数量或链式调用长度。

### 为中间结果命名

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

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

<!-- /deep -->

[检查点: javascript/array-methods](https://codewiki.com/zh/javascript/array-methods/#checkpoint)

## 延伸阅读

- [MDN：`Array` 参考](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)
- [MDN：索引集合指南](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Indexed_collections)
- [MDN：`Array.prototype.toSorted()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSorted)
- [MDN：`Array.prototype.reduce()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce)
- [MDN：`Array.prototype.sort()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort)
