JavaScript语言基础(八)期约[ES6新增]与异步函数

471 阅读17分钟

ES6新增Promise(期约)引用类型,支持优雅地定义和组织异步逻辑。接下来几个版本增加了使用async和await关键字定义异步函数的机制,

期约

是对尚不存在结果的一个替身。所有现代浏览器都支持ES6期约,很多其它浏览器API也以期约为基础。

期约基础

通过new操作符来实例化,创建新期约时需要传入执行器(executor)函数作为参数。

let p = new Promise(() => {});	// 使用空函数对象来应付解释器,若不提供执行器函数,就会抛出SyntaxError
setTimeout(console.log, 0, p);	// Promise <pending>

图片.png

1. 期约状态机

期约是一个有状态的对象,有三种状态:

  1. 待定(pending);
  2. 兑现(fulfilled,有时也称为“解决”,resolved);
  3. 拒绝(rejected)。

图片.png 待定是期约的最初始状态。在待定状态下,期约可以落定为代表成功的兑现状态,或者代表失败的拒绝状态。无论落定为哪种状态都是不可逆的。只要从待定转换为兑现或拒绝,期约的状态就不再改变。而且,也不能保证期约必然会脱离待定状态

期约的状态是私有的,不能直接通过JS检测到。期约故意将异步行为封装起来,从而隔离外部的同步代码。

2. 解决值、拒绝理由及期约用例

期约主要有两大用途。首先是抽象地表示一个异步操作,期约的状态代表期约是否完成。 “待定”——尚未开始/正在进行中,“兑现”——已经成功完成,“拒绝”——没有成功完成。

某些情况下,这个状态机就是期约可以提供的最有用的信息,知道一段异步代码的完成状态。

在另外一些情况下,期约封装的异步操作会实际生成某个值,而程序期待期约状态改变时可以访问这个值。相应地,如果期约被拒绝,程序就会期待期约改变时可以拿到拒绝的理由。

为了支持这两种用例,每个期约只要状态切换为兑现,就会有一个私有的内部。类似地,每个期约只要状态切换为拒绝,就会有一个私有的内部理由。无论值还是理由,都是包含原始值或对象的不可修改的引用。二者都是可选的,且默认值为undefined。

在期约到达某个落定状态时执行的异步代码始终会收到这个值或理由。

3. 通过执行函数控制期约状态

由于期约状态是私有的,所以只能在内部进行操作。内部操作在期约的执行器函数中完成。

执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。 其中,控制状态的最终转换是通过调用它的两个参数实现的。这两个函数参数通常都命名为resolve()和reject()。调用相应的函数,切换状态到相应的状态。另外调用reject()会抛出错误。

无论resolve()和reject()中的哪个被调用,状态转换都不可撤销了,于是继续修改状态会静默失败。

为避免期约卡在待定状态,可以添加一个定时退出功能,通过setTimeout设置一个10秒钟后无论如何都会拒绝期约的回调:

let p = new Promise((resolve, reject) => {
	setTimeout(reject, 10000);	// 10s后调用reject()
	// 执行函数的逻辑
});

setTimeout(console.log, 0, p);	// Promise<pending>
setTimeout(console.log, 11000, p);	// 11s后再检查状态

// (After 10s)Uncaught error
// (After 11s)Promise <rejected>

如果执行器中的代码在超时之前已经解决或拒绝,那么超时回调再尝试拒绝也会静默失败。

4. Promise.resolve()

通过调用Promise.resolve()静态方法,可以实例化一个解决的期约。以下两个期约实例实际上是一样的:

let p1 = new Promise((resolve, reject) => resolve());
let p2 = Promise.resolve();

这个解决的期约的值对应着传给Promise.resolve()的第一个参数,使用这个静态方法,实际上可以把任何值转换为一个期约:

setTimeout(console.log, 0, Promise.resolve());	// Promise<resolved>: undefined
setTimeout(console.log, 0, Promise.resolve(3));	// Promise<resolved>: 3
// 多余的参数会忽略
setTimeout(console.log, 0, Promise.resolve(4,5,6));	// Promise<resolved>: 4

