面向急切程序员的 JavaScript(ES2022 版)
请支持本书:购买捐赠
(广告,请勿屏蔽。)

31 数组 (Array)



31.1 备忘单:数组

JavaScript 数组是一种非常灵活的数据结构,可用作列表、堆栈、队列、元组(例如,对)等。

一些与数组相关的操作会破坏性地更改数组。其他操作则以非破坏性的方式生成新的数组,并将更改应用于原始内容的副本。

31.1.1 使用数组

创建数组、读取和写入元素

// Creating an Array
const arr = ['a', 'b', 'c']; // Array literal
assert.deepEqual(
  arr,
  [ // Array literal
    'a',
    'b',
    'c', // trailing commas are ignored
  ]
);

// Reading elements
assert.equal(
  arr[0], 'a' // negative indices don’t work
);
assert.equal(
  arr.at(-1), 'c' // negative indices work
);

// Writing an element
arr[0] = 'x';
assert.deepEqual(
  arr, ['x', 'b', 'c']
);

数组的长度

const arr = ['a', 'b', 'c'];
assert.equal(
  arr.length, 3 // number of elements
);
arr.length = 1; // removing elements
assert.deepEqual(
  arr, ['a']
);
arr[arr.length] = 'b'; // adding an element
assert.deepEqual(
  arr, ['a', 'b']
);

通过 .push() 破坏性地添加元素

const arr = ['a', 'b'];

arr.push('c'); // adding an element
assert.deepEqual(
  arr, ['a', 'b', 'c']
);

// Pushing Arrays (used as arguments via spreading (...)):
arr.push(...['d', 'e']);
assert.deepEqual(
  arr, ['a', 'b', 'c', 'd', 'e']
);

通过展开运算符 (...) 以非破坏性的方式添加元素

const arr1 = ['a', 'b'];
const arr2 = ['c'];
assert.deepEqual(
  [...arr1, ...arr2, 'd', 'e'],
  ['a', 'b', 'c', 'd', 'e']
);

清空数组(删除所有元素)

// Destructive – affects everyone referring to the Array:
const arr1 = ['a', 'b', 'c'];
arr1.length = 0;
assert.deepEqual(
  arr1, []
);

// Non-destructive – does not affect others referring to the Array:
let arr2 = ['a', 'b', 'c'];
arr2 = [];
assert.deepEqual(
  arr2, []
);

循环遍历元素

const arr = ['a', 'b', 'c'];
for (const value of arr) {
  console.log(value);
}

// Output:
// 'a'
// 'b'
// 'c'

循环遍历索引-值对

const arr = ['a', 'b', 'c'];
for (const [index, value] of arr.entries()) {
  console.log(index, value);
}

// Output:
// 0, 'a'
// 1, 'b'
// 2, 'c'

当我们无法使用数组字面量时(例如,因为我们事先不知道它们的长度或它们太大),创建和填充数组

const four = 4;

// Empty Array that we’ll fill later
assert.deepEqual(
  new Array(four),
  [ , , , ,] // four holes; last comma is ignored
);

// An Array filled with a primitive value
assert.deepEqual(
  new Array(four).fill(0),
  [0, 0, 0, 0]
);

// An Array filled with objects
// Why not .fill()? We’d get single object, shared multiple times.
assert.deepEqual(
  Array.from({length: four}, () => ({})),
  [{}, {}, {}, {}]
);

// A range of integers
assert.deepEqual(
  Array.from({length: four}, (_, i) => i),
  [0, 1, 2, 3]
);

31.1.2 数组方法

本节简要概述了数组 API。本章末尾有更全面的快速参考

从现有数组派生新数组

> ['■','●','▲'].slice(1, 3)
['●','▲']
> ['■','●','■'].filter(x => x==='■') 
['■','■']

> ['▲','●'].map(x => x+x)
['▲▲','●●']
> ['▲','●'].flatMap(x => [x,x])
['▲','▲','●','●']

删除给定索引处的数组元素

// .filter(): remove non-destructively
const arr1 = ['■','●','▲'];
assert.deepEqual(
  arr1.filter((_, index) => index !== 1),
  ['■','▲']
);
assert.deepEqual(
  arr1, ['■','●','▲'] // unchanged
);

// .splice(): remove destructively
const arr2 = ['■','●','▲'];
arr2.splice(1, 1); // start at 1, delete 1 element
assert.deepEqual(
  arr2, ['■','▲'] // changed
);

计算数组的摘要

> ['■','●','▲'].some(x => x==='●')
true
> ['■','●','▲'].every(x => x==='●')
false

> ['■','●','▲'].join('-')
'■-●-▲'

> ['■','▲'].reduce((result,x) => result+x, '●')
'●■▲'
> ['■','▲'].reduceRight((result,x) => result+x, '●')
'●▲■'

反转和填充

// .reverse() changes and returns `arr`
const arr = ['■','●','▲'];
assert.deepEqual(
  arr.reverse(), arr
);
// `arr` was changed:
assert.deepEqual(
  arr, ['▲','●','■']
);

// .fill() works the same way:
assert.deepEqual(
  ['■','●','▲'].fill('●'),
  ['●','●','●']
);

