[译]Promise的进化史

342 阅读17分钟

原文:Javascript高级编程4

异步编程

开场白

同步行为与异步行为之间的对偶是计算机科学中的一个基本概念,尤其是在单线程事件循环模型(如JavaScript)中。面对高延迟操作,异步行为不再需要针对更高的计算吞吐量进行优化。如果在计算完成时仍然可以运行其他指令并且仍保持稳定的系统,那么这样做是实用的。

更重要的是,异步操作不一定是计算密集型操作或高延迟操作。它可以在不需要阻塞执行线程以等待异步行为发生的任何地方使用。

JavsScript中的同步与异步

同步行为类似于内存中的顺序处理器指令。每条指令严格按照其出现的顺序执行,并且每条指令还能够立即检索系统本地存储的信息(例如:在处理器寄存器或系统内存中)。结果,很容易推断出代码中任何给定点的程序状态(例如,变量的值)。

一个简单的例子就是执行一个简单的算术运算:

let x = 3;
x = x + 4;

在该程序的每个步骤中,都可以推断出程序的状态,因为在完成前一条指令之前,执行不会继续进行。当最后一条指令完成时,x的计算值立即可用。所有这些指令都在单个执行线程中串行存在。

相反,异步行为类似于中断,即当前进程外部的实体能够触发代码执行。通常需要异步操作,因为强制操作等待较长时间才能完成操作是不可行的(同步操作就是这种情况)。由于代码正在访问高延迟资源,例如将请求发送到远程服务器并等待响应,因此可能会发生长时间等待。

一个简单的JavaScript示例将在超时内执行算术运算:

let x = 3;
setTimeout(() => x = x + 4, 1000);

该程序最终执行与一个同步程序相同的工作(将两个数字加在一起),但是该执行线程无法确切知道x的值何时会更改,因为这取决于何时从消息队列中使回调出队并执行回调。

上古时代的异步编程模式

长期以来,异步操作一直是JavaScript语言的痛点。在该语言的早期版本中,异步操作仅支持定义回调函数以指示异步操作已完成。异步行为的执行是一个常见的问题,通常可以通过一个充满嵌套回调函数的代码片段来解决,该代码片段通常称为“回调地狱”。

getData(function(a){
    getMoreData(a, function(b){
        getMoreData(b, function(c){
            getMoreData(c, function(d){
                getMoreData(d, function(e){
    	            // todo
    	        });
            });
        });
    });
});

返回异步值

假设setTimeout操作返回了一个有用的值。将值传回的最佳方式是什么?广泛接受的策略是提供对异步操作的回调,其中该回调包含需要访问计算值(作为参数提供)的代码。如下所示:

function double(value, callback) {
 setTimeout(() => callback(value * 2), 1000);
}
double(3, (x) => console.log(`给我: ${x}`));
// 给我: 6 (大约1000ms后打印)

此处,setTimeout调用在经过1000毫秒后将函数推入消息队列。此函数将由运行时出队并异步求值。回调函数及其参数仍然可以通过函数闭包在异步执行中使用。

PROMISES

Promise表示某种尚未产生结果的实体。例如最终(eventual)未来(future)延迟(delay)推迟(deferred)。所有这些都以一种或另一种形式描述了一种用于同步程序执行的编程工具。

Promises/A+规范

一份针对健全、通用JavaScript promises对象的开放标准 — 由实现者制定,供实现者参考。

一个 promise 对象代表一个异步操作的最终结果。与promise进行交互的主要方式是通过它的 then 方法,通过该方法注册回调函数,进而接受promise对象最终的值(value)或不能完成(fulfill)的原因(reason)。

ECMAScript 6引入了Promises/A+兼容Promise类型的一等实现。自推出以来,Promises的采用率就非常高。所有现代浏览器都完全支持ES6 Promise类型,并且多个浏览器API(例如fetch()和Battery API)仅使用它。

Promise状态机

当将promise实例传递到console.log时,控制台输出(可能因浏览器而异)指示此promise实例处于待定(pending)状态。如前所述,promise是一个有状态对象,可以存在以下三种状态之一:

  • Pending (待定 - 尚未执行或拒绝)
  • Fulfilled (已执行 - 有时也指resolved, 与 promise 相关的操作成功)
  • Rejected (已拒绝 - 与 promise 相关的操作失败)

待定(pending)状态是promise开始的初始状态。从待定(pending)状态开始,一个promise可以转换到fulfilled状态(表示成功)或rejected状态(表示失败)。这种过渡到稳定(settled)状态是不可逆的。一旦变成已执行(fulfilled)状态或被拒绝(rejected)状态,promise的状态就永远不会改变。此外,不能保证promise将来会离开待定(pending)状态。因此,不管promise处于何种状态,即成功执行拒绝从未退出待定(pending)状态,结构良好的代码都应能正常运行。