对这个静态方法而言,如果传入的参数本身是一个期约,那它的行为就类似一个空包装。y因此,此方法可以说是幂等方法:

let p = Promise.resolve(7);
setTimeout(console.log, 0, p === Promise.resolve(p));	// true
setTimeout(console.log, 0, p === Promise.resolve(Promise.resolve(p)));	// true

如果参数是非期约值,则立即兑现。 这个静态方法能够包装任何非期约值,包括错误对象,并将其转换为解决的期约。因此,也可能导致不符合预期的行为。

5. Promise.reject()

与Promise.resolve()类似,Promise.reject()会实例化一个拒绝的期约并抛出一个异步错误(这个错误只能通过拒绝处理程序捕获,不能通过try/catch捕获)。

// 两个期约实例是一样的
let p1 = new Promise((resolve, reject) => reject());
let p2 = Promise.reject();

这个拒绝期约的理由就是传给Promise.reject()的第一个参数,也会传给后续的拒绝处理程序。

let p = Promise.reject(1003);
setTimeout(console.log, 0, p);	// Promise<rejected>: 3
p.then(null, (e) => setTimeout(console.log, 0, e));	// 3

reject()并不具有resolve()的幂等逻辑,如果给它传一个期约对象,则这个期约会成为它返回的拒绝期约的理由:

setTimeout(console.log, 0, Promise.reject(Promise.resolve()));	
// Promise<rejected>: Promise<resolved>

期约的实例方法

是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据,处理期约成功和失败的结果,连续对期约求值,或者添加只有期约进入终止状态时才会执行的代码。

1. 实现Thenable接口(了解)

在ES暴露的异步结构中,任何对象都有一个then()方法,这个方法被认为实现了Thenable接口。ES的Promise类型实现了Thenable接口。

2. Promise.prototype.then()

是为期约实例添加处理程序的主要方法。

接收两个参数:onResolved处理程序和onRejected处理程序,两个参数是可选的,如果提供的话,则会在期约分别进入“兑现“和”拒绝“状态时执行。

function onResolved(id){
	setTimeout(console.log, 0, id, ‘resolved’);
}
function onRejected(id){
	setTimeout(console.log, 0, id, ‘rejected’);
}
let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000));
let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000));

p1.then(() => onResolved(‘p1’), () => onRejected(‘p1’));
p2.then(() => onResolved(‘p2’), () => onRejected(‘p2’));

// 3s后
// p1 resolved
// p2 rejected

如果传给then()的参数是任何非函数类型的处理程序,则会静默忽略。 如果只提供onRejected参数,那么在onResolved参数的位置上传入undefined。

p1.then(‘gobbeltygook’);	// 非函数处理程序会静默忽略
p2.then(null, () => onRejected(‘p2’));	// 不传onResolved处理程序的规范写法

// p2 rejected(3s后)

Promise.prototype.then()方法返回一个新的期约实例:

let p1 = new Promise(() => {});
let p2 = p1.then();
setTimeout(console.log, 0, p1);	// Promise pending
setTimeout(console.log, 0, p2);	// Promise pending
setTimeout(console.log, 0, p1 === p2 );	// false

若新实例的期约基于onResolved处理程序的返回值构建, 如果没有提供处理程序,则Promise.resolve()会包装上一个期约解决之后的值。 如果没有显示的返回语句,则Promise.resolve()会包装默认的返回值undefined。如果有显示的返回值,则Promise.resolve()会包装这个值。

抛出异常会返回拒绝的期约。

...
let p10 = p1.then(() => { throw ‘baz’ });	// Uncaught (in promise) baz
setTimeout(console.log, 0, p10);	// Promise<rejected> baz

注意,返回错误值不会触发上面的拒绝行为,而会把错误对象包装在一个解决的期约中。

