深入理解-Promise(期约)和异步函数async/await

587 阅读19分钟

前段时间复习JavaScript编程基础,把Promise期约/异步函数等一些比较重要的知识点整理了一下,篇幅比较长,有需要的可以跳着看~

Promise(期约)

Promise期约是一种可在语言层面实现并行执行的模型。它封装了一个“剥离时间特性的数据”,并代理在该数据上的一切行为。 promise实例相当于封装了数据的触发器:当数据就绪(Ready)时,就触发指定行为(Actions)。而后者(行为),才是真正的执行逻辑。 因此Promise语境下Hello World程序的正确写法是:

Promise.resolve('hello world!') //data Ready?
    .then(console.log) // call Action!

一般情况下,用户代码主要用两种方法来得到一个Promise对象:

  • 1)使用 new Promise()来创建一个promise;
  • 2)使用类方法Promise.XXX()--包括.resolve()、.reject()、.all()或.race()等来获取一个promise。

任何方法得到的promise对象都具有.then()、.catch()等方法,也称为Promise.prototype.XXX()原型方法。JavaScript约定调用 这些方法将“绝对”不会抛出异常,而这也是得到一个新的promise对象的第三种方法。

  • 3)使用原型方法Promise.prototype.XXX()--promise.then()、.catch()和.finally()等将返回一个新的promise。

并且任何一种方法都是立即得到promise对象的。

Promise 的构造方法

Promise()使用一个简单的构造器界面来让用户方便创建promise对象:

/* 需要用户声明的执行器 */
excutor = function(resolve, reject) {
    ...
}
/* 创建promise对象的构造器 */
p = new Promise(excutor);

其中,executor() 是用户定义的执行器函数。当JavaScript引擎通过new运算来创建promise对象时,它事实上会在调用excutor()之前就创建好一个新的promise对象实例,并且得到关联给该实例的两个置值器:

resolve()与reject()函数。接下来,它会调用executor(),并将resolve()与reject()作为入口参数传入,而executor()函数会被执行直到退出。

Promise什么时候执行(重要)

在整个构建promise对象的过程中,有一个事实需要清晰理解,那就是所谓的“没有延时”。在传统的并发思路上理解Promise机制时,最容易犯的错就是搞不清楚“promise什么时候执行”。

在ECMAScript中没有约定任何与调度时间相关的运行期(Runtime libarary)机制,亦即是说,没有进程、线程,也没有单线程/多线程这样的调度模型。 因此仅使用ECMAScript约定的标准库,事实上是无法“写出一个并行过程”的。这也是几乎所有展示Promise特性的示例代码都要使用setTimeout的原因--这样才能创建一个并行任务。 但是setTimeout并不是ECMAScript规范下的,而是由宿主提供的应用层接口。setTimeout将隐含地受到许多宿主限制条件的影响,例如采用何种时间片调度,或者时钟管理机制,又或者 是否是在多核的、多CPU的环境下等。

Promise机制中并没有延时,也没有被延时的行为,更没有对“时间”这个维度的控制。因此在JavaScript中创建一个promise时,创建过程是立即完成的; 使用原型方法promise.XXX来得到一个新的promise(即promise2)时也是立即完成的。同样类似于此的,所有promise对象都是在你需要时立即就生成的, 只不过--重要的是--这些promise所代理的那个值/数据还没有“就绪(Ready)”。

这个就绪过程要推迟到“未知的将来”才会发生。而一旦数据就绪,promise.then(foo)中的foo就会触发了。

期约状态机

期约是一个有状态的对象,可能处于如下3种状态之一:

  • 待定(pending)
  • 兑现(fulfilled,有时候也成为“解决”, resolved)
  • 拒绝(rejected)

待定(pending)是期约的最初始状态。在待定状态下,期约可以落定(settled)为代表成功的兑现(fulfilled)状态,或者代表失败的拒绝(rejected)状态。 无论落定为哪种状态都是不可逆的。只要从待定转换为兑现或拒绝,期约的状态就不再改变。而且,也不能保证期约必然会脱离待定状态。因此,组织合理的代码无论期约解决(resolve) 还是拒绝(reject),甚至永远处于待定(pending)状态,都应该具有恰当的行为。 重要的是,期约的状态是私有的,不能直接通过JavaScript检测到。这主要是为了避免根据读取到的期约状态,以同步方式处理期约对象。 另外,期约的状态也不能被外部JavaScript代码修改。这与不能读取该状态的原因一样:【期约故意将异步行为封装起来,从而隔离外部的同步代码】

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