.sort() 也会修改数组并返回它

// By default, string representations of the Array elements
// are sorted lexicographically:
assert.deepEqual(
  [200, 3, 10].sort(),
  [10, 200, 3]
);

// Sorting can be customized via a callback:
assert.deepEqual(
  [200, 3, 10].sort((a,b) => a - b), // sort numerically
  [ 3, 10, 200 ]
);

查找数组元素

> ['■','●','■'].includes('■')
true
> ['■','●','■'].indexOf('■')
0
> ['■','●','■'].lastIndexOf('■')
2
> ['■','●','■'].find(x => x==='■')
'■'
> ['■','●','■'].findIndex(x => x==='■')
0

在开头或结尾添加或删除元素

// Adding and removing at the start
const arr1 = ['■','●'];
arr1.unshift('▲');
assert.deepEqual(
  arr1, ['▲','■','●']
);
arr1.shift();
assert.deepEqual(
  arr1, ['■','●']
);

// Adding and removing at the end
const arr2 = ['■','●'];
arr2.push('▲');
assert.deepEqual(
  arr2, ['■','●','▲']
);
arr2.pop();
assert.deepEqual(
  arr2, ['■','●']
);

31.2 在 JavaScript 中使用数组的两种方式

在 JavaScript 中有两种使用数组的方式

在实践中,这两种方式经常混合使用。

值得注意的是,序列数组非常灵活,我们可以将它们用作(传统的)数组、堆栈和队列。我们稍后会看到如何做到这一点。

31.3 基本数组操作

31.3.1 创建、读取、写入数组

创建数组的最佳方式是通过数组字面量

const arr = ['a', 'b', 'c'];

数组字面量以方括号 [] 开始和结束。它创建一个包含三个元素的数组:'a''b''c'

数组字面量中允许使用尾随逗号,并且会被忽略

const arr = [
  'a',
  'b',
  'c',
];

要读取数组元素,我们将索引放在方括号中(索引从零开始)

const arr = ['a', 'b', 'c'];
assert.equal(arr[0], 'a');

要更改数组元素,我们将值赋给带有索引的数组

const arr = ['a', 'b', 'c'];
arr[0] = 'x';
assert.deepEqual(arr, ['x', 'b', 'c']);

数组索引的范围为 32 位(不包括最大长度):[0, 232−1)

31.3.2 数组的 .length

每个数组都有一个属性 .length,可用于读取和更改 (!) 数组中元素的数量。

数组的长度始终是最高索引加一

> const arr = ['a', 'b'];
> arr.length
2

如果我们在长度索引处写入数组,则会追加一个元素

> arr[arr.length] = 'c';
> arr
[ 'a', 'b', 'c' ]
> arr.length
3

(破坏性地)追加元素的另一种方法是通过数组方法 .push()

> arr.push('d');
> arr
[ 'a', 'b', 'c', 'd' ]

如果我们设置 .length,我们就是在通过删除元素来修剪数组

> arr.length = 1;
> arr
[ 'a' ]

  练习:通过 .push() 删除空行

exercises/arrays/remove_empty_lines_push_test.mjs

31.3.3 通过负索引引用元素

几种数组方法支持负索引。如果索引为负数,则将其添加到数组的长度以生成可用的索引。因此,以下两次调用 .slice() 是等效的:它们都从最后一个元素开始复制 arr

> const arr = ['a', 'b', 'c'];
> arr.slice(-1)
[ 'c' ]
> arr.slice(arr.length - 1)
[ 'c' ]
31.3.3.1 .at():读取单个元素(支持负索引)[ES2022]

数组方法 .at() 返回给定索引处的元素。它支持正负索引(-1 指的是最后一个元素,-2 指的是倒数第二个元素,等等)

> ['a', 'b', 'c'].at(0)
'a'
> ['a', 'b', 'c'].at(-1)
'c'

相反,方括号运算符 [] 不支持负索引(并且不能更改,因为这会破坏现有代码)。它将它们解释为非元素属性的键

const arr = ['a', 'b', 'c'];

arr[-1] = 'non-element property';
// The Array elements didn’t change:
assert.deepEqual(
  Array.from(arr), // copy just the Array elements
  ['a', 'b', 'c']
);

assert.equal(
  arr[-1], 'non-element property'
);

31.3.4 清空数组

要清空数组,我们可以将其 .length 设置为零

const arr = ['a', 'b', 'c'];
arr.length = 0;
assert.deepEqual(arr, []);

或者我们可以将一个新的空数组赋给存储数组的变量

let arr = ['a', 'b', 'c'];
arr = [];
assert.deepEqual(arr, []);

后一种方法的优点是不会影响指向同一数组的其他位置。但是,如果我们确实想为每个人重置共享数组,那么我们需要前一种方法。

31.3.5 展开到数组字面量 [ES6]

在数组字面量中,展开元素由三个点 (...) 后跟一个表达式组成。它会导致表达式被求值,然后对其进行迭代。每个迭代值都成为一个额外的数组元素 - 例如

> const iterable = ['b', 'c'];
> ['a', ...iterable, 'd']
[ 'a', 'b', 'c', 'd' ]