...
let p11 = p1.then(() =>Error(‘qux’));	
setTimeout(console.log, 0, p11);	// Promise<resolved> Error : qux

obRejected处理程序与之类似:onRejected处理程序返回的值也会被Promise.resolve()包装。

3. Promise.prototype.catch()

用于给期约添加拒绝处理程序,只接收一个参数:onRejected处理程序。事实上,此方法是一个语法糖,调用它相当于调用Promise.prototype.then(null, onRejected)。

let p = new Promise.reject();
let onRejected = function(e){
	setTimeout(console.log, 0, ‘rejected’);
};
p.then(null, onRejected);	// rejected
p.catch(onRejected);		// rejected

Promise.prototype.catch()方法返回一个新的期约实例,其行为与Promise.prototype.then()的onRejected处理程序是一样的。

4. Promise.prototype.finally()

用于给期约添加onFinally处理程序,这个处理程序在期约转换为解决或拒绝状态都会执行。 这个方法可以避免onResolved和onRejected处理程序中出现冗余代码,onFinally处理程序无法知道期约的状态是哪种,主要用于添加清理代码

Promise.prototype.finally()方法返回一个新的期约实例,不同于前两种方式返回的实例。因为onFinally是一个状态无关的方法,在大多数情况下,它将表现为父期约的传递。

let p1 = Promise.resolve(‘foo’);

// 都原样后传
let p2 = p1.finally();
let p3 = p1.finally(() => undefined);
let p4 = p1.finally(() => Promise.resolve());
let p5 = p1.finally(() =>Promise.resolve(‘bar’));
let p6 = p1.finally(() =>Error(‘qux’));

setTimeout(console.log, 0, p2);	// Promise<resolved> : foo
setTimeout(console.log, 0, p3);	// Promise<resolved> : foo
setTimeout(console.log, 0, p4);	// Promise<resolved> : foo
setTimeout(console.log, 0, p5);	// Promise<resolved> : foo
setTimeout(console.log, 0, p6);	// Promise<resolved> : foo

如果返回的是一个待定的期约,或者onFinally处理程序抛出了错误(显示抛出或返回一个拒绝期约),则会返回相应的期约(待定或拒绝)。

...
// Promise.resolve()保留返回的期约
let p7 = p1.finally(() => new Promise(() => {}));
let p8 = p1.finally(() => Promise.reject());		// Uncauguht (in promise) : undefined
let p9 = p1.finally(() => { throw ‘baz’; });		// Uncauguht (in promise) : baz

setTimeout(console.log, 0, p7);	// Promise<pending>
setTimeout(console.log, 0, p8);	// Promise<rejected> : undefined
setTimeout(console.log, 0, p9);	// Promise<rejected> : baz

5. 非重入期约方法

当期约进入落定状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行。跟在添加这个处理程序的代码之后的同步代码,一定会在处理程序之前先执行。 即使期约一开始就与附加处理程序关联的状态,执行顺序也是这样的,被称为“非重入“特性。

let p = Promise.resolve();			// 创建解决的期约
p.then(() => console.log(‘onResolved handler’));	// 添加解决处理程序
console.log(‘then() returns’);			// 同步输出

// 实际输出如下:
// then() returns
// onResolved handler

这个处理程序在当前线程上的同步代码执行完成前不会执行,因此,跟在then()后面的同步代码一定会先于处理程序执行

非重入适用于onResolved/onRejected处理程序、catch()处理程序和finally()处理程序。以下这些处理程序都只能异步执行:

let p1 = Promise.resolve();
p1.then(() => console.log(‘p1.then() onResolved’));
console.log(‘after p1.then()’);

let p2 = Promise.reject();
p2.then(null, () => console.log(‘p2.then() onRejected’));
console.log(‘after p2.then()’);

let p3 = Promise.reject();
p3.catch(() => console.log(‘p3.catch() onRejected’));
console.log(‘after p3.catch()’);

let p4 = Promise.resolve();
p4.finally(() => console.log(‘p4.finally() onFinally’));
console.log(‘after p4.finally()’);

