第 7 章 数组

本章记录了数组、一个在 JavaScript 和大多数其他编程语言中的基本数据类型。数组是值的有序集合。每个值叫做一个元素,而每个元素在数组中有一个位置,以数字表示,称为索引。JavaScript 数组是无类型的:数组元素可以是任意类型,并且同一个数组中的不同元素也可能有不同的类型。数组的元素甚至也可能是对象或其他数组,这允许创建复杂的数据结构,如对象的数组和数组的数组。JavaScript 数组的索引是基于零的 32 位数值:第一个元素的索引为 0,最大可能的索引为 4,294,967,294(232-2),数组最大能容纳 4,294,967,295 个元素。JavaScript 数组是动态的:根据需要它们会增长或缩减,并且在创建数组时无须声明一个固定的大小或者在数组大小变化时无须重新分配空间。JavaScript 数组可能是稀疏的:数组元素的索引不一定要连续的,它们之间可以有空缺。每个 JavaScript 数组都有一个 length 属性。针对非稀疏数组,该属性就是数组元素的个数。针对稀疏数组,length 大于任何元素的最高索引。

JavaScript 数组是 JavaScript 对象的特殊形式,数组索引实际上和碰巧是整数的属性名差不多。我们将在本章的其他地方更多地讨论特殊化的数组。通常,数组的实现是经过优化的,用数字索引来访问数组元素一般来说比访问常规的对象属性要快很多。

数组继承自 Array.prototype 中的属性,它定义了一套丰富的数组操作方法,§7.8 涵盖这方面内容。大多数这些方法是通用的,这意味着它们不仅对真正的数组有效,而且对“类数组对象”同样有效。§7.9 讨论类数组对象。最后,JavaScript 字符串的行为与字符数组类似,我们将在 §7.10 讨论。

ES6 引入了一组新的数组类,这些类统称为“类型化数组”。与常规的 JavaScript 数组不同,类型化数组有固定的长度和固定的数值元素类型。它们提供高性能和对二进制数据的字节级访问,在 §11.2 中有介绍。

7.1 创建数组

有很多种创建数组的方法。以下小节将说明如何使用以下方式创建数组:

  • 数组字面量
  • 可迭代数组 ... 展开运算符
  • Array() 构造函数
  • Array.of() 和 Array.from() 工厂方法

7.1.1 数组字面量

到目前为止使用数组字面量是创建数组最简单的方法,在方括号中将数组元素用逗号隔开即可。例如:

let empty = [];                 // An array with no elements
let primes = [2, 3, 5, 7, 11];  // An array with 5 numeric elements
let misc = [ 1.1, true, "a", ]; // 3 elements of various types + trailing comma

数组字面量中的值不一定要是常量;它们可以是任意的表达式:

let base = 1024;
let table = [base, base+1, base+2, base+3];

数组字面量可以包含对象字面量或其他数组字面量:

let b = [[1, {x: 1, y: 2}], [2, {x: 3, y: 4}]];

如果数组字面量在一行中包含多个逗号,之间没有值,则数组是稀疏的(请参阅 §7.3)。省略值的数组元素不存在,但如果查询它们则返回 undefined:

let count = [1,,3]; // Elements at indexes 0 and 2. No element at index 1
let undefs = [,,];  // An array with no elements but a length of 2

数组字面量语法允许可选的尾部逗号,所以 [,,] 的长度是 2,不是3。

7.1.2 展开运算符

ES6 之后,可以使用展开操作符 ... 将一个数组中的元素展开在数组字面量中:

let a = [1, 2, 3];
let b = [0, ...a, 4];  // b == [0, 1, 2, 3, 4]

三个点展开数组 a,所以它的元素变成了数组字面量,并被创建在数组中。就像 ...a 被数组 a 的元素所替换,被列出作为未闭合的数组字面量的一部分。(注意,尽管我们称三点是展开运算符,但这并不是一个操作,因为它只能用于数组字面量和本书后面提到的函数调用。)

展开运算符可以方便的创建一个数组的拷贝(浅拷贝):

let original = [1,2,3];
let copy = [...original];
copy[0] = 0;  // Modifying the copy does not change the original
original[0]   // => 1

展开运算符可以作用于任何可迭代对象。(可迭代对象是可以用 for/of 进行循环的对象;第一次在 §5.4.4 中提到,在第 12 章会看到更多关于它们的描述。)字符串是可迭代对象,所以可以使用展开操作符将字符串转换成单个字符的数组。

let digits = [..."0123456789ABCDEF"];
digits // => ["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F"]

Set 对象(§11.1.1)是可迭代对象,所以数组去重有一种简单的方法是用展开运算符将数组转换成 set 然后再转成数组:

let letters = [..."hello world"];
[...new Set(letters)]  // => ["h","e","l","o"," ","w","r","d"]

7.1.3 Array() 构造函数

另一种创建数组的方法是使用 Array() 构造函数。可以用三种不同的方式调用这个构造函数:

调用时没有实参:

let a = new Array();

这个方法创建了一个没有元素的空数组,它等价于 [] 数组字面量。

调用时有一个数值实参,它指定了数组的长度:

let a = new Array(10);

该技术创建指定长度的数组。当预先知道所需元素个数时,这种形式的 Array() 构造函数可以用来预分配一个数组空间。注意,数组中没有存储值,甚至数组的索引属性“0”、“1”等还未定义。

为数组显式指定两个或多个数组元素或者非数值元素:

let a = new Array(5, 4, 3, 2, 1, "testing, testing");

以这种形式,构造函数的实参将会成为新数组的元素。使用数组字面量比这样使用 Array() 构造函数要简单多了。

7.1.4 Array.of()

当 Array() 构造函数调用时有一个数值型实参,它会将实参作为数组的长度。但当调用时不止一个数值型实参时,它会将那些实参作为数组的元素创建。这意味着 Array() 构造函数不能创建只有一个数值型元素的数组。

在 ES6 中,Array.of() 函数修复了这个问题:它是一个将其实参值(无论有多少个实参)作为数组元素创建并返回一个新数组的工厂方法:

Array.of()        // => []; returns empty array with no arguments
Array.of(10)      // => [10]; can create arrays with a single numeric argument
Array.of(1,2,3)   // => [1, 2, 3]

7.1.5 Array.from()