由于期约的状态是私有的,所以只能在内部进行操作。内部操作在期约的执行器函数中完成。 执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。其中,控制期约状态的转换是通过调用它的两个函数参数实现的。 这两个函数参数通常都命名为resolve()和reject()。调用resolve()会把状态切换为兑现,调用reject()会把状态切换为拒绝。 另外,调用reject()也会抛出错误。

let p1 = new Promise((resolve, reject) => resolve());
setTimeout(console.log, 0, p1); // Promise<resolved>

let p2 = new Promise((resolve,reject) => reject());
setTimeout(console.log, 0, p2). // Promise<rejected>
// Uncaught error (in promise)

Promise.resolve()

期约并非一开始就必须处于待定状态,然后再通过执行器函数才能转换为落定状态。通过调用Promise.resolve()静态方法,可以实例化一个解决的期约。 下面两个期约实例实际上是一样的:

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

Promise.reject()

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

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

期约的实例方法

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

实现Thenable接口

在ECMAScript暴露的异步结构中,任何对象都有一个then()方法。这个方法被认为实现了Thenable接口。 下面的例子展示了实现这一接口的最简单的类:

class MyThenable {
    then(){}
}

ECMAScript 的 Promise类型实现了Thenable接口。

Promise.prototype.then()

Promise.prototype.then() 是为期约实例添加处理程序的主要方法。这个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'));

// 3秒后
//p1 resolved
//p2 rejected

因为期约只能转换为最终状态一次,所以这两个操作一定是互斥的。

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

Promise.prototype.catch()

Promise.prototype.catch() 方法用于给期约添加拒绝处理程序。这个方法只接收一个参数: onRejected处理程序。事实上,这个方法就是语法糖,调用它就相当于调用Promise.prototype.then(null, onRejected) 下面的代码展示了这两种同样地情况:

let p = Promise.reject()
let onRejected = function(e){
    setTimeout(console.log, 0, 'rejectd');
};

// 这两种添加拒绝处理程序的方式是一样的
p.then(null, onRejected); // rejected
p.catch(onRejected); //rejected
Promise.prototype.catch() 返回一个新的期约实例:
let p1 = new Promise(() => {});
let p2 = p1.catch();
setTimeout(console.log, 0, p1); //Promise <pending>
setTimeout(console.log, 0, p2); //Promise <pending>
setTimeout(console.log, 0, p1 === p2); //false

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

Promise.prototype.finally()

Promise.prototype.finally() 方法用于给期约添加onFinally处理程序,这个处理程序在期约转换为解决或者拒绝状态时都会执行。 这个方法可以避免onResolved和onRejected处理程序出现冗余代码。但onFinally处理程序没有办法知道期约的状态是解决还是拒绝, 所以这个方法主要用于添加清理代码。

let p1 = Promise.resolve();
let p2 = Promise.reject();
let onFinally = function(){
    setTimeout(console.log, 0, 'Finally!')
}
p1.finally(onFinally) //Finally
p2.finally(onFinally) //Finally
Promise.prototype.finally() 方法返回一个新的期约实例:
let p1 = new Promise(() => {});
let p2 = p1.finally();
setTimeout(console.log, 0, p1); //Promise<pending>
setTimeout(console.log, 0, p2); //Promise<pending>
setTimeout(console.log, 0, p1 === p2); //false

这个新的期约实例不同于then()或catch()方式返回的实例。因为onFinally被设计为一个状态无关的方法,所以多数情况下它都会原样后传父期约。 无论父期约是解决还是拒绝,都会原样后传。

非重入期约方法

当期约进入落定状态时,与该状态相关的处理程序仅仅会被排期,而非立即被执行。跟在添加这个处理程序的代码之后的同步代码一定会在处理程序之前先执行。 即使期约一开始就是与附加处理程序关联的状态,执行顺序也是这样的。这个特性由JavaScript运行时保证,被称为“非重入”(non-reentrancy)特性。 下面的例子演示了这个特性:

//创建解决的期约
let p = Promise.resolve();
//添加解决处理程序
//直觉上,这个处理程序会等期约一解决就执行
p.then(() => console.log('onResolved handler'));

//同步输出,证明then()已经返回
console.log('then() returns')

//实际输出
//then() returns
//onResolved handler

在这个例子中,在一个解决期约上调用then()会把onResolved处理程序推进消息队列。但是这个处理程序在当前线程上的同步代码执行完前不会执行。 因此,跟在then()后面的同步代码一定先于处理程序执行。 先添加处理程序后解决期约也是一样的。如果添加处理程序后,同步代码才改变期约状态,那么处理程序仍然会基于该状态变化表现出非重入特性。 下面的例子展示了即使先添加了onResolved处理程序,再同步调用resolve(),处理程序也不会进入同步线程执行:

let synchronouseResolve;
//创建一个期约并将解决函数保存在一个局部变量中
let p = new Promise((resolve) => {
    synchronouseResolve = function(){
        console.log('1: invoking resolve()');
        resolve();
        console.log('2: resolve() returns');
    };
});
p.then(() => console.log('4: then() handler executes'));
synchronouseResolve();
console.log('3: synchronouseResolve() returns');

// 实际输出
1: invoking resolve()
2: resolve() returns
3: synchronouseResolve() returns
4: then() handler executes

在这个例子中,即使期约状态变化发生在添加处理程序之后,处理程序也会等到运行的消息队列让它出列时才会执行。

期约连锁与期约合成

多个期约组合在一起可以构成强大的代码逻辑。这种组合可以通过两种方式实现:期约连锁与期约合成。 前者就是一个期约接一个期约地拼接,后者则是将多个期约组合为一个期约。

期约连锁

把期约逐个地串联起来是一种非常有用的编程模式。之所以可以这样做,是因为每个期约实例的方法(then()、catch()和finally())都会返回一个新的期约对象, 而这个期约对象又有自己的实例方法。这样连缀方法调用就可以构成所谓的“期约连锁”。比如:

let p = new Promise((resolve, reject) => {
    console.log('first');
    resolve();
});
p.then(() => console.log('second'))
 .then(() => console.log('third'))
 .then(() => console.log('fourth'));

 //first
 //second
 //third
 //fourth

这个实现最终执行了一连串同步任务。

下面的例子同时把then()、catch()和finally()串联起来:

let p = new Promise((resolve, reject) => {
    console.log('initial promise rejects');
    reject();
});
p.catch(() => console.log('reject handler'))
 .then(() => console.log('resolve handler'))
 .finally(() => console.log('finally handler'))

 //initial promise rejects
 //reject handler
 //resolve handler
 //finally handler

Promise.all() 和 Promise.race()

Promise类提供两个将多个期约实例组合成一个期约的静态方法:Promise.all()和Promise.race()。而合成期约的行为取决于内部期约的行为。

Promise.all()

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

let p1 = Promise.all([
    Promise.resolve(),
    Promise.resolve()
]);

//可迭代对象中的元素会通过 Promise.resolve()转换为期约
let p2 = Promise.all([3,4]);

// 空的迭代对象等价于Promise.resolve()
let p3 = Promise.all([]);

//无效的语法
let p4 = Promise.all(); 
//TypeError: cannot read Symbol: iterator if undefined

合成的期约只会在每个包含的期约都解决之后才解决:

let p = Promise.all([
    Promise.resolve(),
    new Promise((resolve, reject) => setTimeout(resolve, 1000))
]);
setTimeout(console.log, 0, p); //Promise<pending>
p.then(() => setTimeout(console.log, 0, 'all() resolved!'))

//all() resolved!(大约1秒后)

如果至少有一个包含的期约待定,则合成的期约也会待定。如果有一个包含的期约拒绝,则合成期约也会拒绝:

//永远待定
let p1 = Promise.all([new Promise(() => {})]);
setTimeout(console.log, 0, p1); // Promise<pending>

// 一次拒绝会导致最终期约拒绝
let p2 = Promise.all([
    Promise.resolve(),
    Promise.reject(),
    Promise.resolve()
]);
setTimeout(console.log, 0, p2); //Promise<rejected>