这意味着我们可以使用展开运算符来创建数组的副本并将可迭代对象转换为数组

const original = ['a', 'b', 'c'];

const copy = [...original];

const iterable = original.keys();
assert.deepEqual(
  [...iterable], [0, 1, 2]
);

但是,对于之前的两种用例,我发现 Array.from() 更具描述性,并且更喜欢它

const copy2 = Array.from(original);

assert.deepEqual(
  Array.from(original.keys()), [0, 1, 2]
);

展开运算符对于将数组(和其他可迭代对象)连接成数组也很方便

const arr1 = ['a', 'b'];
const arr2 = ['c', 'd'];

const concatenated = [...arr1, ...arr2, 'e'];
assert.deepEqual(
  concatenated,
  ['a', 'b', 'c', 'd', 'e']);

由于展开运算符使用迭代,因此仅当值是可迭代的时才有效

> [...'abc'] // strings are iterable
[ 'a', 'b', 'c' ]
> [...123]
TypeError: 123 is not iterable
> [...undefined]
TypeError: undefined is not iterable

  展开运算符和 Array.from() 生成浅拷贝

通过展开运算符或 Array.from() 复制数组是浅拷贝:我们在新数组中获得了新的条目,但值与原始数组共享。浅拷贝的后果在§28.4 “展开到对象字面量 (...) [ES2018]” 中进行了演示。

31.3.6 数组:列出索引和条目 [ES6]

方法 .keys() 列出数组的索引

const arr = ['a', 'b'];
assert.deepEqual(
  Array.from(arr.keys()), // (A)
  [0, 1]);

.keys() 返回一个可迭代对象。在 A 行中,我们将该可迭代对象转换为数组。

列出数组索引与列出属性不同。前者生成数字;后者生成字符串化的数字(以及非索引属性键)

const arr = ['a', 'b'];
arr.prop = true;

assert.deepEqual(
  Object.keys(arr),
  ['0', '1', 'prop']);

方法 .entries() 将数组的内容列为 [索引,元素] 对

const arr = ['a', 'b'];
assert.deepEqual(
  Array.from(arr.entries()),
  [[0, 'a'], [1, 'b']]);

31.3.7 值是否为数组?

以下是检查值是否为数组的两种方法

> [] instanceof Array
true
> Array.isArray([])
true

instanceof 通常没问题。如果值可能来自另一个领域,则需要 Array.isArray()。粗略地说,领域是 JavaScript 全局作用域的实例。某些领域彼此隔离(例如,浏览器中的Web Workers),但也有一些领域可以在它们之间移动数据 - 例如,浏览器中同源的 iframe。x instanceof Array 检查 x 的原型链,因此如果 x 是来自另一个领域的数组,则返回 false

typeof 将数组归类为对象

> typeof []
'object'

31.4 for-of 和数组 [ES6]

我们在这本书的前面已经遇到过 for-of 循环。本节简要回顾了如何将其用于数组。

31.4.1 for-of:迭代元素

以下 for-of 循环迭代数组的元素

for (const element of ['a', 'b']) {
  console.log(element);
}
// Output:
// 'a'
// 'b'

31.4.2 for-of:迭代索引

for-of 循环迭代数组的索引

for (const element of ['a', 'b'].keys()) {
  console.log(element);
}
// Output:
// 0
// 1

31.4.3 for-of:迭代 [索引,元素] 对

以下 for-of 循环迭代 [索引,元素] 对。解构(稍后描述)为我们在 for-of 的头部设置 indexelement 提供了方便的语法。

for (const [index, element] of ['a', 'b'].entries()) {
  console.log(index, element);
}
// Output:
// 0, 'a'
// 1, 'b'

31.5 类数组对象

某些使用数组的操作只需要最基本的条件:值必须是类数组的。类数组值是具有以下属性的对象

例如,Array.from() 接受类数组对象并将它们转换为数组

// If we omit .length, it is interpreted as 0
assert.deepEqual(
  Array.from({}),
  []);

assert.deepEqual(
  Array.from({length:2, 0:'a', 1:'b'}),
  [ 'a', 'b' ]);

类数组对象的 TypeScript 接口是

interface ArrayLike<T> {
  length: number;
  [n: number]: T;
}

  类数组对象在现代 JavaScript 中相对少见

类数组对象在 ES6 之前很常见;现在我们不经常看到它们了。

31.6 将可迭代对象和类数组值转换为数组

将可迭代对象和类数组值转换为数组有两种常用方法

我更喜欢后者——我觉得它更易于理解。

31.6.1 通过展开运算符 (...) 将可迭代对象转换为数组

在数组字面量中,通过 ... 展开运算符可以将任何可迭代对象转换为一系列数组元素。例如

// Get an Array-like collection from a web browser’s DOM
const domCollection = document.querySelectorAll('a');

// Alas, the collection is missing many Array methods
assert.equal('map' in domCollection, false);

// Solution: convert it to an Array
const arr = [...domCollection];
assert.deepEqual(
  arr.map(x => x.href),
  ['https://2ality.com', 'https://exploring.javascript.ac.cn']);

