跳到主要内容

数组

创建数组

如果数组直接量中有连续的逗号,那么这就是一个稀疏数组。所有省略掉值的地方都是没有元素的,但是由于 JavaScript 的特性(普通的查询方式无法区分数组某处无元素和数组元素的值为 undefined 的情况),查询这些位置的元素也会返回 undefined。

var count = [1, , 3]; // 只有在索引 0 和 2 处有元素
var undefs = [,,]; // 数组没有元素,但是 length 属性值为 2

数组直接量中,最后一个逗号之后如果没有值,则认为这个逗号之后没有元素。所以 [,,]length 属性值为 2。

var a = [, , ,];
a.length; // => 3

var a = [];var a = new Array(); 等价,创建的都是空数组。

带参数调用构造函数 Array() 时,会预分配一个数组空间,这个数组中没有存储值,连数组的索引属性都还没定义。

数组元素的读写

访问数组元素要使用 a[2] 这种形式,[] 操作符中必须是一个返回非负整数的表达式。如果是负数或非整数表达式,那这个数值就会被转换为字符串作为属性名来使用。

数组其实是对象的特殊形式,使用方括号访问数组元素就像访问对象属性一样。JavaScript 将指定的数字索引转换成字符串——索引值 1 变成 '1'——然后将其作为属性名来用。这种将索引值从数字转换为字符串的方式,常规对象也可以用:

o = {}; // 创建一个普通的对象
o[1] = 'one'; // 用一个整数来索引它
o // => {1: "one"}

数组一个特别的地方在于:当使用小于 2^32 的非负整数作为属性名时,数组会自动维护其 length 属性值。

数组的索引和对象的属性名是不一样的:

  • 所有的索引都是属性名,但只有在 0 ~ (2^32 -2) 之间的整数属性名才是索引(TODO: 一定是索引?)。
  • 所有数组都是对象,可以为其创建任意名字的属性。
  • 如果使用的属性是数组的索引,数组就会根据需要来更新它的 length 属性值。
a[-1.23] = true; // 会创建一个名为 '-1.23' 的属性
a[1.000]; // 等价于 a[1]

其实数组索引只是对象属性名的一种特殊类型,所以在 JavaScript 中不会有数组“越界”的概念。查询对象属性时,如果不存在,只是会返回 undefined 而已,对于数组也是如此。

那么既然数组是对象,就可以从原型中继承元素(TODO: 如何继承?)。在 ES5 中,数组可以定义元素的 gettersetter 方法。如果数组继承了元素,或者使用了元素的 getter 或者 setter 方法,那么建议你使用使用非优化的代码路径(TODO: 具体是什么?):访问这样的数组中的元素的时间,和查找常规对象属性的时间差不多。

稀疏数组

稀疏数组的索引从 0 开始且不连续。对于稀疏数组,length 属性的值大于元素的个数。下面两种方式都创建了一个稀疏数组,delete 操作符也能够产生稀疏数组。

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

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

在数组直接量中省略值时(后面没有值的逗号),会创建稀疏数组。省略的元素在数组中是不存在的:

var a1 = [,]; // 数组 a1 的 length 属性的值为 1,但是没有元素
var a2 = [undefined]; // 数组 a2 有一个元素,就是 undefined
0 in a1; // => false: 数组 a1 并没有元素
0 in a2; // => true: 数组 a2 有索引为 0 的元素

数组长度

数组的 length 属性使其区别于常规的 JavaScript 对象,稠密(非稀疏)数组的 length 属性值代表数组中元素的个数,其值比数组中最大的索引大 1;而稀疏数组的该属性值则大于元素个数。因此,无论数组稀疏与否,其长度肯定大于所有元素的索引值。为了保持此规则永远成立,数组有两个特殊的行为:

  1. 如果为一个数组元素赋值之后,它的索引 i 大于等于现有数组长度时,length 属性将设置为 i+1。
  2. 如果设置 length 属性为一个小于当前长度的非负整数 n 时,当前数组中索引值大于等于 n 的元素将被删除:
a = [1, 2, 3, 4, 5]; // 新建一个有 5 个元素的数组
a.length = 3; // a 现在为 [1, 2, 3]
a.length = 0; // a 现在为 []
a.length = 5; // a 长度为 5,但没有元素,结果等价于 new Array(5)

如果设置 length 属性大于当前长度时,就会在数组尾部创建一个空区域。

在 ES5 中,可以用 Object.defineProperty() 让数组的 length 属性变为只读属性:

a = [1, 2, 3];
Object.defineProperty(a, 'length', { writable: false }); // 让length 属性只读
a.length = 0; // 这样的语句就无效了

另外,如果让数组的元素(到底是指数组,还是指具体的某个元素?)不能被配置,那么就不能删除该元素(这里所说的删除,指的是什么方式?delete 还是 pop() 还是别的方式?)。如果不能删除该元素,length 属性也就不能设置为小于不可配置元素的索引值。(TODO: 书上说要结合 Object.seal()Object.freeze(),但是不知道这段文字的具体含义……)

数组元素的添加和删除

先说添加元素:

  • 可以给新索引赋值,从而向数组添加元素;
  • 也可以用 push() 方法在数组末尾增加任意多个元素;
  • 还可以用 unshift() 方法在数组首部插入任意多个元素:
var a = [];
a[0] = 'zero'; // => a: ['zero']
a[1] = 'one'; // => a: ['zero', 'one']
a.push(2); // => a: ['zero', 'one', 2]
a.push(3, 4); // => a: ['zero', 'one', 2, 3, 4]
a.unshift(0); // => a: [0, 'zero', 'one', 2, 3, 4]

再说删除元素:

  • delete 运算符删除数组元素后,原来的索引不再存在,但数组长度未变,这样数组就变成了稀疏数组;
  • 直接设置 length 属性也可以删除数组后面的一部分元素;
  • pop() 方法删除最后一个元素,并返回该元素的值;
  • shift() 方法则删除第一个元素,然后将所有其余元素的索引减一,并返回所删除元素的值。

数组遍历

最常见的遍历数组元素的方法是 for 循环:

var o = [1, 3, 5, 7, 2, 4, 6, 8];
var keys = Object.keys(o);
var values = [];
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
values[i] = o[key];
}

嵌套循环之类的对性能要求很高的上下文中,应当对上面的代码进一步优化,数组的长度应当只检测一次:

for (var i = 0, len = keys.length; i< len; i++) { // do something }

上面的代码有个假设前提:数组是稠密的,并且所有元素都是合法数据。否则的话,应该先检测一下数组元素,然后再使用。几种检测方式如下:

for (var i = 0; i < keys.length; i++) {
if (a[i] === null || a[i] === undefined) continue; // 跳过 null、undefined 和不存在的元素,如果只是用 !a[i] 判断的话,假值也会一并跳过
if (a[i] === undefined) continue; // 跳过 undefined 和不存在的元素
if (!(i in a)) continue; // 跳过不存在的元素
}

遍历稀疏数组的时候,还可以使用 for/in 循环,将会只遍历可枚举的属性名(包括数组索引):

for (var index in sparseArray) {
var value = sparseArray[index];
// do something
}

由于 for/in 循环遍历的是所有可枚举的属性,那么自然会将继承的属性也遍历出来,比如添加到 Array.prototype 中的方法。为了只遍历出数组的索引,要么不用 for/in 循环,要么在循环体内增加额外的检测手段:

for (var i in a) {
if (!a.hasOwnProperty(i)) continue; // 跳过继承的属性
if (String(Math.floor(Math.abs(i))) != i) continue; // 跳过不是非负整数的 i
}

ES 规范允许 for/in 循环以各种顺序遍历对象的属性,虽然通常是以索引的升序遍历的,但不一定总是这样。如果数组同时拥有对象属性和数组元素,则返回的属性名很可能是按照创建顺序排列的。该问题的实现各不相同,如果需要保证一定的顺序,那么就不要用 for/in 循环,而应该用 for 循环。

ES5 定义了遍历数组元素的新方法,按照索引顺序依次遍历各元素,比如最常用的 forEach() 方法:

var data = [1, 2, 3, 4, 5];
var sumOfSquares = 0;
data.forEach(function(x) {
sumOfSquares += x * x;
});
sumOfSquares;

多维数组

JavaScript 不支持真正的多维数组,可以用数组的数组来近似模拟:a[0][1]

数组方法

本节介绍 ES3 中定义的操作数组的方法。

TODO: 找机会测试一下对于稀疏数组,各种方法的处理结果。

join()

Array.join() 方法将数组中所有元素转换为字符串并连接在一起,然后返回连接起来的字符串。可指定字符串中分隔各元素的分隔符,不指定则默认用逗号。

var a = [1, 2, 3];
a.join(); // => "1,2,3"
a.join(' '); // => "1 2 3"
a.join(''); // => "123"
var b = new Array(10);
b.join('-'); // => "---------"

该方法是 String.split() 方法的逆操作:后者将字符串以指定字符作为分隔符分隔成一个数组。

reserve()

注意:该方法会直接修改原数组。

Array.reserve() 方法返回逆序的数组。

var a = [1, 2, 3];
a.reserve(); // => a: [3, 2, 1]

sort()

注意:该方法会直接修改原数组。

不带参数调用该方法时,以字母表顺序对数组元素排序:

var a = [2, 1, 3];
a.sort(); // => [1, 2, 3]
a; // => [1, 2, 3]

数组中包含 undefined 元素时,该元素会被排到数组尾部。

sort() 方法还接受一个比较函数用来自定义排序的依据。比较函数的两个参数为数组的两个不同的元素,比较函数的结果大于 0 时,第一个参数排在第二个参数的后面;比较函数的结果小于 0 时,第一个参数则排在第二个参数的前面;结果等于 0 的话,两个参数相等,也就不用排序了。

a = [33, 4, 1111, 222];
a.sort(); // => [1111, 222, 33, 4]: 按照字母顺序排序
a.sort(function(a, b) { return a - b; }); // => [4, 33, 222, 1111]: 按照自定义的比较函数排序
// a - b > 0 时,a 排在 b 的后面,说明这个函数将数组按数值顺序由小到大排列

注意这里使用了匿名函数,因为这个比较函数一般只用一次,所以没必要用命名函数。

下面是另一个例子,对字符串数组进行不区分大小写的按字母表排序:

a = ['ant', 'Bug', 'cat', 'Dog'];
a.sort(); // => ["Bug", "Dog", "ant", "cat"]: 按默认的字母顺序排列,小写字母在前
a.sort(function(s, t) {
var a = s.toLowerCase();
var b = t.toLowerCase();
if (a < b) return -1;
if (a > b) return 1;
return 0;
}); // => ["ant", "Bug", "cat", "Dog"]: 按自定义的顺序排列

concat()

该方法将参数合并至调用该方法的数组的后面。如果参数是一维数组,则该方法会将数组扁平化——将参数数组中的每个元素合并至调用方法的数组后面。如果参数是二维以上的数组的话,则不会递归扁平化数组的数组,具体结果见下面的代码:

var a = [1, 2, 3];
a.concat(); // => [1, 2, 3]
a.concat(4, 5); // => [1, 2, 3, 4, 5];
a.concat([4, 5]); // => [1, 2, 3, 4, 5];
a.concat([4, 5], [6, 7]); // => [1, 2, 3, 4, 5, 6, 7];
a.concat(4, [5, [6, 7]]); // => [1, 2, 3, 4, 5, [6, 7]];

slice()

该方法返回参数所指定的索引之间的元素。

  • 一个非负整数参数:从参数索引所指的元素开始,直到数组最后一个元素。如果参数值大于最后一个元素的索引值,则返回空数组。
  • 一个负整数参数:负数索引表示从后往前数,最后一个元素是 -1,倒数第二个是 -2,依此类推。返回的元素返回和前一条相同。
  • 两个整数参数:从第一个参数索引所指的元素开始,到第二个参数索引所指的元素再往回(比第二个参数小 1)一个元素为止。参数为负数的情况参考前一条。
var a = [1, 2, 3, 4, 5];
a.slice(); // => [1, 2, 3, 4, 5]
a.slice(0); // => [1, 2, 3, 4, 5]
a.slice(3); // => [4, 5]: 从指定的索引位置直到数组结束
a.slice(-3); // => [3, 4, 5]
a.slice(-2); // => [4, 5]: 负数索引则是相对于最后一个元素而言,最后一个元素为-1,倒数第二个为-2,依此类推
a.slice(2, 3); // => [3]: 从第一个参数指定的索引开始,到第二个参数指定的索引再往前一个索引为止
a.slice(2, -1); // => [3, 4]: 等价于 a.slice(2, 4)

splice()

注意:该方法会直接修改原数组。

该方法能够对数组实施插入元素、删除元素或同时完成这两种操作。在插入点/删除点之后的数组元素,索引会自动更新,所以数组的其它部分依然是连续的。

  • 只传入一个参数,则将参数索引所指代的元素直到最后一个元素都从原数组中“剪切”出去,并作为返回值返回(注意:在 ES5 规范中并没有规定该方法只传入一个参数时应当如何运行,具体的结果还要看实际的运行环境)。
  • 第二个参数指定所要“剪切”的元素的个数。
  • 之后的所有参数依次“粘接”到第一个参数所指定的插入点。
var a = [1, 2, 3, 4, 5, 6, 7, 8];

a.splice(); // => []: a 未被修改
// 只传入一个参数,则将参数索引所指代的元素直到最后一个元素都从原数组中“剪切”出去,并作为返回值返回
a.splice(0); // => [1, 2, 3, 4, 5, 6, 7, 8]: a 此时为 []
a.splice(5); // => [6, 7, 8]: 从索引 5 所指元素直到最后一个元素,a 此时为 [1, 2, 3, 4, 5]
a.splice(-6); // => [3, 4, 5, 6, 7, 8]: 负数索引的理解方式和 slice 方法一样,a 此时为 [1, 2]
// 第二个参数指定所要“剪切”的元素的个数
a.splice(2, 2); // => [3, 4]: a 此时为 [1, 2, 5, 6, 7, 8],说明 [3, 4] 从原数组中被剪切出去了
// 之后的所有参数依次“粘接”到第一个参数所指定的插入点
a.splice(3, 4, 'a', ['b', 'c'], 55, [66, [77, 88]]); // => [4, 5, 6, 7]: a 此时为 [1, 2, 3, "a", Array(2), 55, Array(2), 8]

TODO: 为什么 slice() 方法的第二个参数所指代的元素不包含在方法返回的数组中,而 splice() 则包含在数组中?

  • 在英语中,slice 的意思是“切片”,所以在 JS 中,该方法就是从数组中“切下”一段数组,但原数组保持不变,所以 slice 方法其实是从原数组中复制一段数组出来。
  • splice 的意思是“粘接”,所以在 JS 中,该方法将前两个参数所指代的数组从原数组中真正地“剪切”出去并作为返回值返回,然后将第三个及之后的所有参数再“粘接”到原数组的插入点/删除点上。

push()pop()

注意:该方法会直接修改原数组。

push()pop() 方法将数组当成栈来使用。push() 方法在数组尾部添加一个或多个数组,pop() 方法则将数组最后一个元素删除,数组长度减一,并返回这个删除的元素。

TODO: push() 的返回值是什么?

var stack = [];
stack.push(1, 2); // stack: [1, 2] 返回 2
stack.pop(); // stack: [1] 返回 2
stack.push(3); // stack: [1, 3] 返回 2
stack.pop(); // stack: [1] 返回 3
stack.push([4, 5]); // stack: [1, [4, 5]] 返回 2
stack.pop(); // stack: [1] 返回 [4, 5] 返回数组的最后一个元素,不管这个元素是什么类型
stack.pop(); // stack: [] 返回 1

unshift()shift()

注意:该方法会直接修改原数组。

这两个方法和 push() 以及 pop() 方法类似,只不过这两个方法是在数组的头部进行插入/删除操作。

unshift() 在数组的头部插入元素并返回新数组的长度,其余元素则往后移动。shift() 则删除并返回数组的第一个元素,其余元素往前移动。

var a = [];             // a: []
a.unshift(); // a: []
a.unshift(1); // a: [1] 返回 1
a.unshift(22); // a: [22, 1] 返回 2
a.shift(); // a: [1] 返回 22
a.unshift(3, [4, 5]); // a: [3, [4, 5], 1] 返回 3
a.shift(); // a: [[4, 5], 1] 返回 3
a.shift(); // a: [1] 返回 [4, 5]
a.shift(); // a: [] 返回 1

注意unshift() 有多个参数时,插入的参数会保持其原来的顺序。上面代码中 a.unshift(3, [4, 5]) 执行后,数组头部变为 [3, [4, 5], ...],可见向 unshift() 一次传入多个参数的话,和依次传入各个参数的效果是相反的。

另外,shift() 可接受参数,但参数不起任何作用。

toString()toLocaleString()

toString() 方法的执行结果,和不带参数调用方法 join() 的结果是一样的。

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

关于 toLocaleString() 这个方法,在 对象 -> 对象方法 一节已经讲过,此处不再重复。

ES5 中的数组方法

ES5 中新增的 9 个操作数组的方法,第一个参数普遍为函数,这个函数会作用于数组的每个(或部分)元素(稀疏数组不存在的元素除外)。

传入第一个参数的函数,通常接收三个参数:数组元素、元素索引、数组本身。一般只需要第一个参数——数组元素就足够了。

接受函数作为第一个参数的方法,还接受可选的第二个参数。如果传入了第二个参数,那么第一个参数——也就是那个函数,是作为第二个参数的方法进行调用的。也就是说,第二个参数是第一个函数内部的 this 关键字的值。

ES5 中的数组方法都不会修改数组的,但是传入方法的函数是可以修改的。

forEach()

该方法遍历数组每个元素,对每个元素执行传入的函数。

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

forEach() 一般是不能用 break 之类的语句提前终止遍历的。必须把它放到 try 块中,并且抛出一个 foreach.break 异常,才能提前终止遍历。

TODO: 书上的代码没法直接执行,试了试网上的代码,循环倒是的确会提前终止,只不过并不会显示异常。

map()

该方法将数组的每个元素传入函数,然后将每次得到的函数返回值组成数组进行返回。

注意:由于是将每次的函数返回值组成数组,因此如果函数没有 return 语句的话,最终返回的会是一个元素全是 undefined 的数组。

var data = [1, 2, 3, 4, 5];
data.map(function(x) { return x * x; }); // [1, 4, 9, 16, 25]

filter()

对数组的每个元素执行该方法内部调用的函数之后,将函数返回值为 true 或者能转化为 true 的值组成新数组,作为该方法的返回值返回。

var a = [5, 4, 3, 2, 1];
smallValues = a.filter(function(x) { return x < 3; }); // [2, 1]
everyother = a.filter(function(x, i) { return i % 2 == 0; }); // [5, 3, 1]

该方法会跳过稀疏数组中缺少的元素,也就是说可以用来压缩稀疏数组。如果要一并压缩 undefined 和 null 的话,可以这样写:

var a = [1, , 3, , 5, , , undefined, , null];
a.filter(function(v) { return !!v; }); // [1, 3, 5]

every()some()

这两个方法对数组中元素用函数进行判定,返回值为 true 或 false。

every() 方法来说,仅当所有元素用判定函数都返回 true 时,它才返回 true。

some() 则是至少有一个元素用判定函数返回 true,它就返回 true。

var a = [1, 2, 3, 4, 5];
a.every(function(v) { return v < 10; }); // true
a.every(function(v) { return !(v % 2); }); // false
a.some(function(v) { return !(v % 2); }); // true
a.some(isNaN); // false

reduce()reduceRight()

这两个方法通过指定的函数将数组元素进行组合,最后生成一个值。函数式编程中常见这种操作,也可以叫做“注入”或者“折叠”。

var a = [1, 2, 3, 4, 5];
var sum = a.reduce((x,y) => { return x + y; }); // 15
var product = a.reduce((x, y) => { return x * y; }); // 120
var max = a.reduce((x, y) => { return (x > y) ? x : y; }); // 5

以上面第二行代码中的函数为例,在不传入第二个参数的情况下,reduce() 方法先将数组前两个元素相加,然后得到的结果再和第三个元素相加,最后就得到了数组全部元素的和。后面两个方法也是如此。

如果向 reduce()reduceRight() 方法中传入第二个参数的话,则第二个参数为内部函数的第一个参数,数组的第一个元素为函数的第二个参数。这句话可参照着下面这段代码进行理解:

var minus = a.reduce((x, y) => { return x - y; }, -10);   // -25
// -10 - 1 = -11
// -11 - 2 = -13
// -13 - 3 = -16
// -16 - 4 = -20
// -20 - 5 = -25

对空数组调用这两种方法,并且不传入第二个参数的话,代码就会报错:Uncaught TypeError: Reduce of empty array with no initial value

如果数组只有一个元素并且不传入第二个参数,或者对空数组调用并且传入第二个参数的话,这两个方法就会直接返回仅有的那个值。

reduceRight() 除了是从右往左处理数组之外,其它地方和 reduce() 方法是相同的。比如下面的代码就可用来进行数组元素依次乘方的计算:

var a = [ 2, 3, 5];
var big = a.reduceRight((accu, order) => { return Math.pow(accu, order); }); // 15625
// = 2^(3^5)

前面两个例子都是数学运算方面的,但是这两个方法还可以对对象数组进行操作,结合前面对象部分定义的 union() 函数,就可以计算对象的并集,帅不帅?

var objects = [ { x: 1 }, { y: 2 }, { z: 3 }];
var merged = objects.reduce(union); // {x: 1, y: 2, z: 3}

两个对象有同名属性时,union() 函数会用第二个对象的同名属性覆盖第一个对象,这样 reduce()reduceRight() 就会有不同的结果:

var objects = [ { x: 1, a: 1 }, { y: 2, a: 2 }, { z: 3, a: 3} ];
var merged = objects.reduce(union); // {x: 1, a: 3, y: 2, z: 3}
var merged = objects.reduceRight(union); // {z: 3, a: 1, y: 2, x: 1}

indexOf()lastIndexOf()

这两个方法都用来查找指定值在数组中首次出现的位置,indexOf() 从左往右查找,lastIndexOf() 则从右往左查找。

另外,这两个方法都接收第二个参数,用于指定查找的起始位置。第二个参数如果为正数,就是正常的数组索引;如果是负数,负数的绝对值 N 代表对应于数组中的倒数第 N 个元素,最后一个元素为 -1,倒数第二个元素为 -2,依此类推。

var a = [0, 1, 2, 1, 0],
value = 1,
position = 1;

a.indexOf(value, position); // 1
position = 2;
a.indexOf(value, position); // 3
position = -1;
a.indexOf(value, position); // -1
position = -2;
a.indexOf(value, position); // 3

position = 1;
a.lastIndexOf(value, position); // 1
position = 3;
a.lastIndexOf(value, position); // 3
position = -2;
a.lastIndexOf(value, position); // 3
position = -3;
a.lastIndexOf(value, position); // 1

下面的例子,就是应用 indexOf() 方法查找指定值在数组中的所有索引:

function findAll(a, x) {
var len = a.length,
pos = 0,
result = [];

while (len > 0 && pos < len) {
pos = a.indexOf(x, pos);
if (pos === -1) break;
result.push(pos);
pos = pos + 1;
}

return result;
}

var a = [0, 1, 2, 1, 0];
findAll(a, 1); // [1, 3]
findAll(a, 0); // [0, 4]

注意:字符串也有这两种方法,功能和数组的类似。因为 JS 中可以将字符串当成数组来看待,这样就容易理解了。

数组类型

对于一个未知对象,要判断它是否为数组,在 ES5 中,可以用 Array.isArray() 方法进行判断。但是在 ES5 之前,就比较困难了。typeof 操作符对于函数之外的目标,得到的结果都是 object

instanceof 虽然能简单地判断一下,但是对于前端来说,Web 浏览器中往往有多个 window 或多个 frame,每个都有自己的 JavaScript 环境,每个环境都有自己的全局对象,而每个全局对象都有自己的一组构造函数。也就是说,即使是看起来完全相同的两个对象,如果这两个对象各属于不同的 frame 的话,在一个 frame 中用 instanceof 判断这两个对象,结果也是不同的。

真正可行的方法,是检查未知对象的类(class)属性,数组对象的这个属性就是 Array,在“类属性”这一节中,定义了一个 classOf() 函数,就是用来判断传入对象的类属性的。所以,在 ES3 中,isArray() 函数可以这样写:

var isArray = Function.isArray || function(o) {
return typeof o === "object" &&
Object.prototype.toString.call(o) === "[object Array]";
};

实际上 ES5 中的 isArray() 函数干的就是这件事。

类数组对象

在 JavaScript 中,数组有一些其它类型的对象所不具备的特性:

  • 数组中增加新元素之后,length 属性会自动更新。
  • length 属性设置为一个比之前小的值,会裁剪 length 值之后的部分。
  • 数组从 Array.prototype 这个原型上继承了很多实用的方法。
  • 数组的 class 这个属性的值为 Array

正是这些特征使得数组独一无二,但它们还不足以成为定义一个数组的关键因素。给一个普通的对象添加一个数值类型的 length 属性然后给这个属性赋上非负数的值,就可以差不多把这个对象当作数组了。

在实际开发中,这些“类数组对象”还真是时不时会用到的。虽然不能通过它们来调用数组的方法,也不能指望它们的 length 属性的表现能和真正的数组一模一样,但是完全可以用迭代真正的数组的方式,来迭代这些类数组对象。许多用于数组的算法,在类数组对象身上的表现和数组差不多。如果把数组当作只读数组来用,或者数组长度不变的话,这种现象就更明显了。

下面的代码拿一个普通的对象作为示范,给这个对象添加了一些属性,就成了类数组对象了,然后再迭代这个伪数组元素:

var a = {}; // a 就是个普通的对象

// 添加些属性,让它看起来像数组
var i = 0;
while(i < 10) {
a[i] = i * i;
i++;
}
a.length = i;

// 用迭代真正的数组的形式迭代它
var total = 0;
for (var j = 0; j < a.length; j++) {
total += a[j];
}

在 变长参数列表/参数对象 这一节中所讲到的概念,也是类数组对象。在客户端 JavaScript 中,很多 DOM 相关的方法返回的都是类数组对象,比如 document.getElementsByTagName()。下面这个函数,你就可以用来判断参数是不是类数组对象:

// 函数判断 o 是否为类数组对象
// 可以用 typeof 排除字符串和函数,因为它们也有数值类型的 length 属性
// 在客户端 JavaScript 中,文本类型的 DOM 节点也有 length 属性,所以需要额外用 o.nodeType != 3 排除
function isArrayLike(o) {
if (o && // 排除 null、undefined
typeof o === 'object' && // 数组也是对象
isFinite(o.length) && // length 属性有限大
o.length >= 0 && // length 属性非负
o.length === Math.floor(o.length) && // length 属性为整数
o.length < 4294967296) // length 属性 < 2^32
return true;
else
return false;
}

下一节可以看到,ES5 中的字符串的表现和数组很像(在 ES5 之前,有些浏览器已经让字符串可以索引了)。不过上面判断类数组对象的函数在用于字符串时,通常返回的都是 false —— 最好是把它们当作字符串处理(TODO: 不太懂……)。

JavaScript 中的数组方法是故意设置为通用的,这样这些方法在类数组对象身上也可以用。在 ES5 中,所有数组方法都是通用的,在 ES3 中则只有 toString()toLocaleString() 不是通用的。不过 concat() 方法是个例外——虽然也可以用在类数组对象上,但它并没有把这个对象扩充进数组中。因为类数组对象并不是从 Array.prototype 继承而来,所以它们不能直接调用数组方法。要通过 Function.call 这个方法来间接调用:

var a = {
'0': 'a',
'1': 'b',
'2': 'c',
length: 3
};

Array.prototype.join.call(a, '+'); // => "a+b+c"
Array.prototype.slice.call(a, 0); // => ["a", "b", "c"]
Array.prototype.map.call(a, function(x) {
return x.toUpperCase();
}); // => ["A", "B", "C"]

关于 Function 对象的 call 方法,在下一章:函数中会深入探讨,此处不做过多讨论。

在 Firefox 1.5 中引入了 ES5 的数组方法,因为这些方法通用性很好,所以 Firefox 还将这些方法作为 Array 对象的构造函数中的方法来引入。有了这些方法,前面的例子就可以改写成下面这样了:

var a = {
'0': 'a',
'1': 'b',
'2': 'c',
length: 3
};

Array.join(a, '+');
Array.slice(a, 0);
Array.map(a, function(x) {
return x.toUpperCase();
});

用在类数组对象上的时候,这些数组方法的静态函数版本就很有用了。不过它们不是标准方法,所以不可能所有浏览器里面都能用。像下面这样写代码,就可以保证只有在需要的函数存在的时候,才会调用它们:

Array.join = Array.join || function(a, sep) {
return Array.prototype.join.call(a, sep);
};
Array.slice = Array.slice || function(a, from, to) {
return Array.prototype.slice.call(a, from, to);
};
Array.map = Array.map || function(a, f, thisArg) {
return Array.prototype.map.call(a, f, thisArg);
};

作为数组的字符串

在 ES5 中,以及 ES5 之前的诸多浏览器——包括 IE8——之中,字符串都表现得像个只读数组。除了用 charAt() 方法访问某个字符,你还可以用方括号实现相同的操作:

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

不过字符串也只是表现得像个数组,用 typeof 判断的话,返回的仍然是 string,给 Array.isArray() 方法传递一个字符串的话,返回的也是 false。

可索引字符串最大的一个好处就是可以用方括号来索引,这样就不需要 charAt() 了,岂不是很方便?这样更精练、可读性更强,而且还可能效率更高。“字符串表现得像个只读数组”也意味着可以对它们使用数组通用的方法:

s = 'JavaScript';
Array.prototype.join.call(s, ' '); // => 字母之间添加空格:"J a v a S c r i p t"
Array.prototype.filter.call(s,
function(x) {
return x.match(/[^aeiou]/);
}).join(''); // => 筛选出非元音字母:"JvScrpt"

一定要记住,字符串是不可变的。所以如果是把它们当作数组用的话,它们肯定就是只读数组。像 push()sort() 之类的方法会修改数组,这些方法对字符串就不起作用。不过用数组方法修改字符串并不报错,这一点要注意。