Array.from 是 ES6 中另外一个数组工厂方法。它期望一个可迭代或类数组对象作为它的第一个实参,并返回一个包含对象中元素的新数组。使用一个可迭代实参,Array.from(iterable) 工作方式类似于展开运算符 [...iterable]。它也可以简单的拷贝一个数组:

let copy = Array.from(original);

Array.from() 也很重要,因为它定义了一个将类数组对象拷贝成数组的方法。类数组对象是一个不是数组的对象,它有一个数值型的 length 属性,并且它的值碰巧保存在属性名为整数的属性中。当使用客户端 JavaScript 时,一些浏览器方法的返回值是类数组的,并且当将其转化成真正的数组后会更容易操作它们:

let truearray = Array.from(arraylike);

Array.from() 第二个实参为可选实参。如果传递一个函数作为第二个实参,那么当新数组被创建,每一个元素都会被作为实参传入这个指定函数中,并且这个函数的每个返回值保存在数组中代替原来的值。(这很像后面会介绍的数组 map() 方法,但是,它会更加高效的执行映射,因其为没有创建数组,而是直接进行映射到另外一个数组。)

7.2 数组元素的读和写

使用 [] 运算符来访问数组中的一个元素。数组的引用位于方括号的左边。方括号中是一个返回非负整数值的任意表达式。使用该语法既可以读又可以写数组的一个元素。因此,如下代码都是合法的 JavaScript 语句:

let a = ["world"];     // Start with a one-element array
let value = a[0];      // Read element 0
a[1] = 3.14;           // Write element 1
let i = 2;
a[i] = 3;              // Write element 2
a[i + 1] = "hello";    // Write element 3
a[a[i]] = a[0];        // Read elements 0 and 2, write element 3

数组特殊的是,当使用小于 232–1 的非负整数属性名时,数组会自动维护 length 属性。例如,上文中我们创建了只有一个元素的数组 a。然后我们为其序列为 1、2 和 3 的元素进行赋值。数组 length 属性会自动改变:

a.length       // => 4

请记住,数组是对象的特殊形式。使用方括号访问数组元素就像用方括号访问对象的属性一样。JavaScript 将指定的数字索引值转换成字符串(索引值 1 变成“1”)然后将其作为属性名来使用。关于索引值从数字转换为字符串没什么特别之处:对常规对象也可以这么做:

let o = {};    // Create a plain object
o[1] = "one";  // Index it with an integer
o["1"]         // => "one"; numeric and string property names are the same

清晰地区分数组的索引和对象的属性名是非常有用的。所有的索引都是属性名,但只有在 0~232-2 之间的整数属性名才是索引。所有的数组都是对象,可以为其创建任意名字的属性。但如果使用的属性是数组的索引,数组的特殊行为就是将根据需要更新它们的 length 属性值。

注意,可以使用负数或非整数来索引数组。这种情况下,数值转换为字符串,字符串作为属性名来用。既然名字不是非负整数,它就只能当做常规的对象属性,而非数组的索引。同样,如果凑巧使用了是非负整数的字符串,它就当做数组索引,而非对象属性。当使用的一个浮点数和一个整数相等时情况也是一样的:

a[-1.23] = true;  // This creates a property named "-1.23"
a["1000"] = 0;    // This the 1001st element of the array
a[1.000] = 1;     // Array index 1. Same as a[1] = 1;

事实上数组索引仅仅是对象属性名的一种特殊类型,这意味着 JavaScript 数组没有“越界”错误的概念。当试图查询任何对象中不存在的属性时,都不会报错,只会得到 undefined 值。类似于对象,对于对象同样存在这种情况。

let a = [true, false]; // This array has elements at indexes 0 and 1
a[2]                   // => undefined; no element at this index.
a[-1]                  // => undefined; no property with this name.

7.3 稀疏数组(Sparse Arrays)

稀疏数组就是包含从 0 开始的不连续索引的数组。通常,数组的 length 属性值代表数组中元素的个数。如果数组是稀疏的,length 属性值大于元素的个数。可以用 Array() 构造函数或简单地指定数组的索引值大于当前的数组长度来创建稀疏数组。

let a = new Array(5); // No elements, but a.length is 5.
a = [];               // Create an array with no elements and length = 0.
a[1000] = 0;          // Assignment adds one element but sets length to 1001.

后面会看到你也可以用 delete 运算符来生产稀疏数组。

足够稀疏的数组通常在实现上比稠密的数组更慢、内存利用率更高,在这样的数组中查找元素的时间与常规对象属性的查找时间一样长。

注意,当在数组字面量中省略值时(像 [1,,3] 中使用重复的逗号)返回的是稀疏数组,省略掉的值是不存在的:

let a1 = [,];           // This array has no elements and length 1
let a2 = [undefined];   // This array has one undefined element
0 in a1                 // => false: a1 has no element with index 0
0 in a2                 // => true: a2 has the undefined value at index 0

了解稀疏数组是了解 JavaScript 数组的真实本质的一部分。尽管如此,实际上你所碰到的绝大多数 JavaScript 数组不是稀疏数组。并且,如果你确实碰到了稀疏数组,你的代码很可能像对待非稀疏数组一样来对待它们,只不过它们包含一些 undefined 元素。

7.4 数组长度

每个数组有一个 length 属性,就是这个属性使其区别于常规的 JavaScript 对象。针对稠密(也就是非稀疏)数组,length 属性值代表数组中元素的个数。其值比数组中最大的索引大 1:

[].length             // => 0: the array has no elements
["a","b","c"].length  // => 3: highest index is 2, length is 3

当数组是稀疏的时,length 属性值大于元素的个数。而且关于此我们可以说数组长度保证大于它每个元素的索引值。或者,换一种说法,在数组中(无论稀疏与否)肯定找不到一个元素的索引值大于或等于它的长度。为了维持此规则不变化,数组有两个特殊的行为。第一个如同上面的描述:如果为一个数组元素赋值,它的索引 i 大于或等于现有数组的长度时,length 属性的值将设置为 i+1。

第二个特殊的行为就是设置 length 属性为一个小于当前长度的非负整数n时,当前数组中那些索引值大于或等于 n 的元素将从中删除:

a = [1,2,3,4,5];     // Start with a 5-element array.
a.length = 3;        // a is now [1,2,3].
a.length = 0;        // Delete all elements.  a is [].
a.length = 5;        // Length is 5, but no elements, like new Array(5)

还可以将数组的 length 属性值设置为大于其当前的长度。实际上这不会向数组中添加新的元素,它只是在数组尾部创建一个稀疏区域。

7.5 数组元素添加和删除

我们已经见过添加数组元素最简单的方法:为新索引赋值:

let a = [];      // Start with an empty array.
a[0] = "zero";   // And add elements to it.
a[1] = "one";

也可以使用push()方法在数组末尾增加一个或多个元素:

let a = [];           // Start with an empty array
a.push("zero");       // Add a value at the end.  a = ["zero"]
a.push("one", "two"); // Add two more values.  a = ["zero", "one", "two"]

在数组尾部压入一个元素与给 a[a.length] 赋值是一样的。可以使用 unshift() 方法(§7.8 有描述)在数组的首部插入一个元素,并且将其他元素依次移到更高的索引处。pop() 方法与 push() 相反:它移除数组最后一个元素并返回这个元素,使数组 length 减 1。同样,shift() 方法移除并返回数组的第一个元素,使数组 length 减 1,并将其他元素依次移到低 1 的索引处。§7.8 有更多关于这些方法的描述。

可以像删除对象属性一样使用 delete 运算符来删除数组元素:

let a = [1,2,3];
delete a[2];   // a now has no element at index 2
2 in a         // => false: no array index 2 is defined
a.length       // => 3: delete does not affect array length

删除数组元素与为其赋 undefined 值是类似的(但有一些微妙的区别)。注意,对一个数组元素使用 delete 不会修改数组的 length 属性,也不会将元素从高索引处移下来填充已删除属性留下的空白。如果从数组中删除一个元素,它就变成稀疏数组。

正如上面所看到的,也可以通过设置新的所需长度,即可从数组尾部删除元素。

最后,splice() 是一个通用的方法来插入、删除或替换数组元素。它会根据需要修改 length 属性并移动元素到更高或较低的索引处。详细内容见 §7.8。

7.6 数组遍历

在 ES6 中,最容易遍历数组元素(或可迭代对象)的方法是 for/of 循环,在 §5.4.4 中详细介绍:

let letters = [..."Hello world"];  // An array of letters
let string = "";
for(let letter of letters) {
    string += letter;
}
string  // => "Hello world"; we reassembled the original text

内置数组迭代器 for/of 循环按照升序返回数组元素。对于稀疏数组它没有特殊的行为,数组中不存在的元素只是单纯的返回 undefined。

如果使用 for/of 循环一个数组时还需要知道每个元素的索引,可以像这样将数组的 entries() 方法和解构语句一同使用:

let everyother = "";
for(let [index, letter] of letters.entries()) {
    if (index % 2 === 0) everyother += letter;  // letters at even indexes
}
everyother  // => "Hlowrd"

另一种不错的遍历数组方法是用 forEach()。这不是 for 循环的新形式,而是提供数组遍历功能方法的数组方法。可以给数组的 forEach() 方法传递一个函数,forEach() 会对数组中每一个元素调用这个方法:

let uppercase = "";
letters.forEach(letter => {  // Note arrow function syntax here
    uppercase += letter.toUpperCase();
});
uppercase  // => "HELLO WORLD"

正如期望的,forEach() 按顺序对数组进行计算,实际上它将数组索引作为第二个实参传递到函数,这有时很有用。与 for/of 循环不同,forEach() 能意识到稀疏数组,并且不会为不存在的元素调用函数。

§7.8.1 更详细地记录了 forEach() 方法。该部分还介绍演示了特定类型的数组遍历方法,如 map() 和 filter()。

也可以用一种非常老旧方式遍历数组的元素(§5.4.3):

let vowels = "";
for(let i = 0; i < letters.length; i++) { // For each index in the array
    let letter = letters[i];              // Get the element at that index
    if (/[aeiou]/.test(letter)) {         // Use a regular expression test
        vowels += letter;                 // If it is a vowel, remember it
    }
}
vowels  // => "eoo"

在嵌套循环或其他性能至关重要的上下文中,有时可能会看到这样的数组遍历,以便数组长度仅被查一次,而不是在每次循环都去查询。以下两种形式都是符合习惯的 for 循环,虽然不是特别常用,而且对于现代 JavaScript 解释器,它们是否对性能有任何影响尚不清楚:

// Save the array length into a local variable
for(let i = 0, len = letters.length; i < len; i++) {
    // loop body remains the same
}

// Iterate backwards from the end of the array to the start
for(let i = letters.length-1; i >= 0; i--) {
    // loop body remains the same
}

这些示例假定数组是稠密的,并且所有元素都包含有效的数据。如果不是这样,应该在使用数组元素之前测试它们。如果要跳过 undefined 和不存在的元素,可以编写:

for(let i = 0; i < a.length; i++) {
    if (a[i] === undefined) continue; // Skip undefined + nonexistent elements
    // loop body here
}

7.7 多维数组

JavaScript 不支持真正的多维数组,但可以用数组的数组来近似。访问数组的数组中的元素,只要简单地使用两次 [] 操作符即可。例如,假设变量 matrix 是一个数组的数组,它的基本元素是数值,那么 matrix[x] 的每个元素是包含一个数值数组,访问数组中特定数值的代码为 matrix[x][y]。这里有一个具体的例子,它使用二维数组作为一个九九乘法表:

// Create a multidimensional array
let table = new Array(10);               // 10 rows of the table
for(let i = 0; i < table.length; i++) {
    table[i] = new Array(10);            // Each row has 10 columns
}

// Initialize the array
for(let row = 0; row < table.length; row++) {
    for(let col = 0; col < table[row].length; col++) {
        table[row][col] = row*col;
    }
}

// Use the multidimensional array to compute 5*7
table[5][7]  // => 35

7.8 数组方法

前面几节重点介绍了用于处理数组的基本 JavaScript 语法。但通常,由 Array 类定义的方法是最强大的。下一节将记录这些方法。在阅读有关这些方法时,请记住,其中一些方法修改了调用的数组,而其中一些方法使数组保持不变。许多方法返回数组:有时,这是一个新数组,原始数组保持不变。其他时候,方法将修改数组,并且返回对修改后数组的引用。

