ECMAScript 标准的发展经历了一个漫长的过程,从 1997 年发布的ECMAScript 1.0,到 2011 年的ECMAScript 5.1,再到 2015 年的ECMAScript 2015,以及后面每年都会发布的一个新的 ECMAScript 版本,版本众多,而且每个版本都有新特性的加入。

我们通常用ECMAScript 6来统称ECMAScript 2015及其之后的的版本。

ECMAScript 2015可谓是 ECMAScript 标准的分水岭,其从制定到发布前后经历了 15 年。目前绝大多数浏览器都已支持ECMAScript 2015特性,并部分的支持了后面版本的新特性,所以对于需要快速入门的初学者,只需要直接学习ECMAScript 2015标准即可,编写完全遵守ECMAScript 2015标准的代码不仅更加严谨,而且可以节省不少学习时间。

即便对于那些不支持ECMAScript 6的浏览器(如 IE11),我们也可以使用Babel之类的工具将其转换为ECMAScript 5标准的代码。

查看浏览器对 ES 特性的支持情况,可以访问:https://kangax.github.io/compat-table/es6/

下文讲述的都是支持 ECMAScript 6 标准的 JavaScript 语法

  1. JavaScript 是大小写敏感的
  2. 在每行只写一条语句时,结尾可以不加分号;,但建议每条语句都以分号结尾

一、七种基本数据类型

基本数据类型有以下 7 种:

  1. undefined
  2. null
  3. Boolean
  4. String
  5. Number
  6. Object
  7. Symbol

JavaScript 虽然是弱类型的语言,但不代表其没有数据类型,其弱类型指的是在声明变量的时候不需要(也不能)指明变量类型,编译器会根据“值”的类型自动将变量转换成合适的类型。

所以无论是值还是变量终究还是有类型的,对于开发者而言,不需要关注什么类型的值应该赋值给什么类型的变量。

我们可以使用typeof查看每个变量或值的类型:

1
2
3
4
5
6
7
console.log(typeof 123); // number
console.log(typeof "abc"); // string
console.log(typeof true); // boolean
console.log(typeof undefined); // undefined
console.log(typeof null); // object
console.log(typeof [1, 2, 3, 4]); // object
console.log(typeof function () {}); // function

值得注意的是undefined的类型是undefined,但null的类型是object,下面的章节会介绍undefinednull的区别。

二、变量声明方式

声明变量的方式有 3 种:

  1. let 用来声明变量
  2. var 也是用来声明变量的
  3. const 用来声明常量,必须在声明的时候赋初始值,且以后不能再修改

varES6之前的,建议不要在代码中使用 var,这样可以避免很多不必要的问题,比如变量提升暂时性死区等,本文也只讲述letconst,忘记var吧,一起走向新世界。

2.1 变量具有作用域的概念

用 let 和 const 声明的变量或常量的作用域可以精确到“块级别”

1
2
3
4
5
{
let a = 1;
console.log(a); // 1
}
console.log(a); // ReferenceError: a is not defined
1
2
3
4
for (let i = 0; i < 10; i++) {
console.log(i); // 依次输出0~9
}
console.log(i); // ReferenceError: i is not defined
1
2
3
4
5
6
7
let a = 123;

if (true) {
let a = 789;
}

console.log(a); // 123
1
2
3
4
5
6
7
8
9
10
// 无论是let还是const都是一样的,在同一个作用域内不能重复声明
{
let a = 123;
let a = 456; // SyntaxError: Identifier 'a' has already been declared
}

{
let a = 123;
const a = 456; // SyntaxError: Identifier 'a' has already been declared
}

2.2 块级作用域内的 let

只要“块级作用域内”存在 let 命令,它所声明的变量就“绑定”到这个区域,不再受外部的影响

1
2
3
4
5
6
7
8
let a = 123;

if (true) {
a = 456; // ReferenceError: Cannot access 'a' before initialization
let a = 789;
}

console.log(a); // 123

这个和其他语言不太一样,如果是 C/C++这样的语言是不会报错的,只会在let a = 789;给出“局部变量覆盖了全局变量”的警告。

