前言
科普 JavaScript,揭开 JavaScript 神秘面纱,直击 JavaScript 灵魂。此系列文章适合任何人阅读。
本文章内容为:
- 标准化数组。
- 数组与数组容器。
- ECMAScript 规范中 Array API 讲解。
- 如果你想用 Array,而又不想学 API 的办法。
- 标准规范以外的 Array API 扩展。
- V8 引擎对 Array 的处理和优化。
- 数据本质。
Array 作为 JavaScript 语言除 Object 外唯一的复杂数据结构,彻底掌握它非常有必要。
这篇文章的初衷,就是讲透数组以及 JavaScript 中 Array 的概念、作用、所有 API 和便捷用法。最终可以达到融会贯通,在无数用法中找到最正确的那一种,让 Array 操作变成得心应手。
千老师写完这篇文章的时候,已经是 2019 年年底,截至文章完成,这些是最新的 ECMAScript 规范、JavaScript 版 v8 的 Array、C++版 V8 的 Array、V8 Array 运行时。
温馨提示:由于文章篇幅过长,我觉得你不太可能坚持一次看完。所以建议你先收藏。如果遇到看不懂的内容,或者不想看的内容,可以快进或者选择性观看。
标准数组:灵魂四问-从数组源头问起
要学习一个东西,最佳方式就是不断地抛出问题,通过对问题的探索,一步一步拨开迷雾,寻找真相。
上面这句话是千老师写的,不具有权威性。所以千老师先提出一个问题,来证明这个观点是正确的。
提问到底有多重要?这是个问题。这里千老师借鉴几位大神的名言解释一下这个问题:
- 创造始于问题,有了问题才会思考,有了思考,才有解决问题的方法,才有找到独立思路的可能。—— 陶行知
- 提出正确的问题,往往等于解决了问题的大半。——海森堡
- 生活的智慧大概就在于逢事都问个为什么。——巴尔扎克
- 有教养的头脑的第一个标志就是善于提问。——普列汉诺夫
- 一个聪明人,永远会发问。——著名程序员千老师
- 善问者,如攻坚木,先其易者,后其节目。 ——《礼记·学记》
- 好问则裕,自用则小。——《尚书·仲虺之诰》
- 敏而好学,不耻下问。——《论语·公冶长》
- 君子之学必好问,问与学,相辅而行者也。非学,无以致疑;非问,无以广识。——刘开
- 知识的问题是一个科学问题,来不得半点虚伪和骄傲,决定的需要的倒是其反面——诚实和谦逊的态度。——毛主席
好,千老师随便一整理,就整理出十个解释问题为什么重要的原因,连伟大的开国领袖毛主席都喜欢问题,非常棒。但这是一篇讲解程序的文章,不是学习名言警句的文章,所以大家收一收。只要明白”带着问题去学习效率是非常高的“这个道理就足够了。下面转入正题。
1.数组是什么?
现在千老师先抛出第一个正式的问题,数组到底是个啥?
这个问题好像很简单哎,可事实真的是这样吗?不服的话,你可以先把你的答案说出来,等看完本篇文章后,再来对比,是否和原来的答案一致。
这里千老师偷个懒,拿出 wiki 百科对数组的解释:
数组数据结构(英语:array data structure),简称数组(英语:Array),是由相同类型的元素(element)的集合所组成的数据结构,分配一块连续的内存来存储。利用元素的索引(index)可以计算出该元素对应的存储地址。
从这句非常官方的解释里面,千老师找到几个关键的点:相同类型的元素、连续的内存、索引。
标准数组,是一定要符和上面这三个条件的。其实还有一个条件,wiki 上面没体现出来,就是固定大小。
数组的设计,最早是出自 C 语言。在后来出现的编程语言中,大都效仿了 C 语言的数组设计。比如 C++、Java、Go 等等。
从这里开始,千老师就要推翻你传统思维中的JavaScript数组概念。只有推翻,才能反向验证。只有打破,才能破镜重圆。
2.数组为什么有类型?
先拿 Java 的标准数组举例。为什么要拿 Java 举例呢?因为 JavaScript 中没有“标准数组”。
int arr[] = new int[3]; /*创建一个长度为3的int类型数组*/ arr[0] = 1; // 给下标0 赋值 arr[1] = 2; // 给下标1 赋值 arr[2] = 3; // 给下标2 赋值
我们在 Java 中来一点在 JavaScript 的常规操作。
arr[2] = "3"; // error: incompatible types: String cannot be converted to int
看,Java 竟然报错了!这个错误的意思是: int 类型的数组,不兼容 String 类型的元素。
如果你一直在使用 JavaScript,而没用过其它强类型编程语言,你肯定觉得这很神奇。数组竟然还可以有类型?赶紧提出第二个问题:数组为什么有类型?
是的,数组有类型,而且数组有类型还有原因,后面千老师再说为什么数组会有类型。
3.数组的长度为什么不可以改变?
再来一个 JavaScript 的常规操作。
arr[3] = 1;// Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
Java 竟然又报错了!如果你一直在使用 JavaScript,看到这里估计你已经惊呆了。这个错误的意思是:索引超过了数组的最大界限。
这个现象又说明一个问题:数组的长度一旦确定,就不会再发生改变。
千老师替你提出第三个问题:数组的长度为什么不可以改变?
看到这里你肯定会想,标准数组这么多限制,用起来未免也太麻烦了吧?
4.数组的下标为什么是从 0 开始的?
最后,千老师再补充一个已经被大家习以为常,甚至已经被忽略掉的的问题:数组的下标为什么是从 0 开始的?
解答时刻
第二题:数组为什么有类型?
因为数组的寻址公式:array[i]Address = headAddress + i * dataTypeSize
啥玩意是寻址公式?
寻址方式就是处理器根据指令中给出的地址信息来寻找有效地址的方式,是确定本条指令的数据地址以及下一条要执行的指令地址的方法。
我翻译一下,就是在内存块中找到这个变量的方法。
这里涉及到一些计算机原理和数据结构与算法的知识,因为本篇文章的主要内容是讲解 JavaScript 的 Array 相关知识,所以千老师不会展开讲这些问题。不过考虑到很多人不是科班出身,或者科班出身没认真学过计算机原理。千老师还是略微讲讲寻址是个啥,有啥用。
首先内存地址是个什么东西?内存地址长这样:0x12345678
。在编程语言中,我们创建的变量,都被存到了内存中,你可以理解成一个hash
,或者是 ECMAScript 中的 object
,0x12345678
是 key
,你创建的变量名和变量的值是 value
。而寻址,就是找到内存地址,把内存地址中的 value
拿出来。换成 JavaScript
表达式大概是这样:Memory["0x12345678"]
。
大概明白了寻址。接下来再看一下创建数组的过程:
创建数组,就是向内存申请一块固定大小的空间。这块空间有多大呢?根据数组的长度和数组的数据类型来得到的。
比如上面的例子。int 类型是 4 个字节,长度是 3 的 int 类型数组,就需要 3*4=12 个字节的内存空间。
一旦申请成功,CPU 就会把这块空间锁住。并且记录一个这块空间的内存首地址。也就是上面那个公式的 headAddress。
在之后的访问中,就可以通过这个公式来快速寻址。
这解释通了 数组为什么有类型。
第三题:数组的长度为什么不可以改变?
因为数组的内存是连续分配的,如果数组的长度发生改变,就意味着数组的占用内存空间也发生改变。而数组原空间后面的空间有可能被其它值所占用了,这也是处于安全性的考虑,所以无法改变。
第四题:数组的下标为什么是从 0 开始的?
如果下标从 1 开始,按照人类十进制的逻辑非常值观,但对于 CPU 而言,就麻烦了。数组的寻址公式就要改成:array[i]Address = headAddress + (i-1) * dataTypeSize
,这样每次对数组的操作,CPU 都会平白无故多一次减法运算,对性能不利。
看到这,你应该明白,我们在 JavaScript 中日常使用的 Array 类型,并不是“标准数组”。同时也明白了,标准化数组的特征。
数组容器:分析一下 Java 中的 ArrayList 和 ECMAScript 中的 Array
通过上面的四道自问自答,相信你也明白了数组设计成这样的苦衷。真的是让我们一言难尽啊,但是又不得不尽。
屏蔽细节的数组容器
如果一直在数组的这么多限制下编程,很多人肯定会被逼疯。所以聪明的程序员们发明了一种屏蔽底层数组操作的数组容器。
比如 Java 中出镜率非常高的 ArrayList。而 ECMAScript 中的 Array 类型,同样也是如此。
这类容器有什么好处呢?
我们来操作一下,就能体验到。
还是拿 Java 举例。为什么还要拿 Java 举例呢?因为只有通过对比才能引起你的深思。
ArrayList arr = new ArrayList(1);// 创建一个初始长度为 1 的数组 arr.add(1);// 加 1 个数据 arr.add("2");// 再加 1 个 String 类型的数据 System.out.println(arr);// 没有问题,正常输出 [1, 2]
可以看到 Java 的 ArrayList 解决了两个重要的问题。
1.可以存储不同类型的数据。
2.可以自动扩容。
那它是怎么实现的呢?这块内容虽然也不属于本篇文章的范围内。但千老师还是忍不住简单说一下。
1.如何实现可以存储不同类型的数据?
不论是 JavaScript 还是 Java,基本的内存都分为堆内存 Head 和栈内存 Stack。因为基本数据类型,(不好意思,打断一下,千老师在这里提个问题?2019 年,ECMAScript 有几种基本数据类型?)都是存到栈内存中的。为什么要存到栈内存中呢?这又是个很好的问题。你可以先猜一下。因为基本数据类型都是固定的值,既然值都是固定的,那么大小也是固定的。说到这里,千老师再来提个问题:在 ECMAScript 中,一个 3 个字符的 String 类型变量占几个字节?你看,**问题无处不在,就看你有没有发现问题的眼睛。**这也算是一个小彩蛋,在 ECMAScript2015 以前,ECMAScript5.0 中,采用 Unicode 编码,中文字符和英文字符都是占 2 个字节大小。所以上面问题的答案就是 2*3=6 个字节。但 ECMAScript6 以后,答案不同了。因为编码换了,换成 utf8 了。这里千老师再提一个问题,unicode 和 utf8 有什么不同?嘿嘿,是不是快崩溃了?utf8 是使用 1 到 4 个字节不等的长度进行编码的。因为老外发现世界上大多数网站都是英文语言的网站,而其他语言(在老外眼里,除了英语,其他统称为其他语言)的网站占比很少。所以 utf8 中,英文字符只占 1 个字节,而像其它语言,比如中文,就占了 3 个字节。所以上面的题目还缺少一个条件,还要明确 3 个字符都是哪国语言才能给出正确答案。扯远了,我们赶紧收回来,继续讲堆和栈的问题。既然基本数据类型的大小都是固定的,那么放在栈里面就很好知道数组总共的大小,就可以申请到连续的内存块。那么存储引用类型的变量时,ECMAScript 是怎么做的呢?聪明的你肯定猜到了,那就是存到堆内存中了。准确的说,是把变量的数据存到堆内存中,而栈内存仍然会存一个东西,那就是堆内存的内存指针,也就是我们常说的引用。这样就解释通了,数组容器怎么存储不同类型数据的。
关于堆内存和栈内存的详细介绍,就不展开说了。
如果想详细了解这部分内容,推荐查阅如果想详细了解这部分内容,推荐查阅《JavaScript高级程序设计(第3版)》第四章。
堆栈内存这部分内容并不是可以被单独拿出来的一个概念,如果想彻底学好,就要有系统的去学,才可以真正理解。基础不好的同学,推荐去读《深入理解计算机系统(原书第3版)》。这本书在豆瓣上获得了9.8的高分。但实际上,它并不是一本传统意义上“深入”的书。而是讲解“计算机底层”整体脉络的书。所以它是一本广度非常高的书,非常适合完善个人的计算机知识体系。
2.如何实现自动扩容?
ArrayList 无参构造,会默认创建一个容量为 10 的数组。每次添加元素,都会检查容量是否够用,如果不够用,在内部创建一个新的数组,该数组的容量为原数组容量的 1.5 倍。再将原数组的数据都搬移到新数组中去。如果新数组的容量还是不够,就会直接创建一个符和所需容量的数组。
这么干没有什么太大的问题,最大的问题就是性能会受到一定的影响。另一个是和 JavaScript 无关的问题,线程安全问题。因为创建新数组,迁移数据这个过程需要一定的时间。Java 这种多线程的语言,如果在这个过程中另一个线程再去访问这个 ArrayList,就会出问题。
为什么要解释 Java 的 ArrayList 呢?因为千老师只看过 ArrayList 的实现源码,很尴尬。没看过 JavaScript 的同学,如果你感兴趣,可以去文章开头我挂的那个 V8 源码链接看看 ECMAScript 是怎么干的。我猜它的实现和 ArrayList 是一个原理,你看完可以回来告诉千老师一下,看千老师猜的对不对。虽然千老师没仔细看过 V8 的实现,但请不要质疑千老师对 JavaScript 的专业程度,也不要胡乱猜测千老师是搞 Java 的。在这里强调一下,千老师是正儿八经的 JavaScript 程序员,从始至终都是以 JavaScript 作为第一编程语言。哦不,现在是 TypeScript。
不论是 Java 的 JDK 还是 ECMAScript 的 V8,归根结底的实现还是 C。所以千老师在这里建议大家:一定不要想不开去看 C 的源码。
总结一下:凡是被程序员用起来不爽的东西,总会被各种方式改造。直到变成大伙儿都喜欢的样子为止。如果你想彻底搞明白一件事情,就必须从源头找起,看看她的原貌,再看看她化妆、整容的全过程,最后看她是如何一步一步蜕茧成蝶。
EAMCAScript 中数组的本质,Array 到底是什么?
这是 JavaScript 中最常见的一个数组。
let arr = [ "h", 9, true, null, undefined, _ => _, { a: "b" }, [1, 2], Symbol(1), 2n ** 1n, Infinity, NaN, globalThis, Error("hi"), Math.LN10, Date(), /\w+/i, new Map([["k1", "v1"]]), new Set([1, 2]), new DataView(new ArrayBuffer(16)), new Promise((resolve, reject) => {}), ];
有点乱,但无伤大雅。可以看到,数组就像是一个巨大的黑洞,可以存放 ECMAScript 中的任何东西。变量,任何数据类型的数据,包括数组本身,都可以。这一点让我想起了QQ空间里面经常出现的游戏广告,山海经,吞食天地、无所不能吞的鲲。
为什么可以这么干呢?
因为和上面介绍的一样,Array 存储基本类型时,存储的是值。存储引用类型时,存储的是内存引用。
ECMAScript 中的 Array,完全不像传统数组。因为它是个对象。
由于 ECMAScript 是一门弱类型语言,没有类型系统的强制约束,任何类型的变量都是可以被挂在上任何属性的。数组也不例外。
给一个对象添加一个属性。
let obj = {}; obj["0"] = 1;
给一个数组添加一个元素。
let arr = []; arr["0"] = 1;
从一个对象中取值。
obj["0"];
从一个数组中取值。
arr["0"];
再举个例子,如下数组:
["dog", "pig", "cat"];
等价于如下对象:
{ "0": "dog", "1": "pig", "2": "cat", "length": 3 }
在某种程度上来看,Array 和 Object 没有太明显的区别。甚至激进点讲,Array 和 Object 本质上是一回事。(这句话千老师不承担任何法律责任,就是随便说说)
在 JavaScript 中,你完全可以把数组理解成是对象的一种高阶实现。
JavaScript 中,Array 到底有多么自由呢?可以存储不同类型的值,可以通过负索引访问,下标可以超过原始声明范围,甚至可以通过非数字索引。虽然 Array 和数组格格不入,但它毕竟还叫作数组,毕竟还是和数组有相似之处的,比如 Array 仍然是以"0"
作为起始下标的。(这是一个冷笑话。)
所以,不要再拿传统的数组概念来定义 ECMAScript 的 Array。因为它只是长的像而已。
硬核实战:ECMAScript 中 Array 的 API
该说的不该说的,该问的不该问的,上面都讲完了。
接下来,就让我们进入本文最后一部分,从所有的 API 中感受 Array 的强大。
在千老师写这篇文章之前,已经有很多人写过类似的优秀文章了,比如 MDN
不过千老师保证比这些人讲的更加生动形象,通俗易懂,风趣十足,别具一格。带你深入……浅出 Array 的世界。
虽然这篇文章出现的时间非常晚了,但是没有办法。千老师相信后浪能把前浪拍在沙滩上,使劲蹂躏。
目前标准规范中,Array 的所有的属性和方法加起来,有足足 36 个之多,实在是令人汗颜。
下面先从创建数组一步步讲起。
创建数组
创造的神秘,有如夜间的黑暗,是伟大的。而知识的幻影,不过如晨间之物。——泰戈尔
常规且标准的创建数组的方式有 3 种。
1.直接使用字面量[]创建
let arr1 = [0, 1, 2];
2.使用 Array 构造函数创建
let arr1 = new Array(0, 1, 2);
3.调用 Array 内置方法创建
let arr1 = Array(0, 1, 2);
异同之处:
方法 2 和方法 3 的作用是相同的,因为在 Array 函数实现内部判断了 this 指针。
new Array
和 Array
有两种用法,在仅传递 1 个参数的时候,创建的不是存储该值的数组,而是创建一个值为参数个数的 undefined 元素。
let arr1 = [10]; // [10] let arr2 = Array(10); // [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]
不常规且不标准的创建数组的方式也有。具体几种千老师也没统计过,因为这不是很重要。最常见的不常规且不标准用法就是from
,这里不剧透了,可以继续朝下看。
访问数组
真理是生活,你不应当从你的头脑里去寻找。——罗曼·罗兰
说完创建数组,接着看看怎么访问数组。
访问数组其实很简单,通过方括号[]
就可以了。
let arr1 = [0, 1, 2]; arr1[0]; // 0
我们习惯写一个number
类型的参数放在方括号里,因为我们知道这个数字是元素的数组下标,数组下标是一个number
类型的值,很正常对吧。但其实不然,上面例子中的arr1[0]
在真正被执行的时候,会变成arr1['0']
。会经过toString
方法隐式转换。为什么会这样呢?因为ECMAScript
规范规定了。这里先卖个关子,暂且不讲,感兴趣的同学可以自己去查一查。虽然会有个隐式转换过程,但一般正常一点的程序员是不会直接使用带引号的写法的。
实例属性
constructor
无父何怙,无母何恃?——《诗经》
在 ECMAScript 中,除了 null 和 undefined 外,几乎所有东西都有这个属性。表明了该对象是由谁构造出来的。
通常用到这个属性的场景,就是在判断对象是不是 Array 的实例。
let arr = []; arr.constructor === Array; // true
但是很遗憾,这个属性是可写的。可写意味着这个方式并不能百分之百辨别出一个对象是否是 Array 的实例。
let arr = []; arr.constructor = String; arr.constructor === Array; // false arr.constructor === String; // true
你看,本来是由 Array 生出来的变量 arr,通过一行代码,就改认 String 做父辈了。再次证实了,Array 的实例,都是有钱便是爹,有奶便是娘的货色。
再看个例子。
let str = ""; str.constructor = Array; str.constructor === Array; // false str.constructor === String; // true
str 和 arr 有着鲜明的差别。str 是孝子啊,亲爹就是亲爹,有钱也不管用。
其实除了 String,其他几种基本类型的 constructor 属性都是可以改,但是改了没起作用。
为什么没起作用呢?因为这涉及到开装箱的操作。
所以这里千老师出一道题:原始类型为什么可以调用方法和访问属性?
搞明白这道题,你就能明白上面这个现象是为什么了。这不算是 Array 的知识点,算是知识扩展吧。
你答上来上面这题,千老师再出一道题,通过构造函数创建出来的原始类型和用字面量创建出来的原始类型,有什么区别?
length
尺有所短,寸有所长
代表数组元素的个数。
let arr = [0, 1, 2]; arr.length; // 3
这个属性好像很简单,没什么好讲的对吧?
其实还真有点东西可以给大家讲讲。
length
最大的妙用,就是直接改变length
属性可以删除元素或增加元素。
let arr = [0, 1, 2]; arr.length = 2; console.log(arr); // [0, 1] arr.length = 5; console.log(arr); // [0, 1, empty × 3]
看到这里,又出现一个empty
,这是个啥?大家知道吗?
empty
是一个和undefined
很像,但又有一点细微区别的东西。
可以做个实验。
console.log(arr[3] === undefined); // true
在这个实验里,我们发现empty
是全等于undefined
的。
但是它们还存在一定区别。比如下面的实验。
arr.indexOf(undefined); // -1 arr.filter(item => item === undefined); // [] arr.forEach(item => { console.log(item); }); // 1, 2
indexOf
、filter
和forEach
都是不认为empty
等于undefined
的。会自动忽略掉empty
。
再做两个实验。
arr.includes(undefined); // true arr.map(item => typeof item); // ["number", "number", empty × 3]
但是includes
很很神奇的认为empty
就是和undefined
一个概念。而在map
中,则会自动保留empty
的空槽。
这里并不是说typeof
不好使,而是typeof item
这条语句,在碰到empty
时直接跳过了,没有执行。
为了证实这个事,再单独拿万能的 typeof
操作符做个实验。
console.log(typeof arr[3]); // undefined
这到底是个怎么回事呢?千老师在 ECMAScript6 的文档中发现,明确规定了empty
就是等于undefined
的,在任何情况下都应该这样对待empty
。千老师又去翻了下 V8 源码,果然在 V8 源码中找到了关于empty
的描述,原来它是一个空的对象引用。
空的对象引用这个东西,在 ECMAScript 中应该是什么类型呢?ECMAScript 一共就那么几个类型,按道理说,它不符合任何类型啊。
没办法,undefined
这个尴尬的数据类型就很莫名其妙地、很委屈地成为了empty
的背锅侠。
方法
from
士不可以不弘毅,任重而道远
从 ECMAScript1 开始,就一直有一个令人头疼的问题(当然令人头疼的问题远不止一个,我这里说有一个,并不是说只有一个,这里必须重点提醒一下。),ECMAScript 中充斥着大量的类数组对象。它们像数组,但又不是数组。最典型的像是arguments
对象和getElementsByTagName
。
为了解决这个问题,很多类库都有自己的解决方案,如大名鼎鼎的上古时代霸主级 JavaScript 库jQuery
,就有makeArray
这个方法。
随着日新月异的科技演变,经过无数 JavaScript 爱好者努力拼搏,追求奉献,经历了二十多年的沧海桑田,白云苍狗。ECMAScript 终于等来了from
这个自己的超级 API。有了这个 API 以后,ECMAScript 再也不需要像makeArray
这类第三方解决方案了。ECMAScript 站起来了!说到这,千老师不禁想起了那些曾为 ECMAScript 的自由,开放,扩展,交融而抛头颅洒热血的大神们,是他们,在 ECMAScript 遭受屈辱的时刻挺身而出,以力挽狂澜之势救黎民于苦难。在 ECMAScript 发展过程中,千老师看到了, ECMAScripter 们,敢于直面惨淡的人生,敢于正视淋漓的鲜血,在Java
,C
,C++
,甚至PHP
的鄙视下,在所有人嘴里的“不就是个脚本语言吗?”的侮辱中,我们以燃烧的激情和鲜血凝聚成精神的火炬,点燃了未来。
扯远了,我们收回来。吹了那么多,赶紧继续学习一下from
的使用吧。
作用:从类数组对象或可迭代对象中创建一个新的数组。
语法:Array.from(arrayLike[, mapFn[, thisArg]])
参数:
arrayLike
:想要转换成数组的伪数组对象或可迭代对象。- **
mapFn
**(可选) :如果指定了该参数,新数组中的每个元素会执行该回调函数。 - **
thisArg
**(可选):执行回调函数mapFn
时this
对象。
返回值:新的数组实例。
支持 String、Set、Map、arguments 等类型。
还支持通过函数来创建。
// String 转 Array let arr1 = Array.from("123"); // ["1", "2", "3"] // Set 转 Array let arr2 = Array.from(new Set([1, 2, 3])); // [1, 2, 3] // Map 转 Array let arr3 = Array.from( new Map([ [1, 1], [2, 2], [3, 3], ]) ); // [[1, 1], [2, 2], [3, 3]] // MapKey 转 Array let arr4 = Array.from( new Map([ [1, 1], [2, 2], [3, 3], ]).keys() ); // [1, 2, 3] // MapValue 转 Array let arr5 = Array.from( new Map([ [1, 1], [2, 2], [3, 3], ]).values() ); // [1, 2, 3] // arguments 转 Array function fn() { return Array.from(arguments); } fn(1, 2, 3); // [1, 2, 3]
除了转换这个作用以外,喜欢探索的程序员又发现了另外几个神奇的用法。
1.用来创建数组。
let arr = Array.from({ length: 3 }); console.log(arr); // [undefined, undefined, undefined]
from
方法很不错,并没有创建 3 个empty
出来。看来 ECMAScript6 的规范还是挺好使的,至少 Chrome 听他的。
还可以在这里加一些逻辑,比如生成某个范围的数字数组。
let arr = Array.from({ length: 3 }, (item, index) => index); console.log(arr); // [0, 1, 2]
2.浅拷贝数组。
let arr = [1, 2, 3]; let arr2 = Array.from(arr);
3.深拷贝数组。
基于浅拷贝数组,结合 Array.isArray 来实现的。原理很简单。
function arrayDeepClone(arr) { return Array.isArray(arr) ? Array.from(arr, arrayDeepClone()) : arr; }
说到这里,千老师提一个问题:在 ECMAScript 中,深浅拷贝数组的方法有几种,有什么优劣,适合哪些应用场景?
除了这几个方法以外,还有很多其它场景的妙用,这里就不一一举例了。总之from
这个 API 非常灵活,喜欢探索的同学可以自己多去尝试。
isArray
假作真时真亦假,真作假时假亦真。
作用:用于判断某个变量是否是数组对象。
语法:Array.isArray(obj)
参数:
obj
:需要检测的值。
返回值:如果值是 Array
,则为 true; 否则为 false。
返回一个 boolean 值。
let arr = []; Array.isArray(arr); // true
判断某个变量是否为数组,还有另外两个常见的方法。
1.使用instanceof
。
let arr = []; arr instanceof Array; // true
instanceof
的原理是通过原型链来实现的。即判断左边对象的原型链上是否存在右边原型。这里出道题:如何手动实现 instanceof
?
2.使用constructor
。
let arr = []; arr2.constructor === Array; // true
constructor
属性保存了实例被创建时的构造方法,但这个属性是可以被修改的。
3.使用Object.prototype.toString.call
let arr = []; Object.prototype.toString.call(arr); // "[object Array]"
Object.prototype.toString.call()
常用于判断 ECMAScript 的内置对象。但这个方法是可以被重写的。
这几种方法各有弊端。但一般强烈推荐直接使用Array.isArray
。因为在iFrame
中,instanceof
和constructor
会失效。而Object.prototype.toString
这种方式又太过繁琐。
这里千老师补充一句,这几种方法的返回值都是可以被篡改的。所以当有时候代码不符合预期的时候,不要太相信自己的眼睛,多动动脑子。
下面是篡改的方法,不过千万不要闲的没事在自己项目里乱改哦,省的被领导K。
let arr = []; Array.isArray = () => false; Array.isArray(arr); // false let arr2 = []; arr2.__proto__ = Object; arr2 instanceof Array; // false let arr3 = []; arr3.constructor = String; arr3.constructor === Array; // false let arr4 = []; Object.prototype.toString = () => "object"; Object.prototype.toString.call(arr4); // "object"
of
差以毫厘,谬以千里。——《汉书》
这里再说一个 Array 设计之初的糟粕,因为使用 new Array
或者 Array
的方式创建数组时,会根据参数的个数和类型做出不同的行为。导致你永远无法使用new Array
来创建一个只有一个 number 类型的数组。夸张点说,of
方法出现的理由就只有一个,很纯粹也很现实,为了**创建一个只有一个 number 类型的数组。**这么干的好处就是统一创建数组的行为方式。
作用:用于创建一个具有可变数量参数的新数组实例,而不考虑参数的数量或类型。
语法:Array.of(element0[, element1[, ...[, elementN]]])
返回值:新创建的数组实例。
let arr = Array.of(10); // [10]
主要是用于区分 Array
传递 1 个 number
类型参数的情况。这里一定会创建一个元素等于第一个参数的数组。
大家以后在创建数组必须用到Array
构造方法时,使用of
方法来替代,是一个不错的方案。
实例修改器方法
会改变数组本身的值。
copyWithin
失之东隅,收之桑榆。——《后汉书》
存在感极低的 API,甚至找不到应用场景,这就是所谓的过度设计。
作用:浅复制数组的一部分到数组的另一个位置,并返回它,不会改变数组的长度。
语法:arr.copyWithin(target[, start[, end]])
参数:
- target:要挪到的目标位置下标
- start:可选,起始索引,默认值为 0。
- end:可选,终止索引(不包含这个元素),默认值为
arr.length
。
返回值:修改后的数组。
let arr1 = [0, 1, 2, 3, 4]; let result = arr1.copyWithin(1, 2, 4); // 截取下标 2-4 的元素,插入到下标 1 的位置 console.log(arr1); // [0, 2, 3, 3, 4] console.log(result); // [0, 2, 3, 3, 4]
fill
不积跬步,无以至千里;不积小流,无以成江海。——《荀子》
存在感和copyWithin
一样。
作用:将数组中指定区间的所有元素的值,都替换成某个固定的值。
语法:arr.fill(value[, start[, end]])
参数:
- value:用来填充数组元素的值。
- start:可选,起始索引,默认值为 0。
- end:可选,终止索引(不包含这个元素),默认值为
arr.length
。
返回值:修改后的数组。
let arr = [0, 1, 2, 3]; let result = arr.fill(1, 2, 3); console.log(arr); // [0, 1, 1, 3] console.log(result); // [0, 1, 1, 3]
pop
君子爱财,取之有道。——《论语》
pop
的灵感来源于栈。其实就是栈的标准操作之一,也是最基础的操作。pop
和push
是一对相爱相杀的好兄弟。
作用:删除数组的最后一个元素,并返回这个元素。
语法:arr.pop()
返回值:从数组中删除的元素(当数组为空时返回undefined
)。
const arr1 = [1, 2, 3]; const result = arr1.pop(); console.log(arr1); // [1, 2] console.log(result); // [1, 2]
pop
作为最古老、最基础的操作,没有太多花里胡哨的玩法。
push
海纳百川,有容乃大;壁立千仞,无欲则刚。——林则徐
作为pop
的孪生兄弟,让我想起一句话,凡是push
给的,pop
都要拿走。
作用:在数组末尾添加一个元素,并返回操作后数组的 length。
参数:被添加到数组末尾的元素。
语法:arr.push(element1, ..., elementN)
返回值:新的 length
属性值。
let arr = [1, 2, 3]; let result = arr.push(5, 6, 7); console.log(arr); // [1, 2, 3, 5, 6, 7] console.log(result); // 4
pop
的反操作函数,同样是老牌 API,操作也非常简单。
reverse
三千功名尘与土,八千里云和月。——岳飞
作用:颠倒数组中元素的排列顺序,即原先的第一个变为最后一个,原先的最后一个变为第一个。
语法:arr.reverse()
返回值:修改后的数组。
const arr = [1, 2, 3]; const result = arr.reverse(); console.log(arr); // [3, 2, 1] console.log(result); // [3, 2, 1]
reverse
可以配合一些其他 API 来实现字符串的逆转。
let str = "hello,world!"; str .split() .reverse("") .join(""); // "!dlrow,olleh"
shift
删除我一生中的任何一瞬间,我都不能成为今天的自己。——芥川龙之介
shift
和pop
的作用是一致的,只不过pop
是删除数组的最后一个元素。
作用:从数组中删除第一个元素,并返回该元素的值。此方法更改数组的长度。
语法:arr.shift()
返回值:从数组中删除的元素; 如果数组为空则返回undefined
。
const arr1 = [1, 2, 3]; const result = arr1.shift(); console.log(arr1); // [2, 3] console.log(result); // 1
sort
人是自己行动的结果,此外什么都不是。——萨特
作用:使用客制化算法对数组的元素进行排序。默认排序顺序是在将元素转换为字符串,然后比较它们的 UTF-16 代码单元值序列时构建的
语法:arr.sort([compareFunction])
参数:
compareFunction([firstEl, secondEl]):可选,用来指定按某种顺序进行排列的函数。如果省略,元素按照转换为的字符串的各个字符的 Unicode 位点进行排序。
firstEl:第一个用于比较的元素。
secondEl:第二个用于比较的元素。
返回值:排序后的数组。
const arr1 = [1, 9, 9, 8, 0, 8, 0, 7]; const result = arr1.sort((x, y) => x - y); console.log(arr1); // [0, 0, 1, 7, 8, 8, 9, 9] console.log(result); // [0, 0, 1, 7, 8, 8, 9, 9]
排序的性能,取决于自定义的函数。以及运行引擎。千老师研究过底层引擎的现实,但是发现每个引擎的实现都不同,所以同样的代码,运行在不同的平台上面,速度都会有差异。
splice
意志命运往往背道而驰,决心到最后会全部推倒。——莎士比亚
作用:通过删除或替换现有元素或者原地添加新的元素来修改数组,并以数组形式返回被修改的内容。此方法会改变原数组。
语法:array.splice(start[, deleteCount[, item1[, item2[, ...]]]])
参数:
start
:指定修改的开始位置(从 0 计数)。如果超出了数组的长度,则从数组末尾开始添加内容;如果是负值,则表示从数组末位开始的第几位(从-1 计数,这意味着-n 是倒数第 n 个元素并且等价于array.length-n
);如果负数的绝对值大于数组的长度,则表示开始位置为第 0 位。
deleteCount
: 可选,整数,表示要移除的数组元素的个数。如果 deleteCount
大于 start
之后的元素的总数,则从 start
后面的元素都将被删除(含第 start
位)。如果 deleteCount
被省略了,或者它的值大于等于array.length - start
(也就是说,如果它大于或者等于start
之后的所有元素的数量),那么start
之后数组的所有元素都会被删除。如果 deleteCount
是 0 或者负数,则不移除元素。这种情况下,至少应添加一个新元素。
item1, item2, ...
:可选,要添加进数组的元素,从start
位置开始。如果不指定,则 splice()
将只删除数组元素。
splice
的意思是剪切,和访问器方法slice
仅一字之差,可相差的可不是一星半点。slice
是切片的意思。很多人经常弄混它们两个。我看到有个老外说了一种区分记忆的好办法,splice
多出来的这个p
,意思是Produces Side Effects
(产生副作用)。类似于这种名字相似,而又截然不同的 API,ECMAScript 可不仅仅只有这么一对,比如还有字符串的substr
和substring
。
splice
用法非常多,变化多端。但是归根结底一共就 4 种操作。
截断操作
只需要关注第 1 个参数就可以。只传递一个参数的时候,就意味着截断。splice
可以和length
一样截断数组。
let array = [0, 1, 2, 3]; array.splice(3); // 执行结果等同于 array.length = 3;
插入操作
只需要关注第 1 个参数和第 3 个参数就可以。第 1 个参数代表从哪开始插入,第 3 个参数代表插入什么元素。第 2 个参数设置为0
就代表插入操作。
let array = [0, 1, 2, 3]; array.splice(1, 0, "1"); // 从下标为1的地方插入元素 '1' console.log(array); // [0, "1", 1, 2, 3]
删除操作
只需要关注第 1 个参数和第 2 个参数就可以。第 1 个参数代表从那开始删除,第 2 个参数代表删除几个元素。
let array = [0, 1, 2, 3]; array.splice(1, 1); // 从下标为1的地方删除1个元素 console.log(array); // [0, 2, 3]
删除并插入操作
这种操作需要关注所有的参数,如同前面所讲。
const arr = [1, 2, 3]; const result = arr.splice(1, 2, 10, 11); // 从下标1的位置,删除2个元素,并加入元素 10 和 11 console.log(arr); // [1, 10, 11] console.log(result); // [2, 3]
需要注意,splice
还支持负下标。
let array = [0, 1, 2, 3]; array.splice(-2, 1, 1, 2, 3); // [0, 1, 1, 2, 3, 3]
unshift
问渠哪得清如许,为有源头活水来。——朱熹
作用:将一个或多个元素添加到数组的开头,并返回该数组的新长度。
语法:arr.unshift(element1, ..., elementN)
参数列表:要添加到数组开头的元素或多个元素。
返回值:返回添加后数组的 length
属性值。
没啥好说的,shift
的反义词,push
的好兄弟。
const arr = [1, 2, 3]; const result = arr.unshift(5); console.log(arr); // [5, 1, 2, 3] console.log(result); // 4
类数组对象
具有数组特征的对象,就是类数组对象,也被称为ArrayLike
。
从第二部分,数组容器这一小节,我们已经了解到在JavaScript中,数组和类数组对象的操作是非常相近的。实际上,还有一个更为有趣的地方在于,我们不止可以把对象当作数组一样操作,甚至还可以使用数组的方法来处理对象。
let obj = { push: function(el) { return [].push.call(this, el); }, }; obj.push(2); console.log(obj); /** [object Object] { 0: 2, length: 1, push: function(el){ return [].push.call(this, el);} } **/
可以看到,push
会自动给对象添加一个0
属性和length
属性。
再做个实验。
let obj = { 0: 0, push: function(el) { return [].push.call(this, el); }, }; obj.push(2); console.log(obj);
发现push
之后,原来的属性0
被替换成了 2。
这就是push
的规则:push
方法根据 length
属性来决定从哪里开始插入给定的值。如果 length
不能被转成一个数值,则插入的元素索引为 0,包括 length
不存在时。当 length
不存在时,将会创建它。
下面再看一下length
存在的情况。
let obj = { 0: 0, length: 10, push: function(el) { return [].push.call(this, el); }, }; obj.push(2); console.log(obj); /** [object Object] { 0: 0, 10: 2, length: 11, push: function(el) { return [].push.call(this, el);} } **/
可以看到length
的属性执行了+1
操作,并且它认为现在数组里面已经存在 10 个元素了,那么新加入的 2 将会是第 11 个元素,下标为 10。push
就是如此愚蠢,是的,他就是这么愚蠢。
而他的兄弟,pop
具有和push
一样的行为。这里就不展开演示了,你可以自己拿一个对象扩展试试。
这种行为被称作鸭子类型
。那什么是鸭子类型呢?
鸭子类型(英语:duck typing)在程序设计中是动态类型的一种风格。在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口,而是由"当前方法和属性的集合"决定。这个概念的名字来源于由詹姆斯·惠特科姆·莱利提出的鸭子测试(见下面的“历史”章节),“鸭子测试”可以这样表述:
“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。”
也就是说,在 ECMAScript 中,长得像数组的对象,都可以被数组的方法所操作。这不仅仅局限于pup
和push
两个方法,其它很多方法都可以适用于这套规则。