以下每个小节都涵盖一组相关的数组方法:

迭代器方法循环遍历数组的元素,通常调用在每个元素上指定的函数。

堆栈和队列方法在数组的开头和结尾添加和删除数组元素。

子数组方法用于提取、删除、插入、填充和复制一个更大数组中相邻的区域。

搜索和排序方法用于查找数组中的元素和排序数组的元素。

以下小节还介绍 Array 类的静态方法和一些用于连接数组和将数组转换为字符串的各种方法。

7.8.1 数组迭代器方法

本节中介绍的方法通过将数组元素按顺序传递到所指定的函数来遍历数组,它们提供了迭代、映射、筛选、测试和减少数组的便捷方法。

然而,在详细解释这些方法之前,值得对它们进行一些概括。首先,所有这些方法都接受函数作为其第一个实参,并使用调用数组的每个元素(或某些元素)作为实参调用该函数。如果数组是稀疏的,则不会为不存在的元素调用传递的函数。在大多数情况下,提供的函数被调用时有三个实参:数组元素的值、数组元素的索引和数组本身。通常,只需要这些实参值中的第一个,并且可以忽略第二个和第三个值。

以下小节中描述的大多数迭代器方法都接受可选的第二个实参。如果指定,则调用函数就像它是第二个实参的方法一样。也就是说,传递的第二个实参将成为第一个函数实参内部的 this 值。传递的函数的返回值通常很重要,但不同的方法以不同的方式处理返回值。此处描述的方法都没有修改调用它们的数组(当然,传递的函数可以修改这个数组)。

这节的每个函数都调用它的第一个函数实参,并且通常将该函数内联定义为方法调用表达式的一部分,而不是使用在其他地方显示定义的函数。箭头函数语法(参见 §8.1.3)在这些方法中特别有效,我们将在下面的示例中使用它。

FOREACH()

forEach() 方法遍历数组,调用为每个元素指定的函数。正如我们已经描述的那样,将函数作为第一个实参传递给 forEach()。forEach() 然后使用三个实参调用函数:数组元素的值、数组元素的索引和数组本身。如果只关心数组元素的值,则编写一个只有一个实参的函数(将忽略其他实参):

let data = [1,2,3,4,5], sum = 0;
// Compute the sum of the elements of the array
data.forEach(value => { sum += value; });          // sum == 15

// Now increment each array element
data.forEach(function(v, i, a) { a[i] = v + 1; }); // data == [2,3,4,5,6]

请注意,forEach() 不提供在所有元素传递给函数之前终止迭代的方法。也就是说,没有等效于常规 for 循环的 break 语句可以使用。

MAP()

map() 方法将调用数组的每个元素传递到指定的函数,并返回一个包含函数返回的值的数组。例如:

let a = [1, 2, 3];
a.map(x => x*x)   // => [1, 4, 9]: the function takes input x and returns x*x

传递到 map() 的函数的调用方式与传递给 forEach() 的函数相同。但是,对于 map() 方法,传递的函数应返回一个值。请注意,map() 返回一个新数组:它不会修改调用它的数组。如果该数组是稀疏的,则不会为缺失的元素调用函数,但返回的数组将稀疏,其确实元素与原始数组的位置相同:它将具有相同的长度和相同的缺失元素。

FILTER()

filter() 方法返回一个数组,其中包含调用该数组的数组元素的子集。传递给它的函数应该是断言:返回真或假的函数。断言函数的调用就像 forEach() 和 map() 调用一样。如果返回值为 true,或者能转换为 true 的值,则传递给断言的元素是子集的成员,并将添加到将成为返回值的数组中。例子:

let a = [5, 4, 3, 2, 1];
a.filter(x => x < 3)         // => [2, 1]; values less than 3
a.filter((x,i) => i%2 === 0) // => [5, 3, 1]; every other value

注意 filter() 跳过稀疏数组中的丢失元素并且返回值也总是稠密的。要缩小稀疏数组的间距,可以这样做:

let dense = sparse.filter(() => true);

要缩小间隙并移除 undefined 和 null 元素,可以用 filter 这样做:

a = a.filter(x => x !== undefined && x !== null);

FIND() 和 FINDINDEX()

find() 和 findIndex() 方法就像 filter(),因为它们在数组中迭代,查找断言函数返回真实值的元素。但是,与 filter()不同,这两种方法在断言首次查找元素到时停止遍历。发生这种情况时,find() 返回匹配元素,而 findIndex() 返回匹配元素的索引。如果未找到匹配元素,find() 返回 undefined,findIndex() 返回 -1:

let a = [1,2,3,4,5];
a.findIndex(x => x === 3)  // => 2; the value 3 appears at index 2
a.findIndex(x => x < 0)    // => -1; no negative numbers in the array
a.find(x => x % 5 === 0)   // => 5: this is a multiple of 5
a.find(x => x % 7 === 0)   // => undefined: no multiples of 7 in the array

EVERY() 和 SOME()

every() 和 some() 方法是数组断言:它们将指定的断言函数应用于数组的元素,然后返回 true 或 false。

every() 方法与数学全称量化符号 ∀ 相似:如果数组中所有元素执行断言函数返回值都为 true,则返回 true:

let a = [1,2,3,4,5];
a.every(x => x < 10)      // => true: all values are < 10.
a.every(x => x % 2 === 0) // => false: not all values are even.

some() 方法与数学存在限定符 ∃ 相同:如果数组中存在至少有一个元素调用断言函数返回 true 的返回 true,仅在断言全部返回 false 时返回 false:

let a = [1,2,3,4,5];
a.some(x => x%2===0)  // => true; a has some even numbers.
a.some(isNaN)         // => false; a has no non-numbers.

请注意,every() 和 some() 只要它们知道要返回的值,都停止对数组元素的遍历。some() 在断言函数第一次返回 true 时返回 true,并且只有在每个元素调用断言函数都返回 false 时,才会遍历整个数组 。every() 正好相反:它返回 false 时,您的谓词返回 false,并且仅在谓词始终返回 true 时,才会回注所有元素。另请注意,根据数学约定,every() 返回 true,有些返回 false,当在空数组上调用时,某些返回 false。

REDUCE() 和 REDUCERIGHT()