更重要的是,promise的状态是私有的,不能在JavaScript中直接检查。这样做的原因主要是为了防止在读取promise对象时根据其状态进行同步编程处理。此外,外部JavaScript无法更改Promise的状态。

用Executor控制promise状态

由于promise状态是私有的,因此它只能在内部被维护操作。这种内部操作是在promise的执行者(executor)函数内部执行的。执行函数有两个主要职责:初始化promise的异步行为,以及控制任何最终的状态转换。通过调用状态转换的两个函数参数之一(通常命名为resolvereject)来完成对状态转换的控制。调用resolve将使状态变为已实现fulfilled;调用reject会将状态更改为拒绝rejected。调用rejected()也会引发错误。

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)

一旦调用resolvereject,状态转移将无法撤消。试图进一步改变状态的尝试将会被忽略。如下所示:

let p = new Promise((resolve, reject) => {
 resolve();
 reject(); // 被忽略
});

setTimeout(console.log, 0, p); // Promise <resolved>

您可以通过添加定时退出行为来避免Promise陷入待定(pending)状态。例如,您可以设置超时以在10秒后拒绝这个promise:

let p = new Promise((resolve, reject) => {
 setTimeout(reject, 10000); // 10秒后, 调用reject()
 // 执行其他代码
});
setTimeout(console.log, 0, p); // Promise <pending>
setTimeout(console.log, 11000, p); // 11秒后检查状态
// (10秒后) Uncaught error
// (11秒后) Promise <rejected>

因为一个promise只能更改状态一次,所以此超时行为使您可以安全地设置一个可以保持在待定(pending)状态的时间的最大值。如果执行程序内部的代码要在超时之前resolvereject,则超时处理程序拒绝reject的尝试将被忽略。

使用Promise.resolve()进行Promise转换

promise不一定需要从待定(pending)状态开始并利用执行程序函数来达到稳定settled状态。通过调用Promise .resolve()静态方法,可以在resolved状态下实例化Promise。以下两个promise实例实际上是等效的:

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

此已解决resolved的Promise的值将成为传递给Promise.resolve()的第一个参数。这有效地使您可以将任何值转成一个promise:

setTimeout(console.log, 0, Promise.resolve());
// Promise <resolved>: undefined
setTimeout(console.log, 0, Promise.resolve(3));
// Promise <resolved>: 3
// Additional arguments are ignored
setTimeout(console.log, 0, Promise.resolve(4, 5, 6));
// Promise <resolved>: 4

也许此静态方法最重要的用处之一是当参数已经是一个promise实例时,它可以充当传递passthrough的能力。也就是说,Promise.resolve()是一个幂等方法,如此处所示:

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

这种幂等操作将保持传递给它的promise的状态:

let p = new Promise(() => {});
setTimeout(console.log, 0, p); // Promise <pending>
setTimeout(console.log, 0, Promise.resolve(p)); // Promise <pending>
setTimeout(console.log, 0, p === Promise.resolve(p)); // true

但请注意,此静态方法将愉快地将任何非promise(包括错误对象)包装为已解决resolved的promise,这可能会导致意外的行为:

let p = Promise.resolve(new Error('foo'));
setTimeout(console.log, 0, p);
// Promise <resolved>: Error: foo
使用Promise.reject()拒绝Promise

与Promise.resolve()的概念类似,Promise.reject()实例化一个被拒绝rejected的promise并引发异步错误(try/catch不会捕获该异步错误,而该错误只能由拒绝处理程序捕获)。以下两个promise实例实际上是等效的:

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

此已解决的promise的原因(reason)字段将是传递给Promise.reject()的第一个参数。它也会被传递给拒绝处理程序:

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

更重要的是,Promise.reject()不能反映等幂性的Promise.resolve()行为。如果传递了一个promise对象,它将很乐意使用该promise作为被拒绝promise的原因(reason)字段:

setTimeout(console.log, 0, Promise.reject(Promise.resolve()));
// Promise <rejected>: Promise <resolved>
同步/异步执行二元性(Duality)

Promise构造的许多设计都是为了在JavaScript中产生一种完全独立的计算模式。在下面的示例中,它被巧妙地封装,从而以两种不同的方式引发错误:

try {
 throw new Error('foo');
} catch(e) {
 console.log(e); // Error: foo
}
try {
 Promise.reject(new Error('bar'));
} catch(e) {
 console.log(e);
}
// Uncaught (in promise) Error: bar