// after p1.then()  after p2.then()  after p3.catch()  after p4.finally()
// p1.then() onResolved  p2.then() onRejected  p3.catch() onRejected  p4.finally() onFinally

6. 邻近处理程序的执行顺序

如果给期约添加了多个处理程序,当期约状态变化时,相关处理程序会按照添加它们的顺序依次执行,无论是then()、catch()还是finally()添加的处理程序都是如此。

7. 传递解决值和拒绝理由

到了落定状态后,期约会提供其解决值(如果兑现)或其拒绝理由(如果拒绝)给相关状态的处理程序。拿到返回值后,就可进一步对这个值进行操作。

在执行函数中,解决值和拒绝理由是分别作为resolve()和reject()的第一个参数往后传的。然后又传给它们各自的处理程序,作为onResolved或onRejected处理程序的唯一参数。

let p1 = new Promise((resolve, reject) => resolve(‘foo’));
p1.then((value) => console.log(value));	// foo

let p2 = new Promise((resolve, reject) => reject(‘err’));
p2.catch((reason) => console.log(reason));	// err

Promise.resolve()和Promise.reject()在被调用时就会接收解决值和拒绝理由,同样地,会像执行器一样传值给onResolved或onRejected处理程序。

let p1 = Promise.resolve(‘foo’);
p1.then((value) => console.log(value));	// foo

let p2 = Promise.reject(‘err’);
p2.catch((reason) => console.log(reason));	// err

8. 拒绝期约与拒绝错误处理

拒绝期约类似于throw表达式,在期约的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象现会成为拒绝的理由。

期约可以以任何理由拒绝,包括undefined,但最好统一使用错误对象。

在期约中抛出错误时,因为错误实际上是从消息队列中异步抛出的,所以并不会阻止运行时继续执行同步指令:

Promise.reject(‘Error(‘foo’)’);
console.log(‘xixi’);
// xixi

// Uncaught (in promise) Error: foo

异步错误只能通过异步的onRejected处理程序捕获:

Promise.reject(Error(‘foo’)).catch((e) => {}); 在解决或拒绝期约之前,仍然可以使用try/catch在执行函数中捕获错误:

let p = new Promise((resolve, reject) => {
	try{
		throw Error(‘foo’);
	}catch(e) {}

	resolve(‘cln’);
});
setTimeout(console.log, 0, p);	// Promise<resolved> : cln

then()和catch()的onRejected处理程序在语义上相当于try/catch,出发点都是捕获错误之后将其隔离,同时不影响正常逻辑执行。onRejected处理程序的任务应该是在捕获异步错误之后返回一个解决的期约。异步错误处理:

new Promise((resolve, reject) => {
	console.log(‘begin execution’);
	reject(Error(‘err’));
}).catch((e) => {
	console.log(‘caught error’, e);
}).then(() => {
	console.log(‘continue execution’);
});

// begin execution
// caught error : err
// continue execution

期约连锁与期约合成

1. 期约连锁

即把期约逐个地串联起来,每个期约实例的方法(then()、catch()和finally())都会返回一个新的期约对象,而这个新期约又有自己的实例方法,这样连缀方法调用就可构成所谓的“期约连锁”。 串行化异步任务,让每个期约在一定时间后解决:

function producePromise(str){
	return new Promise((resolve, reject) => {
		console.log(str);
		setTimeout(resolve, 1000);
	});
}
producePromise(‘p1 executor’)
	.then(() => producePromise(‘p2 executor’))
	.then(() => producePromise(‘p3 executor’))
	.then(() => producePromise(‘p4 executor’))

// p1 executor(1s后)
// p2 executor(2s后)
// p3 executor(3s后)
// p4 executor(4s后)

2. 期约图

因为一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图的结构。每个期约都是图中一个节点,而使用实例方法添加的处理程序则是有向顶点。图的方向是期约的解决或拒绝顺序。

3. Promise.all()和Promise.race()