reduce() 和 reduceRight() 方法使用指定的函数将数组元素进行组合,生成单个值。这在函数式编程中是常见的操作,也可以称为“注入”和“折叠”。举例说明它是如何工作的:

let a = [1,2,3,4,5];
a.reduce((x,y) => x+y, 0)          // => 15; the sum of the values
a.reduce((x,y) => x*y, 1)          // => 120; the product of the values
a.reduce((x,y) => (x > y) ? x : y) // => 5; the largest of the values

reduce() 需要两个实参。第一个是执行化简操作的函数。化简函数的任务就是用某种方法把两个值组合或化简为一个值,并返回化简后的值。在上述例子中,函数通过加法、乘法或取最大值的方法组合两个值。第二个(可选)的实参是一个传递给函数的初始值。

reduce() 使用的函数与 forEach() 和 map() 使用的函数不同。比较熟悉的是,数组元素、元素的索引和数组本身将作为第 2~4 个实参传递给函数。第一个实参是到目前为止的化简操作累积的结果。第一次调用函数时,第一个实参是一个初始值,它就是传递给 reduce() 的第二个实参。在接下来的调用中,这个值就是上一次化简函数的返回值。在上面的第一个例子中,第一次调用化简函数时的实参是 0 和 1。将两者相加并返回 1。再次调用时的实参是 1 和 2,它返回 3。然后它计算 3+3=6、6+4=10, 最后计算 10+5=15。最后的值是 15,reduce() 返回这个值。

可能已经注意到了,上面第三次调用 reduce() 时只有一个实参:没有指定初始值。当不指定初始值调用 reduce() 时,它将使用数组的第一个元素作为其初始值。这意味着第一次调用化简函数就使用了第一个和第二个数组元素作为其第一个和第二个实参。在上面求和与求积的例子中,可以省略初始值实参。

在空数组上,不带初始值实参调用 reduce() 将导致类型错误异常。如果调用它的时候只有一个值(数组只有一个元素并且没有指定初始值,或者有一个空数组并且指定一个初始值)reduce() 只是简单地返回那个值而不会调用化简函数。

reduceRight() 的工作原理和 reduce() 一样,不同的是它按照数组索引从高到低(从右到左)处理数组,而不是从低到高。如果 reduction 操作的优先顺序是从右到左,你可能想使用它,例如:

// Compute 2^(3^4).  Exponentiation has right-to-left precedence
let a = [2, 3, 4];
a.reduceRight((acc,val) => Math.pow(val,acc)) // => 2.4178516392292583e+24

注意,reduce() 和 reduceRight() 都能接收一个可选的实参,它指定了化简函数调用时的 this 关键字的值。可选的初始值实参仍然需要占一个位置。如果想让化简函数作为一个特殊对象的方法调用,请参看 Function.bind() 方法(§8.7.5)。

为了简单起见,到目前位置所展示的例子都是数值的,但数学计算不是 reduce() 和 reduceRight() 的唯一意图。任何想要将两个相同类型的值(例如两个对象)合并到一个值的函数都可以用化简函数。另一方面,使用数组化简的算法可能很快变得复杂且难以理解,可能会发现,如果使用常规循环构造来处理数组则更容易读、写和推理。

7.8.2 用 flat() 和 flatMap() 展平数组

在 ES2019 中,flat() 方法创建并返回一个新的数组,该数组包含与调用的数组相同的元素,只不过作为数组的任何元素都"展平"到返回的数组中。例如:

[1, [2, 3]].flat()    // => [1, 2, 3]
[1, [2, [3]]].flat()  // => [1, 2, [3]]

当调用时没有实参,flat() 将平展一个级别的嵌套。作为数组的原始数组的元素被展平,但这些数组的数组元素不会展平。如果要展平更多级别,需要传递数字给 flat():

let a = [1, [2, [3, [4]]]];
a.flat(1)   // => [1, 2, [3, [4]]]
a.flat(2)   // => [1, 2, 3, [4]]
a.flat(3)   // => [1, 2, 3, 4]
a.flat(4)   // => [1, 2, 3, 4]

flatMap() 方法的工作方式与 map() 方法(见“map()”)类似,只不过返回的数组会自动展平,就像传递到 flat()。也就是说,调用 a.flatMap(f) 与 a.map(f).flat()(但更高效)相同:

let phrases = ["hello world", "the definitive guide"];
let words = phrases.flatMap(phrase => phrase.split(" "));
words // => ["hello", "world", "the", "definitive", "guide"];

可以将 flatMap() 视为 map() 的泛化,它允许输入数组的每个元素映射到输出数组的多个元素。特别的是,flatMap() 允许将输入元素映射到空数组,该数组在平展后不输出到数组中:

// Map non-negative numbers to their square roots
[-2, -1, 1, 2].flatMap(x => x < 0 ? [] : Math.sqrt(x)) // => [1, 2**0.5]

7.8.3 用 concat() 添加数组

concat() 方法创建并返回一个新数组,它的元素包括调用 concat() 的原始数组的元素和 concat() 的每个实参。如果这些实参中的任何一个自身是数组,则连接的是数组的元素,而非数组本身。但要注意,concat() 不会递归扁平化数组的数组。concat() 也不会修改调用的数组:

let a = [1,2,3];
a.concat(4, 5)          // => [1,2,3,4,5]
a.concat([4,5],[6,7])   // => [1,2,3,4,5,6,7]; arrays are flattened
a.concat(4, [5,[6,7]])  // => [1,2,3,4,5,[6,7]]; but not nested arrays
a                       // => [1,2,3]; the original array is unmodified

请注意,concat() 创建调用数组的新副本。在许多情况下,这是正确的做法,但它是一个昂贵的操作。如果您发现自己编写代码像 a = a.concat(x),那么您应该考虑使用 push() 或 splice() 修改数组,而不是创建新的数组。

7.8.4 push()、pop()、shift() 和 unshift() 与堆栈和队列

push() 和 pop() 方法允许将数组当做栈来使用。push() 方法在数组的尾部添加一个或多个元素,并返回数组新的长度。pop() 方法则相反:它删除数组的最后一个元素,减小数组长度并返回它删除的值。注意,两个方法都修改并替换原始数组而非生成一个修改版的新数组。组合使用 push() 和 pop() 能够用 JavaScript 数组实现先进后出的栈。例如:

let stack = [];       // stack == []
stack.push(1,2);      // stack == [1,2];
stack.pop();          // stack == [1]; returns 2
stack.push(3);        // stack == [1,3]
stack.pop();          // stack == [1]; returns 3
stack.push([4,5]);    // stack == [1,[4,5]]
stack.pop()           // stack == [1]; returns [4,5]
stack.pop();          // stack == []; returns 1

push() 方法不展平传入的数组,但如果想要将数组的元素全部压入另外一个数组,可以使用展开运算符(§8.3.4)来显示展开:

a.push(...values);

unshift() 和 shift() 方法的行为非常类似于 push() 和 pop(),不一样的是前者是在数组的头部而非尾部进行元素的插入和删除操作。unshift() 在数组的头部添加一个或多个元素,并将已存在的元素移动到更高索引的位置来获得足够的空间,最后返回数组新的长度。shift() 删除数组的第一个元素并将其返回,然后把所有随后的元素下移一个位置来填补数组头部的空缺。可以使用 unshift() 和 shift() 实现栈,但它比使用 push() 和 pop() 的效率低,因为每次在数组头部添加或删除元素时,都需要向上或向下移动数组元素。但是,您可以使用 push() 在数组末尾添加元素并 shift() 从数组的头部删除它们来实现队列数据解构:

let q = [];            // q == []
q.push(1,2);           // q == [1,2]
q.shift();             // q == [2]; returns 1
q.push(3)              // q == [2, 3]
q.shift()              // q == [3]; returns 2
q.shift()              // q == []; returns 3

unshift() 有一个特性是值得一提的,你可能会觉得它令人惊讶。将多个实参传入 unshift() 时,它们将一次全部插入,这意味着它们最终在数组中的顺序与一次插入一个实参的顺序时不同的:

let a = [];            // a == []
a.unshift(1)           // a == [1]
a.unshift(2)           // a == [2, 1]
a = [];                // a == []
a.unshift(1,2)         // a == [1, 2]

7.8.5 slice()、splice()、fill() 和 copyWithin() 与子数组

数组定义了许多在连续区域,子数组或数组的“片段”上工作的方法。 以下各节描述了提取,替换,填充和复制片段的方法。

SLICE()

slice() 方法返回指定数组的一个片段或子数组。它的两个实参分别指定了片段的开始和结束的位置。返回的数组包含第一个实参指定的位置到(但不包含)第二个实参指定的位置之间的所有数组元素。如果只指定一个实参,返回的数组将包含从开始位置到数组结尾的所有元素。如实参中出现负数,它表示相对于数组 length 的位置。例如,实参 -1 指定了最后一个元素,而 -2 指定了它前面的元素。注意,slice() 不会修改调用的数组。下面有一些示例:

let a = [1,2,3,4,5];
a.slice(0,3);    // Returns [1,2,3]
a.slice(3);      // Returns [4,5]
a.slice(1,-1);   // Returns [2,3,4]
a.slice(-3,-2);  // Returns [3]

SPLICE()

splice() 方法是在数组中插入或删除元素的通用方法。不同于 slice() 和 concat(),splice() 会修改调用的数组。注意,splice() 和 slice() 拥有非常相似的名字, 但它们的功能却有本质的区别。

splice() 能够从数组中删除元素、插入元素到数组中或者同时完成这两种操作。在插入或删除点之后的数组元素会根据需要增加或减小它们的索引值,因此数组的其他部分仍然保持连续的。splice() 的第一个实参指定了插入和(或)删除的起始位置。第二个实参指定了应该从数组中删除的元素的个数。(注意这里是这两个方法的另外一个不同。slice() 的第二个实参是结束的位置。splice() 的第二个实参是长度。)如果省略第二个实参,从起始点开始到数组结尾的所有元素都将被删除。splice() 返回一个由删除元素组成的数组,或者如果没有删除元素就返回一个空数组。例如:

let a = [1,2,3,4,5,6,7,8];
a.splice(4)    // => [5,6,7,8]; a is now [1,2,3,4]
a.splice(1,2)  // => [2,3]; a is now [1,4]
a.splice(1,1)  // => [4]; a is now [1]

splice() 的前两个实参指定了需要删除的数组元素。紧随其后的任意个数的实参指定了需要插入到数组中的元素,从第一个实参指定的位置开始插入。例如:

let a = [1,2,3,4,5];
a.splice(2,0,"a","b")  // => []; a is now [1,2,"a","b",3,4,5]
a.splice(2,2,[1,2],3)  // => ["a","b"]; a is now [1,2,[1,2],3,3,4,5]

注意,不同于 concat(),splice() 插入数组本身,不是数组的元素。

FILL()

fill() 方法将数组或数组片段的元素填充为指定值。它将对调用它的数组进行突变,并返回修改后的数组:

let a = new Array(5);   // Start with no elements and length 5
a.fill(0)               // => [0,0,0,0,0]; fill the array with zeros
a.fill(9, 1)            // => [0,9,9,9,9]; fill with 9 starting at index 1
a.fill(8, 2, -1)        // => [0,9,8,8,9]; fill with 8 at indexes 2, 3

fill() 的第一个实参是将数组元素填充的值。可选的第二个实参指定起始索引。如果省略,则填充将从索引 0 开始。可选的第三个实参指定结束索引,将填充到(但不包括)该索引的数组元素。 如果省略此实参,则从起始索引到末尾填充数组。可以通过传递负数来指定相对于数组末尾的索引,就像 slice() 一样。

COPYWITHIN()

copyWithin() 将数组的一个片段复制到数组中的新位置。它在适当的位置修改数组并返回修改后的数组,但不会更改数组的长度。第一个实参指定将第一个元素复制到的目标索引。第二个实参指定被复制的第一个元素的索引。如果省略此第二个实参,则使用 0。第三个实参指定被复制的元素片段的结尾。如果省略,则使用数组的长度。从开始索引到结束索引(但不包括结束索引)的元素将被复制。可以通过传递负数来指定相对于数组末尾的索引,就像 slice() 一样:

let a = [1,2,3,4,5];
a.copyWithin(1)       // => [1,1,2,3,4]: copy array elements up one
a.copyWithin(2, 3, 5) // => [1,2,3,4,4]: copy last 2 elements to index 2
a.copyWithin(0, -2)   // => [4,4,3,4,4]: negative offsets work, too