第一个try/catch块抛出一个错误,然后继续捕获它,但是第二个try/catch块抛出了一个未被捕获的错误。这似乎是违反直觉的,因为代码似乎是在同步创建被拒绝的Promise实例,然后在被拒绝时引发错误。但是,未捕获第二个promise的原因是代码没有尝试在适当的异步模式下捕获错误。这种行为强调了promise的实际行为:它们是同步对象-在同步执行模式内使用-充当通往异步执行模式的桥梁。

在前面的示例中,来自被拒绝的promise的错误不是在同步执行线程中引发的,而是在浏览器的异步消息队列执行中引发的。因此,封装try/catch块不足以捕获此错误。一旦代码开始以这种异步模式执行,与之交互的唯一方法就是使用异步模式构造—更具体地说,是promise方法。

Promise实例方法

在promise实例上公开的方法用于弥合同步外部代码路径和异步内部代码路径之间的差距。这些方法可用于访问从异步操作返回的数据,处理promise的成功和失败结果,串行评估promise或添加仅在promise进入终端状态后才执行的功能。

实现Thenable接口

出于ECMAScript异步构造的目的,任何公开了then()方法的对象都被视为实现了Thenable接口。以下是实现此接口的最简单类的示例:

class MyThenable {
 then() {}
}

ECMAScript Promise类型实现了Thenable接口。不要将这种简单化的接口与诸如TypeScript之类的包中的其他接口或类型定义相混淆,后者提供了thenable接口的更具体形式。

Promise.prototype.then()

Promise.prototype.then()方法是用于将处理程序附加到Promise实例的主要方法。then()方法最多接受两个参数:一个可选的onResolved处理函数和一个可选的onRejected处理函数。每个仅在定义了它们的promise达到其各自的已实现(fulfilled)已拒绝(rejected)状态时才执行。

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只能转换一次到最终状态,所以可以保证这些处理函数的执行是互斥的。

如前所述,两个处理程序参数都是完全可选的。作为then()的参数提供的任何非函数类型都将被静默忽略。如果只想显式地提供onRejected处理程序,则将undefined作为onResolved参数是一种典型选择。这样可以避免在内存中创建临时对象,以免被解释器忽略。

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('hello');
// 显式跳过onResolved处理程序
p2.then(null, () => onRejected('p2'));
// p2 rejected (3秒后) 

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

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实例p2是从onResolved处理程序的返回值派生的。处理程序的返回值包装在Promise.resolve()中以生成新的Promise。如果未提供处理函数,则该方法将直接传递初始promise的已解决的值。如果没有显式的return语句,则默认的返回值是undefined,并包装在Promise.resolve()中。

let p1 = Promise.resolve('foo');

// 调用then()方法时,没有提供处理函数参数,p1.then()的结果是直接返回p1给p2
let p2 = p1.then();
setTimeout(console.log, 0, p2); // Promise <resolved>: foo
// 这些是等效的
let p3 = p1.then(() => undefined);
let p4 = p1.then(() => {});
let p5 = p1.then(() => Promise.resolve());

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

把显式返回值包装在Promise.resolve()中:

// 这些是等效的:
let p6 = p1.then(() => 'bar');
let p7 = p1.then(() => Promise.resolve('bar'));

setTimeout(console.log, 0, p6); // Promise <resolved>: bar
setTimeout(console.log, 0, p7); // Promise <resolved>: bar

// 1, 处理程序的返回值包装在Promise.resolve()中以生成新的Promise
// 2, Promise.resolve()保留返回的promise
let p8 = p1.then(() => new Promise(() => {}));
let p9 = p1.then(() => Promise.reject());
// Uncaught (in promise): undefined
setTimeout(console.log, 0, p8); // Promise <pending>
setTimeout(console.log, 0, p9); // Promise <rejected>: undefined

抛出异常将返回被拒绝的promise:

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

更重要的是,返回错误不会触发相同的拒绝行为,而是将错误对象包装在已解决的Promise中:

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

onRejected处理函数的处理方式也相同:从onRejected处理函数返回的值包装在Promise.resolve()中。乍一看,这似乎违反直觉,但是onRejected处理程序正在执行其工作以捕获异步错误。因此,该拒绝处理函数在不引发其他错误的情况下完成执行应视为预期的promise行为,并因此返回已解决的promise。

以下Promise.reject()代码片段和使用Promise.resolve()的先前示例类似:

let p1 = Promise.reject('foo');
// 调用then()方法时,没有提供处理函数参数,p1.then()的结果是直接返回p1给p2
let p2 = p1.then();
// Uncaught (in promise) foo