两个将多个期约实例组合成一个期约的静态方法,而合成后期约的行为取决于内部期约的行为。

* Promise.all()

此静态方法创建的期约,会在一组期约全部解决之后再解决,接收一个可迭代对象,返回一个新期约:

let p1 = Promise.all([
	Promise.resolve(),
	Promise.resolve()
]);
let p2 = Promise.all([3,4]);	// 可迭代对象中的元素通过Promise.resolve()转换为期约
let p3 = Promise.all([]);		// 空的可迭代对象等价于Promise.resolve()
let p4 = Promise.all();		// 无效语法

如果至少有一个包含的期约的状态为待定,则合成期约也会待定。如果有一个包含的期约的状态为拒绝,则合成期约也会拒绝。一次拒绝会导致最终期约拒绝。

let p = Promise.all([
	Promise.resolve(),
	Promise.reject(),
	Promise.resolve()
]);
setTimeout(console.log, 0, p);	// Promise<rejected>
// Uncaught (in promise) undefined

如果所有期约都成功解决,则合成期约的解决值就是所有包含期约的解决值的数组,按照迭代器顺序

如果有期约拒绝,则第一个拒绝的期约会将自己的理由,作为合成期约的拒绝理由。之后再拒绝的期约不会影响最终期约的拒绝理由。合成的期约会静默处理所有包含期约的拒绝操作。

// 虽然只有第一个期约的拒绝理由会进入拒绝处理程序,
// 第二个期约的拒绝也会被静默处理,不会有错误地跑掉
let p = Promise.all([
	Promise.reject(3),
	new Promise((resolve, reject) => setTimeout(reject, 1000))
]);
p.catch((reason) => setTimeout(console.log, 0, reason));		// 3
// 没有未处理的错误

* Promise.race()

此静态方法返回一个包装期约,是一组集合中最先解决或拒绝的期约的镜像,接收一个可迭代对象,返回一个新期约。

可迭代对象中的元素通过Promise.resolve()转换为期约。 空的可迭代对象等价于new Promise(() => {})。

Promise.race()对于解决或拒绝状态,只要是第一个落定的期约,Promise.race()就会包装其解决值或拒绝理由并返回新期约。迭代顺序决定了落定顺序。

如果有一个期约拒绝,只要它是第一个落定的,就会成为拒绝合成期约的理由。之后再拒绝的期约不会影响最终期约的拒绝理由。与Promise.all()类似,合成的期约会静默处理所有包含期约的拒绝操作。

ES6不支持取消期约和进度通知,主要因为这样会导致期约连锁和期约合成过度复杂化。

异步函数(async/await)

也称为“async/await”(语法关键字),是ES6期约模式在ES函数中的应用。async/await是ES8规范新增的。 ES对函数进行了扩展,为其增加了两个新关键字:async和await。

1. async

用于声明异步函数,可以用在函数声明、函数表达式、箭头函数和方法上:

async function foo() {}
let bar = async function() {};
let baz = async () => {};
class Tex{
	async tex() {}
}

总体上其代码仍是同步求值的,而在参数或闭包方面,异步函数仍然具有普通JavaScript函数的正常行为。如下面的foo()函数仍然会在后面指令之前被求值:

async foo(){
	console.log(1);
}
foo();
console.log(2);
// 1
// 2

如果异步函数使用return关键字返回了值(若没有return则返回undefined),这个值会被Promise.resolve()包装成一个期约对象。在函数外部调用这个函数,可以得到它返回的期约:

async foo(){
	console.log(1);
	return 3;
	// return Promise.resolve(3);		// 直接返回一个期约对象也是一样的
}
foo().then(console.log);	// 给返回的期约添加一个解决处理程序
console.log(2);
// 1
// 2
// 3

如果返回的是实现thenable接口的对象,则这个对象可以由提供给then()处理程序“解包”。如果不是,则返回值就被当作已解决的期约。

async function foo(){		// 返回原始值
	return ‘foo’;
}
foo().then(console.log);		// foo