但在 ES6 中却会报错,我们可以这样理解为“强龙压不过地头蛇”:
我用let a = 789;在蓝色区域内声明了变量a,那么在这个蓝色区域的一亩三分地内的都是我说了算,a = 456;敢在我还没声明前都赋值,那我还不给你报个错。

2.3 for 循环的变量作用域

for 循环设置循环变量的部分是一个父作用域,而循环体内部是一个单独的子作用域

1
2
3
4
for (let i = 0; i < 10; i++) {
let i = "abc";
console.log(i); // abc
}

虽然编译器没有报错,但尽量不要写这样代码,不利于后期维护。

三、Object 与 Class

Object(对象)是 ES5 就已经有的概念,Class(类)是 ES6 引入的概念。

3.1 关于 JavaScript 中类和对象的叫法

学过任何一个面向对象编程语言的同学都知道,对象是类的实例,比如在 Java 中:

1
A a = new A();

a为对象,A为类,也就是“对象 a 为类型 A 的实例”。

而大家可能也发现了很多 JavaScript 教程称ObjectNumberString对象,如果根据这个称呼来,下面的代码岂不是“对象 a 为对象 Number 的实例”,很显然在这种叫法很别扭,也不科学:

1
let a = new Number(123);

为什么会出现这种蹩脚的叫法了?因为在 ES6 之前,JavaScript 中没有完全引入Class类的概念,其最多只能算是一个伪的面向对象语言,估计也是为了降低学习理解的难度,所以统称为对象吧。

既然在 ES6 中明确引入了 Class 概念,我们在学习的时候就要明确区分“类”和“对象”的概念了。

3.2 万物皆对象

在 JavaScript 中万物皆对象。学过其他面向对象编程语言的同学知道,对象是类的实例,每个类都可以有构造函数、析构函数、属性和方法。

在 JavaScript 中,每个对象都有构造函数、属性、方法,但没有析构函数,我们可以通过下面的代码来验证“万物皆对象”:

1
2
3
4
console.log("John".constructor); // [Function: String]
console.log((3.14).constructor); // [Function: Number]
console.log(false.constructor); // [Function: Boolean]
console.log({ name: "John", age: 34 }.constructor); // [Function: Object]

3.2.1 隐式声明对象

JavaScript 访问属性和方法的方式和其他面向对象语言类似:

1
2
3
4
5
6
7
8
9
10
11
let msg1 = "Hello World!"; // msg1为对象,String类的实例
let x = msg1.length; // 访问length属性
console.log(x); // 12

let msg2 = "Hello world!"; // // msg2为对象,String类的实例
let y = msg2.toUpperCase(); // 访问toUpperCase方法
console.log(y); // HELLO WORLD!

let car = { name: "BMW", price: 123000 }; // car也为对象,{name: "BMW", price: 123000}类的实例
console.log(car.name); // BMW
console.log(car.price); // 123000

大家可以看到上面的代码中都没有使用new来声明对象,但这些对象确确实实存在了,不然我们也无法访问其“属性”和“方法”,我们可以姑且认为这是 JavaScript 的语法糖吧,或者叫“隐式声明对象”吧。

3.2.2 显式声明对象

前面介绍的 8 种基本数据类型中除了nullundefined之外,都可以使用 new 来显示的声明对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
let a1 = new Number(123);
console.log(a1); // [Number: 123]
console.log(1 + a1); // 124

let a2 = new Function();
console.log(a2); // [Function: anonymous]

let a3 = new String("xyz");
console.log(a3); // [String: 'xyz']

let a4 = new null(); // TypeError: null is not a constructor

let a5 = new undefined(); // TypeError: null is not a constructor

3.3 使用 Class 定义类

在 ES6 中可以使用 Class 关键字定义类,且每个类都有默认的 name 属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Rectangle {
// 构造函数
// constructor方法名称不能修改
// 通过new命令生成对象实例时,自动调用该方法。
// 一个类必须有constructor方法,如果没有显式定义,一个空的constructor方法会被默认添加
constructor(h, w) {
this.height = h;
this.width = w;
}

// Getter
get area() {
return this.calcArea();
}

// Settter
set setWidth(w) {
this.width = w;
}

// 方法
calcArea() {
return this.height * this.width;
}

// 静态方法
static printInfo(w, h) {
console.log("width:" + w + ", height:" + h);
}
}

