天天看点

2021-10-13第七章 数组

JavaScript权威指南 第七章 数组

  • 第七章 数组
    • 7.1 创建数组
      • 7.1.1 数组字面量
      • 7.1.2 扩展操作符
      • 7.1.3 Array() 构造函数
      • 7.1.4 Array.of()
      • 7.1.5 Array.from()
    • 7.2 读写数组元素
    • 7.3 稀疏数组
    • 7.4 数组长度
    • 7.5 添加和删除数组元素
    • 7.6 迭代数组
    • 7.7 多维数组
    • 7.8 数组方法
      • 7.8.1 数组迭代器方法
        • forEach()
        • map()
        • fliter()
        • find()和findIndex()
        • every()与some()
        • reduce()与reduceRight()
        • 7.8.2 使用flat()和flatMap()打平数组
      • 7.8.3 使用concat()添加数组
      • 7.8.4 通过push()、pop()、shift()和unshift()实现栈和队列操作
      • 7.8.5 使用slice()、splice()、fill()和copyWithin
        • slice()
        • splice()
        • fill()
        • copyWith()
      • 7.8.6 数组索引与排序方法
        • lastOf()和lastIndexOf()
        • includes()
        • sort()
        • reverse()
      • 7.8.7 数组到字符串的转换
      • 7.8.8 静态数组函数
    • 7.9 类数组对象
    • 7.10 作为数组的字符串

第七章 数组

数组是值的有序集合,其中的值叫作元素,每个元素有一个数值表示的位置,叫作索引。JavaScript的数组是没有类型限制的,即数组中的元素可以是任意类型,同一数组的不同元素也可以是不同的类型。数组元素甚至可以是对象或其他数组,从而创建复杂的数据结构。JavaScript数组是基于零且使用32位数值索引的,第一个元素的索引为0,最大可能的索引值为4 294 967 294(2^32-2)。JavaScript的数组是动态的,它们会按需增大或缩小,因此创建数组时可以是稀疏的,即元素不一定具有连续的索引,中间可能有间隙。每个JavaScript数组都有length属性,对于非稀疏数组,这个属性保存元素的个数。对于稀疏数组,length大于所有元素的最高索引。

JavaScript数组是一种特殊的JavaScript对象,因此数组索引更像是属性名,只不过碰巧是正数而已。

数组从Array.propotype继承属性,这个原型上定义了很多数组操作方法。其中很多方法都是泛型的,这意味着它们不仅可以用于真正的数组,也可以用于任何“类数组对象”。

ES6增加了一批新的数组类,统称为“定型数组”。与常规JavaScript数组不同,定型数组具有固定长度和固定的数值元素类型。定型数组具有极高的性能,支持对二进制数据的字节级访问。

7.1 创建数组

7.1.1 数组字面量

迄今为止,创建数组最简单的方式就是使用数组字面量。数组字面量其实就是一堆方括号中逗号分隔的数组元素的列表。例如:

let empty=[];                       //没有元素的数组
let primes=[2,3,5,7,11];            //有5个数值元素的数组
let misc=[1.1,true,"a"];            //3种不同类型的元素,最后还有一个逗号
           

数组字面量中的值不需要是常量,可以是任意表达式:

let base=1024;
let table=[base,base+1,base+2,base+3]
table
=>(4) [1024, 1025, 1026, 1027]
           

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

如果数组字面量中包含多个逗号,且逗号之间没有值,那么这个数组就是稀疏的。这些省略了值的数组元素并不存在,但按照索引查询它们时又会返回undefined:

let count=[1,,3];        //索引0和2有元素,索引1没有元素
let undefs=[,,];         //这个数组没有元素但长度为2
count[1]
=>undefined
undefs[1]
=>undefined
           

数组字面量语法末尾出现逗号,因此[,]的长度是2不是3。

7.1.2 扩展操作符

在ES6及之后的版本中,可以使用扩展操作符…在一个数组字面量中包含另一个数组的元素:

let a=[1,2,3];
let b=[0,...a,4]
b
=>(5) [0, 1, 2, 3, 4]
           

这里的三个点会“扩展”数组a,因而它的元素变成了要创建的数组字面量的元素。可以把…a想象成代表数组a的所有元素,这些元素依次出现在了包含它们的数组字面量中(注意,虽然我们把这三个点称作扩展操作符,但它们实际上并不是操作符,因为只能在数组字面量和本书后面介绍的函数调用中使用它们)。

扩展操作符是创建数组(浅)副本的一种便捷方式:

let origina=[1,2,3];
let copy=[...origina]
copy[0]=0
=>0
origina[0]          //修改copy不会影响origina
=>1        
           

