全文共13k+字,系统讲解了JavaScript数组的各种特性和API。
数组是一种非常重要的数据类型,它语法简单、灵活、高效。 在多数编程语言中,数组都充当着至关重要的角色,以至于很难想象没有数组的编程语言会是什么模样。特别是JavaScript,它天生的灵活性,又进一步发挥了数组的特长,丰富了数组的使用场景。可以豪不夸张地说,不深入地了解数组,不足以写JavaScript。
截止ES7规范,数组共包含33个标准的API方法和一个非标准的API方法,使用场景和使用方案纷繁复杂,其中有不少浅坑、深坑、甚至神坑。下面将从Array构造器及ES6新特性开始,逐步帮助你掌握数组。
声明:以下未特别标明的方法均为ES5已实现的方法。
Array构造器
Array构造器用于创建一个新的数组。通常,我们推荐使用对象字面量创建数组,这是一个好习惯,但是总有对象字面量乏力的时候,比如说,我想创建一个长度为8的空数组。请比较如下两种方式:
// 使用Array构造器
var
a = Array(8);
// [undefined × 8]
// 使用对象字面量
var
b = [];
b.length = 8;
// [undefined × 8]
|
Array构造器明显要简洁一些,当然你也许会说,对象字面量也不错啊,那么我保持沉默。
如上,我使用了Array(8)而不是new Array(8),这会有影响吗?实际上,并没有影响,这得益于Array构造器内部对this指针的判断,ELS5_HTML规范是这么说的:
When Array is called as a function rather than as a constructor, it creates and initialises a new Array object. Thus the function call Array(…) is equivalent to the object creation expression new Array(…) with the same arguments.
从规范来看,浏览器内部大致做了如下类似的实现:
function
Array(){
// 如果this不是Array的实例,那就重新new一个实例
if
(!(
this
instanceof
arguments.callee)){
return
new
arguments.callee();
}
}
|
上面,我似乎跳过了对Array构造器语法的介绍,没事,接下来我补上。
Array构造器根据参数长度的不同,有如下两种不同的处理:
- new Array(arg1, arg2,…),参数长度为0或长度大于等于2时,传入的参数将按照顺序依次成为新数组的第0至N项(参数长度为0时,返回空数组)。
- new Array(len),当len不是数值时,处理同上,返回一个只包含len元素一项的数组;当len为数值时,根据如下规范,len最大不能超过32位无符号整型,即需要小于2的32次方(len最大为Math.pow(2,32) -1或-1>>>0),否则将抛出RangeError。
If the argument len is a Number and ToUint32(len) is equal to len, then the length property of the newly constructed object is set to ToUint32(len). If the argument len is a Number and ToUint32(len) is not equal to len, a RangeError exception is thrown.
以上,请注意Array构造器对于单个数值参数的特殊处理,如果仅仅需要使用数组包裹?? 若干参数,不妨使用Array.of,具体请移步下一节。
如果你想学习前端可以来这个群,首先是二九一,中间是八五一,最后是一八九,里面可以学习交流,也有资料可以下载。
ES6新增的构造函数方法
鉴于数组的常用性,ES6专门扩展了数组构造器Array ,新增2个方法:Array.of、Array.from。下面展开来聊。
Array.of
Array.of用于将参数依次转化为数组中的一项,然后返回这个新数组,而不管这个参数是数字还是其它。它基本上与Array构造器功能一致,唯一的区别就在单个数字参数的处理上。如下:
Array.of(8.0);
// [8]
Array(8.0);
// [undefined × 8]
|
参数为多个,或单个参数不是数字时,Array.of 与 Array构造器等同。
Array.of(8.0, 5);
// [8, 5]
Array(8.0, 5);
// [8, 5]
Array.of(
'8'
);
// ["8"]
Array(
'8'
);
// ["8"]
|
因此,若是需要使用数组包裹元素,推荐优先使用Array.of方法。
目前,以下版本浏览器提供了对Array.of的支持。
Chrome | Firefox | Edge | Safari |
---|---|---|---|
45+ | 25+ | 9.0+ |
即使其他版本浏览器不支持也不必担心,由于Array.of与Array构造器的这种高度相似性,实现一个polyfill十分简单。如下:
if
(!Array.of){
Array.of =
function
(){
return
Array.prototype.slice.call(arguments);
};
}
|
Array.from
语法:Array.from(arrayLike[, processingFn[, thisArg]])
Array.from的设计初衷是快速便捷的基于其他对象创建新数组,准确来说就是从一个类似数组的可迭代对象创建一个新的数组实例,说人话就是,只要一个对象有迭代器,Array.from就能把它变成一个数组(当然,是返回新的数组,不改变原对象)。
从语法上看,Array.from拥有3个形参,第一个为类似数组的对象,必选。第二个为加工函数,新生成的数组会经过该函数的加工再返回。第三个为this作用域,表示加工函数执行时this的值。后两个参数都是可选的。我们来看看用法。
var
obj = {0:
'a'
, 1:
'b'
, 2:
'c'
, length: 3};
Array.from(obj,
function
(value, index){
console.log(value, index,
this
, arguments.length);
return
value.repeat(3);
//必须指定返回值,否则返回undefined
}, obj);
|
执行结果如下:
可以看到加工函数的this作用域被obj对象取代,也可以看到加工函数默认拥有两个形参,分别为迭代器当前元素的值和其索引。
注意,一旦使用加工函数,必须明确指定返回值,否则将隐式返回undefined,最终生成的数组也会变成一个只包含若干个undefined元素的空数组。
实际上,如果不需要指定this,加工函数完全可以是一个箭头函数。上述代码可以简化如下:
Array.from(obj, (value) => value.repeat(3));
|
除了上述obj对象以外,拥有迭代器的对象还包括这些:String,Set,Map,arguments 等,Array.from统统可以处理。如下所示:
// String
Array.from(
'abc'
);
// ["a", "b", "c"]
// Set
Array.from(
new
Set([
'abc'
,
'def'
]));
// ["abc", "def"]
// Map
Array.from(
new
Map([[1,
'abc'
], [2,
'def'
]]));
// [[1
,
'abc'
], [2,
'def'
]]
// 天生的类数组对象arguments
function
fn(){
return
Array.from(arguments);
}
fn(1, 2, 3);
// [1, 2, 3]
|
到这你可能以为Array.from就讲完了,实际上还有一个重要的扩展场景必须提下。比如说生成一个从0到指定数字的新数组,Array.from就可以轻易的做到。
Array.from({length: 10}, (v, i) => i);
// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
后面我们将会看到,利用数组的keys方法实现上述功能,可能还要简单一些。
目前,以下版本浏览器提供了对Array.from的支持。
Chrome | Firefox | Edge | Opera | Safari |
---|---|---|---|---|
45+ | 32+ | 9.0+ |
Array.isArray
顾名思义,Array.isArray用来判断一个变量是否数组类型。JS的弱类型机制导致判断变量类型是初级前端开发者面试时的必考题,一般我都会将其作为考察候选人第一题,然后基于此展开。在ES6提供该方法之前,ES5至少有如下5种方式判断一个值是否数组:
var
a = [];
// 1.基于instanceof
a
instanceof
Array;
// 2.基于constructor
a.constructor === Array;
// 3.基于Object.prototype.isPrototypeOf
Array.prototype.isPrototypeOf(a);
// 4.基于getPrototypeOf
Object.getPrototypeOf(a) === Array.prototype;
// 5.基于Object.prototype.toString
Object.prototype.toString.apply(a) ===
'[object Array]'
;
|
以上,除了Object.prototype.toString外,其它方法都不能正确判断变量的类型。
要知道,代码的运行环境十分复杂,一个变量可能使用浑身解数去迷惑它的创造者。且看:
var
a = {
__proto__: Array.prototype
};
// 分别在控制台试运行以下代码
// 1.基于instanceof
a
instanceof
Array;
// true
// 2.基于constructor
a.constructor === Array;
// true
// 3.基于Object.prototype.isPrototypeOf
Array.prototype.isPrototypeOf(a);
// true
// 4.基于getPrototypeOf
Object.getPrototypeOf(a) === Array.prototype;
// true
|
以上,4种方法将全部返回true,为什么呢?我们只是手动指定了某个对象的__proto__属性为Array.prototype,便导致了该对象继承了Array对象,这种毫不负责任的继承方式,使得基于继承的判断方案瞬间土崩瓦解。
不仅如此,我们还知道,Array是堆数据,变量指向的只是它的引用地址,因此每个页面的Array对象引用的地址都是不一样的。iframe中声明的数组,它的构造函数是iframe中的Array对象。如果在iframe声明了一个数组x,将其赋值给父页面的变量y,那么在父页面使用y instanceof Array ,结果一定是false的。而最后一种返回的是字符串,不会存在引用问题。实际上,多页面或系统之间的交互只有字符串能够畅行无阻。
鉴于上述的两点原因,故笔者推荐使用最后一种方法去撩面试官(别提是我说的),如果你还不信,这里恰好有篇文章跟我持有相同的观点:
回到ES6,使用Array.isArray则非常简单,如下:Determining with absolute accuracy whether or not a JavaScript object is an array。
回到ES6,使用Array.isArray则非常简单,如下:
Array.isArray([]);
// true
Array.isArray({0:
'a'
, length: 1});
// false
|
目前,以下版本浏览器提供了对Array.isArray的支持。
Chrome | Firefox | Edge | Opera | Safari |
---|---|---|---|---|
5+ | 4+ | 9+ | 10.5+ | 5+ |
实际上,通过Object.prototype.toString去判断一个值的类型,也是各大主流库的标准。因此Array.isArray的polyfill通常长这样:
if
(!Array.isArray){
Array.isArray =
function
(arg){
return
Object.prototype.toString.call(arg) ===
'[object Array]'
;
};
}
|
数组推导
ES6对数组的增强不止是体现在api上,还包括语法糖。比如说for of,它就是借鉴其它语言而成的语法糖,这种基于原数组使用for of生成新数组的语法糖,叫做数组推导。数组推导最初起早在ES6的草案中,但在第27版(2014年8月)中被移除,目前只有Firefox v30+支持,推导有风险,使用需谨慎。所幸如今这些语言都还支持推导:CoffeeScript、Python、Haskell、Clojure,我们可以从中一窥端倪。这里我们以python的for in推导打个比方:
# python for in 推导
a = [1, 2, 3, 4]
print [i * i
for
i
in
a
if
i == 3]
# [9]
|
如下是SpiderMonkey引擎(Firefox)之前基于ES4规范实现的数组推导(与python的推导十分相似):
[i * i
for
(i of a)]
// [1, 4, 9, 16]
|
ES6中数组有关的for of在ES4的基础上进一步演化,for关键字居首,in在中间,最后才是运算表达式。如下:
[
for
(i of [1, 2, 3, 4]) i * i]
// [1, 4, 9, 16]
|
同python的示例,ES6中数组有关的for of也可以使用if语句:
// 单个if
[
for
(i of [1, 2, 3, 4])
if
(i == 3) i * i]
// [9]
// 甚至是多个if
[
for
(i of [1, 2, 3, 4])
if
(i > 2)
if
(i < 4) i * i]
// [9]
|
更为强大的是,ES6数组推导还允许多重for of。
[
for
(i of [1, 2, 3])
for
(j of [10, 100]) i * j]
// [10, 100, 20, 200, 30, 300]
|
甚至,数组推导还能够嵌入另一个数组推导中。
[
for
(i of [1, 2, 3]) [
for
(j of [10, 100]) i * j] ]
// [[10, 100], [20, 200], [30, 300]]
|
对于上述两个表达式,前者和后者唯一的区别,就在于后者的第二个推导是先返回数组,然后与外部的推导再进行一次运算。
除了多个数组推导嵌套外,ES6的数组推导还会为每次迭代分配一个新的作用域(目前Firefox也没有为每次迭代创建新的作用域):
// ES6规范
[
for
(x of [0, 1, 2]) () => x][0]()
// 0
// Firefox运行
[
for
(x of [0, 1, 2]) () => x][0]()
// 2
|
通过上面的实例,我们看到使用数组推导来创建新数组比forEach,map,filter等遍历方法更加简洁,只是非常可惜,它不是标准规范。
ES6不仅新增了对Array构造器相关API,还新增了8个原型的方法。接下来我会在原型方法的介绍中穿插着ES6相关方法的讲解,请耐心往下读。
原型
继承的常识告诉我们,js中所有的数组方法均来自于Array.prototype,和其他构造函数一样,你可以通过扩展 Array 的 prototype 属性上的方法来给所有数组实例增加方法。
值得一说的是,Array.prototype本身就是一个数组。
Array.isArray(Array.prototype);
// true
console.log(Array.prototype.length);
// 0
|
以下方法可以进一步验证:
console.log([].__proto__.length);
// 0
console.log([].__proto__);
// [Symbol(Symbol.unscopables): Object]
|
有关Symbol(Symbol.unscopables)的知识,这里不做详述,具体请移步后续章节。
方法
数组原型提供的方法非常之多,主要分为三种,一种是会改变自身值的,一种是不会改变自身值的,另外一种是遍历方法。
由于 Array.prototype 的某些属性被设置为[[DontEnum]],因此不能用一般的方法进行遍历,我们可以通过如下方式获取 Array.prototype 的所有方法:
Object.getOwnPropertyNames(Array.prototype);
// ["length", "constructor", "toString", "toLocaleString", "join", "pop", "push", "reverse", "shift", "unshift", "slice", "splice", "sort", "filter", "forEach", "some", "every", "map", "indexOf", "lastIndexOf", "reduce", "reduceRight", "copyWithin", "find", "findIndex", "fill", "includes", "entries", "keys", "concat"]
|
改变自身值的方法(9个)
基于ES6,改变自身值的方法一共有9个,分别为pop、push、reverse、shift、sort、splice、unshift,以及两个ES6新增的方法copyWithin 和 fill。
对于能改变自身值的数组方法,日常开发中需要特别注意,尽量避免在循环遍历中去改变原数组的项。接下来,我们一起来深入地了解这些方法。
pop
pop() 方法删除一个数组中的最后的一个元素,并且返回这个元素。如果是栈的话,这个过程就是栈顶弹出。
var
array = [
"cat"
,
"dog"
,
"cow"
,
"chicken"
,
"mouse"
];
var
item = array.pop();
console.log(array);
// ["cat", "dog", "cow", "chicken"]
console.log(item);
// mouse
|
由于设计上的巧妙,pop方法可以应用在类数组对象上,即 鸭式辨型. 如下:
var
o = {0:
"cat"
, 1:
"dog"
, 2:
"cow"
, 3:
"chicken"
, 4:
"mouse"
, length:5}
var
item = Array.prototype.pop.call(o);
console.log(o);
// Object {0: "cat", 1: "dog", 2: "cow", 3: "chicken", length: 4}
console.log(item);
// mouse
|
但如果类数组对象不具有length属性,那么该对象将被创建length属性,length值为0。如下:
var
o = {0:
"cat"
, 1:
"dog"
, 2:
"cow"
, 3:
"chicken"
, 4:
"mouse"
}
var
item = Array.prototype.pop.call(o);
console.log(array);
// Object {0: "cat", 1: "dog", 2: "cow", 3: "chicken", 4: "mouse", length: 0}
console.log(item);
// undefined
|
push
push()方法添加一个或者多个元素到数组末尾,并且返回数组新的长度。如果是栈的话,这个过程就是栈顶压入。
语法:arr.push(element1, ..., elementN)
var
array = [
"football"
,
"basketball"
,
"volleyball"
,
"Table tennis"
,
"badminton"
];
var
i = array.push(
"golfball"
);
console.log(array);
// ["football", "basketball", "volleyball", "Table tennis", "badminton", "golfball"]
console.log(i);
// 6
|
同pop方法一样,push方法也可以应用到类数组对象上,如果length不能被转成一个数值或者不存在length属性时,则插入的元素索引为0,且length属性不存在时,将会创建它。
var
o = {0:
"football"
, 1:
"basketball"
};
var
i = Array.prototype.push.call(o,
"golfball"
);
console.log(o);
// Object {0: "golfball", 1: "basketball", length: 1}
console.log(i);
// 1
|
实际上,push方法是根据length属性来决定从哪里开始插入给定的值。
var
o = {0:
"football"
, 1:
"basketball"
,length:1};
var
i = Array.prototype.push.call(o,
"golfball"
);
console.log(o);
// Object {0: "football", 1: "golfball", length: 2}
console.log(i);
// 2
|
利用push根据length属性插入元素这个特点,可以实现数组的合并,如下:
var
array = [
"football"
,
"basketball"
];
var
array2 = [
"volleyball"
,
"golfball"
];
var
i = Array.prototype.push.apply(array,array2);
console.log(array);
// ["football", "basketball", "volleyball", "golfball"]
console.log(i);
// 4
|
reverse
reverse()方法颠倒数组中元素的位置,第一个会成为最后一个,最后一个会成为第一个,该方法返回对数组的引用。
语法:arr.reverse()
var
array = [1,2,3,4,5];
var
array2 = array.reverse();
console.log(array);
// [5,4,3,2,1]
console.log(array2===array);
// true
|
同上,reverse 也是鸭式辨型的受益者,颠倒元素的范围受length属性制约。如下:
var
o = {0:
"a"
, 1:
"b"
, 2:
"c"
, length:2};
var
o2 = Array.prototype.reverse.call(o);
console.log(o);
// Object {0: "b", 1: "a", 2: "c", length: 2}
console.log(o === o2);
// true
|
如果 length 属性小于2 或者 length 属性不为数值,那么原类数组对象将没有变化。即使 length 属性不存在,该对象也不会去创建 length 属性。特别的是,当 length 属性较大时,类数组对象的『索引』会尽可能的向 length 看齐。如下:
var
o = {0:
"a"
, 1:
"b"
, 2:
"c"
,length:100};
var
o2 = Array.prototype.reverse.call(o);
console.log(o);
// Object {97: "c", 98: "b", 99: "a", length: 100}
console.log(o === o2);
// true
|
shift
shift()方法删除数组的第一个元素,并返回这个元素。
语法:arr.shift()
var
array = [1,2,3,4,5];
var
item = array.shift();
console.log(array);
// [2,3,4,5]
console.log(item);
// 1
|
同样受益于鸭式辨型,对于类数组对象,shift仍然能够处理。如下:
var
o = {0:
"a"
, 1:
"b"
, 2:
"c"
, length:3};
var
item = Array.prototype.shift.call(o);
console.log(o);
// Object {0: "b", 1: "c", length: 2}
console.log(item);
// a
|
如果类数组对象length属性不存在,将添加length属性,并初始化为0。如下:
var
o = {0:
"a"
, 1:
"b"
, 2:
"c"
};
var
item = Array.prototype.shift.call(o);
console.log(o);
// Object {0: "a", 1: "b", 2:"c" length: 0}
console.log(item);
// undefined
|
sort
sort()方法对数组元素进行排序,并返回这个数组。sort方法比较复杂,这里我将多花些篇幅来讲这块。
语法:arr.sort([comparefn])
comparefn是可选的,如果省略,数组元素将按照各自转换为字符串的Unicode(万国码)位点顺序排序,例如"Boy"将排到"apple"之前。当对数字排序的时候,25将会排到8之前,因为转换为字符串后,"25"将比"8"靠前。例如:
var
array = [
"apple"
,
"Boy"
,
"Cat"
,
"dog"
];
var
array2 = array.sort();
console.log(array);
// ["Boy", "Cat", "apple", "dog"]
console.log(array2 == array);
// true
array = [10, 1, 3, 20];
var
array3 = array.sort();
console.log(array3);
// [1, 10, 20, 3]
|
如果指明了comparefn,数组将按照调用该函数的返回值来排序。若 a 和 b 是两个将要比较的元素:
- 若 comparefn(a, b) < 0,那么a 将排到 b 前面;
- 若 comparefn(a, b) = 0,那么a 和 b 相对位置不变;
- 若 comparefn(a, b) > 0,那么a , b 将调换位置;
如果数组元素为数字,则排序函数comparefn格式如下所示:
function
compare(a, b){
return
a-b;
}
|
如果数组元素为非ASCII字符的字符串(如包含类似 e、é、è、a、? 或中文字符等非英文字符的字符串),则需要使用String.localeCompare。下面这个函数将排到正确的顺序。
var
array = [
'互'
,
'联'
,
'网'
,
'改'
,
'变'
,
'世'
,
'界'
];
var
array2 = array.sort();
var
array = [
'互'
,
'联'
,
'网'
,
'改'
,
'变'
,
'世'
,
'界'
];
// 重新赋值,避免干扰array2
var
array3 = array.sort(
function
(a, b) {
return
a.localeCompare(b);
});
console.log(array2);
// ["世", "互", "变", "改", "界", "网", "联"]
console.log(array3);
// ["变", "改", "互", "界", "联", "世", "网"]
|
如上,『互联网改变世界』这个数组,sort函数默认按照数组元素unicode字符串形式进行排序,然而实际上,我们期望的是按照拼音先后顺序进行排序,显然String.localeCompare 帮助我们达到了这个目的。
为什么上面测试中需要重新给array赋值呢,这是因为sort每次排序时改变的是数组本身,并且返回数组引用。如果不这么做,经过连续两次排序后,array2 和 array3 将指向同一个数组,最终影响我们测试。array重新赋值后就断开了对原数组的引用。
同上,sort一样受益于鸭式辨型,比如:
var
o = {0:
'互'
,1:
'联'
,2:
'网'
,3:
'改'
,4:
'变'
,5:
'世'
,6:
'界'
,length:7};
Array.prototype.sort.call(o,
function
(a, b){
return
a.localeCompare(b);
});
console.log(o);
// Object {0: "变", 1: "改", 2: "互", 3: "界", 4: "联", 5: "世", 6: "网", length: 7}, 可见同上述排序结果一致
|
注意:使用sort的鸭式辨型特性时,若类数组对象不具有length属性,它并不会进行排序,也不会为其添加length属性。
var
o = {0:
'互'
,1:
'联'
,2:
'网'
,3:
'改'
,4:
'变'
,5:
'世'
,6:
'界'
};
Array.prototype.sort.call(o,
function
(a, b){
return
a.localeCompare(b);
});
console.log(o);
// Object {0: "互", 1: "联", 2: "网", 3: "改", 4: "变", 5: "世", 6: "界"}, 可见并未添加length属性
|
使用映射改善排序