这种转换之所以有效,是因为 DOM 集合是可迭代的。

31.6.2 通过 Array.from() 将可迭代对象和类数组对象转换为数组

Array.from() 可以以两种模式使用。

31.6.2.1 Array.from() 的模式 1:转换

第一种模式具有以下类型签名

.from<T>(iterable: Iterable<T> | ArrayLike<T>): T[]

接口 Iterable 在关于同步迭代的章节中 展示。接口 ArrayLike 在本节前面 出现过。

使用单个参数,Array.from() 可以将任何可迭代对象或类数组对象转换为数组

> Array.from(new Set(['a', 'b']))
[ 'a', 'b' ]
> Array.from({length: 2, 0:'a', 1:'b'})
[ 'a', 'b' ]
31.6.2.2 Array.from() 的模式 2:转换和映射

Array.from() 的第二种模式涉及两个参数

.from<T, U>(
  iterable: Iterable<T> | ArrayLike<T>,
  mapFunc: (v: T, i: number) => U,
  thisArg?: any)
  : U[]

在这种模式下,Array.from() 会执行以下操作

换句话说:我们将类型为 T 的元素的可迭代对象转换为类型为 U 的元素的数组。

这是一个例子

> Array.from(new Set(['a', 'b']), x => x + x)
[ 'aa', 'bb' ]

31.7 创建和填充任意长度的数组

创建数组的最佳方法是使用数组字面量。但是,我们不能总是使用它:数组可能太大,我们在开发过程中可能不知道它的长度,或者我们可能希望保持它的长度灵活。然后,我建议使用以下技术来创建和填充数组。

31.7.1 我们是否需要创建一个稍后将完全填充的空数组?

> new Array(3)
[ , , ,]

请注意,结果有三个 空洞(空槽)——数组字面量中的最后一个逗号总是被忽略。

31.7.2 我们是否需要创建一个填充了原始值的数组?

> new Array(3).fill(0)
[0, 0, 0]

注意:如果我们对对象使用 .fill(),则每个数组元素都将引用此对象(共享它)。

const arr = new Array(3).fill({});
arr[0].prop = true;
assert.deepEqual(
  arr, [
    {prop: true},
    {prop: true},
    {prop: true},
  ]);

下一小节 将解释如何解决此问题。

31.7.3 我们是否需要创建一个填充了对象的数组?

> new Array(3).fill(0)
[0, 0, 0]

对于大型数组,临时数组可能会消耗大量内存。以下方法没有这个缺点,但可读性较差

> Array.from({length: 3}, () => ({}))
[{}, {}, {}]

我们使用的是一个临时的 类数组对象,而不是临时数组。

31.7.4 我们是否需要创建一个整数范围?

function createRange(start, end) {
  return Array.from({length: end-start}, (_, i) => i+start);
}
assert.deepEqual(
  createRange(2, 5),
  [2, 3, 4]);

以下是另一种创建从零开始的整数范围的技巧,但有点 hacky

/** Returns an iterable */
function createRange(end) {
  return new Array(end).keys();
}
assert.deepEqual(
  Array.from(createRange(4)),
  [0, 1, 2, 3]);

之所以可行,是因为 .keys()空洞 视为 undefined 元素并列出它们的索引。

31.7.5 如果元素都是整数或浮点数,请使用类型化数组

在处理整数或浮点数数组时,我们应该考虑 类型化数组,它们就是为此目的而创建的。

31.8 多维数组

JavaScript 没有真正的多维数组;我们需要求助于元素为数组的数组

function initMultiArray(...dimensions) {
  function initMultiArrayRec(dimIndex) {
    if (dimIndex >= dimensions.length) {
      return 0;
    } else {
      const dim = dimensions[dimIndex];
      const arr = [];
      for (let i=0; i<dim; i++) {
        arr.push(initMultiArrayRec(dimIndex+1));
      }
      return arr;
    }
  }
  return initMultiArrayRec(0);
}

const arr = initMultiArray(4, 3, 2);
arr[3][2][1] = 'X'; // last in each dimension
assert.deepEqual(arr, [
  [ [ 0, 0 ], [ 0, 0 ], [ 0, 0 ] ],
  [ [ 0, 0 ], [ 0, 0 ], [ 0, 0 ] ],
  [ [ 0, 0 ], [ 0, 0 ], [ 0, 0 ] ],
  [ [ 0, 0 ], [ 0, 0 ], [ 0, 'X' ] ],
]);

31.9 更多数组特性(高级)

在本节中,我们将研究在使用数组时不常遇到的现象。

31.9.1 数组索引是(稍微特殊的)属性键

你可能认为数组元素很特殊,因为我们是通过数字访问它们的。但是用于执行此操作的方括号运算符 [] 与用于访问属性的运算符相同。它会将任何值(符号除外)强制转换为字符串。因此,数组元素(几乎)是普通属性(A 行),我们使用数字还是字符串作为索引无关紧要(B 行和 C 行)

const arr = ['a', 'b'];
arr.prop = 123;
assert.deepEqual(
  Object.keys(arr),
  ['0', '1', 'prop']); // (A)