//Uncaught (in promise) undefined

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

let p = Promise.all([
    Promise.resolve(3),
    Promise.resolve(),
    Promise.resolve(4)
]);
p.then((values) => setTimeout(console.log, 0, values)); //[3, undefined, 4]

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

//虽然只有第一个期约的拒绝理由会进入
//拒绝处理程序,第二个期约的拒绝也会被静默处理,不会有错误跑掉
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.race() 静态方法返回一个包装期约,是一组集合中最先解决或拒绝的期约镜像。这个方法接收一个可迭代对象,返回一个新期约:

let p1 = Promise.race([
    Promise.resolve(),
    Promise.resolve()
]);

// 可迭代对象中的元素通过Promise.resolve()转换为期约
let p2 = Promise.race([3,4]);

//空的可迭代对象等价于 new Promise(() => {})
let p3 = Promise.race([]);

//无效的语法
let p4 = Promise.race();
//TypeError: cannot read Symbol.iterator of undefined

Promise.race() 不会对解决或拒绝的期约区别对待。无论是解决还是拒绝,只要是第一个落定的期约,Promise.race()就会包装其解决值或拒绝理由并返回新期约:

//解决先发生,超时后的拒绝被忽略
let p1 = Promise.race([
    Promise.resolve(3),
    new Promise((resolve, reject) => setTimeout(reject, 10000))
]);
setTimeout(console.log, 0, p1); //Promise<resolved>: 3

//拒绝先发生,超时后的解决被忽略
let p2 = Promise.race([
    Promise.reject(4),
    new Promise((resolve, reject) => setTimeout(resolve, 10000))
]);
setTimeout(console.log, 0, p2); //Promise<rejected>: 4

//迭代顺序决定了落定顺序
let p3 = Promise.race([
    Promise.resolve(5);
    Promise.resolve(6);
    Promise.resolve(7);
]);
setTimeout(console.log, 0, p3); //Promise<resolved>: 5

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

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

p.catch((reason) => setTimeout(console.log, 0, reason)); //3

//没有未处理的错误

异步函数 async/await

异步函数,也称为“async/await”(语法关键字),是ES6期约模式在ECMAScript函数中的应用。 async/await是ES8规范新增的。这个特性从行为和语法上都增强了JavaScript,让以同步方式写的代码能够异步执行。

async关键字

async 关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上:

async function foo(){}

let bar = async function(){};

let baz = async () => {};

class Qux {
    async qux(){}
}

使用async关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。而在参数或闭包方面,异步函数仍然具有普通JavaScript函数的正常行为。

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

async function foo(){
    console.log(1);
    return 3;
}
//给返回的期约添加一个解决处理程序
foo().then(console.log);

console.log(2);

//1
//2
//3

当然,直接返回一个期约对象也是一样的

async function foo(){
    console.log(1);
    return Promise.resolve(3);
}
//给返回的期约添加一个解决处理程序
foo().then(console.log);

console.log(2);

//1
//2
//3

await关键字

因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力。 使用await关键字可以暂停异步函数代码的执行,等待期约解决。看下面的例子:

let p = new Promise((resolve,reject) => setTimeout(resolve, 1000, 3));

p.then((x) => console.log(x));  //3

使用async/await可以xss写成这样:
async function foo(){
    let p = new Promise((resolve, reject) => setTime(resolve,1000, 3))
    consile.log(await p);
}

foo();
//3

注意,await关键字会暂停执行异步函数后面的代码,让出JavaScript运行时的执行线程。 这个行为与生成器中的yield关键字一样。await关键字同样时尝试“解包”对象的值,然后将这个值传给表达式,再异步恢复异步函数的执行。

await关键字的用法与JavaScript的一元操作一样。它可以单使用,也可以在表达式中使用,如下面的例子所示:

//异步打印“foo”
async function foo(){
    console.log(await Promise.resolve('foo'));
}
foo();xq
//foo

//异步打印“bar”
async function bar(){
    return await Promise.resolve('bar');
}
bar().then(console.log);
//bar

//1000毫秒后异步打印“baz”
async function baz(){
    await new Promise((resolve, reject) => setTimeout(resolve, 1000));
    console.log('baz');
}
baz();
//baz(1000毫秒后)