// 注意:这个name属性是通过类本身来访问的,而不是类的实例来访问的,是不是有点乱了。。。
console.log(Rectangle.name); // Rectangle

let rect = new Rectangle(5, 6);
console.log(rect.area); // 30
//rect.printInfo(); // TypeError: rect.printInfo is not a function
Rectangle.printInfo(5, 6); // width:5, height:6

Class 的成员都是公共的,外部都可以访问,目前没有从语法层面规定如何定义一个私有的成员

类表达式

一个类表达式是定义一个类的另一种方式。类表达式可以是具名的或匿名的。

一个具名类表达式的名称是类内的一个局部属性,它可以通过类的 name 属性来获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 匿名类
let Rectangle1 = class {
constructor(height, width) {
this.height = height;
this.width = width;
}
};
console.log(Rectangle1.name); // Rectangle1

// 具名类
let RectangleX = class Rectangle2 {
constructor(height, width) {
this.height = height;
this.width = width;
}
};
console.log(RectangleX.name); // Rectangle2

四、函数

4.1 函数参数是默认声明的变量

1
2
3
function foo1(x) {
let x = 0; // SyntaxError: Identifier 'x' has already been declared
}

4.2 函数参数可以有默认值

1
2
3
4
function foo2(x, y = 1) {
console.log(x + y);
}
foo2(1); // 2

与 C++不同,JavaSript 没有要求拥有默认值的参数必须放在参数列表的尾部,所以可以这样写:

1
2
3
function foo2(x = 1, y) {
console.log(x + y);
}

但仔细想一想,这样写并没有什么实际意义。

另外值得注意的是,函数参数的默认值是每次函数调用时都会动态计算的:

1
2
3
4
5
6
7
8
9
let x = 99;
function foo(p = x + 1) {
console.log(p);
}

foo(); // 100

x = 100;
foo(); // 101

4.3 箭头函数

1
2
3
4
5
6
7
8
9
function foo(x, y) {
return x + y;
}

// 等同于

(x, y) => {
return x + y;
};

如果函数只有一个参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo(x) {
return x * x;
}

// 等同于

(x) => {
return x * x;
};

// 括号可以省略,等同于

(x) => {
return x * x;
};

如果函数没有返回值大括号可以省略:

1
2
3
4
5
6
7
function foo() {
console.log("hi");
}

// 大括号可以省略,等同于

() => console.log("hi");

4.4 函数的 name,length 属性

name可以返回函数名;
length可以返回没有指定默认值的参数个数;

五、数据结构

5.1 Object

我们可以向 Object 对象添加任何属性,所以 Object 也是一个容器或集合:

1
2
3
4
5
6
7
8
9
10
let obj = new Object("hello");
console.log(obj); // [String: 'hello']

obj.name = "jeff";
console.log(obj.name); // jeff

obj.print = function () {
console.log("I'm object");
};
obj.print(); // I'm object

5.2 数组

JavaScript 中使用 Array 类创建数组对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 使用Array类创建数组
let cars1 = new Array("Saab", "Volvo", "BMW");

// 使用Array类创建数组
let cars2 = new Array();
cars2[0] = "Saab"; // 支持[]方式访问和赋值,下标从0开始
cars2[1] = "Volvo";
cars2[2] = "BMW";

// 简写方式
let cars3 = ["Saab", "Volvo", "BMW"];
console.log(cars3.length); // 3

// 数组中的元素可以为不同的类型
let a = [1, "x", true];
console.log(a[1]); // x
console.log(a[5]); // undefined,访问数组中不存在的元素会返回undefined,不会报错

a.testProp = "xyz"; // 可以向Array对象添加任意属性
console.log(a.testProp); // xyz

a.print = function () {
console.log("I'm a");
};
a.print(); // I'm a

5.2.1 Array 类完整的属性