assert.equal(arr[0], 'a');  // (B)
assert.equal(arr['0'], 'a'); // (C)

更令人困惑的是,这只是语言规范定义事物的方式(如果你愿意,可以称之为 JavaScript 的理论)。大多数 JavaScript 引擎在底层进行了优化,并确实使用实际的整数来访问数组元素(如果你愿意,可以称之为 JavaScript 的实践)。

用于数组元素的属性键(字符串!)称为 索引。如果将字符串 str 转换为 32 位无符号整数再转换回来,结果是原始值,则该字符串就是一个索引。写成公式就是

ToString(ToUint32(str)) === str
31.9.1.1 列出索引

在列出属性键时,索引的处理方式很特殊——它们总是排在第一位,并且像数字一样排序('2''10' 之前)

const arr = [];
arr.prop = true;
arr[1] = 'b';
arr[0] = 'a';

assert.deepEqual(
  Object.keys(arr),
  ['0', '1', 'prop']);

请注意,.length.entries().keys() 将数组索引视为数字,并忽略非索引属性

assert.equal(arr.length, 2);
assert.deepEqual(
  Array.from(arr.keys()), [0, 1]);
assert.deepEqual(
  Array.from(arr.entries()), [[0, 'a'], [1, 'b']]);

我们使用 Array.from().keys().entries() 返回的可迭代对象转换为数组。

31.9.2 数组是字典,可以有空洞

我们在 JavaScript 中区分两种数组

JavaScript 中的数组可以是稀疏的,因为数组实际上是从索引到值的字典。

  建议:避免空洞

到目前为止,我们只见过密集数组,实际上建议避免空洞:它们会使我们的代码更加复杂,并且数组方法对它们的处理方式也不一致。此外,JavaScript 引擎会优化密集数组,使其速度更快。

31.9.2.1 创建空洞

我们可以在分配元素时跳过索引来创建空洞

const arr = [];
arr[0] = 'a';
arr[2] = 'c';

assert.deepEqual(Object.keys(arr), ['0', '2']); // (A)

assert.equal(0 in arr, true); // element
assert.equal(1 in arr, false); // hole

在 A 行中,我们使用的是 Object.keys(),因为 arr.keys() 会将空洞视为 undefined 元素,不会显示它们。

另一种创建空洞的方法是在数组字面量中跳过元素

const arr = ['a', , 'c'];

assert.deepEqual(Object.keys(arr), ['0', '2']);

我们还可以删除数组元素

const arr = ['a', 'b', 'c'];
assert.deepEqual(Object.keys(arr), ['0', '1', '2']);
delete arr[1];
assert.deepEqual(Object.keys(arr), ['0', '2']);
31.9.2.2 数组操作如何处理空洞?

唉,数组操作处理空洞的方式有很多种。

一些数组操作会删除空洞

> ['a',,'b'].filter(x => true)
[ 'a', 'b' ]

一些数组操作会忽略空洞

> ['a', ,'a'].every(x => x === 'a')
true

一些数组操作会忽略但保留空洞

> ['a',,'b'].map(x => 'c')
[ 'c', , 'c' ]

一些数组操作会将空洞视为 undefined 元素

> Array.from(['a',,'b'], x => x)
[ 'a', undefined, 'b' ]
> Array.from(['a',,'b'].entries())
[[0, 'a'], [1, undefined], [2, 'b']]

Object.keys() 的工作方式与 .keys() 不同(字符串与数字,空洞没有键)

> Array.from(['a',,'b'].keys())
[ 0, 1, 2 ]
> Object.keys(['a',,'b'])
[ '0', '2' ]

这里没有规则可循。如果数组操作如何处理空洞很重要,最好的方法是在控制台中进行快速测试。

31.10 添加和删除元素(破坏性和非破坏性)

JavaScript 的 Array 非常灵活,更像是数组、堆栈和队列的组合。本节探讨添加和删除数组元素的方法。大多数操作都可以破坏性地(修改数组)和非破坏性地(生成修改后的副本)执行。

31.10.1 前置元素和数组

在以下代码中,我们破坏性地将单个元素前置到 arr1,并将数组前置到 arr2

const arr1 = ['a', 'b'];
arr1.unshift('x', 'y'); // prepend single elements
assert.deepEqual(arr1, ['x', 'y', 'a', 'b']);

const arr2 = ['a', 'b'];
arr2.unshift(...['x', 'y']); // prepend Array
assert.deepEqual(arr2, ['x', 'y', 'a', 'b']);

展开运算符允许我们将数组 unshift 到 arr2 中。

非破坏性前置是通过展开元素完成的

const arr1 = ['a', 'b'];
assert.deepEqual(
  ['x', 'y', ...arr1], // prepend single elements
  ['x', 'y', 'a', 'b']);
assert.deepEqual(arr1, ['a', 'b']); // unchanged!

const arr2 = ['a', 'b'];
assert.deepEqual(
  [...['x', 'y'], ...arr2], // prepend Array
  ['x', 'y', 'a', 'b']);
assert.deepEqual(arr2, ['a', 'b']); // unchanged!

31.10.2 追加元素和数组

在以下代码中,我们破坏性地将单个元素追加到 arr1,并将数组追加到 arr2