await关键字期待(但实际不要求)一个实现thenable接口的对象,但常规的值也可以。 如果是实现thenable接口对象,则这个对象可以由await来“解包”。如果不是,则这个值就被当作已解决的期约。 下面代码演示了这些情况:

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

// 等待一个没有实现thenable接口的对象
async function bar(){
    console.log(await ['bar']);
}
bar();
//["bar"]

//等待一个实现了thenable接口的对象
async funcriin baz(){
    const thenable = {
        then(callback){callback('baz');}
    };
    console.log(await thenable);
}

baz();
//baz

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

等待会抛出错误的同步操作,会返回拒绝的期约:

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

//给返回的期约添加一个拒绝处理程序
foo().catch(console.log);
console.log(2);

//1
//2
//3

如前面的例子所示,单独的Promise.reject()不会被异步函数捕获,而会抛出未捕获错误。 不过,对拒绝的期约使用await则会释放出错误值(将拒绝期约返回):

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

//给返回的期约添加一个拒绝处理程序
foo().catch(console.log);
console.log(2);

//1
//2
//3

await的限制

await关键字必须在异步函数中使用,不能在顶级上下文如script标签或模块中使用。 不过定义并立即调用异步函数是没问题的。下面两段代码实际是相同的:

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

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

此外,异步函数的特质不会扩展到嵌套函数。因此, await关键字也只能直接出现在异步函数的定义中。 在同步函数内部使用await会抛出SyntaxError。 下面展示一些错误的例子:

//不允许:await出现在了箭头函数中
function foo(){
    const syncFn = () => {
        return await Promise.resolve('foo');
    };
    console.log(syncFn());
}

//不允许:await出现在了同步函数中
function bar(){
    function syncFn(){
        return await Promise.resolve('bar');
    }
    console.log(syncFn());
}

//不允许:await出现在同步函数表达式中
function baz(){
    const syncFn = function(){
        return await Promise.resolve('baz');
    };
    console.log(syncFn());
}

//不允许:IIFE使用同步函数表达式或箭头函数
function qux(){
    (function(){ console.log(await Promise.resolve('qux'));})();
    (()=> console.log(await Promise.resolve('qux')))();
}

执行栈

最后再来看看执行栈,执行栈是用来确保多个执行上下文可以按约定顺序依次处理的另一个结构。任何情况下,这个所谓的“约定顺序”只有一项原则:

当前执行栈的上下文,就是运行中的活动上下文。

因此:

当一个新的上下文入栈时,那么新的上下文必然是活动的。这正好对应于“在当前函数中调用新函数”,亦即是说,函数调用就是将目标函数的上下文入栈。

与此相反的:

当有一个并行行为(例如Promise的Reactions)出现时,该行为在概念上由于是并行的而不能入栈(入栈就会激活它),因此需要通过EnqueueJob() 过程将它作为一个PendingJob添加到任务队列(Job Queues)中。

并且上述原则还隐含了一个推论: 当前栈顶为空时,意味着引擎在闲(所有上下文都处理完毕) 因此: 当栈顶为空(即引擎在闲着)时,从上述队列中取出PendingJob来执行即可。 这个过程就是所谓的RunJobs()。

小结

长期以来,掌握单线程JavaScript运行时的异步行为一直都是个艰巨的任务。随着ES6新增了期约和ES8新增了异步函数, ECMAScript的异步编程特性有了长足的进度。通过期约和async/await,不仅可以实现之前难以实现或不可实现的任务, 而且也能写出清晰、简洁,并且易于理解、调试的代码。

期约的主要功能是为了异步代码提供清晰的抽象。可以用期约表示异步执行的代码块,也可以用期约表示异步计算的值。

在需要串行异步代码时,期约的价值最突出。作为可塑性极强的一种结构,期约可以被序列化、连锁使用,复合、扩展和重组。

异步函数是将期约应用于JavaScript函数的结果。异步函数可以暂停执行,而不阻塞主线程。无论是编写基于期约的代码, 还是组织串行或平行执行的异步代码,使用异步函数都非常得心应手。异步函数可以说是现代JavaScript工具箱中最重要的工具之一。

最后通过对执行栈的分析,我们会加深对Promise和异步函数执行原理的理解。