属性 描述
length 设置或获取数组元素的个数
prototype 允许你向数组对象添加属性或方法

5.2.2 Array 类完整的方法

方法 描述
concat() 连接两个或更多的数组,并返回结果
copyWithin() 从数组的指定位置拷贝元素到数组的另一个指定位置中
entries() 返回数组的可迭代对象
every() 检测数值元素的每个元素是否都符合条件
fill() 使用一个固定值来填充数组
filter() 检测数值元素,并返回符合条件所有元素的数组
find() 返回符合传入测试(函数)条件的数组元素
findIndex() 返回符合传入测试(函数)条件的数组元素索引
forEach() 数组每个元素都执行一次回调函数
from() 通过给定的对象中创建一个数组
includes() 判断一个数组是否包含一个指定的值
indexOf() 搜索数组中的元素,并返回它所在的位置
isArray() 判断对象是否为数组
join() 把数组的所有元素放入一个字符串
keys() 返回数组的可迭代对象,包含原始数组的键(key)
lastIndexOf() 搜索数组中的元素,并返回它最后出现的位置
map() 通过指定函数处理数组的每个元素,并返回处理后的数组
pop() 删除数组的最后一个元素并返回删除的元素
push() 向数组的末尾添加一个或更多元素,并返回新的长度
reduce() 将数组元素计算为一个值(从左到右)
reduceRight() 将数组元素计算为一个值(从右到左)
reverse() 反转数组的元素顺序
shift() 删除并返回数组的第一个元素
slice() 选取数组的一部分,并返回一个新数组
some() 检测数组元素中是否有元素符合指定条件
sort() 对数组的元素进行排序
splice() 从数组中添加或删除元素
toString() 把数组转换为字符串,并返回结果
unshift() 向数组的开头添加一个或更多元素,并返回新的长度
valueOf() 返回数组对象的原始值

5.2.3 数组的复制

1
2
3
4
5
6
let a1 = [1, 2, 3];
let a2 = a1;

a2[1] = 4;

console.log(a1[1]); // 4

从上面的例子中,我们发现改变数组a2[1]的值时,数组a1[1]的值也随着改变了,这说明 a1 和 a2 指向的是同一块内存区域,这个在 C/C++中就是指针的概念,let a2 = a1; 做的是浅拷贝操作。

如果需要做深拷贝,也就是将数组 a1 的所有元素克隆一份给 a2,可以通过下面的两种方式:

1
2
3
4
5
6
let a1 = [1, 2, 3];
let a2 = a1.concat();

a2[1] = 4;

console.log(a1[1]); // 2
1
2
3
4
5
6
let a1 = [1, 2, 3];
let a2 = [...a1]; // 也可以写成 let [...a2] = a1;

a2[1] = 4;

console.log(a1[1]); // 2

5.3 Map

Map 对象用来存储键值对(key-value),并且能够记住键的原始插入顺序。任何对象都可以作为键或值。
在 Map 中 Key 是唯一的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
let myMap = new Map();

let keyObj = {};
let keyFunc = function () {};
let keyString = "a string";

// 添加键
myMap.set(keyString, "和键'a string'关联的值");
myMap.set(keyObj, "和键keyObj关联的值");
myMap.set(keyFunc, "和键keyFunc关联的值");

console.log(myMap.size); // 3

// 读取值
console.log(myMap.get(keyString)); // 和键'a string'关联的值
console.log(myMap.get("a string")); // 和键'a string'关联的值
console.log(myMap.get(keyObj)); // 和键keyObj关联的值
console.log(myMap.get(keyFunc)); // 和键keyFunc关联的值

console.log(myMap.get({})); // undefined, 因为keyObj !== {}
console.log(myMap.get(function () {})); // undefined, 因为keyFunc !== function () {}

console.log(myMap.size); // 3,返回有多少个键值对

// 和Array一样,可以添加任意属性和方法
myMap.test = "a";
console.log(myMap.test);

myMap.print = function () {
console.log("I'm map");
};
myMap.print(); // I'm map

5.3.1 Map 与 Array 相互转换

Array => Map

1
2
3
4
5
6
let map = new Map([
[1, "one"],
[2, "two"],
]);