copyWithin() 旨在作为一种高性能方法,对类型化数组特别有用(请参见 §11.2)。它模仿的 C 标准库中 memmove() 函数。 请注意,即使源区域和目标区域之间存在重叠,该拷贝也可以正常工作。

7.8.6 数组的查询和排序方法

数组实现 indexOf()、lastIndexOf() 和 include() 方法,这些方法类似于名称相同的字符串方法。还有 sort() 和 reverse() 方法,用于对数组元素进行重新排序。这些方法在下面的小节中介绍。

INDEXOF() 和 LASTINDEXOF()

indexOf() 和 lastIndexOf() 在数组中搜索具有指定值的元素,并返回找到的第一个元素的索引,如果未找到,则返回 -1。indexOf() 从头到尾搜索数组,lastIndexOf() 从尾到头搜索:

let a = [0,1,2,1,0];
a.indexOf(1)       // => 1: a[1] is 1
a.lastIndexOf(1)   // => 3: a[3] is 1
a.indexOf(3)       // => -1: no element has value 3

indexOf() 和 lastIndexOf() 使用 === 运算符将其实参与数组元素进行比较。如果数组包含对象而不是原始值,则这些方法将检查两个引用是否都指向完全相同的对象。如果要实际查看对象的内容,尝试将 find() 方法代替自定义的断言函数。

indexOf() 和 lastIndexOf() 采用可选的第二个实参,该实参指定开始搜索的数组索引。如果省略此参数,则 indexOf() 从开头开始,lastIndexOf() 从结尾开始。第二个参数允许使用负值,并将其视为距数组末端的偏移量,就像 slice() 方法一样:例如,值 –1 指定数组的最后一个元素。

以下函数在数组中搜索指定的值,并返回所有匹配索引的数组。这演示了如何使用 indexOf() 的第二个参数来查找第一个参数之外的匹配项。

// Find all occurrences of a value x in an array a and return an array
// of matching indexes
function findall(a, x) {
    let results = [],            // The array of indexes we'll return
        len = a.length,          // The length of the array to be searched
        pos = 0;                 // The position to search from
    while(pos < len) {           // While more elements to search...
        pos = a.indexOf(x, pos); // Search
        if (pos === -1) break;   // If nothing found, we're done.
        results.push(pos);       // Otherwise, store index in array
        pos = pos + 1;           // And start next search at next element
    }
    return results;              // Return array of indexes
}

请注意,字符串具有 indexOf() 和 lastIndexOf() 方法,它们与这些数组方法一样工作,不同之处在于第二个实参是负数时被视为零。

INCLUDES()

ES2016 的 includes() 方法采用单个实参,如果数组包含该值返回 true 否则 false。它不会告诉你值的索引,只告诉你该值是否存在。includes() 方法实际上是数组集的成员身份测试。但是请注意,数组不是 Set 的高效表示形式,如果使用多个元素,则应使用真正的 Set 对象(§11.1.1)。

includes() 方法在一个重要方面与 indexOf() 方法略有不同。indexOf() 与 === 运算符使用相同的算法测试相等性,并且这个相等算法认为非数字值与所有其他值(包括自身)不同。includes() 使用略有不同的相等算法,它认为 NaN 等于自身。这意味着 indexOf() 不会检测数组中的 NaN 值,但 includes() 可以:

let a = [1,true,3,NaN];
a.includes(true)            // => true
a.includes(2)               // => false
a.includes(NaN)             // => true
a.indexOf(NaN)              // => -1; indexOf can't find NaN

SORT()

sort() 对数组的元素直接进行排序,并返回排序后的数组。当调用 sort() 时,它会按字母顺序对数组元素进行排序(如有必要,暂时将它们转换为字符串以执行比较):

let a = ["banana", "cherry", "apple"];
a.sort(); // a == ["apple", "banana", "cherry"]

如果数组中包含 undefined 元素,它们会被放在数组的结尾。

若要将数组按字母顺序以外的顺序排序,必须将比较函数作为实参传递给 sort()。该函数决定了它的两个实参在排好序的数组中的先后顺序。假设第一个实参应该在前,比较函数应该返回一个小于 0 的数值。反之,假设第一个参数应该在后,函数应该返回一个大于 0 的数值。并且,假设两个值相等(也就是说,它们的顺序无关紧要),函数应该返回 0。例如,用数值大小而非字母表顺序进行数组排序,代码如下:

let a = [33, 4, 1111, 222];
a.sort();               // a == [1111, 222, 33, 4]; alphabetical order
a.sort(function(a,b) {  // Pass a comparator function
    return a-b;         // Returns < 0, 0, or > 0, depending on order
});                     // a == [4, 33, 222, 1111]; numerical order
a.sort((a,b) => b-a);   // a == [1111, 222, 33, 4]; reverse numerical order

另外一个数组元素排序的例子,也许需要对一个字符串数组执行不区分大小写的字母表排序,比较函数首先将实参都转化为小写字符串(使用 toLowerCase() 方法),再开始比较:

let a = ["ant", "Bug", "cat", "Dog"];
a.sort();    // a == ["Bug","Dog","ant","cat"]; case-sensitive sort
a.sort(function(s,t) {
    let a = s.toLowerCase();
    let b = t.toLowerCase();
    if (a < b) return -1;
    if (a > b) return 1;
    return 0;
});   // a == ["ant","Bug","cat","Dog"]; case-insensitive sort

REVERSE()

reverse() 方法反转数组中元素的顺序并返回反转后的数组。它直接在数组中操作,换一种说法,它不创建一个新的数组,它不创建一个新的带有排序后的元素的数组,而是直接在已存在的数组中进行排序。

let a = [1,2,3];
a.reverse();   // a == [3,2,1]

7.8.7 数组转化字符串

Array 类定义了三个方法来将数组转化为字符串,通常在创建日志和错误信息时会用到。(如果要以文本形式保存数组的内容供以后重用,请使用 JSON.stringify()(§6.8)序列化数组,而不是使用此处描述的方法。)

join() 方法将数组的所有元素转换为字符串并连接它们,返回生成的字符串。可以指定一个可选字符串来分隔生成的字符串中的元素。如果未指定分隔符字符串,则使用逗号:

let a = [1, 2, 3];
a.join()               // => "1,2,3"
a.join(" ")            // => "1 2 3"
a.join("")             // => "123"
let b = new Array(10); // An array of length 10 with no elements
b.join("-")            // => "---------": a string of 9 hyphens

join() 方法是 String.split() 方法的反向方法,该方法通过将字符串拆分为多个片段来创建数组。

数组与所有 JavaScript 对象一样,具有 toString() 方法。对于数组,此方法的工作方式与没有参数的 join() 方法相同:

[1,2,3].toString()          // => "1,2,3"
["a", "b", "c"].toString()  // => "a,b,c"
[1, [2,"c"]].toString()     // => "1,2,c"

请注意,输出不包括方括号或数组值周围的任何其他分隔符。

toLocaleString() 是 toString() 的本地化版本。它通过调用元素的 toLocaleString() 方法将每个数组元素转换为字符串,然后使用特定于区域设置(和实现定义)分隔符字符串连接生成的字符串。

7.8.8 Array 的静态方法

除了我们已经记录的数组方法之外,Array 类还定义了三个静态函数,可以通过 Array 构造函数而不是数组调用。Array.of() 和 Array.from() 是用于创建新数组的工厂方法。它们记录在 §7.1.4 和 §7.1.5 中。

另外一个静态数组方法是 Array.isArray(),用来判断一个未知值是否是数组:

Array.isArray([])     // => true
Array.isArray({})     // => false

7.9 类数组对象

我们已经看到,JavaScript 数组的有一些特性是其他对象所没有的:

  • 当有新的元素添加到列表中时,自动更新 length 属性。
  • length 设置为一个较小值将截断数组。
  • 从 Array.prototype 中继承一些有用的方法。
  • 数组传入 Array.isArray() 方法返回 true。

这些特性让 JavaScript 数组和常规的对象有明显的区别。但是它们不是定义数组的本质特性。一种常常完全合理的看法是把拥有一个数值型 length 属性和对应非负整数属性的对象看作数组的同类。

实际上这些“类数组”对象在实践中偶尔出现,虽然不能通过它们直接调用数组方法或者期望 length 属性有什么特殊的行为,但是仍然可以用针对真正数组遍历代码来遍历它们。结论就是很多数组算法针对类数组对象同样奏效,就像针对真正的数组一样。尤其是这种情况,算法把数组看成只读的或者如果保持数组长度不变。

以下代码为一个常规对象增加了一些属性使其变成类数组对象,然后遍历生成的伪数组的“元素”:

let a = {};  // Start with a regular empty object

// Add properties to make it "array-like"
let i = 0;
while(i < 10) {
    a[i] = i * i;
    i++;
}
a.length = i;

// Now iterate through it as if it were a real array
let total = 0;
for(let j = 0; j < a.length; j++) {
    total += a[j];
}

在客户端 JavaScript 中,很多作用于 HTML documents 的方法(例如 document.querySelectorAll())返回类数组对象。下面这个函数可能会用于测试对象是否可以用作类数组:

// Determine if o is an array-like object.
// Strings and functions have numeric length properties, but are
// excluded by the typeof test. In client-side JavaScript, DOM text
// nodes have a numeric length property, and may need to be excluded
// with an additional o.nodeType !== 3 test.
function isArrayLike(o) {
    if (o &&                            // o is not null, undefined, etc.
        typeof o === "object" &&        // o is an object
        Number.isFinite(o.length) &&    // o.length is a finite number
        o.length >= 0 &&                // o.length is non-negative
        Number.isInteger(o.length) &&   // o.length is an integer
        o.length < 4294967295) {        // o.length < 2^32 - 1
        return true;                    // Then o is array-like.
    } else {
        return false;                   // Otherwise it is not.
    }
}

我们会在下一节看到字符串的行为像数组一样。尽管如此,对于数组这种测试(对字符串通常返回 false )它们通常最好作为字符串处理,而不是作为数组处理。

大多数 JavaScript 数组方法都特意定义为泛型,以便它们在应用于除数组之外的类数组可以正常工作。由于类数组对象不会从 Array.prototype 继承,因此不能直接在它们上调用数组方法。但是,可以使用 Function.call 方法间接调用它们(详情请参阅 §8.7.4):

let a = {"0": "a", "1": "b", "2": "c", length: 3}; // An array-like object
Array.prototype.join.call(a, "+")                  // => "a+b+c"
Array.prototype.map.call(a, x => x.toUpperCase())  // => ["A","B","C"]
Array.prototype.slice.call(a, 0)   // => ["a","b","c"]: true array copy
Array.from(a)                      // => ["a","b","c"]: easier array copy

此代码倒数第二行调用数组类对象上的 Array slice() 方法,以便将该对象的元素复制到真正的数组对象中。这是一个惯用的技巧,存在于许多旧代码中,但现在使用 Array.from() 要容易得多。

7.10 作为数组的字符串

JavaScript 字符串的行为类似于 UTF-16 Unicode 字符的只读数组。可以使用方括号替代 charAt() 方法访问单个字符:

let s = "test";
s.charAt(0)    // => "t"
s[1]           // => "e"

当然,字符串使用 typeof 运算符仍然返回 "string",如果将字符串传递给 Array.isArray() 方法,则返回 false。

可索引字符串的主要好处是,我们可以用方括号替换对 charAt() 的调用,方括号更简洁、更可读,而且可能更高效。但是,字符串的行为类似于数组,也意味着我们可以对它们应用泛型数组方法。例如:

Array.prototype.join.call("JavaScript", " ")  // => "J a v a S c r i p t"

请记住,字符串是不可变值,因此当字符串被视为数组时,它们是只读数组。数组方法 push()、sort()、reverse() 和 splice() 直接修改数组,它们不能处理字符串。但是,尝试使用数组方法修改字符串不会引发异常:它只是静默失败。

7.11 总结

本章深入介绍了 JavaScript 数组,包括有关稀疏数组和类数组对象的深奥细节。本章要点包括:

  • 数组字面量编写:方括号内逗号分隔值列表。
  • 通过在方括号内指定所需的数组索引来访问单个数组元素。
  • for/of 循环和 ES6 中引入的 ... 展开运算符是遍历数组的特别有用的方法。
  • Array 类定义了一组用于操作数组的丰富方法,应该确保熟悉 Array API。