setTimeout(console.log, 0, p2); // Promise <rejected>: foo

// 这些是等效的:
let p3 = p1.then(null, () => undefined);
let p4 = p1.then(null, () => {});
let p5 = p1.then(null, () => Promise.resolve());

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

// 这些是等效的:
let p6 = p1.then(null, () => 'bar');
let p7 = p1.then(null, () => Promise.resolve('bar'));

setTimeout(console.log, 0, p6); // Promise <resolved>: bar
setTimeout(console.log, 0, p7); // Promise <resolved>: bar

// Promise.resolve()保留返回的promise
let p8 = p1.then(null, () => new Promise(() => {}));
let p9 = p1.then(null, () => Promise.reject());
// Uncaught (in promise): undefined

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

let p10 = p1.then(null, () => { throw 'baz'; });
// Uncaught (in promise) baz

setTimeout(console.log, 0, p10); // Promise <rejected>: baz

let p11 = p1.then(null, () => Error('qux'));
setTimeout(console.log, 0, p11); // Promise <resolved>: Error: qux 
Promise.prototype.catch()

Promise.prototype.catch()方法只能用于将拒绝处理函数附加到Promise。它只需要一个参数,即onRejected处理函数。该方法仅是语法糖,与使用Promise.prototype.then(null,onRejected)并无不同。

下面的代码演示了这种等效性:

let p = Promise.reject();
let onRejected = function(e) {
 setTimeout(console.log, 0, 'rejected');
};
// 这两个拒绝处理程序的行为相同:
p.then(null, onRejected); // rejected
p.catch(onRejected); // rejected

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

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实例,Promise.prototype.catch()的行为与Promise.prototype.then()的onRejected处理程序相同。

Promise.prototype.finally()

Promise.protoype.finally()方法可用于附加onFinally处理程序,该处理程序在promise达到已解决或已拒绝状态时执行。这对于避免onResolved和onRejected处理程序之间的代码重复很有用。重要的是,处理程序没有任何方法可以确定promise是否已解决或被拒绝,因此该方法旨在用于清除之类的事情。

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()方法返回一个新的Promise实例:

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

这个新的Promise实例是通过不同于then()或catch()的方式派生的。因为onFinally旨在成为状态未知的方法,所以在大多数情况下,它将作为直接传递父promose的作用。无论是已解决状态还是被拒绝状态,都是如此。

let p1 = Promise.resolve('foo');
// 这些都充当直接传递之前的promise, 即p1
let p2 = p1.finally();
let p3 = p1.finally(() => undefined);
let p4 = p1.finally(() => {});
let p5 = p1.finally(() => Promise.resolve());
let p6 = p1.finally(() => 'bar');
let p7 = p1.finally(() => Promise.resolve('bar'));
let p8 = 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
setTimeout(console.log, 0, p7); // Promise <resolved>: foo
setTimeout(console.log, 0, p8); // Promise <resolved>: foo

唯一的例外是它返回待定的promise或引发错误(通过显式throw或返回被拒绝的promise)。在这些情况下,将返回相应的promise(待定或拒绝),如下所示:

// Promise.resolve()保留返回的promise
let p9 = p1.finally(() => new Promise(() => {}));
let p10 = p1.finally(() => Promise.reject());
// Uncaught (in promise): undefined

setTimeout(console.log, 0, p9); // Promise <pending>
setTimeout(console.log, 0, p10); // Promise <rejected>: undefined
let p11 = p1.finally(() => { throw 'baz';});
// Uncaught (in promise) baz

setTimeout(console.log, 0, p11); // Promise <rejected>: baz

返回待定的promise是一种不常见的情况,因为一旦promise解决,新的promise仍将充当初始promise来传递:

let p1 = Promise.resolve('foo');

// resolve('bar')将被忽略
let p2 = p1.finally(
 () => new Promise((resolve, reject) => setTimeout(() => resolve('bar'), 100)));
setTimeout(console.log, 0, p2); // Promise <pending>
setTimeout(() => setTimeout(console.log, 0, p2), 200);
// 200毫秒后:
// Promise <resolved>: foo

待续。。。

总结

长期以来,在单线程JavaScript运行时内部掌握异步行为一直是一项艰巨的任务。随着ES6中引入Promise和ES7中引入async/await ,ECMAScript中的异步构造得到了极大的增强。Promise和async/await不仅启用了以前难以实现或无法实现的模式,而且还带来了一种全新的JavaScript编写方式,该方式更加简洁,简短,易于理解和调试。它们是现代JavaScript工具箱中最重要的工具之一。

阿里云,云服务器,仅86元/年,有需要的同学可以直接点击链接参团购买