console.log(map); // Map { 1 => 'one', 2 => 'two' }

Map => Array

1
2
3
4
5
6
let map = new Map();
map.set(1, "one");
map.set(2, "tow");

let arr2 = Array.from(map);
console.log(arr2); // [ [ 1, 'one' ], [ 2, 'two' ] ]

5.3.2 Map 的复制

Map 在直接赋值的时候会遇到和 Array 同样的“浅拷贝”的问题,如:

1
2
3
4
5
6
7
8
9
let map1 = new Map();
map1.set(1, "one");
map1.set(2, "tow");

let map2 = map1;

map2.set(1, "three");

console.log(map1.get(1)); // three,修改map2会导致map1的值也被修改了

可以通过下面的方式完成 Map 的深拷贝:

1
2
3
4
5
6
7
8
9
10
let map1 = new Map();
map1.set(1, "one");
map1.set(2, "tow");

let map2 = new Map(map1);

map2.set(1, "three");

console.log(map1.get(1)); // one
console.log(map2.get(1)); // three

5.4 Set

Map 是键值对的集合,而 Set 则只是键(key)的集合。

Set 中的每个元素都是唯一的。

任何对象都可以作为 Set 的元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let keys = [1, 1, 2, 3, "str"];
let s = new Set(keys);

// 自动去重了
console.log(s); // Set { 1, 2, 3, 'str' }

// 新增使用Add
s.add(9);
s.add(function () {});
console.log(s); // Set { 1, 2, 3, 'str', 9, [Function] }

// 删除使用delete
s.delete(1);
console.log(s); // Set { 2, 3, 'str', 9, [Function] }

5.5 迭代器

前面介绍 Object、Array、Map、Set 这些容器的时候,都避开了一个话题:遍历。本节主要介绍如何遍历 JavaScript 中的容器或集合。

迭代器(Iterator)就是一个接口,为各种不同的数据结构提供统一的遍历访问机制。任何数据结构只要实现 Iterator 接口,就可以完成遍历操作。

在学习如何自定义迭代器之前,我们先学习一下如何遍历 JavaScript 常用的数据集合:

5.5.1 遍历字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
let str = "hello";

for (let v of str) {
console.log(v);
}

/*
h
e
l
l
o
*/

5.5.2 遍历数组

1
2
3
4
5
6
7
8
9
let arr = [1, "str", 2];
for (let v of arr) {
console.log(v);
}
/*
1
str
2
*/

5.5.3 遍历 Map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let map = new Map([
[1, "one"],
[2, "two"],
[3, function () {}],
]);

for (let [k, v] of map) {
console.log(k + ":" + v);
}

/*
1:one
2:two
3:function () { }
*/

六、解构赋值

解构(Destructuring)赋值分为“数组的解构赋值”和“对象的解构赋值”。

6.1 数组的解构赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
let [a, b, c] = [1, 2, 3];   // 根据位置依次取值
// a = 1
// b = 2
// c = 3


let [foo, [[bar], baz]] = [1, [[2], 3]];
// foo = 1
// bar = 2
// baz = 3


let [ , , third] = ["foo", "bar", "baz"];
// third = "baz"


let [head, ...tail] = [1, 2, 3, 4];
// head = 1
// tail = [2,3,4]


let [x, y, ...z] = ["a"];
// x = "a"
// y = undefined
// z = []


let [foo] = []; // foo = undefined
let [bar, foo] = [1]; // foo = undefined


let [bar, foo = true] = [1]; // 可以赋默认值
// bar = 1
// foo = true


let [x, y] = [1, 2, 3];
// x = 1
// y = 2


let [a, [b], d] = [1, [2, 3], 4];
// a = 1
// b = 2
// d = 4

6.2 对象的解构赋值

1
2
3
4
5
6
7
8
9
10
11
let { foo, bar } = { foo: "aaa", bar: "bbb" }; // 和顺序没关系,根据属性名来取值
// foo = "aaa"
// bar = "bbb"

let { foo } = { bar: "baz" };
// foo = undefined