const arr1 = ['a', 'b'];
arr1.push('x', 'y'); // append single elements
assert.deepEqual(arr1, ['a', 'b', 'x', 'y']);

const arr2 = ['a', 'b'];
arr2.push(...['x', 'y']); // (A) append Array
assert.deepEqual(arr2, ['a', 'b', 'x', 'y']);

展开运算符 (...) 允许我们将数组 push 到 arr2 中(A 行)。

非破坏性追加是通过展开元素完成的

const arr1 = ['a', 'b'];
assert.deepEqual(
  [...arr1, 'x', 'y'], // append single elements
  ['a', 'b', 'x', 'y']);
assert.deepEqual(arr1, ['a', 'b']); // unchanged!

const arr2 = ['a', 'b'];
assert.deepEqual(
  [...arr2, ...['x', 'y']], // append Array
  ['a', 'b', 'x', 'y']);
assert.deepEqual(arr2, ['a', 'b']); // unchanged!

31.10.3 删除元素

以下是删除数组元素的三种破坏性方法

// Destructively remove first element:
const arr1 = ['a', 'b', 'c'];
assert.equal(arr1.shift(), 'a');
assert.deepEqual(arr1, ['b', 'c']);

// Destructively remove last element:
const arr2 = ['a', 'b', 'c'];
assert.equal(arr2.pop(), 'c');
assert.deepEqual(arr2, ['a', 'b']);

// Remove one or more elements anywhere:
const arr3 = ['a', 'b', 'c', 'd'];
assert.deepEqual(arr3.splice(1, 2), ['b', 'c']);
assert.deepEqual(arr3, ['a', 'd']);

.splice()本章末尾的快速参考 中有更详细的介绍。

通过剩余元素进行解构允许我们以非破坏性的方式从数组的开头删除元素(解构将在 后面 介绍)。

const arr1 = ['a', 'b', 'c'];
// Ignore first element, extract remaining elements
const [, ...arr2] = arr1;

assert.deepEqual(arr2, ['b', 'c']);
assert.deepEqual(arr1, ['a', 'b', 'c']); // unchanged!

唉,剩余元素必须位于数组的最后。因此,我们只能使用它来提取后缀。

  练习:通过数组实现队列

exercises/arrays/queue_via_array_test.mjs

31.11 方法:迭代和转换(.find().map().filter() 等)

在本节中,我们将研究用于迭代数组和转换数组的数组方法。

31.11.1 迭代和转换方法的回调

所有迭代和转换方法都使用回调。前者将其所有迭代值都提供给其回调;后者询问其回调如何转换数组。

这些回调具有如下所示的类型签名

callback: (value: T, index: number, array: Array<T>) => boolean

也就是说,回调会获得三个参数(它可以忽略其中的任何一个)

回调预期返回什么取决于它被传递给哪个方法。可能性包括

这两种方法将在后面详细介绍。

31.11.2 搜索元素:.find().findIndex()