async function baz(){		// 返回一个实现了thenable接口的非期约对象
	const thenable = {
		then(callback) { callback(‘baz’); }
	};
	return thenable;
}
baz().then(console.log);		// baz

async function qux(){		// 返回一个期约
	return Promise.resolve(‘qux’);
}
qux().then(console.log);		// qux

与在期约处理程序中一样,在异步函数中抛出错误会返回拒绝的期约。拒绝期约的错误不会被异步函数捕获

async function bar(){		
	console.log(1);
	throw 3;
}
bar().catch(console.log);
console.log(2);	
// 1
// 2
// 3

async function foo(){		
	console.log(1);
	Promise.reject(3);
}
foo().catch(console.log);
console.log(2);		
// 1
// 2
// Uncaught (in promise) : 3

2. await

可以暂停异步函数代码的执行,等待期约解决。 可以单独使用,也可以在表达式中使用。

async function foo(){
	console.log(await Promise.resolve(‘foo’));
}
foo();		// foo

async function bar(){
	return await Promise.resolve(‘bar’);
}
bar().then(console.log);		// bar

async function baz(){
	await new Promise((resolve, reject) => setTimeout(resolve, 1000));
}
baz();		// baz (1000ms后)

如果是实现thenable接口的对象,则这个对象可以由await来“解包”。如果不是,则这个值就被当作已解决的期约。

async function foo(){		// 等待一个原始值
	console.log(await ‘foo’);
}
foo();		// foo

async function baz(){		// 等待一个实现了thenable接口的非期约对象
	const thenable = {
		then(callback) { callback(‘baz’); }
	};
	console.log(await thenable);
}
baz();		// baz

async function qux(){		// 等待一个期约
	console.log(await Promise.resolve(‘qux’));
}
qux();		// qux

等待会抛出错误的同步操作,会返回拒绝的期约。对拒绝的期约使用await,则会释放错误值(即将拒绝期约返回)。

async function foo(){
	console.log(1);
	await(() => { throw 3; });
}
foo().catch(console.log);
console.log(2);

// 1
// 2
// 3

async function bar(){
	console.log(1);
	await Promise.reject(3);
	console.log(4);	// 这段代码不会执行
}
bar().catch(console.log);
console.log(2);

// 1
// 2
// 3

3. await的限制

必须在异步函数中使用,不能在顶级上下文如<script>标签或模块中使用。定义并立即调用异步函数是没问题的。

(async function(){			// 立即调用的异步函数表达式
	console.log(await Promise.resolve(3));
}) {};
// 3

在同步函数内部使用await会抛出SyntaxError。

停止和恢复执行

async/await中真正起作用的是await。JS运行时在碰到await关键字时,会记录在哪里暂停执行。等到await右边的值可用了,JS运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。

即使await后面跟着一个立即可用的值,函数的其余部分也会被异步求值。

async foo(){
	console.log(2);
	await null;
	console.log(4);
}
console.log(1);
foo();
console.log(3);

// 1
// 2
// 3	// 此时同步线程的代码执行完毕,JS运行时从消息队列取出任务,恢复异步函数执行
// 4

如果await后面是一个期约,为了执行异步函数,实际上会有两个任务添加到消息队列并被异步求值。

异步函数策略

  1. 实现sleep()
async function sleep(delay){
	return new Promise((resolve) => setTimeout(resolve, delay));
}
async function foo(){
	const t0 = Date.now();
	await sleep(1500);		// 暂停约1500ms
	console.log(Date.now() – t0);
}
foo();	// 1502
  1. 串行执行期约 使用async/await,期约连锁会变得很简单:
async function addTwo(x) { return x+2; }		// 异步函数,都返回期约
async function addThree(x) { return x+3; }
async function addFive(x) { return x+5; }

async function addTen(x){
	for ( const fn of (addTwo, addThree, addFive)){
		x = await fn(x);
	}
	return x;
}
addTen(9).then(console.log);	// 19