// 对象的解构赋值,可以很方便地将现有对象的方法,赋值到某个变量
// 如:console对象有log方法,我们可以这样使用:
const { log } = console;
log("hello"); // hello

七、promise、async、await

promise、async、await 这三个关键字都和异步编程有关。

7.1 Promise

Promise 翻译成中文就是“承诺”的意思,声明一个 Promise 就是立下了一个承诺,无论怎么样,都会给被承诺人一个结果,而且这个结果是板上钉钉的,不会再变。

Promise 对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。

声明 Promise 对象时需要传入一个函数对象作为参数,这个函数对象的 2 个参数也是函数对象(resolve, reject),resolvereject不需要开发者定义,Javascript 引擎会自动生成这 2 个函数。

当 Promise 对象生成后会立即变成pending状态,调用resolve函数会将 Promise 对象标记为fulfilled状态,而调用reject函数则会将当前 Promise 对象标记rejected状态。

resolverejected函数,我们只能调用它们中的一个,不能即调用resolve又调用rejected。如果我们先调用了resolve,此时 Promise 状态会标记为fulfilled,然后又调用了rejected函数,此时 Promise 状态并不会再改变,仍然使fulfilled状态,因为承诺的结果是板上钉钉的,不会再变。建议将resolvereject作为最后一行代码调用,简单起见,可以在这 2 个函数前面加上return, 即return resolve();return reject();

1
2
3
4
5
6
7
8
9
10
11
12
// Promise的参数为一个函数对象,函数有2个参数resolve, reject
let promise = new Promise((resolve, reject) => {
// 做一些耗时的操作,比如网络请求
// 这里我们使用一个延时器来模拟耗时的网络请求
setTimeout(() => {
// 延时1000ms之后成功
resolve("ok");
}, 1000);

// 成功则调用 resolve();
// 失败则调用 reject();
});

那么,我想在这个异步操作完成之后,再根据结果(是成功了,还是失败了)来继续做下一件事情,那我们该怎么做了?
Promise 对象提供了then方法,该方法接受 2 个函数对象作为参数:
第一个回调函数是 Promise 对象的状态变为resolved时调用;
第二个回调函数是 Promise 对象的状态变为rejected时调用。
其中,第二个函数是可选的,不一定要提供。每个回调函数都可以接受一个参数,这个参数就是上一步调用 resolve 或 reject 时传入的。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 上面的耗时操作完成之后,我们可能还需要根据结果来继续做一些事情
// 此时就可以使用then,then函数有2个参数,分别为2个函数对象。
// 上一步操作中,如果调用resolve(data),则then函数第一个函数对象参数会被调用;
// 如果调用reject(data),则then函数第二个函数对象参数会被调用
promise.then(
(data) => {
console.log("resolve1: " + data);
return "hello";
},
(data) => {
console.log("reject1: " + data);
}
);

在这个例子中,我在第一个参数中直接通过 return 返回了”hello”字符串,那这个返回值的意义在哪里了?还有其他人可以使用到这个返回值吗?
是的,还可以继续使用。因为then的返回值是一个 Promise 对象,虽然我只是使用的return "hello";,并没有new Promise,但 JavaScript 引擎会自动包装成一个 Promise 对象,等同于:

1
2
3
4
5
6
7
8
9
10
11
promise.then(
(data) => {
console.log("resolve1: " + data);
return new Promise((resolve, reject) => {
return resolve("hello");
});
},
(data) => {
console.log("reject1: " + data);
}
);

到这儿了,我们知道了then的返回的是一个Promise对象。既然then返回的是Promise对象,那么Promise就可以继续then呀,然后一直then下去….这样我们就可以将一系列异步的操作串联起来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
let p = new Promise((resolve, reject) => {
// ...
// resolve(); 或 reject();
})
.then(() => {
// ...
})
.then(() => {
// ...
})
.then(() => {
// ...
});

但多个异步操作串联执行,还有一点需要注意,我们看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let promise = new Promise((resolve, reject) => {
setTimeout(() => {
// 延时1000ms
resolve("ok1");
}, 1000);
})
.then((data) => {
console.log("resolve1: " + data);

setTimeout(() => {
// 延时1000ms
return "ok2";
}, 1000);
})
.then((data) => {
console.log("resolve2: " + data);
});

