学透JavaScript:你真的懂 Array 吗?

6,707 阅读1小时+

banner

前言

科普 JavaScript,揭开 JavaScript 神秘面纱,直击 JavaScript 灵魂。此系列文章适合任何人阅读。

本文章内容为:

  1. 标准化数组。
  2. 数组与数组容器。
  3. ECMAScript 规范中 Array API 讲解。
  4. 如果你想用 Array,而又不想学 API 的办法。
  5. 标准规范以外的 Array API 扩展。
  6. V8 引擎对 Array 的处理和优化。
  7. 数据本质。

Array 作为 JavaScript 语言除 Object 外唯一的复杂数据结构,彻底掌握它非常有必要。

这篇文章的初衷,就是讲透数组以及 JavaScript 中 Array 的概念、作用、所有 API 和便捷用法。最终可以达到融会贯通,在无数用法中找到最正确的那一种,让 Array 操作变成得心应手。

千老师写完这篇文章的时候,已经是 2019 年年底,截至文章完成,这些是最新的 ECMAScript 规范JavaScript 版 v8 的 ArrayC++版 V8 的 ArrayV8 Array 运行时

温馨提示:由于文章篇幅过长,我觉得你不太可能坚持一次看完。所以建议你先收藏。如果遇到看不懂的内容,或者不想看的内容,可以快进或者选择性观看。

标准数组:灵魂四问-从数组源头问起

要学习一个东西,最佳方式就是不断地抛出问题,通过对问题的探索,一步一步拨开迷雾,寻找真相。

上面这句话是千老师写的,不具有权威性。所以千老师先提出一个问题,来证明这个观点是正确的。

提问到底有多重要?这是个问题。这里千老师借鉴几位大神的名言解释一下这个问题:

  1. 创造始于问题,有了问题才会思考,有了思考,才有解决问题的方法,才有找到独立思路的可能。—— 陶行知

  2. 提出正确的问题,往往等于解决了问题的大半。——海森堡

  3. 生活的智慧大概就在于逢事都问个为什么。——巴尔扎克

  4. 有教养的头脑的第一个标志就是善于提问。——普列汉诺夫

  5. 一个聪明人,永远会发问。——著名程序员千老师

  6. 善问者,如攻坚木,先其易者,后其节目。 ——《礼记·学记》

  7. 好问则裕,自用则小。——《尚书·仲虺之诰》

  8. 敏而好学,不耻下问。——《论语·公冶长》

  9. 君子之学必好问,问与学,相辅而行者也。非学,无以致疑;非问,无以广识。——刘开

  10. 知识的问题是一个科学问题,来不得半点虚伪和骄傲,决定的需要的倒是其反面——诚实和谦逊的态度。——毛主席

好,千老师随便一整理,就整理出十个解释问题为什么重要的原因,连伟大的开国领袖毛主席都喜欢问题,非常棒。但这是一篇讲解程序的文章,不是学习名言警句的文章,所以大家收一收。只要明白”带着问题去学习效率是非常高的“这个道理就足够了。下面转入正题。

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 中的 object0x12345678key,你创建的变量名和变量的值是 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 ArrayArray有两种用法,在仅传递 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

indexOffilterforEach都是不认为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 们,敢于直面惨淡的人生,敢于正视淋漓的鲜血,在JavaCC++,甚至PHP的鄙视下,在所有人嘴里的“不就是个脚本语言吗?”的侮辱中,我们以燃烧的激情和鲜血凝聚成精神的火炬,点燃了未来。

扯远了,我们收回来。吹了那么多,赶紧继续学习一下from的使用吧。

作用:从类数组对象或可迭代对象中创建一个新的数组。

语法:Array.from(arrayLike[, mapFn[, thisArg]])

参数:

  • arrayLike:想要转换成数组的伪数组对象或可迭代对象。

  • **mapFn **(可选) :如果指定了该参数,新数组中的每个元素会执行该回调函数。

  • **thisArg **(可选):执行回调函数 mapFnthis 对象。

返回值:新的数组实例。

支持 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中,instanceofconstructor会失效。而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的灵感来源于栈。其实就是栈的标准操作之一,也是最基础的操作。poppush是一对相爱相杀的好兄弟。

作用:删除数组的最后一个元素,并返回这个元素。

语法: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

删除我一生中的任何一瞬间,我都不能成为今天的自己。——芥川龙之介

shiftpop的作用是一致的,只不过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 可不仅仅只有这么一对,比如还有字符串的substrsubstring

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 中,长得像数组的对象,都可以被数组的方法所操作。这不仅仅局限于puppush两个方法,其它很多方法都可以适用于这套规则。

实例访问方法

不会改变数组本身的值,会返回新的值。

concat

大厦之成,非一木之材也;大海之润,非一流之归也。——《东周列国志》

作用:合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组。

语法:var new_array = old_array.concat(value1[, value2[, ...[, valueN]]])

参数:value*N*可选

将数组和/或值连接成新数组。如果省略了valueN参数参数,则concat会返回一个它所调用的已存在的数组的浅拷贝。

返回值:新 Array 实例。

const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const result = arr1.concat(arr2, 7, "8", [9]);
console.log(result); // [1, 2, 3, 4, 5, 6, 7, "8", [9]]

concat主要需要注意 2 个点。第 1 点,可以连接所有东西,并不一定是数组,如上面的例子。

第 2 个点是concat的操作是浅拷贝,如下。

const arr1 = [1, 2, 3];
const obj = { k1: "v1" };
const arr2 = [[4]];
const result = arr1.concat(obj, arr2);
console.log(result); // [1, 2, 3,{ k1: "v1" }, [4]]
obj.k2 = "v2";
arr2[0].push(5);
console.log(result); // [1, 2, 3,{ k1: "v1", k2: "v2" }, [4, 5]]

includes

无息乌乎生,无绝乌乎续,无无乌乎有? ——宋应星《谈天·日说三》

作用:判断一个数组是否包含一个指定的值,根据情况,如果包含则返回 true,否则返回 false。

语法:arr.includes(valueToFind[, fromIndex])

参数:

valueToFind:可选,需要查找的元素值。如果不传递,直接返回false

fromIndex:可选,从fromIndex 索引处开始查找 valueToFind。如果为负值,则按升序从 array.length + fromIndex 的索引开始搜 (即使从末尾开始往前跳 fromIndex 的绝对值个索引,然后往后搜寻)。默认为 0。

返回值:boolean 值,表示元素是否存在。

const arr1 = [1, 2, 3];
const result = arr1.includes(1);
console.log(result); // true
const result2 = arr1.includes(22);
console.log(result2); // false

注意:无法判断字面量的引用类型,只能判断基础类型和引用。

const t = [1];
const arr1 = [t, 2, 3];
const result = arr1.includes(t);
console.log(result); // true
const result2 = arr1.includes([1]);
console.log(result2); // false

includes是在 EMAScript6 中诞生的。早在 EMAScript5 中,大家喜欢用indexOf是否等于-1来判断某个元素是否存在于数组中。业界也曾经有过一个作用类似的第三方 API,叫做contains。那么既然是新出来的 API,而又在做和已存在 API 类似的事情,那么一定是有原因的,什么原因呢?

先来看一个常规操作。

const arr = [1, 2, 3];
console.log(arr.indexOf(2) !== -1); // true
console.log(arr.includes(2)); // true

好像没有问题?

那么再来看一个不一般的操作。

const arr = [NaN];
console.log(arr.indexOf(NaN) !== -1); // false
console.log(arr.includes(NaN)); // true

区别出来了!indexOf并不能正常匹配到NaN,因为在 ECMAScript 中,NaN === NaN的结果是false

再来看一个例子。

const arr = [, , , ,];
console.log(arr.indexOf(undefined) !== -1); // false
console.log(arr.includes(undefined)); // true

这两个大概就是includesindexOf最大的区别吧。

join

时人莫小池中水,浅处无妨有卧龙。——窦庠《醉中赠符载》

作用:将一个数组的所有元素连接成一个字符串并返回这个字符串。如果数组只有一个项目,那么将返回该项目而不使用分隔符。

语法:arr.join([separator])

参数:

separator:可选,指定一个字符串来分隔数组的每个元素。如果需要,将分隔符转换为字符串。如果缺省该值,数组元素用逗号(,)分隔。如果separator是空字符串(""),则所有元素之间都没有任何字符。

返回值:一个所有数组元素连接的字符串。如果 arr.length 为 0,则返回空字符串。

const arr = [1, 2, 3];
const result = arr.join();
console.log(result); // "1,2,3"

join和字符串的split的作用几乎是相反的。

join的使用需要注意一点,它会将每个元素先调用toString再进行拼接。像空数组这种元素,转成字符串就是"",所以拼接起来毫无存在感可言。

let arr = ["h", 9, true, null, [], {}];
let result = arr.join("|");
console.log(result); // "h|9|true|||[object Object]"

当然你可以主动覆盖它的toString方法,这样结果就不一样了。

let arr = ["h", 9, true, null, [], {}];
[].__proto__.toString = function() {
  return "Array";
};
let result = arr.join("|");
console.log(result); // "h|9|true||Array|[object Object]"

而如果数组中存在可能转成String的元素,就会发生异常。比如Symbol

let arr = ["h", 9, true, null, Symbol(1)];
arr.join("|"); // TypeError: Cannot convert a Symbol value to a string

slice

敢于浪费哪怕一个钟头时间的人,说明他还不懂得珍惜生命的全部价值。——达尔文