扩展操作符适用于任何可迭代对象(可迭代对象可以使用for/of循环遍历)。字符串是可迭代对象,因此可以适用扩展操作符把任意字符串转换为单个字符的数组:

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

集合对象(参见11.1.1节)是可迭代的,因此要去除数组中的重复元素,一种便捷方式就是先把数组转换为集合,然后再适用扩展操作符把这个集合转换为数组:

let letters=[..."hello world"]
[...new Set(letters)]
=>(8) ["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()        //返回没有参数的空数组
=>[]
Array.of(10)      //可以创建只有一个数值元素的数组
=>[10]
Array.of(1,2,3)
=>(3) [1, 2, 3]
           

7.1.5 Array.from()

Array.from()是ES6新增的另一个工厂方法。这个方法期待一个可迭代对象或类数组对象作为第一个参数,并返回包含该对象元素的新数组。如果传入可迭代对象,Array.from(iterable)与使用扩展操作符[…iterable]一样。因此,它也是创建数组副本的一种简单方式:

Array.from()确实很重要,因为它定义了一种给类数组对象创建真正的数组副本的机制。类数组对象不是数组对象,但也有一个数值length属性,而且每个属性的键也都是整数。在客户端JavaScript中,有些浏览器方法返回的就是类数组对象,那么像这样先把它们转换成真正的数组便于后续的操作:

Array.from()也接受可选的第二个参数。如果给第二个参数传入了一个函数,那么在构建新数组时,源对象的每个元素都会传入这个函数,这个函数的返回值将代替原始值成为新数组的元素。

7.2 读写数组元素

可以使用[]操作符访问数组元素,方括号左侧应该是一个对数组的引用,方括号内应该是一个具有非负整数值的表达式。这个语法可以读和写数组元素的值。

数组特殊的地方在于,只要你使用小于232-1的非负整数作为属性名,数组就会自动为你维护length属性的值。

记住,数组是一种特殊的对象。用于访问数组元素的方括号与用于访问对象属性的方括号是类似的。JavaScript会将数组索引转换为字符串,即索引1会变成字符串“1”,然后再将这个字符串作为属性名。这个从数值到字符串的转换没有上面特别的,使用普通的对象也一样:

let o={};          //创建一个普通对象
o[1]="one"         //通过整数索引一个值
=>"one"
o["1"]
=>"one"            //数值和字符串属性名是同一个
           

明确区分数值索引和对象属性名是非常有帮助的。所有索引名都是属性名,但只有介于0和232-2之间的整数属性名才是索引。所有数组都是对象,可以在数组上以任意名字创建属性。只不过,如果这个属性是数组索引,数组会有特殊的行为,即自动按需更新其length属性。

注意,可以使用负数或非整数值来索引数组。此时,数值会转换为字符串,而这个字符串会作为属性名。因为这个名字是非负整数,所以会被当成常规的对象属性,而不是数组索引。另外,如果你碰巧使用了非负整数的字符串来索引数组,那这个值会成为数组索引,而不是对象属性。同样,如果使用了与整数相等的浮点数也是如此:

a[-1.23]=true      //这样会创建一个属性"-1.23"
a["1000"]=0        //这是数组中第1001个元素
a[1.000]=1         //数组索引1,相当于a[1]=1
           

由于数组索引其实就是一种特殊的对象属性,所以JavaScript没有所谓“越界”错误。查询任何对象中不存在的属性都不会导致错误,只会返回undefined。数组作为一种特殊对象也是如此:

let a=[true,false]
a[2]           
=>undefined          //这个索引没有元素
a[-1]
=>undefined          //这个名字没有属性
           

7.3 稀疏数组

稀疏数组就是其元素没有从0开始的索引的数组。正常情况下,数组的length属性表明数组中元素的个数。如果数组是稀疏的,则length属性的值会大于元素个数。可以使用Array()构造函数创建稀疏数组,或者直接给大于当前数组length的数组索引赋值。

let a=new Array(5)     //没有元素,但a.length是5
a=[];                  //创建一个空数组,此时length=0
a[1000]=0              //赋值增加了一个元素,但length变成了1001
           

使用delete操作符也可以创建稀疏数组。

足够稀疏的数组通常是比较稠密数组慢、但内存占用少的方式实现的,查询这种数组的元素与查询常规对象属性的时间相当。

注意,如果省略数组字面量中的一个值(像[1,3]这样重复逗号两次),也会得到稀疏数组,被省略的元素是不存在的:

let a1=[,]             //这个数组没有元素,但length是1
let a2=[undefined]     //这个数组有一个undefined元素
0 in a1                //a1在索引0没有元素
=>false
0 in a2                //a2在索引0有undefined元素
=>true
           

理解稀疏数组是真正理解JavaScript数组的重要一环。但在实践中,我们碰到的多数JavaScript数组都不是稀疏的。如果真的碰到了稀疏数组,可以把稀疏数组当成包含undefined元素的非稀疏数组。

7.4 数组长度

每个数组都哦哟length属性,正是这个属性让数组有别于常规的JavaScript对象。对于稠密数组(即非稀疏数组),length属性就是数组中元素的个数。这个值比数组的最高索引大1:

[].length
=>0                       //数组没有元素
["a","b","c"].length
=>3                       //最高索引为2,length值为3
           

对于稀疏数组,length属性会大于元素个数,也可以说稀疏数组的length值一定大于数组中任何元素的索引。从另一个角度说,数组(无论稀疏与否)中任何元素的索引都不会大于或等于数组的length。为了维护这种不变式,数组有两个特殊行为。第一个前面已经提到了,即如果给一个索引为i的数组元素赋值,而i大于或等于数组当前的length,则数组的length属性会被设置为i+1。

数组实现以维护长度不变式的第二个特殊行为,就是如果将length属性设置为一个小于其当前值的非负整数n,则任何索引大于或等于n的数组元素都会从数组中被删除:

a=[1,2,3,4,5];                //先定义一个包含5个元素的数组
a.length=3;
a
=>(3) [1, 2, 3]   
a.length=0;
a
=>[]
a.length=5
a
=>(5) [empty × 5]          //长度是5,但没有元素,类似new Array(5)
           

也可以把数组的length属性设置为一个大于其当前值的值。这样做并不会向数组中添加新元素,只会在数组末尾创建一个稀疏的区域。

7.5 添加和删除数组元素

添加数组最简单的方式,就是给它的一个新索引赋值:

let a=[];
a[0]="zero";
a[1]="one";
           

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

let a=[];                   //创建一个空数组
a.push("zero");             //在末尾添加一个值,a=["zero"]
a.push("one","two");        //再在末尾添加两个值,a=["zero","one","two"]
           

向数组a中推入一个值等同于把这个值赋给a[a.length]。要在数组开头插入值,可以占用unshift()方法,这个方法将已有数组元素移动到更高索引位。与push()执行相反操作的是pop()方法,它删除数组最后一个元素并返回该元素,同时导致数组长度减一。类似地,shift()方法删除并返回数组的第一个元素,让数组长度减一,并将所有元素移动到低一位的索引。

可以使用delete操作符删除数组元素:

let a=[1,2,3]
delete a[2]
=>true
2 in a
=>false
a.length
=>3
           

删除数组怨怒类似于(但不完全大等同于)给该元素赋undefined值。注意,对数组元素使用delete操作符不会修改length属性,也不会把高引位的元素向下来填充被删除属性的空隙。从数组中删除元素后,数组会变稀疏。

如前所诉,把数组length属性设置位一个新长度值,也可以从数组末尾删除元素。

splice()是一个可以插入、删除或替换数组元素的通用方法。这个方法修改length属性并按照需要向更高索引或更低索引移动数组。

7.6 迭代数组

到ES6为止,遍历一个数组(或任何可迭代对象)的最简单方式就是使用for/of循环。

for/of循环使用的内置数组迭代器会按照升序返回每个数组的元素,这个循环没有特殊行为,凡是不存在的元素都返回undefined。

如果要对数组使用for/of循环,并且想知道每个元素的索引,可以使用数组的entries()方法和解构数组:

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

另一种迭代数组的推荐方式是使用forEach()。它并不是一种新的for循环,而是数组提高的一种用于自身迭代的函数式方法。因此需要给forEach()传一个函数,然后forEach()会用数组的每个元素调用一次这个函数:

let uppercase="";
letters.forEach(letter=>{
    uppercase+=letter.toUpperCase();
});
uppercase
=>"HELLO WORLD"
           

正如我们预期的,forEach()按顺序迭代数组,而且会将索引作为第二个参数传给函数。与for/of循环不同,forEach()能够感知稀疏数组,不会对没有的元素调用函数。

当然,使用老式的for循环也可以遍历数组:

let vowels="";
for(let i=0;i<letters.length;i++){//对数组中的每个索引
    let letter=letters[i];
    if(/[aeiou]/.test(letter)){
        vowels+=letter;
    }
}
vowels
=>"eoo"
           

在嵌套循环中,或其他性能攸关的场合,有时候会看到这种简单的数组迭代循环,但只会地区一次数组长度,而不是在每个迭代中都读取一次。下面展示的两种for循环形式都是比较推荐的:

//把数组长度保存到局部变量中
for(let i=0,len=letters.length;i<len;i++){
    //循环体不变
}

//从后向前迭代数组
for(let i=letters.length-1;i>=0;i--){
    //循环体不变
}
           

这两个例子假定数组是稠密的,即所有元素都包含有效数据。如果不是这种情况,那应该在使用每个元素前先进行测试。如果想跳过未定义或不存在的元素,可以这样写:

for(let i=0;i<a.length;i++){
    if(a[i]===undefined) continue;    //跳过未定义及不存在的元素
    //这里的循环体
}
           

7.7 多维数组

JavaScript并不支持真正的多维数组,但我们可以使用数组的数组来模拟。要访问数组的数组的值,使用两个[]即可。比如,假设变量matrix是一个数值数组的数组,则matrix[x]的每个元素都是一个数值数组。要访问这个数组中的某个数值,就要使用matrix[x][y]这种形式即可。

生成乘法表:

//创建一个多维数组
let table=new Array(10);               //表格的10行
for(let i=0;i<table.length;i++){
    table[i]=new Array(10);            //每行有10列
}

//初始化数组
for(let row=0;row<table.length;row++){
    for(let col=0;col<table[row].length;col++){
        table[row][col]=row*col;
    }
}

table[5][7]
=>35
           

7.8 数组方法

7.8.1 数组迭代器方法

本节介绍的方法用于迭代数组元素,它们会按照顺序把数组传给我们提供的函数,可便于对数组进行迭代、映射、测试和归并。

首先,所有这些方法都接收一个函数作为第一个参数,并且对数组的每个元素(或某些元素)都调用一次这个函数。如果数组是稀疏的,则不会对不存在的数组怨怒调用传入的这个函数。多数情况下,我们提供的这个函数都会收到3个参数,分别是数组元素的值、数组元素的索引和数组本身。通常,我们只需要这几个参数的第一个,可以忽略第二个和第三个值。

接下来要jies3d饿多数迭代器都接收可选的第二个参数。如果指定这个参数,则第一个函数在被调用时就好像它是第二个参数的方法一样。换句话说,我们传入的第二个函数会作为第一个参数传入的函数内部的this值。传入函数的返回值通常不重要,但不同的方法会以不同的方式处理这个返回值。本节介绍的所有方法都不会修改调用它们的数组(当然,传入的函数可能会修改这个数组)。

所有这些到达在被调用时第一个参数都是函数,因此在方法调用表达式中直接定义这个函数参数是很常见的,相对而言,使用在其他地方已经定义好的函数倒不常见。箭头函数特别适合在这些方法中使用,接下来的例子中你也会使用。

forEach()

forEach()方法迭代数组的每个元素,并对每个元素都调用一次我们指定的函数。如前所述,传统forEach()方法的第一个参数是函数。forEach()在调用这个函数时会给它传三个参数:数组元素的值、数组元素的索引和数组本身。如果只关心数组元素的只,可以把函数写成只接收一个参数,即忽略其他参数:

let data=[1,2,3,4,5],sum=0;
data.forEach(value=>{
    sum+=value;
})
sum
=>15
data.forEach(function(v,i,a){
    a[i]=v+i;
});
data
=>(5) [1, 3, 5, 7, 9]
           

注意,forEach()并未提供一种提前终止迭代的方式。换句话说,在这里没有与常规for循环中的break语句对等的机制。

map()

map()方法把调用它的数组的每个元素分别传给我们指定大的函数,返回这个函数的返回值构成的数组。例如:

let a=[1,2,3]
a.map(x=>x*x)
=>(3) [1, 4, 9]
           

传给map()的函数与传给forEach()的函数以同样的方式被调用。但对于map()方法来说,我们传入的函数应该返回值。注意,map()返回一个新数组,并不修改调用它的数组。如果数组是稀疏的,则却是元素不会调用我们的函数,但返回的数组也会与原始数组一样稀疏:长度相同,缺失的元素也相同。

fliter()

filter()方法返回一个一个数组,该数组包含调用它的数组的子数组。传给这个方法的函数应该是个断言函数,即返回true或false的函数。这个函数与传给forEach()和map()的函数一样被调用。如果函数返回true或返回的值被转换为true,则传给这个函数的元素就是filter()最终返回的子数组的成员。

eg:

let a=[5,4,3,2,1];
a.filter(x=>x<3)
=>(2) [2, 1]
a.filter((x,i)=>i%2===0)
=>(3) [5, 3, 1]
           

注意,filterr()会跳过稀疏数组中缺失的函数,它返回的数组始终是稠密的。因此可以使用filter()方法像下面这样清理掉稀疏数组中的空隙:

如果既想要清理空隙,又想删除值为undefined和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
a.findIndex(x=>x<0)
=>-1
a.find(x=>x%5===0)
=>5
a.find(x=>x%7===0)
=>undefined
           

every()与some()

every()与some()方法是数组断言方法,即它们会对数组元素调用我们传入的断言函数,最后返回true或false。

every()方法与数学上的“全称”量词类似,它在且只在断言函数对数组的所有元素都返回true时才返回true:

let a=[1,2,3,4,5]
a.every(x=>x<10)
=>true                       //所有值都小于10
a.every(x=>x%2===0)
=>false                      //并非所有值都是偶数
           

some()方法类似于数学上的“存在”量词,只要数组元素中有一个让断言函数返回true它就返回true,但必须数组的所有元素对断言函数都返回false才返回false:

let a=[1,2,3,4,5];
a.some(x=>x%2===0)
=>true                   //a包含偶数
a.some(isNaN)
=>false                  //a没有非数值
           

注意,every()和some()都会在它们知道要返回什么值时才停止迭代数组。some()在断言函数第一次返回true时就返回true,只有全部断言都返回false才会遍历数组。every正好相反,它在断言函数第一次返回false时就返回false,只有全部断言都返回true时才会遍历数组。同样也要注意,如果在空数组上调用它们,按照数学的传统,every()返回true,some()返回false。

[].every(x=>x>0)
true
[].some(x=>x>0)
false
           

reduce()与reduceRight()

reduce()和reduceRight()方法使用我们指定的函数归并数组元素,最终产生一个值。归并是一个常规操作,有时候也称为注入或折叠。

let a=[1,2,3,4,5];
a.reduce((x,y)=>x+y,0)
=>15                          //所有值之和
a.reduce((x,y)=>x*y,1)
=>120                         //所有值之积
a.reduce((x,y)=>(x>y)?x:y)
=>5                           //最大值
           

reduce()接收两个参数。第一个参数是执行并操作的函数。这个归并函数的任务就是把两个自归并或组合为一个值并返回这个值。在上面的例子中,归并函数通过把两个值相加、相乘和选择最大值来合并两个值。第二个参数是可选的,是传给归并函数的初始值。

在reduce()中使用的函数与在forEach()和map()中使用的函数不同。我们熟悉的值、索引和数组本身在这里作为第二、第三和第四个参数。第一个参数是目前为止归并操作的累计结果。在第一次调用这个函数时,第一个参数作为reduce()的第二个参数的初始值。在后续调用中,第一个参数则是上一次调用这个函数的返回值。

上面例子中第三次调用reduce()传了一个参数,即并未指定初始值。在像这样不指定初始值调用时,reduce()会使用数组的第一个元素作为初始值。 这意味着首次调用归并函数将以数组的第一和第二个元素作为其第一和第二个参数。在求和与求积的例子中,也可以省略这个初始值参数。

如果不传初始函数,在空数组上调用reduce()会导致TypeError。如果调用它时只有一个值,比如用只包含一个元素的数组调用且不传初始值,或者用数组调用但传了初始值,则reduce()直接返回这个值,不会调用归并函数。

reduceRignt()与reduce()类似,只不过是从高到低向更低索引(从右到左)处理数组,而不是从低到高。如果归并操作具有从右到左的结合性,那可能要考虑使用reduceRight()。

eg:

//计算2^(3^4)。求幂具有从右到左的优先级
let a=[2,3,4]
a.reduceRight((acc,val)=>Math.pow(val,acc))     
=>2.4178516392292583e+24
           

注意,无论reduce()还是reduceRight()都不接收用于指定归并函数this值的可选参数。它用可选的初始值参数代替了这个值。

7.8.2 使用flat()和flatMap()打平数组

在ES2019中,flat()方法用于创建并返回一个新数组,这个新数组包含与它调用flat()的数组相同的元素,只不过其中额和本身也是数组的元素会被“打平”填充到返回的数组中。

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

在不传参调用时,flat()会打平一级嵌套。原始数组中本身也是数组的元素会被打平,但打平后的元素如果还是数组则不会打平。如果需要打平更多层级,需要给flat()传一个数值参数:

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

flatmap()方法与map()方法相似,只不过返回的数组会自动被打平,就像传给了flat()一样。换句话说,调用a.flatMap(f)等同于(但效率远高于)a.map(f).flat():

let phrares=["hello world","the definitive guide"];
undefined
let words=phrares.flaM
undefined
let words=phrares.flatMap(phrase=>phrase.split(" "));
undefined
words
(5) ["hello", "world", "the", "definitive", "guide"]
           

可以把flatMap()想象成一个通用版的map(),可以把输入数组中的一个元素映射为输出数组中的多个元素。特别地,flatMap()允许把输入元素映射为空数组,这样打平后并不会有元素出现在数组中:

//将非负数映射为它们的平凡根
[-2,-1,1,2].flatMap(x=>x<0?[]:Math.sqrt(x))
=>(2) [1, 1.4142135623730951]
           

7.8.3 使用concat()添加数组

concat()方法创建并返回一个新数组,新数组包含调用concat()方法的数组的元素,以及传给concat()的参数。如果这些参数中有数组,则拼接的是它们的元素而非数组本身。但要注意,concat()不会打平数组的数组。concat()并不修改调用它的数组:

let a=[1,2,3];
a.concat(4,5)
=>(5) [1, 2, 3, 4, 5]         
a.concat([4,5],[6,7])
=>(7) [1, 2, 3, 4, 5, 6, 7]      //数组被打平了
a.concat(4,[5,[6,7]])
=>(6) [1, 2, 3, 4, 5, Array(2)]   //不会打平嵌套的数组
a
=>(3) [1, 2, 3]                   //原始数组没有改变
           

注意,concat()会创建调用它的数组的副本。很多情况下,这样做都是正确的,只不过操作代价有点大。如果你发现自己正在写类似a=a.concat(x)这样的代码,那应该考虑使用push()或splice()就地修改数组,就不要创建新数组了。

7.8.4 通过push()、pop()、shift()和unshift()实现栈和队列操作

push()和pop()方法可以把数组作为栈来操作。其中,push()方法用于在数组末尾添加一个或多个元素,并返回数组的新长度。与concat()不同,push()不会打平数组参数。pop()方法恰好相反,它用于删除数组最后的元素,减少数组长度,并返回删除的值。注意,这两个方法都会就地修改数组。组合使用push()和pop()可以使用JavaScript数组实现先进后出的栈。

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

push()方法不会打平传入的数组,如果想把数组中的所有元素都推送到另一个数组中,可以使用扩展操作符显式打平它:

unshift()和shift()方法与push()和pop()很类似,只不过它们是从数组开头而非末尾插入和删除元素。unshift()用于在数组开头添加一个或多个元素,已有元素的索引会向更高索引移动,并返回数组的新长度。shift()删除并返回数组的第一个元素,所有后续元素都会向下移动一个位置,以占据数组开头空出的位置。使用unshift()和shift()可以实现栈,但效率不如使用使用push()和pop(),因为每次在数组开头添加或删除元素都要向上向下移动元素。

可以使用push()在数组末尾添加元素,使用shift()在数组开头删除元素来实现队列:

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

unshilf()还有一个特殊性需要注意。在给unshilf()传多个参数时,这些参数会一次性插入数组。这意味着一次插入与多次插入之后的数组顺序不一样:

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

7.8.5 使用slice()、splice()、fill()和copyWithin

数组定义了几个处理连续区域(或子数组,或数组“切片”)的方法。

slice()

slice()方法返回一个数组的切片或子数组。这个方法接收两个参数,分别用于指定切片的起止位置。返回的数组包含第一个参数指定的元素,以及所有后续元素,直到(但不包括)第二个参数指定的元素。如果只指定一个参数,返回的数组将包含从该起点开始直到数组结尾的所有元素。如果任何一个参数是负值,那这个值相对于数组长度指定数组元素。比如,参数-1指定数组的最后一个元素。参数-2指定倒数第二个元素。注意,slice()不会修改调用它的数组。

let a=[1,2,3,4,5];
a.slice(0,3);
=>(3) [1, 2, 3]
a.slice(3)
=>(2) [4, 5]
a.slice(1,-1)
=>(3) [2, 3, 4]
a.slice(-3,-2)
=>[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)
=>(4) [5, 6, 7, 8]
a.splice(1,2)
=>(2) [2, 3]
a.splice(1,1)
=>[4]
a.splice(0,0)
=>[]
           

splice()的前两个参数指定要删除哪些元素。这两个参数后面还可以跟任意多个参数,表示要在第一个参数指定的位置插入到数组中的元素。例如:

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

注意,与concat()不同,splice()插入数组本身,而不是数组的元素。(还有一点就是,不会打平本身为数组的元素)。

fill()

fill()方法将数组的元素或切片设置为指定的值。它会修改调用它的数组,也返回修改后的数组:

let a=new Array(5);             //创建一个长度为5的没有元素的数组
a.fill(0)
=>(5) [0, 0, 0, 0, 0]           //用0填充数组
a.fill(9,1)
=>(5) [0, 9, 9, 9, 9]           //从索引1开始填充9
a.fill(8,2,-1)
(5) [0, 9, 8, 8, 9]             //在索引2、3填充8
           

fill()的第一个参数是要把数组元素设置成的值。可选的第二个参数指定起始索引,如果省略从索引0开始填充。可选的第三个参数指定终止索引,到这个索引为止(但不包含)的数组元素会被填充。如果省略第三个参数,则从开始索引位置一直填充到数组末尾。与使用slice()一样,也可以传入负值相对于数组末尾指定索引。

copyWith()

copyWith()把数组切片复制到数组中的新位置。它会就地修改数组并返回修改后的数组,但不会改变数组的长度。第一个参数指定要把第一个元素复制到的目的索引。第二个参数指定要复制的第一个元素复制到的目的索引。第二个参数指定要复制的元素切片的终止索引。如果省略,则使用数组的长度。从开始索引到(但不包含)终止索引的元素会被复制。与使用slice()一样,也可以传入负值相对于数组末尾指定索引:

let a=[1,2,3,4,5];
a.copyWithin(1)                  //把数组元素复制到索引1及之后
=>(5) [1, 1, 2, 3, 4]              
a.copyWithin(2,3,5);             //把最后两个元素复制到索引2
=>(5) [1, 1, 3, 4, 4]
a.copyWithin(0,-2)               //负偏移量也可以
=>(5) [4, 4, 3, 4, 4]
a                                //证明copyWithin()方法是就地修改
=>(5) [4, 4, 3, 4, 4]         
           

copyWithin()本意是作为一个高性能方法,尤其对定型数组特别有哟。它模仿的是C标准库的memmove()函数,注意,即使来源和目标区域有重叠,复制也是正确的。

7.8.6 数组索引与排序方法

数组实现与字符串的同名方法类似的indexOf()、lastIndexOf()和includes()方法。此外还有sort()和reverse()用于对数组元素重新排序。

lastOf()和lastIndexOf()

indexOf()和lastIndexOf()从数组中搜索指定的值并返回第一个找到的元素的索引,如果没找到则返回-1。indexOf()从前到后搜索数组,而lastIndexOf()从后向前搜索数组:

let a=[0,1,2,1,0];
a.indexOf(1)
=>1
a.lastIndexOf(1)
=>3
a.indexOf(3)
=>-1
           

indexOf()和lastIndexOf()使用===操作符比较它们的参数和数组元素。如果数组包含对象而非原始值,这些方法会检查两个引用是否引用同一个对象。如果想查找对象的内容,可以使用find()方法并传入自定义的断言函数。

indexOf()和lastIndexOf()都接收第二个可选的参数,指定从哪个位置开始搜索。如果省略这个参数,indexOf()会从头开始搜索,lastIndexOf()会从尾开始搜索。第二个参数可以是负值,相对于数组末尾偏移。

下面这个函数从指定的数组中搜索指定的值,并返回所有匹配元素的索引。这个例子演示了indexOf()的第二个参数可以用来找到除第一个之外的匹配值。

//从数组a中找到所有值x,返回匹配索引的数组
function findall(a,x){
      let results=[],              //要返回的索引数组
        len=a.length,              //要搜索的数组长度
        pos=0;                     //搜索的起始位置
    while(pos<len){                //如果元素还没有搜索到...
        pos=a.indexOf(x,pos);      //搜索
        if(pos===-1) break;        //如果没找到,结束
        results.push(pos);         //否则把索引保存在数组中
        pos=pos+1;                 //从下一个元素开始搜索
    }
    return results;                //返回索引数组
}
           

字符串也有indexOf()和lastIndexOf()方法,跟这两个数组方法类似,区别在于第二个参数如果是负值会被当成0。

includes()

ES2016的includes()方法接收一个参数,如果数组包含该值则返回true,否则返回false。它并不告诉你值的索引,只告诉你是否存在。includes()方法实际上是测试数组的成员是否属于某个集合。不要要注意,数组并非集合的有效表示方式,如果元素数量庞大,应该选择真正的Set对象。

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()无法找到NaN
           

sort()

sort()对数组元素就地排序并返回排序后的数组。在不传参调用时,sort()按字母顺序对数组元素进行拍摄(如有必要,临时把它们转换为字符串再比较):

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

如果数组包含未定义的元素,它们会被排到数组末尾。

要对数组元素执行非字母顺序的排序,必须给sort()传一个比较函数作为参数。这个函数决定它的两个参数哪一个再排序后的数组中应该出现再前面。如果第一个参数应该出现在第二个参数前面,比较函数影噶返回一个一个小于0的数值。如果第一个参数应该出现在第二个参数后面,比较函数应该返回一个大于0的数值。如果两个值相等(也就是它们的顺序并不重要),则比较函数应该返回0。

eg:

let a=[33,44,1111,222];
a.sort();
=>(4) [1111, 222, 33, 44]
a.sort(function(a,b){ 
           return a-b; 
})
=>(4) [33, 44, 222, 1111]
a.sort((a,b)=>{b-a})
=>(4) [33, 44, 222, 1111]
           

reverse()

reverse()方法反转数组元素的顺序,并返回反序后的数组。这个反序是就地反序,换句话说,不会重新用排序后的数组创建新数组,而是直接对已经存在的数组重新排序。

7.8.7 数组到字符串的转换

Array()类定义了3个把数组转换为字符串的方法,通常可以用在记录日志或错误消息的时候。

join()方法把数组的所有元素转换为字符串,然后把它们拼接起来并返回结果字符串。可以指定一个可选的字符串参数,用于分隔结果字符串中的元素。如果不指定分隔符,则默认使用逗号:

let a=[1,2,3];
a.join()
=>"1,2,3"
a.join(" ")
=>"1 2 3"
a.join("")
=>"123"
let b=new Array(10)              //长度为10但没有元素的数组
b.join('-')
=>"---------"
           

join()方法执行的是String.split()方法的反向操作,后者通过把字符串分隔为多个片段来创建数组。

与任何JavaScript对象一样,数组也有toString()方法。对于数组而言,这个方法的逻辑与没有参数的join()方法一样。

注意,输出不包含方括号或者数组值的定界符。

toLocaleString()是toString()的本地化版本。它调用toLocaleString()方法将每个数组元素转换为字符串,然后再使用(实现定义的)当地分隔字符串来拼接结果字符串。

7.8.8 静态数组函数

除了前面介绍的数组方法,Array()类也定义了3个静态函数,可以通过Array构造函数而非数组调用。Array.of()和Array.from()是创建新数组的工厂方法。

另一个静态数组函数是Array.isArray(),用于缺点一个未知值是不是数组。

7.9 类数组对象

如前所见,JavaScript数组具有一些其他对象不具备的特殊特性。

  • 数组的length属性会在新元素加入时自动更新。
  • 设置length为更小的值会截断数组。
  • 数组从Array.propotype继承有用的方法。
  • Array.isArray()对数组返回true。

这些特殊让JavaScript数组与常规对象有了明显区别。但是,这些特性并非定义数组的本质特性。事实上,只要对象有一个数字属性length,而且有相应的非负整数属性,那就可以完全视同为数组。

实践当中,我们偶尔会遇到“类数组”对象。虽然不能直接在它们上面调用数组方法或期待length属性的特殊行为,但仍然可以通过写给真正数组的代码来遍历它们。说到底,就是因为很多数组算法既适用于真正的数组,也适用于类数组对象。特别是在将数组视为只读或者至少不会修改数组长度的情况下,就更是这样了。

下面的代码会为一个常规对象添加属性,让它成为一个类数组对象,然后再遍历得到的伪数组的“元素”:

let a={};                     //创建一个常规的空对象

//添加属性让它变成“类数组”对象
let i=0;
while(i<10){
    a[i]=i*i;
    i++;
}
=>9
a.length=i;
=>10

//像遍历真正的数组一样遍历数组
let total=0;
for(let j=0;j<a.length;j++){
    total+=a[j];
}
=>285
           

在客户端JavaScript中,很多操作HTML文档的方法(比如document.querySeletorAll())都返回类数组对象。下面的函数可以用来测试对象是不是类数组对象:

//字符串和函数有数值length数值
//但是通过typepf测试可以排除。在客户端JavaScript中
//DOM文本节点有数值length属性,可能需要加上
//o.nodeType!==3测试来排除
function isArrayLike(o){
    if(o&&typeof o==="object"&&Number.isFinite(o.length)&&o.length>=0&&Number.isInteger(o.length)&&o.length<4294967295){
        return true;
    }else{
        return false;
    }
}
           

多数JavaScript数组方法有意地设计成了泛型方法,因此除了真正的数组,同样也可以用于类数组对象。但由于类数组对象不会继承Array.propotype,所以无法直接在它们上面调用数组方法。为此,可以使用Function.call()方法来调用:

let a={"0":"a","1":"b","2":"c",length:3};    //类数组对象
Array.prototype.join.call(a,"+")
=>"a+b+c"
Array.prototype.map.call(a,x=>x.toUpperCase())
=>(3) ["A", "B", "C"]
Array.prototype.slice.call(a,0)
=>(3) ["a", "b", "c"]
Array.from(a)
=>(3) ["a", "b", "c"]
           

倒数第二行代码在类数组对象上调用了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()这些就地修改数组的数组方法,对字符串都不起作用。但尝试用数组方法修改字符串并不会导致错误,只会静默失败。

继续阅读