我们期望的输出是:

1
2
resolve1: ok1;
resolve2: ok2;

但实际的输出却是:

1
2
resolve1: ok1;
resolve2: undefined;

问题出在第二个 setTimeout 模拟的耗时操作,我们以为程序会等第二个 setTimeout 执行完了再执行第二个 then,但事实上 setTimeout 也是一个异步操作,虽然其延时了一秒执行其回调函数,但 setTimeout 这条语句却马上执行完成了,导致第一个 then 没有任何返回,针对这种情况,我们需要将代码改成下面的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let promise = new Promise((resolve, reject) => {
setTimeout(() => {
// 延时1000ms
resolve("ok1");
}, 1000);
})
.then((data) => {
console.log("resolve1: " + data);

return new Promise((resolve, reject) => {
setTimeout(() => {
// 延时1000ms
resolve("ok2");
}, 1000);
});
})
.then((data) => {
console.log("resolve2: " + data);
});

7.2 Promise 异常捕获

Promise 对象还提供了catch方法,用来捕获异常。在介绍catch前,我们先看看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let promise = new Promise((resolve, reject) => {
throw new Error("an error");
resolve("ok");
}).then(
(data) => {
console.log("resolve: " + data);
},
(data) => {
console.log("reject: " + data);
}
);

// 输出:
// reject: Error: an error

我们在 Promise 中人为抛出了一个异常,但是程序却还是没有中止,而是运行到了 reject 过程中去了。

这是因为 Promise 默认会捕获其操作过程中的异常,如果有异常发生,其状态就会自动变成rejected,还记得前面说过 Promise 状态一旦确定就不会再改变了吧,所以即便后面的resolve("ok");执行了,也不会改变 promise 状态(事实上 throw 语句后的代码并没有机会执行)。

那么,假如我们没有写 reject 回调函数会怎么样了?看看下面的代码:

1
2
3
4
5
6
let promise = new Promise((resolve, reject) => {
throw new Error("an error");
resolve("ok");
}).then((data) => {
console.log("resolve: " + data);
});

上面的代码中由于没有指定异常处理函数,所以程序抛出了异常信息,中止执行了。

另外,Promise 的异常是会一直向下传递的,直到最后有人处理,如果始终没人处理,程序就会抛出异常信息,然后中止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let promise = new Promise((resolve, reject) => {
throw new Error("an error");
resolve("ok");
})
.then((data) => {
console.log("resolve1: " + data);
})
.then(
(data) => {
console.log("resolve2: " + data);
},
(data) => {
console.log("reject2: " + data);
}
);

// 输出:
// reject: Error: an error

上面的代码中,第一个 then 没有处理异常,异常向下传递给第二个 then, 第二个 then 处理了该异常,程序继续运行。

现在理解Promise.catch()方法就容易多了。catch()方法其实就是.then(null, rejectiion).then(undefined, rejection)的别名,用于指定发生错误时的回调函数。

我们一般总是建议,Promise 对象后面要跟 catch()方法,这样可以处理 Promise 内部发生的错误。catch()方法返回的还是一个 Promise 对象,因此后面还可以接着调用 then()方法。

7.3 async, await

async 是ES7才有的与异步操作有关的关键字,需要和Promise配合使用,async函数返回一个 Promise对象,可以使用then方法添加回调函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
async function helloAsync() {
return "helloAsync";
}

console.log(helloAsync());

helloAsync().then((v) => {
console.log(v);
});

// 输出:
// Promise { 'helloAsync' }
// helloAsync

await关键字能用在被async标记的函数体内,async函数执行时,如果遇到await就会先暂停执行,等到触发的异步操作完成后,恢复async函数的执行并返回解析值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function testAwait() {
return new Promise((resolve) => {
setTimeout(function () {
console.log("testAwait");
resolve();
}, 1000);
});
}

async function helloAsync() {
await testAwait();
console.log("helloAsync");
}
helloAsync();

// 输出:
// testAwait
// helloAsync