作用:从数组中截取一段形成新的数组。接收 2 个参数,第 1 个是开始元素的下标,第二个是结束元素的下标(不包含这个元素)。

语法:arr.slice([begin[, end]])

参数:

begin:可选,默认为 0,提取起始处的索引。

end:可选,默认为数组length。提取终止处的索引。(包含begin,不包含end

返回值:一个含有被提取元素的新数组。

const arr1 = [1, 2, 3];
const result = arr1.slice(1, 2); // 从下标1的位置,截取到下标2
console.log(result); // [2]

特殊用法:

slice()可以浅拷贝数组。

const result = arr1.slice();

toSource

书到用时方恨少、事非经过不知难。——陆游

toSource不属于标准 API,仅属于Firefox浏览器独有,不建议使用,可以直接 pass 掉,看下面的toString

作用:返回一个字符串,代表该数组的源代码。

语法:array.toSource()

const array = [1, 2, 3];
array.toSource(); // "[1, 2, 3]"

toString

鲸落海底,哺暗界众生十五年。——加里·斯奈德

作用:返回一个字符串,表示指定的数组及其元素。

语法:arr.toString()

返回值:一个表示指定的数组及其元素的字符串。

toString覆盖了ObjecttoString方法,返回一个字符串,其中包含用逗号分隔的每个数组元素。当一个数组被作为文本值或者进行字符串连接操作时,将会自动调用其 toString 方法。

join 不传递参数时作用相同,如果要手动将数组转成字符串时,建议使用join,因为更加灵活。

const arr1 = [1, 2, 3];
const result = arr1.toString();
console.log(result); // "1,2,3"

toLocaleString

大鹏之动,非一羽之轻也;骐骥之速,非一足之力也。——《潜夫论·释难》

语法:arr.toLocaleString([locales[,options]]);

参数:

locales:可选,带有 BCP 47 语言标记的字符串或字符串数组。

options:可选,一个可配置属性的对象,对于数字 Number.prototype.toLocaleString(),对于日期Date.prototype.toLocaleString()

返回值:表示数组元素的字符串。

既然存在了toString,那么toLocaleString是为了解决什么问题呢?

除了Array具有这个 API,DateNumberObject都存在这个 API。

toLocaleStringtoString的主要区别就是toLocaleString的参数了,它可以将元素转化成哪个国家的人类语言。比如一百万这个数字。西班牙人的表示方式为1.000.000,英国人的表示方式为1,000,000。而日期也是如此,比如中国大陆用 年年年年/月月/日日 上午 or 下午 12 小时制时分秒,而国外大多数是 日日/月月/年年年年 24 小时制时分秒。

const arr1 = [1000000, new Date()];
const resultENGB = arr1.toLocaleString("en-GB");
const resultESES = arr1.toLocaleString("es-ES");
const resultAREG = arr1.toLocaleString("ar-EG");
const resultZHCN = arr1.toLocaleString("zh-CN");
console.log(resultENGB); // "1,000,000,29/12/2019, 23:40:39"
console.log(resultESES); // "1.000.000,29/12/2019 23:40:39"
console.log(resultAREG); // ١٬٠٠٠٬٠٠٠,٢٩‏/١٢‏/٢٠١٩ ١١:٤١:٣١ م
console.log(resultZHCN); // "1,000,000,2019/12/29 下午11:40:39"

从上面的例子中,可以总结出toLocaleString存在的根本目的是为了保证多个国家的用户浏览器来是符合各自习惯的。因为中国人完全看不懂阿拉伯的数字和日期,阿拉伯人同样也不容易看懂中国人的日期一样。

第 2 个参数非常强大,应用场景一般是展示货币,它可以自定义转换后的样式。

const arr1 = [1000000, new Date()];
const resultGBP = arr1.toLocaleString("en-GB", {
  style: "currency",
  currency: "GBP",
});
const resultCNY = arr1.toLocaleString("zh-CN", {
  style: "currency",
  currency: "CNY",
});
console.log(resultGBP); // "£1,000,000.00,29/12/2019, 23:51:18"
console.log(resultCNY); // "¥1,000,000.00,2019/12/29 下午11:51:18"

可以看到,设置了钞票代码后,就可以将数字转换成钞票的样式。需要注意一点,人民币的代码是CNY,而不是RMB。好吧,开个玩笑。

其实在调用 Array 的toLocaleString时,会自动调用每个元素的toLocaleString。但是前面说了,只有数组、日期、数字和对象存在这个 API,那么其它的类型没有这个 API 咋办呢?调用toString呗。

indexOf

有些鸟是注定不会被关在牢笼里的,它们的每一片羽毛都闪耀着自由的光辉。——《肖申克的救赎》

作用:返回在数组中可以找到一个给定元素的第一个索引,如果不存在,则返回-1。

语法:arr.indexOf(searchElement[, fromIndex])

参数:

searchElement:要查找的元素。

fromIndex:可选,开始查找的位置。如果该索引值大于或等于数组长度,意味着不会在数组里查找,返回-1。如果参数中提供的索引值是一个负值,则将其作为数组末尾的一个抵消,即-1 表示从最后一个元素开始查找,-2 表示从倒数第二个元素开始查找 ,以此类推。 注意:如果参数中提供的索引值是一个负值,并不改变其查找顺序,查找顺序仍然是从前向后查询数组。如果抵消后的索引值仍小于 0,则整个数组都将会被查询。其默认值为 0。

在 ECMAScript5 时期,indexOf还一直在做一件和 includes 类似的事,前面也提到了,就是通过 indexOf 得到结果是否为-1 来判断数组中是否存在某个元素。后来为了职责单一,创造出了includes。所以indexOf目前的用途最主要的还是获取数组内第一个与参数匹配的某个下标。

const arr1 = [1, 2, 3];
const result = arr1.indexOf(3);
console.log(result); // 2

const result2 = arr1.indexOf(21);
console.log(result2); // -1

indexOf匹配元素时,只能正常匹配基本类型的元素。像引用类型,就必须要使用引用来匹配。

const arr1 = [];
const arr2 = [0, [], arr1];
console.log(arr2.indexOf([])); // -1
console.log(arr2.indexOf(arr1)); // 2

indexOflastIndexOf是一对,其实indexOf的名字应该叫作firstIndexOf

lastIndexOf

方向是比速度更重要的追求。——白岩松

作用:返回指定元素在数组中的最后一个的索引,如果不存在则返回 -1。从数组的后面向前查找,从 fromIndex 处开始。

参数:

searchElement:被查找的元素。

fromIndex:可选,从此位置开始逆向查找。默认为数组的长度减 1(arr.length - 1),即整个数组都被查找。如果该值大于或等于数组的长度,则整个数组会被查找。如果为负值,将其视为从数组末尾向前的偏移。即使该值为负,数组仍然会被从后向前查找。如果该值为负时,其绝对值大于数组长度,则方法返回 -1,即数组不会被查找。

返回值:数组中该元素最后一次出现的索引,如未找到返回-1。

和 indexOf 作用几乎相同,唯一的区别是从后面进行匹配。

const arr1 = [1, 2, 3, 2];
const result = arr1.lastIndexOf(2);
console.log(result); // 3

const result2 = arr1.indexOf(2);
console.log(result2); // 1

flat

即使我们生活在阴沟里,我们也要仰望星空。——电影《少年的你》影评

flat是2019年新加入的API,由于“把多维数组摊平”这个需求一直存在,但需求程度不是很高,所以并没有被官方特别重视,直到现在才出现了这个API。

在早期的JavaScript中,事实上,在2019年这个API没有开放之前,我们还在使用自己制作的摊平API来实现这个功能,具体可以看后面的内容,有具体的代码实现。

flat的作用很简单,就是把一个数组摊平而已。摊平到什么程度,由你而定。

比如这样一个数组:

let arr = [1, 2, [3, 4, [5, 6, [7, 8, 9]]]];

摊开一层。

let result = arr.flat(1);
console.log(result);// [1, 2, 3, 4, [5, 6, [7, 8, 9]]]

摊开两层。

let result = arr.flat(2);
console.log(result);// [1, 2, 3, 4, 5, 6, [7, 8, 9]]

如果你不知道这是一个几维数组,而又想将它们全部摊开,那么就传入Infinity即可。

let result = arr.flat(Infinity);
console.log(result);// [1, 2, 3, 4, 5, 6, 7, 8, 9]

如果直接调用flat()而不传递任何参数,你认为效果应该是怎样的呢?是全部摊开吗?

那你就猜错了。

如果不传递任何参数,那么效果和传递1是一样的。

flatMap

再多的才智也无法阻挡愚蠢和庸碌的空虚——《瑞克与莫蒂》

flatMapflat是一起出生的。你可以尝试能否从ECMAScript神奇的API命名规则上猜测一下它的作用。

也许让你猜对了。flatMap的作用就是flatmap这两个API的结合体。

如果你想让一个数组中每个元素复制自身并且创造一个为自身2倍的值加入到下一个下标中。

你需要做两步,先使用map得到这些值,但它们的返回值变成了一个二维数组。

第二步自然就是把这个返回的数组摊平成一维数组了。

let arr = [1, 2, 3];
const result = arr.map((item) => [item, item * 2]).flat();
console.log(result);// [1, 2, 2, 4, 3, 6]

flatMap的作用是什么呢?就是把这个链式调用的API,合并成一个API。

let arr = [1, 2, 3];
const result = arr.flatMap((item) => [item, item * 2]);
console.log(result);// [1, 2, 2, 4, 3, 6]

看,多么无聊的API。真佩服ECMA那帮语言学的设计天才们。

这只是一句无足轻重的吐槽,请不要在意。

著名的作家Kevin Kelly在一次演讲中说过一段话:

关于技术,在最开始时,没有人知道新的发明最适合用于做什么,例如艾迪生的留声机,他原本不知道这能用来干什么。留声机慢慢被应用于两个场景:一是录下临终遗言;二是录下教堂里的讲话,包括唱歌。后来留声机主要用于录制音乐等。

但我们生活中不乏很多人有这种思想:世界不需要没有用的创新。

觉得地说,世界上不存在没有用的创新,所有创新都有它的用途。那些认为无用的创新无用的人,大概是没办法等待漫长的发掘创新用途的过程。其实:存在即合理,合理即存在。

实例迭代方法

对原始数组进行遍历。在遍历过程中,数组元素的操作不会受到影响。

forEach

千般荒凉,以此为梦;万般蹀躞,以此为归。——余秋雨

作用:对数组的每个元素执行一次提供的函数。

语法:arr.forEach(callback(currentValue [, index [, array]])[, thisArg]);

参数:

callback:为数组中每个元素执行的函数,该函数接收三个参数:

  • currentValue:数组中正在处理的当前元素。
  • index:可选,数组中正在处理的当前元素的索引。
  • array 可选,forEach() 方法正在操作的数组。

thisArg:可选,可选参数。当执行回调函数 callback 时,用作 this 的值。

返回值:undefined

虽然说forEach作为日常开发最为频繁的一个 API,但仍然有非常多的细节不被大家所熟知。导致很多同学在使用forEach时出现意料之外的现象发生,让人感到困惑。

下面千老师来分析一下forEach到底有哪些需要注意的细节。

forEach在第一次执行时就会确定执行范围。在forEach执行期间,人为改变数组元素会影响forEach的执行。在上面我们学到了,修改数组的方法一共有 9 种。

分别是添加类(push、unshfit、splice)、删除类(pop、shift、splice)、填充类(copyWithin、fill)、改变顺序类(reverse、sort)。除数组自身的方法以外,arr[i] = xarr.length = idelete array[i]这几种方式也会改变数组。

先看一个简单的例子。

const array = [0, 1, 2, 3, 4, 5];

array.forEach((currentValue, index, array) => {
  if (currentValue % 2 === 0) {
    array.push(1);
  }
  console.log(currentValue);
});

console.log(array);
/*
0
1
2
3
4
5
[0, 1, 2, 3, 4, 5, 1, 1, 1]
*/

可以看到,在forEach过程中,向数组新添加的数据,是不会被遍历到的。

但是如果在forEach的过程中修改数据,forEach则会读取遍历到它的那一刻的值。比如调整一下上面的那个例子。

const array = [0, 1, 2, 3, 4, 5];

array.forEach((currentValue, index, array) => {
  if (currentValue % 2 === 0) {
    array[index + 1]++;
  }
  console.log(currentValue);
});

console.log(array);
/*
0
2
3
3
4
6
[0, 2, 3, 3, 4, 6, NaN]
*/

注意事项

因为 API 太多,不再一一举例。这里简单归纳总结一下forEach的规律:

1.forEach执行开始时,就会确定执行次数。无论数组长度如何变化,都不会超过这个执行次数。但可能会低于这个次数。

2.forEach执行过程中,长度被改变。增长时没作用,减少时,到达数组最大长度后,就会结束(跳过)遍历。

3.forEach执行过程中,元素被改变。会读取遍历到该元素那一刻的值。

4.forEach不可以被像mapfilter一样被链式调用,因为它的返回值是undefined,而不是个数组。

5.除了抛出异常以外,没有办法中止或跳出 forEach() 循环。如果你需要中止或跳出循环,forEach() 方法不是应当使用的工具。最简单的办法是使用for,或者everysome等元素。

forEachfor

性能问题

在早期的浏览器中,forEach的性能一直都不如for的性能。所以导致大家的一个错误观点,即使是现在人们仍认为forEach的性能不如for,其实不然,得益于 V8 引擎的优化。如今在较新版的的浏览器或者 nodejs 里面,forEachfor的性能都是不相上下的,也许for会占据一点性能优势,但这个差距微乎其微。

为此千老师还特意做了一个实验,在 Chrome79 版本下, 长度为 100 万的数组的性能对比,我运行了 5 次:

let array = Array.from({ length: 1000000 }, (v, i) => {
  return i;
});
// for
console.time("log");
for (let i = 0; i < array.length; i++) {}
console.timeEnd("log");
// log: 17.89697265625ms
// log: 12.362060546875ms
// log: 18.535888671875ms
// log: 13.59326171875ms
// log: 13.08984375ms

// forEach
console.time("log");
array.forEach(function(val, i) {});
console.timeEnd("log");
// log: 16.1630859375ms
// log: 19.702392578125ms
// log: 18.179931640625ms
// log: 19.887939453125ms
// log: 20.77197265625ms

可以看到性能差距非常微小,甚至有时forEach的性能会胜过for

复杂性

for是典型的命令式编程产物。而forEach和其它的迭代方法一样,都属于函数式编程。for的唯一好处就是灵活性,breakcontinue的随时跳出。**但最大的优点同时也是最大的缺点。**功能强大就会导致出现一些难以阅读的复杂代码。比如下面这段代码。

for (var i = 0, len = grid.length, j = len - 1, p1, p2, sum; i < len; j = i++) {
  p1 = grid[i];
  p2 = grid[j];
  sum += p1 + p2;
}

forEach就不会出现这种情况,因为它屏蔽了for的配置条件。

最后千老师给出的建议就是,98%的情况下都应该优先使用forEach或者其它迭代方法,剩下 2%的情况应该是你在乎那一点点性能的情况,这时就需要你自己权衡了。

entries

内外相应,言行相称。——韩非

作用:返回一个新的Array Iterator对象,该对象包含数组中每个索引的键/值对。

语法:arr.entries()

返回值:一个迭代器(interator)对象。

迭代器 interator

文章到这里,第一次出现interator这个名词。千老师相信很多同学直到这个概念,但更多的同学可能不知道。这里有必要讲明白Iterator是什么。以便更好地理解数组。

其实要讲Iterator可以再写一篇文章的,但千老师尽量控制。简明扼要的把这个概念讲明白就行了。

它是Iterable对象上的[Symbol.iterator]属性。准确地讲,Array 也属于iterable

在 ECMAScript6 之前,JavaScript 中的对象没有办法区分哪些对象能迭代,哪些对象不能迭代。我们总会说数组可以被迭代,但我们没办法说一些类数组对象也能迭代。虽然它们能够被我们通过一些手段迭代,比如Array.prototype.forEach.call()。这些都是对象,都是靠我们的习惯认定一个对象能不能被迭代,并没有规范来约束。这样子很奇怪。所以 ECMAScript6 推出了一个迭代协议,来解决这个问题。

所有具有[Symbol.iterator]属性的对象,都属于可迭代对象(Iterable)。通过调用可迭代对象上的[Symbol.iterator]方法就可以得到一个迭代器(iterator)。通过调用迭代器身上的next方法就可以实现迭代。

let str = "hello World!";
let iterator = str[Symbol.iterator]();
let _done = false;
while (!_done) {
  const { value, done } = iterator.next();
  if (!done) console.log(value);
  _done = done;
}
/**
"h"
"e"
"l"
"l"
"o"
" "
"W"
"o"
"r"
"l"
"d"
"!"
**/

为什么这个属性的名字这么奇怪呢?长得并不是像length这种属性一样。**要知道 JavaScript 中所有看起来奇怪的设计都是有原因的。**因为在 ECMAScript2015 之前是没有迭代器这个概念的。这属于新加入的概念,为了保证兼容性,不能随便在对象原型上添加iterator这么个属性,不然就会导致之前的 JavaScript 代码产生意想不到的问题。刚好 ECMAScript2016 引入了Symbol概念。使用Symbol可以很好的解决属性冲突问题。

我们可以利用Symbol.iterator属性来创建可迭代对象。

class Rand {
  [Symbol.iterator]() {
    let count = 0;
    return {
      next: () => ({
        value: count++,
        done: count > 5,
      }),
    };
  }
}
var rand = new Rand();
var iterator = rand[Symbol.iterator]();
iterator.next(); // {value: 0, done: false}
iterator.next(); // {value: 1, done: false}
// ..
iterator.next(); // {value: 5, done: false}
iterator.next(); // {value: undefined, done: true}

上面的代码虽然看上去花里胡哨,其实语法很简单。Symbol.iterator方法返回一个带有next方法的对象。而next方法每次调用时会返回一个包含valuedone属性的对象,就这么简单。

value表示当前迭代的值,done表示迭代是否结束。

注意:可迭代对象(iterable)和迭代器对象(iterator)不是一回事。唯一的联系就是可迭代对象上会包含一个Symbol.iterator的属性,它指向一个迭代器对象。

ECMAScript6 还增加了一种新语法,中文叫展开操作符(Spread syntax)。可迭代对象可以利用这种操作符将一个可迭代对象展开。

let str = "hello World!";
let iterator = str[Symbol.iterator]();
let _done = false;
console.log(...str);
/*
"h"
"e"
"l"
"l"
"o"
" "
"W"
"o"
"r"
"l"
"d"
"!"
*/

ECMAScript6 中新添加的for of语法也是依据iteratorvaluedone进行循环。

稍微了解数据结构的同学会发现,这玩意不就是一个单向链表吗?还别说,iterator就是个单向链表。

明白了迭代器的概念,我们回到entries上面继续研究。

var arr = [1, 2, 3];
var iterator = arr.entries();
iterator.next();
/*
{value: Array(2), done: false}
value: (2) [0, 1]
done: false
*/
iterator.next();
/*
{value: Array(2), done: false}
value: (2) [1, 2]
done: false
__proto__: Object
*/

entries会将数组转化成迭代器。这个迭代器中每个迭代出的值都是一个数组,[下标, 值]的形式。这样有什么用呢?其实千老师一时半会也想不到有什么用,但是当你应该用到它的时候,自然就知道它的应用场景是什么了。(如果你还能记住有这么个 API 的话。)

every

天下之事常成于困约,而败于奢靡。——陆游

作用:测试一个数组内的所有元素是否都能通过某个指定函数的测试。它返回一个布尔值。

语法:arr.every(callback[, thisArg])

参数:

callback:用来测试每个元素的函数,它可以接收三个参数:

  • element:用于测试的当前值。

  • index:可选,用于测试的当前值的索引。

  • array:可选,调用 every 的当前数组。

thisArg:执行 callback 时使用的 this 值。

返回值:如果回调函数的每一次返回都为 truthy 值,返回 true ,否则返回 false

everysome很像,其实every和早期的几个迭代方法都很像,更恰当的说法是,早期的几个迭代方法都很像。everyforEach的区别有两点,第一点,every的执行会在不满足条件时停止遍历。第二点,every有一个返回值。

const arr = [0, 1, 2, 30, 4, 5];
const result = arr.every(function(item, index) {
  console.log(index);
  /*
	0
    1
    2
    3
	*/
  return item < 10;
});
console.log(result); // false

some

业精于勤,荒于嬉;行成于思,毁于随。——韩愈

作用:测试数组中是不是至少有 1 个元素通过了被提供的函数测试。它返回的是一个 Boolean 类型的值。

语法:arr.some(callback(element[, index[, array]])[, thisArg])

参数:

callback:用来测试每个元素的函数,接受三个参数:

  • element

    数组中正在处理的元素。

  • index 可选

    数组中正在处理的元素的索引值。

  • array可选

    some()被调用的数组。

thisArg:可选,执行 callback 时使用的 this 值。

返回值:数组中有至少一个元素通过回调函数的测试就会返回true;所有元素都没有通过回调函数的测试返回值才会为 false。

someevery很像,区别在于some会在碰到第一个符合条件的元素时停止遍历。所以这里也没什么好说的。把every的例子拿到这里就可以看到区别。

const arr = [0, 1, 2, 30, 4, 5];
const result = arr.some(function(item, index) {
  console.log(index);
  // 0
  // 1
  return item < 10;
});
console.log(result); // true

filter

物以类聚,人以群分。——《易经》

作用:创建一个新数组, 其包含通过所提供函数实现的测试的所有元素。

语法:var newArray = arr.filter(callback(element[, index[, array]])[, thisArg])

参数:callback:用来测试数组的每个元素的函数。返回 true 表示该元素通过测试,保留该元素,false 则不保留。它接受以下三个参数:

  • element:数组中当前正在处理的元素。
  • index:可选,正在处理的元素在数组中的索引。
  • array:可选,调用了 filter 的数组本身。

thisArg:可选,执行 callback 时,用于 this 的值。

返回值:一个新的、由通过测试的元素组成的数组,如果没有任何数组元素通过测试,则返回空数组。

可以用一句话理解filter取走我们想要的东西。

const arr = [0, 1, 2, 3, 4, 5];
const result = arr.filter(function(item, index) {
  return item % 2 === 0;
});
console.log(result); // [0, 2, 4]

filtermap是在 ES6 中最早加入的api。在没有filter时,forEach同样可以实现filter功能。

const arr = [0, 1, 2, 3, 4, 5];
let result = [];
arr.forEach(function(item, index) {
  if (item % 2 === 0) {
    result.push(item);
  }
});
console.log(result); // true

find

勇气通往天堂,怯懦通往地狱。——塞内加

作用:返回数组中满足提供的测试函数的第一个元素的值。否则返回 undefined

语法:arr.find(callback[, thisArg])

参数:

callback:在数组每一项上执行的函数,接收 3 个参数:

  • element:当前遍历到的元素。
  • index:可选,当前遍历到的索引。
  • array:可选:数组本身。

thisArg:可选,执行回调时用作this 的对象。

返回值:数组中第一个满足所提供测试函数的元素的值,否则返回 undefined

const arr = [0, 1, 20, 30, 40];
const result = arr.find((item) => item > 10);
console.log(result);// 20

findIndex

得之,我幸;不得,我命,如此而已。——徐志摩

作用:返回数组中满足提供的测试函数的第一个元素的索引。否则返回-1。

语法:arr.findIndex(callback[, thisArg])

参数:

callback:针对数组中的每个元素, 都会执行该回调函数, 执行时会自动传入下面三个参数:

  • element:当前元素。
  • index:当前元素的索引。
  • array:调用findIndex的数组。

thisArg:可选。执行callback时作为this对象的值。

返回值:数组中通过提供测试函数的第一个元素的索引。否则返回-1。

findIndex返回的结果就是find返回元素的索引。

const arr = [0, 1, 20, 30, 40];
const result = arr.findIndex((item) => item > 10);
console.log(result);// 2

keys

知人者智,自知者明。胜人者有力,自胜者强。——老子

作用:返回一个包含数组中每个索引键的Array Iterator对象。

语法:arr.keys()

返回值:一个新的Array迭代器对象。

let arr = ['a', 'b', 'c'];
const result = arr.keys();
for(let key of result){
  console.log(key);  
}
/*
0
1
2
*/

作用就是把数组转换成了一个存储了数组全部索引的迭代器。

keysObject.keys不同的是,keys不会忽略empty元素。

let arr = ['a', ,'b', , 'c', ,];
const result = arr.keys();
for(let key of result){
  console.log(key);  
}
/*
0
1
2
3
4
5
*/

map

有则改之,无则加勉。——《论语》

作用:创建一个新数组,其结果是该数组中的每个元素都调用一个提供的函数后返回的结果。

语法:

var new_array = arr.map(function callback(currentValue[, index[, array]]) {
 // Return element for new_array 
}[, thisArg])

参数:

callback:生成新数组元素的函数,使用三个参数:

  • currentValuecallback数组中正在处理的当前元素。
  • index:可选,callback 数组中正在处理的当前元素的索引。
  • array:可选,map方法调用的数组。

thisArg:可选,执行 callback 函数时值被用作this

返回值:回调函数的结果组成了新数组的每一个元素。

map是ECMAScript2015中最为最古老的一批API,它的主要作用就是映射一个数组。它的功能使用forEach同样能够实现。

比如将一个数组所有元素翻倍。

let arr = [1, 2, 3, 4, 5, 6, 7];
const result = arr.map(function(item) {
    return item * 2;
});
console.log(result);// [2, 4, 6, 8, 10, 12, 14]

forEach同样能够实现,但比map稍微麻烦一点。

let arr = [1, 2, 3, 4, 5, 6, 7];
const result = [];
arr.forEach(function(item) {
    result.push(item * 2);
});
console.log(result);// [2, 4, 6, 8, 10, 12, 14]

既然两者都可以实现,虽然map更简洁。那么应该在什么情况下使用map呢?

1.是否需要返回一个新的数组。

2.是否需要从回掉函数中得到返回值。

满足任一条件都可以使用map,否则使用forEach或者for...of

最常见的用法是从对象数组中提取某些值。

let arr = [
  { name: "dog", age: 11 },
  { name: "cat", age: 4 },
  { name: "小明", age: 15 },
];
const result = arr.map(function(item) {return item.age});
console.log(result); // [11, 4, 15]

当你不知道是否应该使用map时,也不用纠结,因为map最大的意义就是可以简化一些代码。

reduce

人的一生是短的,但如果卑劣地过这一生,就太长了。——莎士比亚

作用:对数组中的每个元素执行一个由您提供的reducer函数(升序执行),将其结果汇总为单个返回值。

语法:arr.reduce(callback(accumulator, currentValue[, index[, array]])[, initialValue])

参数:

callback:执行数组中每个值 (如果没有提供 initialValue则第一个值除外)的函数,包含四个参数:

  • accumulator:累计器累计回调的返回值; 它是上一次调用回调时返回的累积值,或initialValue(见于下方)。

  • currentValue:数组中正在处理的元素。

  • index:可选,数组中正在处理的当前元素的索引。 如果提供了initialValue,则起始索引号为0,否则从索引1起始。

  • array:可选,调用reduce()的数组。

initialValue:可选,作为第一次调用 callback函数时的第一个参数的值。 如果没有提供初始值,则将使用数组中的第一个元素。 在没有初始值的空数组上调用 reduce 将报错。

返回值:函数累计处理的结果。

let arr = [1, 2, 3, 4, 5, 6];
const result = arr.reduce(function(acc, val) {
    return acc + val;
});
console.log(result);// 21

mapfilter等API相比,reduce好像有点与众不同。不同在哪里呢?

reduce的字面意思是“减少”,实际上,称呼它为组合更合适一些。

举个形象点的例子,健身计算蛋白质、脂肪、碳水化合物。

这是一个食物的营养表。

const nutritionFacts = {
  "🥚": {
    carbohydrate: 3,// 碳水化合物
    protein: 13,// 蛋白质
    fat: 9,// 脂肪
    calories: 144// 热量
  },
  "🍏": {
    carbohydrate: 12,
    protein: 0,
    fat: 0,
    calories: 52
  },
  "🍌": {
    carbohydrate: 21,
    protein: 1,
    fat: 0,
    calories: 91
  },
  "🍞": {
    carbohydrate: 58,
    protein: 8,
    fat: 5,
    calories: 312
  },
  "🥦": {
    carbohydrate: 3,
    protein: 4,
    fat: 1,
    calories: 33
  },
  "🥩": {
    carbohydrate: 2,
    protein: 20,
    fat: 4,
    calories: 125
  }
};

下面我们可以通过reduce来封装一个计算热量的函数。

const calculation = (foods) => {
  return foods.reduce((nutrition, food) => {
    return {
      carbohydrate:
        nutritionFacts[nutrition].carbohydrate +
        nutritionFacts[food].carbohydrate,
      protein: nutritionFacts[nutrition].protein + nutritionFacts[food].protein,
      fat: nutritionFacts[nutrition].fat + nutritionFacts[food].fat,
      calories:
        nutritionFacts[nutrition].calories + nutritionFacts[food].calories
    };
  });
};

const result = calculation(["🥩", "🥦"]);
console.log(result);
/*
{
  calories: 158,
  carbohydrate: 5,
  fat: 5,
  protein: 24
}
*/

你可以多尝试一下,然后就能从上面的代码中发现一个BUG。如果没有发现,请继续尝试。

这个BUG就是:如果你只吃了一块牛肉,那么它会把牛肉原封不动地返回给你。(这不符合事实,正确答案应该是💩)

const result = calculation(["🥩"]);
console.log(result);// "🥩"

那么该怎样修复呢?

首先要确定原因,是什么原因导致出现了这个现象?你可能发现了,如果reduce的调用者的length为1时,它不会去调用callback的逻辑,而是直接返回该元素。

那么照着这个思路,改造方法大体上是根据传入的参数foodslength来给定返回值,如果length是1的话,直接返回它对应的营养成分。可以写出以下代码:

if (foods.length === 1) {
	const { carbohydrate, protein, fat, calories } = nutritionFacts[foods[0]];
    return {
      carbohydrate,
      protein,
      fat,
      calories
    };
} else if (foods.length < 1) {
    // 原来的逻辑
}

但这样明显感觉到很笨拙,有没有更加睿智的做法呢?当然是有的,别忘了reduce还存在第二个参数。如果不传递第二个参数,第一个参数就要被作为初始状态。这种情况下,第一个值就要被跳过。不过一旦有了第二个参数,第一个值就不会被跳过。所以我们可以用第二个参数更加优雅的解决这个问题。

nutritionFacts["💧"] = {carbohydrate: 0, protein: 0, fat: 0, calories: 0};// 添加 💧 默认所有营养都为0

const calculation = (foods) => {
  return foods.reduce((nutrition, food) => {
    return {
      carbohydrate:
        nutritionFacts[nutrition].carbohydrate +
        nutritionFacts[food].carbohydrate,
      protein: nutritionFacts[nutrition].protein + nutritionFacts[food].protein,
      fat: nutritionFacts[nutrition].fat + nutritionFacts[food].fat,
      calories:
        nutritionFacts[nutrition].calories + nutritionFacts[food].calories
    };
  }, "💧");
};

聪明的同学发现了一个等式。

["🥩", "🥦"].reduce(reducer, initialState);

[initialState, "🥩", "🥦"].reduce(reducer);

上面这两种用法,效果是相等的。

如果你用过Redux或者Rxjs,那么从上面的代码中,你应该看到了熟悉的东西。reducerinitialState

是的,它们都是使用的同一种思想和原理。

你可以这么想,Redux中的reduce并不是同步自动完成的,而是异步手动激活的。reducer的第一个参数currentState就是当前的基础状态。每次发动不同的action时,会触发一次reduce的调用。通过reducer的第二个参数currentValuereducer的逻辑来改变currentStatecurrentValue对应的是Redux中Action传递过来的参数typepayload

你可能有点听不明白,没关系,你早晚会明白的。

for一样可以实现reduce。比如计算碳水化合物。

let foods = ["🥩", "🥦"];
let result = {};
for(let i = 0; i < foods.length; i++) {
    result.carbohydrate = (result.carbohydrate || 0) + nutritionFacts[foods[i]].carbohydrate;
}
console.log(result);// { carbohydrate: 5 }

从上面的例子中可以看到,forreduce多了一个变量来存储上一次计算的结果值。

reduce其实也存在这么一个值,只不过得益于函数式编程的好处,它不会被你直接看到。可能现在你并不能感受到reducefor有什么太大的优势。但是当你面对一份数百行的代码文件时,reduce自动替你维护这个变量的优势又很容易体现出来了。

还需要注意一个点,reduce从字面意思看是减少,实际上它并不是减少。因为它的回调函数中的第一个参数可以是任何值,比如数组或者对象。既然是数组或者是对象,那么就是一个可以无限扩展的数据结构。所以,千万不要被reduce的字面意思骗过去了。

let arr = [0, 1, 2, 3, 4, 5];
const result = arr.reduce((accumulator, currentValue) => {
  accumulator.push(currentValue * 2);
  return accumulator;
}, []);
console.log(result);// [0, 2, 4, 6, 8, 10]

看,reduce可以实现类似于map的功能。同样的,reduce也可以实现forEachfilter等功能。

reduceRight

不要回避苦恼和困难,挺起身来向它挑战,进而克服它。——池田大作

作用:接受一个函数作为累加器(accumulator)和数组的每个值(从右到左)将其减少为单个值。

语法:arr.reduceRight(callback(accumulator, currentValue[, index[, array]])[, initialValue])

参数:

callback:一个回调函数,用来操作数组中的每个元素,可接受四个参数:

  • accumulator:上一次调用回调的返回值,或提供的 initialValue。
  • currentValue:当前被处理的元素。
  • index:可选,数组中当前被处理的元素的索引。
  • array:可选,调用 reduceRight() 的数组。

initialValue:可选,值用作回调的第一次调用的累加器。如果未提供初始值,则将使用并跳过数组中的最后一个元素。在没有初始值的空数组上调用reduce或reduceRight就会创建一个TypeError。

返回值:执行之后的返回值。

reduceRightreduce是一对双胞胎,不同之处是从后面朝前迭代。可以类比indexOflastIndexOf

values

我和谁都不争,和谁争我都不屑。——兰德

作用:返回一个新的 Array Iterator 对象,该对象包含数组每个索引的值。

语法:arr.values()

返回值:一个新的 Array 迭代对象。

let arr = ['a', 'b', 'c'];
const iterator = arr.values();

for(let item of iterator){
  console.log(item);
}
/*
"a"
"b"
"c"
*/

Symbol.iterator

一切特立独行的人格都意味着强大——加缪

作用:@@iterator 属性和 Array.prototype.values() 属性的初始值是同一个函数对象。

语法:arr[Symbol.iterator]()

返回值:与values相同。

一般不建议用这个方法,直接用values就好了。

let arr = ['a', 'b', 'c'];
const iterator = arr[Symbol.iterator]();

for(let item of iterator){
  console.log(item);
}
/*
"a"
"b"
"c"
*/

对象的数组相关方法

entries

忘记过去就意味着背叛。—— 列宁

作用:返回一个给定对象自身可枚举属性的键值对数组,其排列与使用 for...in 循环遍历该对象时返回的顺序一致(区别在于 for-in 循环还会枚举原型链中的属性)。

语法:Object.entries(obj)

参数:obj:可以返回其可枚举属性的键值对的对象。

返回值:给定对象自身可枚举属性的键值对数组。

let animals = {"dog": "🐶", "cat": "🐱", "bird": "🐦", "wolf": "🐺"};
const result = Object.entries(animals);
console.log(result);// [["dog", "🐶"], ["cat", "🐱"], ["bird", "🐦"], ["wolf", "🐺"]]

对象转换成数组后,元素的排列顺序并不取决于对象的定义顺序,但这个顺序是和for...in保持一致的。

entries常见的应用场景有两个。

1.遍历对象

for(let [key, value] of Object.entries(animals)) {
    console.log(`${key}:${value}`);
}
/*
"dog:🐶"
"cat:🐱"
"bird:🐦"
"wolf:🐺"
*/

或者使用forEach,结果是相同的。

Object.entries(animals).forEach(([key, value]) => console.log(`${key}: ${value}`));
/*
"dog: 🐶"
"cat: 🐱"
"bird: 🐦"
"wolf: 🐺"
*/

2.转换为Map

const map = new Map(Object.entries(animals));
console.log(map.size); // 4
console.log(map.has('dog')); // true
console.log(map.get('cat')); // "🐱"

keys

从善如登,从恶如崩。一一《国语》

作用:返回一个由一个给定对象的自身可枚举属性组成的数组,数组中属性名的排列顺序和使用 for...in循环遍历该对象时返回的顺序一致 。

语法:Object.keys(obj)

参数:obj:要返回其枚举自身属性的对象。

返回值:一个表示给定对象的所有可枚举属性的字符串数组。

keys就是把一个对象所有可枚举的属性名收集到一个数组中。

let animals = {"dog": "🐶", "cat": "🐱", "bird": "🐦", "wolf": "🐺"};
const result = Object.keys(animals);
console.log(result);// ["dog", "cat", "bird", "wolf"]

values

功崇惟志,业广惟勤。一一《尚书》

作用:返回一个给定对象自身的所有可枚举属性值的数组。

语法:Object.values(obj)

参数:obj:被返回可枚举属性值的对象。

返回值:一个包含对象自身的所有可枚举属性值的数组。

values的工作方式和entries十分类似。它们都会自动忽略原型链和不可枚举的属性。

let animals = {"dog": "🐶", "cat": "🐱", "bird": "🐦", "wolf": "🐺"};
const result = Object.values(animals);
console.log(result);// ["🐶", "🐱", "🐦", "🐺"]

values可以轻松将对象转换为Set。

let animals = {"dog": "🐶", "cat": "🐱", "bird": "🐦", "wolf": "🐺"};
const set = new Set(Object.values(animals));
console.log(set); // Set(4) {"🐶", "🐱", "🐦", "🐺"}

valueskeys是一对功能相似的API。

fromEntries

如果不忘记许多,人生无法再继续。——巴尔扎克

作用:把键值对列表转换为一个对象。

语法:Object.fromEntries(iterable);

参数:iterable:可迭代对象,类似 ArrayMap 或者其它实现了可迭代协议的对象。

返回值:一个由该迭代对象条目提供对应属性的新对象。

fromEntries是2019年新加入的API,fromEntriesentries的反转函数,设计初衷也是为了解决entries的使用后,数据被保留在数组中无法逆转的问题。

let animals = {"dog": "🐶", "cat": "🐱", "bird": "🐦", "wolf": "🐺"};
const animalsList = Object.entries(animals);
const animals2 = Object.fromEntries(animalsList);
console.log(animals2);
/*
{
  bird: "🐦",
  cat: "🐱",
  dog: "🐶",
  wolf: "🐺"
}
*/

fromEntries的作用不仅限于将Array转换为Object,它可以将所有可迭代对象转换为Object,如SetMap

字符串转数组

split

无事时不可或倦,人贤者视其自修。——左宗棠

作用:使用指定的分隔符字符串将一个String对象分割成子字符串数组,以一个指定的分割字串来决定每个拆分的位置。

语法:str.split([separator[, limit]])

参数:

separator:指定表示每个拆分应发生的点的字符串。separator 可以是一个字符串或正则表达式。 如果纯文本分隔符包含多个字符,则必须找到整个字符串来表示分割点。如果在str中省略或不出现分隔符,则返回的数组包含一个由整个字符串组成的元素。如果分隔符为空字符串,则将str原字符串中每个字符的数组形式返回。

limit:一个整数,限定返回的分割片段数量。当提供此参数时,split 方法会在指定分隔符的每次出现时分割该字符串,但在限制条目已放入数组时停止。如果在达到指定限制之前达到字符串的末尾,它可能仍然包含少于限制的条目。新数组中不返回剩下的文本。

返回值:返回源字符串以分隔符出现位置分隔而成的一个Array。

const str = "hello,world!";
const result = str.split(',');
console.log(result);// ["hello", "world!"]

使用split切割字符串返回的数组,每个元素都是源字符串的子串。

如果想把这个切割后的数组还原,可以用join

const str2 = result.join(',');
console.log(str2);// "hello,world!"

总结:面对如此庞杂的API,我们该怎么做?

在ECMAScript5.0时期,API并没有那么多。你可以去看《JavaScript高级程序设计(第3版)》,一共就那么几个关键的API。

从现在来看,很少有人能够在不借助文档的情况下完全清楚什么情景下应该用什么API。要知道,上面洋洋洒洒介绍的这么多API,仅仅是Array这么一个数据类型自己而已。而且,你确定上面这些内容你都记住并理解了吗?我相信你并没有记住多少。你或许现在正在烦恼该怎么记住它们。

千老师的建议是:不要刻意的背诵它们,这样没有意义。

如果你是一个长期和JavaScript接触的工作者;或者是一位JavaScript资深爱好者,可以适当的在历史项目的重构和新项目的开发中使用这些功能。不过要注意Transplier的支持。**练习是加深记忆的一个绝佳手段。**当你练习到一定程度后,就可以去找同道中人去交流、分享彼此的心得。不要只停留在自己的知识框架内,多出去走走。

如果你并不经常接触JavaScript,或者说JavaScript并不作为你的唯一语言,比如你是一个同时掌握了其它语言的全栈工程师,比如Java。那你更没有必要记住全部的API了。因为Java那边还有更多、更重要的东西需要去记忆。

但是,无论如何你都必须记住几个关键且核心的API。

哪些是关键且核心的呢?ECMAScript5.0时代的那些API以及ECMAScript2015第一批加入的API。记住这些你就可以解决所有场景。至于ECMAScript2015之后的API,其目的大多是为了修补一些历史问题,或者为了实现单一职责的设计而刻意设计出来的。虽然有趣,但未必真的有用。

神兵利器:Array 新手和懒人的工具

由于 Array 的 API 实在是太多,没有认真学习和理解,加上大量的实践和经验。是很难记住所有的 API 的。针对这种情况,千老师才会写出这篇文章。但是这篇文章的内容稍微有一点儿多。千老师还研究了一下如何偷懒的办法。比如现在你并没有记住和理解所有的 API,但你要用,而且要用对。怎么办呢?每次都查文档可是非常慢的。

没关系,一位美女程序员 Sarah Drasner 开源了一个 Array 利器ArrayExplorer。你只需要选择你需要的 Array 操作,就可以得到对应的 API。

其实呢,千老师觉得如果**作为一名一般的工程师,没有必须把 Array 学精通的必要。当然,如果你想作为一名不一般的工程师,那么千老师还是建议你把 Array 学精通。**所以不要侥幸,是福不是祸,是祸躲不过。千老师相信,每一名读这篇文章的你,都不会只是一名一般的工程师。

锦上添花:社区对 Array 的扩展

如何扩展原型?

得益于 ECMAScript 灵活的原型系统,每种数据类型都可以通过原型扩展的方式增加新的功能。

这一点是和 Java 有非常大的区别的。Java 要通过继承来实现对数组的扩展。

class MyArrayList extends ArrayList{
    public void hello(){
        System.out.println("hello");
    }
}

并且在此之后使用数组时,就不能再用 ArrayList 了,而要改用 MyArrayList。

MyArrayList arr = new MyArrayList();
arr.add(1);
arr.add("2");
arr.hello();// hello

除此之外还有另一种方式,那就是直接修改 JDK。

而 ECMAScript 就简单多了。扩展之后不影响原使用方式。

Array.prototype.hello = function() {
    console.log("hello");
}

[].hello();// hello

当然 ECMASCript 也支持继承的方式来扩展。

class MyArray extends Array {
  hello() {
    console.log("hello");
  }
}

坏处显而易见,创建数组的方式能通过new关键字,字面量的方式就行不通了。而依照我们的习惯,99%的人都是喜欢使用字面量的。

var arr = new MyArray();
arr.hello(); // hello

var arr2 = [];
arr2.hello(); // Uncaught TypeError: [].hello is not a function

在原型扩展这方面,prototype.js 算是业界鼻祖了。紧随其后的 right.js、ext.js、undersore.js 等框架都纷纷效仿。曾经有很长一段时间,几乎每个库都要对 Array 进行扩展。就好像不扩展一下 Array,都不敢称自己是个 JavaScript 库似的。Array 已经有三十多个属性和方法了,你们在扩展 Array 时,有替 Array 想一下吗?当然他们都没想,但 ECMA 替它想了。所以现在的原生 Array 已经非常非常强大,而这些库反而不行了。

现在来说,除了 lodash.js 以外,其它同类型的库几乎都不见踪影。但它们所扩展的方法确实是刚需,因为这些方法都是大家在写业务时碰到的决解方案,所以仍然值得我们去学习。如果你觉得现在学这些没有太大意义,说明你写的代码量还是不够,多写写复杂业务,早晚会碰到使用场景。

扩展API实现

千老师推荐一个比较成熟的 array 扩展库,d3-array。这个库很完善,可以直接拿到项目中使用。

下面展示一些比较常见的扩展方法的实现。

remove

作用:根据下标移除元素。

实现:

Array.prototype.remove = function(index) {
  return !!this.splice(index, 0);
};

用法:

var arr = ["a", "b", "c"];
const result = arr.remove(1);
console.log(result); // true
console.log(arr); // ['a', 'c']

removeAt

作用:根据元素移除元素

实现:

Array.prototype.removeAt = function(item) {
  const index = this.indexOf(item);
  if (index !== -1) {
    return !!this.splice(index, 1);
  }
  return false;
};

用法:

var arr = ["a", "b", "c"];
const result = arr.removeAt(1);
console.log(result); // true
console.log(arr); // ['b', 'c']

shuffle

作用:对数组进行洗牌,打乱数组元素。

洗牌算法相对来说是一个比较复杂的 API。它是一个非常古老的问题,可以追溯到 1938 年的 Knuth shuffle 算法,那时的我们还没有出生。

Knuth shuffle 的操作步骤大致为:

1.记录从 1 到 length-1 的数字。

2.从 1 到剩余未遍历数字之间选择随机数 k。

3.从尾端开始计数,每次剔除被随机取到的 k,并将其放置到数组末尾。

4.重复第 2-3 步,直到所有数字都被遍历。

其核心是使用Math对象提供的random函数。

在 npm 上已经有 N 个实现好的库,knuth-shuffle-seededknuth-shuffle

实现:

Array.prototype.shuffle = function() {
  for (len = this.length - 1; i > 0; i--) {
    rand = Math.floor(Math.random() * i);
    temp = this[rand];
    this[rand] = this[i];
    this[i] = temp;
  }
};

用法:

let arr = [0, 1, 2, 3, 4, 5, 6];
arr.shuffle();
console.log(arr); // [5, 4, 6, 1, 0, 2, 3]

random

作用:从数组中随机取出一个元素。

实现:

Array.prototype.random = function() {
  return this[Math.floor(Math.random() * this.length)];
};

用法:

let arr = [0, 1, 2, 3, 4, 5, 6];
const result = arr.random();
console.log(result);

flatten

作用:对数组进行平坦化处理,返回一个一维数组。

实现:

Array.prototype.flatten = function() {
  let result = [];
  this.forEach(function(item) {
    if (Array.isArray(item)) {
      result = result.concat(item.flatten());
    } else {
      result.push(item);
    }
  });
  return result;
};

用法:

let arr = [0, [1, 2], 3, [[[4], 5], 6]];
const result = arr.flatten();
console.log(result); // [0, 1, 2, 3, 4, 5, 6]

在ECMAScript2019之后,原生Array拥有了flatAPI,就不再需要这个第三方API了。但在更高的环境中仍可以使用。

unique

作用:对数组进行去重,返回一个没有重复元素的数组。

在 ECMAScript6 之前,unique的实现相对麻烦,但是在 ECMAScript6 之后,有了新的数据结构Setunique的实现就非常简单了。

使用Set实现:

Array.prototype.unique = function() {
  let set = new Set(this);
  return [...set];
};

ECMAScript5 实现:

Array.prototype.unique = function() {
  var result = [];
  loop: for (let i = 0; i < this.length; i++) {
    for (let j = i + 1; j < this.length; j++) {
      if (this[i] === this[j]) {
        continue loop;
      }
    }
    result.push(this[i]);
  }
  return result;
};

用法:

let arr = [0, 1, 1, 1, 2, 3, 3];
const result = arr.unique();
console.log(result); // [0, 1, 2, 3]

compact

作用:去除数组中的 null 与 undefined,并返回新数组。

实现:

Array.prototype.compact = function() {
  return this.filter(function(item) {
    return item != null;
  });
};

用法:

let arr = [0, null, 1, , undefined, 2];
const result = arr.compact();
console.log(result); // [0, 1, 2]

pluck

作用:取得数组中每个对象的某个属性,并组成数组返回。

实现:

Array.prototype.pluck = function(propertyName) {
  let result = [];
  this.forEach(function(item) {
    if (propertyName in item) {
      result.push(item[propertyName]);
    }
  });
  return result;
};

用法:

let arr = [
  { name: "dog", age: 11 },
  { name: "cat", age: 4 },
  { name: "小明", age: 15 },
];
const result = arr.pluck("age");
console.log(result); // [11, 4, 15]

groupBy

作用:根据指定条件进行分组,返回对象。

实现:

Array.prototype.groupBy = function(key) {
  return this.reduce((acc, i) => {
    (acc[i[key]] = acc[i[key]] || []).push(i);
    return acc;
  }, {});
}

使用:

let arr = [
  {
    name: "金庸",
    profession: "作家"
  },
  {
    name: "李小龙",
    profession: "武术家"
  },
  {
    name: "古龙",
    profession: "作家"
  }
];
const result = arr.groupBy("profession");
console.log(result);
/*
{
  作家: [
    {
      name: "金庸",
      profession: "作家"
	},
	{
  	  name: "古龙",
  	  profession: "作家"
	}
  ],
  武术家: [
    {
      name: "李小龙",
      profession: "武术家"
    }
  ]
}
*/

这个API的灵感来源于SQL中的GROUP BY操作。

union

作用:两个数组取并集。

实现思路和unique非常相似,两个数组拼接起来,再去重即可。

实现:

Array.prototype.union = function(arr) {
  let set = new Set(this.concat(arr));
  return [...set];
};

用法:

const result = [1, 2, 3, 4, 5].union([2, 3, 4, 5, 6]);
console.log(result);// [1, 2, 3, 4, 5, 6]

intersect

作用:两个数组取交集。

实现:

Array.prototype.intersect = function(arr) {
  return this.filter(function(item) {
      return ~arr.indexOf(item);
  });
};

用法:

const result = [1, 2, 3, 4, 5].intersect([2, 3, 4, 5, 6]);
console.log(result);// [2, 3, 4, 5]

diff

作用:两个数组取差集。

Array.prototype.diff = function(arr) {
    let result = [];
    for(let i = 0;i < this.length; i++) {
        if(!arr.includes(this[i])) {  
            result.push(this[i]);
        }
    }
    return result;
}

用法:

let arr = ['🐇', '🐘', '🐿️', '🐑'];
let arr2 = ['🐇', '🐑', '🐒', '🐄'];
const result = arr.diff(arr2);
console.log(result);// ["🐘", "🐿️"]

min

作用:取数组中最小值,仅用于数字数组。

实现思路是利用Math.minapply这两个函数的组合。

Array.prototype.min = function() {
    return Math.min.apply(0, this);
}

用法:

const result = [1, 2, 3, 4, 5].min();
console.log(result);// 1

max

作用:取数组中最大值,仅用于数字数组。

max的实现思路类同于min

Array.prototype.max = function() {
    return Math.max.apply(0, this);
}

用法:

const result = [1, 2, 3, 4, 5].max();
console.log(result);// 5

chunk

作用:将一个大数组分成N个小数组。

Array.prototype.chunk = function(size) {
    let chunked = [];
    let arr = this.slice();
    while(arr.length) {
        chunked.push(arr.splice(0, size));
    }
    return chunked;
}

用法:

const arr =  [1, 2, 3, 4, 5, 6, 7];
const result1 = arr.chunk(2);
console.log(result1);// [[1, 2], [3, 4], [5, 6], [7]]
const result2 = arr.chunk(3);
console.log(result2);// [[1, 2, 3], [4, 5, 6], [7]]
const result3 = arr.chunk(4);
console.log(result3);// [[1, 2, 3, 4], [5, 6, 7]]

总结

如何不污染原型链?

上面的所有例子都是通过扩展原型链来实现的。如果你不想修改原型链,也可以通过其它方式来实现这些功能,比如把它们都写成函数的形式或者写成一个类。

写成类的例子,在这一小节的最开始已经讲过了。这里主要讲讲改写成函数的方式,其实很简单,拿remove来举例。

原型链的实现方式:

Array.prototype.remove = function(index) {
  return !!this.splice(index, 0);
};

改写成函数的方式:

function remove(arr, index) {
  return !!arr.splice(index, 0);
};

用法:

remove([1, 2, 3, 4], 1);

只需要将函数的原参数列表的首部添加一个数组参数,将函数内部的this改为传入的参数即可,确实非常简单,这样做的好处就是不会改变数组原有的使用习惯。

追求优雅的极简化

不乏有很多对代码有洁癖的人。他们对代码有着一丝不苟的追求,喜欢更为优雅的实现方式。而拉姆达表达式(lambda),则是一个让代码简化到极致的手段。

比如使用lambdachunk实现进行简化。

Array.prototype.remove = index => !!arr.splice(index, 0);

这样简单多了。但是好像并不是很明显,因为原来的实现就很短。那么我们再来看另一个例子吧。

使用Math.ceilchunk进行改造。

const chunk = (arr, n) => [...new Array(Math.ceil(arr.length / n))].map((x, i) => arr.slice(i * n, (i + 1) * n ));

现在能看出一些对比了吗?N行代码被转换为1行。

凡是都有两面性,虽然看上去简洁多了,但是要读懂这行代码,成本要比原来的写法高一些。

编程是一门艺术,既然是艺术,就没有绝对的优劣。因为每个人对作品的理解不同。

扩展API的思路

其实如果你足够仔细,就能发现其实所有的扩展API或多或少都有依赖原有API或者其它API。将原来需要两步或者更多步骤的操作,组合成一个便于操作的API。如果你玩过英雄联盟,应该知道里面有一个英雄叫做锐雯,她有一个连招叫做光速QA。这个连招可以在极短的时间内打出爆炸性的伤害,但操作非常繁琐,需要“鼠标右键点击敌人进行普攻-键盘Q键释放技能-鼠标右键点击地板取消Q技能硬直”,然后重复此操作3次。年龄稍大的人,手速跟不上,是很难零失误打出这种连招的。但是中年人往往更加聪明,他们利用鼠标宏的功能,将连招的操作提前拼接好,录入鼠标中,当需要使用时,只需要按一下宏按钮,就可以轻松实现这一套繁琐的操作。这个道理和扩展API十分相似。依照这个思路,你可以想象并制作出更多的实用性API。

选读:V8 引擎层面上的处理

在灵魂四问中,我们知道了数组的内存范围是固定的,但 JavaScript 根本不在乎这些。

在语言层面上,既然 JavaScript 不管不顾,那么在引擎层面上,又是怎么处理的呢?

在 V8 的内部,数组的元素都被称为elements。为了优化性能,V8 将数组进行了特殊处理。而且还对类型进行了更为精确的区分。比如 number 类型的元素,在语言层面,JavaScript 只有一个typeof的操作符来得知一个变量是否为数字,而无法得知是否为整数型、浮点型或者双精度类型。在 V8 内部,一个数组内,全部元素都为整数型的话,那么这个数组的类型就被标记为PACKED_SMI_ELEMENTS。如果只存在整数型和浮点型的元素类型,那么这个数组的类型为PACKED_DOUBLE_ELEMENTS。除此以外,一个数组包含其它的元素,都被标记为PACKED_ELEMENTS。而这些数组类型并非一成不变,而是在运行时随时更改的。但是数组的类型只能从特定种类变更为普通种类。即初始为PACKED_SMI_ELEMENTS的数组,只能过渡为PACKED_DOUBLE_ELEMENTS或者PACKED_ELEMENTS。而PACKED_DOUBLE_ELEMENTS只能过渡为PACKED_ELEMENTS。至于初始就是PACKED_ELEMENTS类型的数组,就无法再过渡了。

而上述的这三种类型,都属于密集(压缩)数组。与之相对应的,是稀疏数组,标记为HOLEY_ELEMENTS,稀疏数组同样具有三种类型。任何一种PACKED都可以过渡到HOLEY

lattice

为什么有这两种区分呢?因为密集数组要比稀疏数组在操作上效率更高。

什么是密集数组?其实就是一个内存块连续的堆栈。

什么是稀疏数组?其实就是一个内存块散列的链表。

那在语言层面上,什么是稀疏数组?就是存在empty元素的数组。

let array = [0, 1, 2]; // PACKED_SMI_ELEMENTS
array.push(2.1); // PACKED_DOUBLE_ELEMENTS
array.push("3"); // PACKED_ELEMENTS
array[10] = "10"; // HOLEY_ELEMENTS

数组的类型越靠下,性能就越差。而且数组的类型过渡,只会由上到下,而不会从下到上。一个被标记为HOLEY_ELEMENTS的数组,即使把empty填充后,也不会再标记为PACKED_ELEMENTS

微小的性能优化技巧

这一小部分的优化其实意义不是很大,只是能够将性能提升到极致而已。

避免读取超出数组长度的内容

let array = [0, 1, 2];
array[10];

读取超出数组长度的数据产生的影响就是执行代价昂贵的原型链查找。

在 jQuery 某些地方,会存在这种模式的循环代码。

for (let i = 0, item; (item = items[i]) != null; i++) {
  doSomething(item);
}

这段代码的意思就是读取数组中所有元素,然后再读取一个。直到遇到 undefined 或者 null 元素时结束。

替代方案有三种,传统的for,可迭代对象的for-offorEach。现在的 V8,for-offorEach的性能与for相当。

更糟糕的一种情况是,在该数组的原型链中找到了这个值。

let arr = [1, 2, 3];
arr.__proto__ = { "10": "gg" };
console.log(arr[10]); // "gg"

避免元素种类转换

let array = [+0, 1, 2]; // PACKED_SMI_ELEMENTS
array.push(-0); // PACKED_DOUBLE_ELEMENTS

避免使用-0NaNInfinity,因为它们任何一个元素进入数组后都会导致数组的类型变为PACKED_DOUBLE_ELEMENTS

优先于数组而不是类似数组的对象

虽然在 JavaScript 中很多对象和数组极为相似,而且我们可以自己用对象创建类似数组的对象。这些在上面我们都讲过也演示过了。但终究还是有区别的。

比如这样一段代码:

let arrayLike = {};
arrayLike[0] = "a";
arrayLike[1] = "b";
arrayLike[2] = "c";
arrayLike.length = 3;
Array.prototype.forEach.call(arrayLike, (value, index) => {
  console.log(`${index}: ${value}`);
});

虽然运行起来逻辑没有问题,但是比数组直接调用forEach要慢得多,因为在 V8 中,forEach已经高度优化。

提高性能的方案就是,先将类似数组的对象转化为数组,在进行数组方法的调用。虽然有一次转换的成本,但是换来的性能优化是值得的,尤其是在数组上执行大量操作时。

const actualArray = Array.prototype.slice.call(arrayLike, 0);
actualArray.forEach((value, index) => {
  console.log(`${index}: ${value}`);
});

另一个比较常见的情况是arguments。 在以前,要输出参数,我们一般都会这么做。

const logArgs = function() {
  Array.prototype.forEach.call(arguments, (value, index) => {
    console.log(`${index}: ${value}`);
  });
};
logArgs("a", "b", "c");

但在 ES2015 中,我们可以借助rest参数解决这个问题。

const logArgs = (...args) => {
  args.forEach((value, index) => {
    console.log(`${index}: ${value}`);
  });
};
logArgs("a", "b", "c");

如今我们完全没有任何理由,直接使用arguments对象。除非你的代码要运行在 ES5 上面。

避免创造HOLEY

在现实世界的编码中,访问密集数组和稀疏数组的性能差异非常小,可能小到无法察觉,甚至无法测量。但是,保持极致的编码习惯仍然重要。

let array = new Array(3);
array[0] = 1;
array[1] = 2;
array[2] = 3;

这个构造函数会创造一个HOLEY_SMI_ELEMENTS类型的数组。按照上面 V8 的介绍,数组的类型过渡只会朝下,而不会朝上。所以一旦被标记为HOLEY,就永远都会是HOLEY。哪怕是把这些empty都填充上也无济于事。

更好的方式是使用字面量来创建数组。

let array = [1, 2, 3];

这可能会在该数组某些操作上起到优化代码的作用。

作者注:关于 V8 这一部分,并非千老师原创。算是译作,原文是从V8 官方博客上面看到的,作者是Mathias Bynens。千老师读完觉得不错,就加了一些个人的理解,借鉴到我的博客上来了。有兴趣的同学可以去看看原文

结语 从抽象看本质:功夫和数据

这篇文章的内容是简单了介绍 JavaScript 的 Array。

其实 Array 并不难,它只是组织数据的一种方式。

所有编程语言里,数据的种类都是一样的。数字、字符和布尔值。在人类的概念里,只有这三种。其它的类型都是基于这三种的变种。当然一些其它类型不算此列,如比特,缓冲。这些准确地讲不算数据类型。因为它们都无法和现实世界中的任何东西对应起来,它们属于计算机的数据类型。而其它组织数据的方式,如数组和对象,都是对这三种数据的组织和封装。所以它们不属于基本的数据类型,而应该称之为数据结构。

这一点和拳击或咏春非常像。拳击的世界里,人只有两只手,出拳的不同无非就是方向和角度。各个门派都会给它们取不同的名字,但归根结底拳只能分成三种,一种是直拳,一种是勾拳,一种是摆拳。而咏春也有三板斧——摊膀伏。这都和三大数据类型不谋而合。身体的移动和不同的出拳顺序,以及不同的速度与力量,就构成了被称为”组合拳“或者”招式“一类的东西,这类事物和数组或对象是一个作用。颇有道生一,一生二、二生三、三生万物的味道。

武术会衍生。功夫之王、MMA 先驱、20 世纪最伟大的中国人李小龙所创立的截拳道,也是衍生自中国传统武术,咏春拳,又夹杂了哲学思想、西洋拳、空手道、柔术等多种技艺。

数据也会衍生,数字可以衍生出intfloatdoublelongbigint。或者按进制,衍生出诸如int8int16int32int64一类的东西。

每一种语言都有自己的思想,golang 喜欢简洁,JavaScript 喜欢复杂,C 喜欢无限制,Java、C++喜欢面向对象思想。就像太极拳、泰拳、相扑、摔跤、拳击一样,都有自己的规则和思想。它们都有自己认为正确的那一套东西,从而形成一套系统。比如中国有些传统武术认为”起腿三分输。“,而跆拳道和泰拳则持有完全相反的意见。针对它们自己认为的缺陷、弊端、优势,做出一些自己的东西,这就是衍生出来的产物。

每个派系的程序员都有着自己的一套语言体系,做 Java 的会关心floatdoubleintlong。做 golang 的会关心uintintfloat和位数。做 JavaScript 的程序员只会关心number。但几乎每一个程序员都知道一个叫做 JSON 的东西。

大家思考一个问题,JSON 为什么是业界通用的东西?

有两个原因。

一是因为它足够简单,同时又满足所有需求。JSON 一共只有数字、字符串、布尔值、对象、数组和 null 六种数据类型,但它能满足所有语言。无论是写配置文件还是作为数据交互格式。

二是因为它的全称叫做 JavaScript Object Notation。早期 Ajax 时代,数据交互很多都是使用 xml 的。但 xml 的写法过于繁琐,而且解析起来性能也不好。JSON 出现,直接击溃 xml,成为标准。Web2.0 至今,几乎 90%以上的网站都是使用 JSON。等到后来移动端时代来临,没有发明出更好的数据交互类型,而是直接采用了 JSON。这又是为什么呢?一是历史原因,二是没必要。技术发展得快是因为老技术有痛点。而 JSON 并没有,当然也可能是痛点不明显,大家都默认接受了。哪怕是现在比较热门的新 API 交互语言 Graphql,仍然是使用 JSON 作为基础数据格式。

所以,希望大家不要被某种语言的类型系统限制住自己。每个语言的思想多少都有差异,特点也不同,这些都是对的。这都是风格,而不是错误。每个人都有自己喜欢的风格。这和性格、年龄、心态、经历都有关系。有些喜欢泰拳的人认为太极拳都是花架子,不具备实战能力,有些喜欢太极拳的人认为泰拳太粗鲁,伤身体。对错难分难解,视角不同、思想不同、初衷不同。在程序员世界里,有些 Java 程序员认为 JavaScript 因为太过灵活而显得太乱,同样会有些 JavaScript 认为 Java 因为太过于模板化而显得太烦。这些都无可避免。同样有些人在喜欢 JavaScript 的同时也喜欢 Java,所以有了 TypeScript。

最后,告诫大家一句:你练习了多少东西,你就能得到多少东西。

原文地址:www.luzhenqian.top/reunderstan…