.find() 返回其回调返回真值(truthy value)的第一个元素(如果找不到任何元素,则返回 undefined

> [6, -5, 8].find(x => x < 0)
-5
> [6, 5, 8].find(x => x < 0)
undefined

.findIndex() 返回其回调返回真值(truthy value)的第一个元素的索引(如果找不到任何元素,则返回 -1

> [6, -5, 8].findIndex(x => x < 0)
1
> [6, 5, 8].findIndex(x => x < 0)
-1

.findIndex() 可以实现如下

function findIndex(arr, callback) {
  for (const [i, x] of arr.entries()) {
    if (callback(x, i, arr)) {
      return i;
    }
  }
  return -1;
}

31.11.3 .map():复制并为元素赋予新值

.map() 返回接收者的修改副本。副本的元素是将 map 的回调应用于接收者的元素的结果。

通过示例更容易理解这一切

> [1, 2, 3].map(x => x * 3)
[ 3, 6, 9 ]
> ['how', 'are', 'you'].map(str => str.toUpperCase())
[ 'HOW', 'ARE', 'YOU' ]
> [true, true, true].map((_x, index) => index)
[ 0, 1, 2 ]

.map() 可以实现如下

function map(arr, mapFunc) {
  const result = [];
  for (const [i, x] of arr.entries()) {
    result.push(mapFunc(x, i, arr));
  }
  return result;
}

  练习:通过 .map() 对行进行编号

exercises/arrays/number_lines_test.mjs

31.11.4 .flatMap():映射到零个或多个值

Array<T>.prototype.flatMap() 的类型签名是

.flatMap<U>(
  callback: (value: T, index: number, array: T[]) => U|Array<U>,
  thisValue?: any
): U[]

.map().flatMap() 都将函数 callback 作为参数,该函数控制如何将输入数组转换为输出数组

这是 .flatMap() 的实际应用

> ['a', 'b', 'c'].flatMap(x => [x,x])
[ 'a', 'a', 'b', 'b', 'c', 'c' ]
> ['a', 'b', 'c'].flatMap(x => [x])
[ 'a', 'b', 'c' ]
> ['a', 'b', 'c'].flatMap(x => [])
[]

在探讨如何实现此方法之前,我们将先考虑用例。

31.11.4.1 用例:同时过滤和映射

数组方法 .map() 的结果始终与其调用它的数组具有相同的长度。也就是说,它的回调不能跳过它不感兴趣的数组元素。.flatMap() 的这种能力在下一个示例中很有用。

我们将使用以下函数 processArray() 创建一个数组,然后我们将通过 .flatMap() 对其进行过滤和映射

function processArray(arr, callback) {
  return arr.map(x => {
    try {
      return { value: callback(x) };
    } catch (e) {
      return { error: e };
    }
  });
}

接下来,我们通过 processArray() 创建一个数组 results

const results = processArray([1, -5, 6], throwIfNegative);
assert.deepEqual(results, [
  { value: 1 },
  { error: new Error('Illegal value: -5') },
  { value: 6 },
]);

function throwIfNegative(value) {
  if (value < 0) {
    throw new Error('Illegal value: '+value);
  }
  return value;
}

现在,我们可以使用 .flatMap()results 中仅提取值或仅提取错误

const values = results.flatMap(
  result => result.value ? [result.value] : []);
assert.deepEqual(values, [1, 6]);
  
const errors = results.flatMap(
  result => result.error ? [result.error] : []);
assert.deepEqual(errors, [new Error('Illegal value: -5')]);
31.11.4.2 用例:将单个输入值映射到多个输出值

数组方法 .map() 将每个输入数组元素映射到一个输出元素。但是,如果我们想将其映射到多个输出元素呢?

在以下示例中,这变得很有必要

> stringsToCodePoints(['many', 'a', 'moon'])
['m', 'a', 'n', 'y', 'a', 'm', 'o', 'o', 'n']

我们想将字符串数组转换为 Unicode 字符(码位)数组。以下函数通过 .flatMap() 实现这一点。

function stringsToCodePoints(strs) {
  return strs.flatMap(str => Array.from(str));
}
31.11.4.3 简单实现

我们可以按如下方式实现 .flatMap()。注意:此实现比内置版本更简单,例如,内置版本执行更多检查。

function flatMap(arr, mapFunc) {
  const result = [];
  for (const [index, elem] of arr.entries()) {
    const x = mapFunc(elem, index, arr);
    // We allow mapFunc() to return non-Arrays
    if (Array.isArray(x)) {
      result.push(...x);
    } else {
      result.push(x);
    }
  }
  return result;
}

  练习:.flatMap()

31.11.5 .filter():仅保留某些元素

数组方法 .filter() 返回一个数组,该数组收集回调函数返回真值的所有元素。

例如:

> [-1, 2, 5, -7, 6].filter(x => x >= 0)
[ 2, 5, 6 ]
> ['a', 'b', 'c', 'd'].filter((_x,i) => (i%2)===0)
[ 'a', 'c' ]

.filter() 可以按如下方式实现:

function filter(arr, filterFunc) {
  const result = [];
  for (const [i, x] of arr.entries()) {
    if (filterFunc(x, i, arr)) {
      result.push(x);
    }
  }
  return result;
}

  练习:通过 .filter() 删除空行

exercises/arrays/remove_empty_lines_filter_test.mjs

31.11.6 .reduce():从数组派生值(高级)

方法 .reduce() 是一个强大的工具,用于计算数组 arr 的“汇总”。汇总可以是任何类型的值:

reduce 在函数式编程中也称为 foldl(“左折叠”),并且在那里很流行。需要注意的是,它可能会使代码难以理解。

.reduce() 具有以下类型签名(在 Array<T> 内部):

.reduce<U>(
  callback: (accumulator: U, element: T, index: number, array: T[]) => U,
  init?: U)
  : U

T 是数组元素的类型,U 是汇总的类型。两者可能相同,也可能不同。accumulator 只是“汇总”的另一个名称。

为了计算数组 arr 的汇总,.reduce() 将所有数组元素一次一个地提供给其回调函数:

const accumulator_0 = callback(init, arr[0]);
const accumulator_1 = callback(accumulator_0, arr[1]);
const accumulator_2 = callback(accumulator_1, arr[2]);
// Etc.

callback 将先前计算的汇总(存储在其参数 accumulator 中)与当前数组元素组合在一起,并返回下一个 accumulator.reduce() 的结果是最终的累加器 - callback 访问所有元素后的最后一个结果。

换句话说:callback 完成了大部分工作;.reduce() 只是以一种有用的方式调用它。

我们可以说回调函数将数组元素折叠到累加器中。这就是为什么此操作在函数式编程中称为“折叠”的原因。

31.11.6.1 第一个示例

让我们看一个 .reduce() 的实际示例:函数 addAll() 计算数组 arr 中所有数字的总和。

function addAll(arr) {
  const startSum = 0;
  const callback = (sum, element) => sum + element;
  return arr.reduce(callback, startSum);
}
assert.equal(addAll([1,  2, 3]), 6); // (A)
assert.equal(addAll([7, -4, 2]), 5);

在这种情况下,累加器保存 callback 已经访问过的所有数组元素的总和。

如何从 A 行中的数组派生出结果 6?通过以下对 callback 的调用:

callback(0, 1) --> 1
callback(1, 2) --> 3
callback(3, 3) --> 6

注意:

或者,我们可以通过 for-of 循环来实现 addAll()

function addAll(arr) {
  let sum = 0;
  for (const element of arr) {
    sum = sum + element;
  }
  return sum;
}

很难说这两种实现哪种“更好”:基于 .reduce() 的实现更简洁一些,而基于 for-of 的实现可能更容易理解 - 特别是如果有人不熟悉函数式编程的话。

31.11.6.2 示例:通过 .reduce() 查找索引

以下函数是数组方法 .indexOf() 的实现。它返回给定 searchValue 出现在数组 arr 中的第一个索引。

const NOT_FOUND = -1;
function indexOf(arr, searchValue) {
  return arr.reduce(
    (result, elem, index) => {
      if (result !== NOT_FOUND) {
        // We have already found something: don’t change anything
        return result;
      } else if (elem === searchValue) {
        return index;
      } else {
        return NOT_FOUND;
      }
    },
    NOT_FOUND);
}
assert.equal(indexOf(['a', 'b', 'c'], 'b'), 1);
assert.equal(indexOf(['a', 'b', 'c'], 'x'), -1);

.reduce() 的一个限制是我们不能提前结束(在 for-of 循环中,我们可以使用 break)。在这里,我们总是在找到结果后立即返回它。

31.11.6.3 示例:将数组元素加倍

函数 double(arr) 返回 inArr 的副本,其元素都乘以 2。

function double(inArr) {
  return inArr.reduce(
    (outArr, element) => {
      outArr.push(element * 2);
      return outArr;
    },
    []);
}
assert.deepEqual(
  double([1, 2, 3]),
  [2, 4, 6]);

我们通过推入初始值 [] 来修改它。double() 的非破坏性、更函数式的版本如下所示:

function double(inArr) {
  return inArr.reduce(
    // Don’t change `outArr`, return a fresh Array
    (outArr, element) => [...outArr, element * 2],
    []);
}
assert.deepEqual(
  double([1, 2, 3]),
  [2, 4, 6]);

此版本更优雅,但速度较慢,并且使用更多内存。

  练习:.reduce()

31.12 .sort():对数组进行排序

.sort() 具有以下类型定义:

sort(compareFunc?: (a: T, b: T) => number): this

默认情况下,.sort() 对元素的字符串表示形式进行排序。这些表示形式通过 < 进行比较。此运算符按*字典顺序*进行比较(第一个字符最先比较)。我们可以在对数字进行排序时看到这一点:

> [200, 3, 10].sort()
[ 10, 200, 3 ]

在对人类语言字符串进行排序时,需要注意它们是根据其代码单元值(字符代码)进行比较的。

> ['pie', 'cookie', 'éclair', 'Pie', 'Cookie', 'Éclair'].sort()
[ 'Cookie', 'Pie', 'cookie', 'pie', 'Éclair', 'éclair' ]

所有不带重音符号的大写字母都在所有不带重音符号的小写字母之前,而所有带重音符号的字母都在所有不带重音符号的小写字母之后。如果我们想对人类语言进行正确的排序,我们可以使用 IntlJavaScript 国际化 API

.sort() *就地*排序;它会更改并返回其接收器。

> const arr = ['a', 'c', 'b'];
> arr.sort() === arr
true
> arr
[ 'a', 'b', 'c' ]

31.12.1 自定义排序顺序

我们可以通过参数 compareFunc 自定义排序顺序,该参数必须返回一个数字,该数字:

  记住这些规则的技巧

负数*小于*零(等等)。

31.12.2 对数字进行排序

我们可以使用此辅助函数对数字进行排序:

function compareNumbers(a, b) {
  if (a < b) {
    return -1;
  } else if (a === b) {
    return 0;
  } else {
    return 1;
  }
}
assert.deepEqual(
  [200, 3, 10].sort(compareNumbers),
  [3, 10, 200]);

以下是一种快速但不太规范的替代方法。

> [200, 3, 10].sort((a,b) => a - b)
[ 3, 10, 200 ]

这种方法的缺点是:

31.12.3 对对象进行排序

如果我们想对对象进行排序,我们还需要使用比较函数。例如,以下代码显示了如何按年龄对对象进行排序。

const arr = [ {age: 200}, {age: 3}, {age: 10} ];
assert.deepEqual(
  arr.sort((obj1, obj2) => obj1.age - obj2.age),
  [{ age: 3 }, { age: 10 }, { age: 200 }] );

  练习:按名称对对象进行排序

exercises/arrays/sort_objects_test.mjs

31.13 快速参考:Array

图例:

31.13.1 new Array()

new Array(n) 创建一个长度为 n 的数组,其中包含 n 个空位。

// Trailing commas are always ignored.
// Therefore: number of commas = number of holes
assert.deepEqual(new Array(3), [,,,]);

new Array() 创建一个空数组。但是,我建议始终使用 [] 来代替。

31.13.2 Array 的静态方法

31.13.3 Array.prototype 的方法

31.13.4 来源

  测验

参见测验应用程序