你不懂JS: 异步与性能

第三章: Promises

在第二章中,我们定位了在使用回调表达程序异步性和管理并发的两个主要类别的不足:缺乏顺序性和缺乏可靠性。现在我们更亲近地理解了问题,是时候将我们的注意力转向解决它们的模式了。

我们首先想要解决的是 控制倒转 问题,信任是如此脆弱而且是如此的容易丢失。

回想一下,我们将我们的程序的延续包装进一个回调函数中,将这个回调交给另一个团体(甚至是潜在的外部代码),并双手合十祈祷它会做正确的事情并调用这个回调。

我们这么做是因为我们想说,“这是 稍后 将要发生的事,在当前的步骤完成之后。”

但是如果我们能够反向倒转这种 控制倒转 呢?如果不是将我们程序的延续交给另一个团体,而是希望它返回给我们一个可以知道它何时完成的能力,然后我们的代码可以决定下一步做什么呢?

这种规范被称为 Promise

Promise正在像风暴一样席卷JS世界,因为开发者和语言规范作者之流拼命地想要在他们的代码/设计中结束回调地狱的疯狂。事实上,大多数新被加入JS/DOM平台的异步API都是建立在Promise之上的。所以深入学习它们可能是个好主意,你不这么认为吗?

注意: “立即”这个词将在本章频繁使用,一般来说它指代一些Promise解析行为。然而,本质上在所有情况下,“立即”意味着就工作队列行为(参见第一章)而言,不是严格同步的 现在 的感觉。

什么是Promise?

当开发者们决定要学习一种新技术或模式的时候,他们的第一步总是“给我看代码!”。摸着石头过河对我们来讲是十分自然的。

但事实上仅仅考察API丢失了一些抽象过程。Promise是这样一种工具:它能非常明显地看出使用者是否理解了它是为什么和关于什么,还是仅仅学习和使用API。

所以在我展示Promise的代码之前,我想在概念上完整地解释一下Promise到底是什么。我希望这能更好地指引你探索如何将Promise理论整合到你自己的异步流程中。

带着这样的想法,让我们来看两种类比,来解释Promise是什么。

未来的值

想象这样的场景:我走到快餐店的柜台前,点了一个起士汉堡。并交了1.47美元的现金。通过点餐和付款,我为得到一个 (起士汉堡)制造了一个请求。我发起了一个事务。

但是通常来说,起士汉堡不会立即到我手中。收银员交给一些东西代替我的起士汉堡:一个带有点餐排队号的收据。这个点餐号是一个“我欠你”的许诺(Promise),它保证我最终会得到我的起士汉堡。

于是我就拿着我的收据和点餐号。我知道它代表我的 未来的起士汉堡,所以我无需再担心它——除了挨饿!

在我等待的时候,我可以做其他的事情,比如给我的朋友发微信说,“嘿,一块儿吃午餐吗?我要吃起士汉堡”。

我已经在用我的 未来的起士汉堡 进行推理了,即便它还没有到我手中。我的大脑可以这么做是因为它将点餐号作为起士汉堡的占位符号。这个占位符号实质上使这个值 与时间无关。它是一个 未来的值

最终,我听到,“113号!”。于是我愉快地拿着收据走回柜台前。我把收据递给收银员,拿回我的起士汉堡。

换句话说,一旦我的 未来的值 准备好,我就用我的许诺值换回值本身。

但还有另外一种可能的输出。它们叫我的号,但当我去取起士汉堡时,收银员遗憾地告诉我,“对不起,看起来我们的起士汉堡卖光了。”把这种场景下顾客有多沮丧放在一边,我们可以看到 未来的值 的一个重要性质:它们既可以表示成功也可以表示失败。

每次我点起士汉堡时,我都知道我要么最终得到一个起士汉堡,要么得到起士汉堡卖光的坏消息,并且不得不考虑中午吃点儿别的东西。

注意: 在代码中,事情没有这么简单,因为还隐含着一种点餐号永远也不会被叫到的情况,这时我们就被搁置在了一种无限等待的未解析状态。我们待会儿再回头处理这种情况。

现在和稍后的值

这一切也许听起来在思维上太过抽象而不能实施在你的代码中。那么,让我们更具体一些。

然而,在我们能介绍Promise是如何以这种方式工作之前,我们先看看我们已经明白的代码——回调!——是如何处理这些 未来值 的。

在你写代码来推导一个值时,比如在一个number上进行数学操作,不论你是否理解,对于这个值你已经假设了某些非常基础的事实——这个值已经是一个实在的 现在 值:

var x, y = 2;

console.log( x + y ); // NaN  <-- 因为`x`还没有被赋值

x + y操作假定xy都已经被设定好了。用我们一会将要阐述的术语来讲,我们假定xy的值已经被 解析(resovle) 了。

期盼+操作符本身能够魔法般地检测并等待xy的值被解析(也就是准备好),然后仅在那之后才进行操作是没道理的。如果不同的语句 现在 完成而其他的 稍后 完成,这就会在程序中造成混乱,对吧?

如果两个语句中的一个(或两者同时)可能还没有完成,你如何才能推断它们的关系呢?如果语句2要依赖语句1的完成,那么这里仅有两种输出:不是语句1 现在 立即完成而且一切处理正常进行,就是语句1还没有完成,所以语句2将会失败。

如果这些东西听起来很像第一章的内容,很好!

回到我们的x + y的数学操作。想象有一种方法可以说,“将xy相加,但如果它们中任意一个还没有被设置,就等到它们都被设置。尽快将它们相加。”

你的大脑也许刚刚跳进回调。好吧,那么...

function add(getX,getY,cb) {
    var x, y;
    getX( function(xVal){
        x = xVal;
        // 两者都准备好了?
        if (y != undefined) {
            cb( x + y );    // 发送加法的结果
        }
    } );
    getY( function(yVal){
        y = yVal;
        // 两者都准备好了?
        if (x != undefined) {
            cb( x + y );    // 发送加法的结果
        }
    } );
}

// `fetchX()`和`fetchY()`是同步或异步的函数
add( fetchX, fetchY, function(sum){
    console.log( sum ); // 很简单吧?
} );

花点儿时间来感受一下这段代码的美妙(或者丑陋),我耐心地等你。

虽然丑陋是无法否认的,但是关于这种异步模式有一些非常重要的事情需要注意。

在这段代码中,我们将xy作为未来的值对待,我们将add(..)操作表达为:(从外部看来)它并不关心xy或它们两者现在是否可用。换句话所,它泛化了 现在稍后,如此我们可以信赖add(..)操作的一个可预测的结果。

通过使用一个临时一致的add(..)——它跨越 现在稍后 的行为是相同的——异步代码的推理变得容易的多了。

更直白地说:为了一致地处理 现在稍后,我们将它们都作为 稍后:所有的操作都变成异步的。

当然,这种粗略的基于回调的方法留下了许多提升的空间。为了理解在不用关心 未来的值 在时间上什么时候变得可用的情况下推理它而带来的好处,这仅仅是迈出的一小步。

Promise值

我们绝对会在本章的后面深入更多关于Promise的细节——所以如果这让你犯糊涂,不要担心——但让我们先简单地看一下我们如何通过Promise来表达x + y的例子:

function add(xPromise,yPromise) {
    // `Promise.all([ .. ])`接收一个Promise的数组,
    // 并返回一个等待它们全部完成的新Promise
    return Promise.all( [xPromise, yPromise] )

    // 当这个Promise被解析后,我们拿起收到的`X`和`Y`的值,并把它们相加
    .then( function(values){
        // `values`是一个从先前被解析的Promise那里收到的消息数组
        return values[0] + values[1];
    } );
}

// `fetchX()`和`fetchY()`分别为它们的值返回一个Promise,
// 这些值可能在 *现在* 或 *稍后* 准备好
add( fetchX(), fetchY() )

// 为了将两个数字相加,我们得到一个Promise。
// 现在我们链式地调用`then(..)`来等待返回的Promise被解析
.then( function(sum){
    console.log( sum ); // 这容易多了!
} );

在这个代码段中有两层Promise。

fetchX()fetchY()被直接调用,它们的返回值(promise!)被传入add(..)。这些promise表示的值将在 现在稍后 准备好,但是每个promise都将行为泛化为与时间无关。我们以一种时间无关的方式来推理XY的值。它们是 未来值

第二层是由add(..)创建(通过Promise.all([ .. ]))并返回的promise,我们通过调用then(..)来等待它。当add(..)操作完成后,我们的sum未来值 就准备好并可以打印了。我们将等待XY未来值 的逻辑隐藏在add(..)内部。

注意:add(..)内部。Promise.all([ .. ])调用创建了一个promise(它在等待promiseXpromiseY被解析)。链式调用.then(..)创建了另一个promise,它的return values[0] + values[1]这一行会被立即解析(使用加法的结果)。这样,我们链接在add(..)调用末尾的then(..)调用——在代码段最后——实际上是在第二个被返回的promise上进行操作,而非被Promise.all([ .. ])创建的第一个promise。另外,虽然我们没有在这第二个then(..)的末尾链接任何操作,它也已经创建了另一个promise,我们可以选择监听/使用它。这类Promise链的细节将会在本章后面进行讲解。

就像点一个起士汉堡,Promise的解析可能是一个拒绝(rejection)而非完成(fulfillment)。不同的是,被完成的Promise的值总是程序化的,而一个拒绝值——通常被称为“拒绝理由”——既可以被程序逻辑设置,也可以被运行时异常隐含地设置。

使用Promise,then(..)调用实际上可以接受两个函数,第一个用作完成(正如刚才所示),而第二个用作拒绝:

add( fetchX(), fetchY() )
.then(
    // 完成处理器
    function(sum) {
        console.log( sum );
    },
    // 拒绝处理器
    function(err) {
        console.error( err ); // 倒霉!
    }
);

如果在取得XY时出现了错误,或在加法操作时某些事情不知怎地失败了,add(..)返回的promise就被拒绝了,传入then(..)的第二个错误处理回调函数会从promise那里收到拒绝的值。

因为Promise包装了时间相关的状态——等待当前值的完成或拒绝——从外部看来,Promise本身是时间无关的,如此Promise就可以用可预测的方式组合,而不用关心时间或底层的结果。

另外,一旦Promise被解析,它就永远保持那个状态——它在那个时刻变成了一个 不可变的值——而且可以根据需要 被监听 任意多次。

注意: 因为Promise一旦被解析就是外部不可变的,所以现在将这个值传递给任何其他团体都是安全的,而且我们知道它不会被意外或恶意地被修改。这在许多团体监听同一个Promise的解析时特别有用。一个团体去影响另一个团体对Promise解析的监听能力是不可能的。不可变性听起来是一个学院派话题,但它实际上是Promise设计中最基础且最重要的方面之一,因此不能将它随意地跳过。

这是用于理解Promise的最强大且最重要的概念之一。通过大量的工作,你可以仅仅使用丑陋的回调组合来创建相同的效果,但这真的不是一个高效的策略,特别是你不得不一遍一遍地重复它。

Promise是一种用来包装与组合 未来值,并且可以很容易复用的机制。

完成事件

正如我们刚才看到的,一个独立的Promise作为一个 未来值 动作。但还有另外一种方式考虑Promise的解析:在一个异步任务的两个或以上步骤中,作为一种流程控制机制——俗称“这个然后那个”。

让我们想象调用foo(..)来执行某个任务。我们对它的细节一无所知,我们也不关心。它可能会立即完成任务,也可能会花一段时间完成。

我们仅仅想简单地知道foo(..)什么时候完成,以便于我们可以移动到下一个任务。换句话说,我们想要一种方法被告知foo(..)的完成,以便于我们可以 继续

在典型的JavaScript风格中,如果你需要监听一个通知,你很可能会想到事件(event)。那么我们可以将我们的通知需求重新表述为,监听由foo(..)发出的 完成(或 继续)事件。

注意: 将它称为一个“完成事件”还是一个“继续事件”取决于你的角度。你是更关心foo(..)发生的事情,还是更关心foo(..)完成 之后 发生的事情?两种角度都对而且都有用。事件通知告诉我们foo(..)已经 完成,但是 继续 到下一个步骤也没问题。的确,你为了事件通知调用而传入的回调函数本身,在前面我们称它为一个 延续。因为 完成事件 更加聚焦于foo(..),也就是我们当前注意的东西,所以在这篇文章的其余部分我们稍稍偏向于使用 完成事件

使用回调,“通知”就是被任务(foo(..))调用的我们的回调函数。但是使用Promise,我们将关系扭转过来,我们希望能够监听一个来自于foo(..)的事件,当我们被通知时,做相应的处理。

首先,考虑一些假想代码:

foo(x) {
    // 开始做一些可能会花一段时间的事情
}

foo( 42 )

on (foo "completion") {
    // 现在我们可以做下一步了!
}

on (foo "error") {
    // 噢,在`foo(..)`中有某些事情搞错了
}

我们调用foo(..)然后我们设置两个事件监听器,一个给"completion",一个给"error"——foo(..)调用的两种可能的最终结果。实质上,foo(..)甚至不知道调用它的代码监听了这些事件,这构成了一个非常美妙的 关注分离(separation of concerns)

不幸的是,这样的代码将需要JS环境不具备的一些“魔法”(而且显得有些不切实际)。这里是一种用JS表达它的更自然的方式:

function foo(x) {
    // 开始做一些可能会花一段时间的事情

    // 制造一个`listener`事件通知能力并返回

    return listener;
}

var evt = foo( 42 );

evt.on( "completion", function(){
    // 现在我们可以做下一步了!
} );

evt.on( "failure", function(err){
    // 噢,在`foo(..)`中有某些事情搞错了
} );

foo(..)明确地创建并返回了一个事件监听能力,调用方代码接收并在它上面注册了两个事件监听器。

很明显这反转了一般的面向回调代码,而且是有意为之。与将回调传入foo(..)相反,它返回一个我们称之为evt的事件能力,它接收回调。

但如果你回想第二章,回调本身代表着一种 控制反转。所以反转回调模式实际上是 反转的反转,或者说是一个 控制非反转——将控制权归还给我们希望保持它的调用方代码,

一个重要的好处是,代码的多个分离部分都可以被赋予事件监听能力,而且它们都可在foo(..)完成时被独立地通知,来执行后续的步骤:

var evt = foo( 42 );

// 让`bar(..)`监听`foo(..)`的完成
bar( evt );

// 同时,让`baz(..)`监听`foo(..)`的完成
baz( evt );

控制非反转 导致了更好的 关注分离,也就是bar(..)baz(..)不必卷入foo(..)是如何被调用的问题。相似地,foo(..)也不必知道或关心bar(..)baz(..)的存在或它们是否在等待foo(..)完成的通知。

实质上,这个evt对象是一个中立的第三方团体,在分离的关注点之间进行交涉。

Promise“事件”

正如你可能已经猜到的,evt事件监听能力是一个Promise的类比。

在一个基于Promise的方式中,前面的代码段将会使foo(..)创建并返回一个Promise实例,而且这个promise将会被传入bar(..)baz(..)

注意: 我们监听的Promise解析“事件”并不是严格的事件(虽然它们为了某些目的表现得像事件),而且它们也不经常称为"completion""error"。相反,我们用then(..)来注册一个"then"事件。或者也许更准确地讲,then(..)注册了"fulfillment(完成)"和/或"rejection(拒绝)"事件,虽然我们在代码中不会看到这些名词被明确地使用。

考虑:

function foo(x) {
    // 开始做一些可能会花一段时间的事情

    // 构建并返回一个promise
    return new Promise( function(resolve,reject){
        // 最终需要调用`resolve(..)`或`reject(..)`
        // 它们是这个promise的解析回调
    } );
}

var p = foo( 42 );

bar( p );

baz( p );

注意:new Promise( function(..){ .. } )中展示的模式通常被称为“揭示构造器(revealing constructor)”。被传入的函数被立即执行(不会被异步推迟,像then(..)的回调那样),而且它被提供了两个参数,我们叫它们resolvereject。这些是Promise的解析函数。resolve(..)一般表示完成,而reject(..)表示拒绝。

你可能猜到了bar(..)baz(..)的内部看起来是什么样子:

function bar(fooPromise) {
    // 监听`foo(..)`的完成
    fooPromise.then(
        function(){
            // `foo(..)`现在完成了,那么做`bar(..)`的任务
        },
        function(){
            // 噢,在`foo(..)`中有某些事情搞错了
        }
    );
}

// `baz(..)`同上

Promise解析没有必要一定发送消息,就像我们将Promise作为 未来值 考察时那样。它可以仅仅作为一种流程控制信号,就像前面的代码中那样使用。

另一种表达方式是:

function bar() {
    // `foo(..)`绝对已经完成了,那么做`bar(..)`的任务
}

function oopsBar() {
    // 噢,在`foo(..)`中有某些事情搞错了,那么`bar(..)`不会运行
}

// `baz()`和`oopsBaz()`同上

var p = foo( 42 );

p.then( bar, oopsBar );

p.then( baz, oopsBaz );

注意: 如果你以前见过基于Promise的代码,你可能会相信这段代码的最后两行应当写做p.then( .. ).then( .. ),使用链接,而不是p.then(..); p.then(..)。这将会是两种完全不同的行为,所以要小心!这种区别现在看起来可能不明显,但是它们实际上是我们目前还没有见过的异步模式:分割(splitting)/分叉(forking)。不必担心!本章后面我们会回到这个话题。

与将ppromise传入bar(..)baz(..)相反,我们使用promise来控制bar(..)baz(..)何时该运行,如果有这样的时刻。主要区别在于错误处理。

在第一个代码段的方式中,无论foo(..)是否成功bar(..)都会被调用,如果被通知foo(..)失败了的话它提供自己的后备逻辑。显然,baz(..)也是这样做的。

在第二个代码段中,bar(..)仅在foo(..)成功后才被调用,否则oopsBar(..)会被调用。baz(..)也是。

两种方式本身都 。但会有一些情况使一种优于另一种。

在这两种方式中,从foo(..)返回的promisep都被用于控制下一步发生什么。

另外,两个代码段都以对同一个promisep调用两次then(..)结束,这展示了先前的观点,也就是Promise(一旦被解析)会永远保持相同的解析结果(完成或拒绝),而且可以按需要后续地被监听任意多次。

无论何时p被解析,下一步都将总是相同的,包括 现在稍后

Thenable鸭子类型(Duck Typing)

在Promise的世界中,一个重要的细节是如何确定一个值是否是纯粹的Promise。或者更直接地说,一个值会不会像Promise那样动作?

我们知道Promise是由new Promise(..)语法构建的,你可能会想p instanceof Promise将是一个可以接受的检查。但不幸的是,有几个理由表明它不是完全够用。

主要原因是,你可以从其他浏览器窗口中收到Promise值(iframe等),其他的浏览器窗口会拥有自己的不同于当前窗口/frame的Promise,这种检查将会在定位Promise实例时失效。

另外,一个库或框架可能会选择实现自己的Promise而不是用ES6原生的Promise实现。事实上,你很可能在根本没有Promise的老版本浏览器中通过一个库来使用Promise。

当我们在本章稍后讨论Promise的解析过程时,为什么识别并同化一个非纯种但相似Promise的值仍然很重要会愈发明显。但目前只需要相信我,它是拼图中很重要的一块。

如此,人们决定识别一个Promise(或像Promise一样动作的某些东西)的方法是定义一种称为“thenable”的东西,也就是任何拥有then(..)方法的对象或函数。这种方法假定任何这样的值都是一个符合Promise的thenable。

根据值的形状(存在什么属性)来推测它的“类型”的“类型检查”有一个一般的名称,称为“鸭子类型检查”——“如果它看起来像一只鸭子,并且叫起来像一只鸭子,那么它一定是一只鸭子”(参见本丛书的 类型与文法)。所以对thenable的鸭子类型检查可能大致是这样:

if (
    p !== null &&
    (
        typeof p === "object" ||
        typeof p === "function"
    ) &&
    typeof p.then === "function"
) {
    // 认为它是一个thenable!
}
else {
    // 不是一个thenable
}

晕!先把将这种逻辑在各种地方实现有点丑陋的事实放在一边不谈,这里还有更多更深层的麻烦。

如果你试着用一个偶然拥有then(..)函数的任意对象/函数来完成一个Promise,但你又没想把它当做一个Promise/thenable来对待,你的运气就用光了,因为它会被自动地识别为一个thenable并以特殊的规则来对待(见本章后面的部分)。

如果你不知道一个值上面拥有then(..)就更是这样。比如:

var o = { then: function(){} };

// 使`v`用`[[Prototype]]`链接到`o`
var v = Object.create( o );

v.someStuff = "cool";
v.otherStuff = "not so cool";

v.hasOwnProperty( "then" );     // false

v看起来根本不像是一个Promise或thenable。它只是一个拥有一些属性的直白的对象。你可能只是想要把这个值像其他对象那样传递而已。

但你不知道的是,v[[Prototype]]连接着(见本丛书的 this与对象原型)另一个对象o,在它上面偶然拥有一个then(..)。所以thenable鸭子类型检查将会认为并假定v是一个thenable。噢。

它甚至不需要直接故意那么做:

Object.prototype.then = function(){};
Array.prototype.then = function(){};

var v1 = { hello: "world" };
var v2 = [ "Hello", "World" ];

v1v2都将被假定为是thenalbe的。你不能控制或预测是否有其他代码偶然或恶意地将then(..)加到Object.prototypeArray.prototype,或其他任何原生原型上。而且如果这个指定的函数并不将它的任何参数作为回调调用,那么任何用这样的值被解析的Promise都将无声地永远挂起!疯狂。

听起来难以置信或不太可能?也许。

要知道,在ES6之前就有几种广为人知的非Promise库在社区中存在了,而且它们已经偶然拥有了称为then(..)的方法。这些库中的一些选择了重命名它们自己的方法来回避冲突(这很烂!)。另一些则因为它们无法改变来回避冲突,简单地降级为“不兼容基于Promise的代码”的不幸状态。

用来劫持原先非保留的——而且听起来完全是通用的——then属性名称的标准决议是,没有值(或它的任何委托),无论是过去,现在,还是将来,可以拥有then(..)函数,不管是有意的还是偶然的,否则这个值将在Promise系统中被混淆为一个thenable,从而可能产生非常难以追踪的Bug。

警告: 我不喜欢我们用thenable的鸭子类型来结束对Promise认知的方式。还有其他的选项,比如“branding”或者甚至是“anti-branding”;我们得到的似乎是一个最差劲儿的妥协。但它并不全是悲观与失望。thenable鸭子类型可以很有用,就像我们马上要看到的。只是要小心,如果thenable鸭子类型将不是Promise的东西误认为是Promise,它就可能成为灾难。

Promise的信任

我们已经看过了两个强烈的类比,它们解释了Promise可以为我们的异步代码所做的事的不同方面。但如果我们停在这里,我们就可能会错过一个Promise模式建立的最重要的性质:信任。

随着 未来值完成事件 的类别在我们探索的代码模式中的明确展开,有一个问题依然没有完全明确:Promise是为什么,以及如何被设计为来解决所有我们在第二章“信任问题”一节中提出的 控制倒转 的信任问题的。但是只要深挖一点儿,我们就可以发现一些重要的保证,来重建第二章中毁掉的对异步代码的信心!

让我们从复习仅使用回调的代码中的信任问题开始。当你传递一个回调给一个工具foo(..)的时候,它可能:

  • 调用回调太早
  • 调用回调太晚(或根本不调)
  • 调用回调太少或太多次
  • 没能传递必要的环境/参数
  • 吞掉了任何可能发生的错误/异常

Promise的性质被有意地设计为给这些顾虑提供有用的,可复用的答案。

调的太早

这种顾虑主要是代码是否会引入类Zalgo效应,也就是一个任务有时会同步完地成,而有时会异步地完成,这将导致竞合状态。

Promise被定义为不能受这种顾虑的影响,因为即便是立即完成的Promise(比如 new Promise(function(resolve){ resolve(42); }))也不可能被同步地 监听

也就是说,当你在Promise上调用then(..)的时候,即便这个Promise已经被解析了,你给then(..)提供的回调也将 总是 被异步地调用(更多关于这里的内容,参照第一章的"Jobs")。

不必再插入你自己的setTimeout(..,0)黑科技了。Promise自动地防止了Zalgo效应。

调的太晚

和前一点相似,在resolve(..)reject(..)被Promise创建机制调用时,一个Promise的then(..)上注册的监听回调将自动地被排程。这些被排程好的回调将在下一个异步时刻被可预测地触发(参照第一章的"Jobs")。

同步监听是不可能的,所以不可能有一个同步的任务链的运行来“推迟”另一个回调的发生。也就是说,当一个Promise被解析时,所有在then(..)上注册的回调都将被立即,按顺序地,在下一个异步机会时被调用(再一次,参照第一章的"Jobs"),而且没有任何在这些回调中发生的事情可以影响/推迟其他回调的调用。

举例来说:

p.then( function(){
    p.then( function(){
        console.log( "C" );
    } );
    console.log( "A" );
} );
p.then( function(){
    console.log( "B" );
} );
// A B C

这里,有赖于Promise如何定义操作,"C"不可能干扰并优先于"B"

Promise排程的怪现象

重要并需要注意的是,排程有许多微妙的地方:链接在两个分离的Promise上的回调之间的相对顺序,是不能可靠预测的。

如果两个promisep1p2都准备好被解析了,那么p1.then(..); p2.then(..)应当归结为首先调用p1的回调,然后调用p2的。但有一些微妙的情形可能会使这不成立,比如下面这样:

var p3 = new Promise( function(resolve,reject){
    resolve( "B" );
} );

var p1 = new Promise( function(resolve,reject){
    resolve( p3 );
} );

var p2 = new Promise( function(resolve,reject){
    resolve( "A" );
} );

p1.then( function(v){
    console.log( v );
} );

p2.then( function(v){
    console.log( v );
} );

// A B  <-- 不是你可能期望的 B A

我们稍后会更多地讲解这个问题,但如你所见,p1不是被一个立即值所解析的,而是由另一个promisep3所解析,而p3本身被一个值"B"所解析。这种指定的行为将p3展开p1,但是是异步地,所以在异步工作队列中p1的回调位于p2的回调之后(参照第一章的"Jobs")。

为了回避这样的微妙的噩梦,你绝不应该依靠任何跨Promise的回调顺序/排程。事实上,一个好的实践方式是在代码中根本不要让多个回调的顺序成为问题。尽可能回避它。

根本不调回调

这是一个很常见的顾虑。Promise用几种方式解决它。

首先,没有任何东西(JS错误都不能)可以阻止一个Promise通知你它的解析(如果它被解析了的话)。如果你在一个Promise上同时注册了完成和拒绝回调,而且这个Promise被解析了,两个回调中的一个总会被调用。

当然,如果你的回调本身有JS错误,你可能不会看到你期望的结果,但是回调事实上已经被调用了。我们待会儿就会讲到如何在你的回调中收到关于一个错误的通知,因为就算是它们也不会被吞掉。

那如果Promise本身不管怎样永远没有被解析呢?即便是这种状态Promise也给出了答案,使用一个称为“竞赛(race)”的高级抽象。

// 一个使Promise超时的工具
function timeoutPromise(delay) {
    return new Promise( function(resolve,reject){
        setTimeout( function(){
            reject( "Timeout!" );
        }, delay );
    } );
}

// 为`foo()`设置一个超时
Promise.race( [
    foo(),                  // 尝试调用`foo()`
    timeoutPromise( 3000 )  // 给它3秒钟
] )
.then(
    function(){
        // `foo(..)`及时地完成了!
    },
    function(err){
        // `foo()`不是被拒绝了,就是它没有及时完成
        // 那么可以考察`err`来知道是哪种情况
    }
);

这种Promise的超时模式有更多的细节需要考虑,但我们待会儿再回头讨论。

重要的是,我们可以确保一个信号作为foo(..)的结果,来防止它无限地挂起我们的程序。

调太少或太多次

根据定义,对于被调用的回调来讲 一次 是一个合适的次数。“太少”的情况将会是0次,和我们刚刚考察的从不调用是相同的。

“太多”的情况则很容易解释。Promise被定义为只能被解析一次。如果因为某些原因,Promise的创建代码试着调用resolve(..)reject(..)许多次,或者试着同时调用它们俩,Promise将仅接受第一次解析,而无声地忽略后续的尝试。

因为一个Promise仅能被解析一次,所以任何then(..)上注册的(每个)回调将仅仅被调用一次。

当然,如果你把同一个回调注册多次(比如p.then(f); p.then(f);),那么它就会被调用注册的那么多次。响应函数仅被调用一次的保证并不能防止你砸自己的脚。

没能传入任何参数/环境

Promise可以拥有最多一个解析值(完成或拒绝)。

如果无论怎样你没有用一个值明确地解析它,它的值就是undefined,就像JS中常见的那样。但不管是什么值,它总是会被传入所有被注册的(并且适当地:完成或拒绝)回调中,不管是 现在 还是将来。

需要意识到的是:如果你使用多个参数调用resolve(..)reject(..),所有第一个参数之外的后续参数都会被无声地忽略。虽然这看起来违反了我们刚才描述的保证,但并不确切,因为它构成了一种Promise机制的无效使用方式。其他的API无效使用方式(比如调用resolve(..)许多次)也都相似地 被保护,所以Promise的行为在这里是一致的(除了有一点点让人沮丧)。

如果你想传递多个值,你必须将它们包装在另一个单独的值中,比如一个array或一个object

至于环境,JS中的函数总是保持他们被定义时所在作用域的闭包(见本系列的 作用域与闭包),所以它们理所当然地可以继续访问你提供的环境状态。当然,这对仅使用回调的设计来讲也是对的,所以这不能算是Promise带来的增益——但尽管如此,它依然是我们可以依赖的保证。

吞掉所有错误/异常

在基本的感觉上,这是前一点的重述。如果你用一个 理由(也就是错误消息)拒绝一个Promise,这个值就会被传入拒绝回调。

但是这里有一个更重要的事情。如果在Promise的创建过程中的任意一点,或者在监听它的解析的过程中,一个JS异常错误发生的话,比如TypeErrorReferenceError,这个异常将会被捕获,并且强制当前的Promise变为拒绝。

举例来说:

var p = new Promise( function(resolve,reject){
    foo.bar();  // `foo`没有定义,所以这是一个错误!
    resolve( 42 );  // 永远不会跑到这里 :(
} );

p.then(
    function fulfilled(){
        // 永远不会跑到这里 :(
    },
    function rejected(err){
        // `err`将是一个来自`foo.bar()`那一行的`TypeError`异常对象
    }
);

foo.bar()上发生的JS异常变成了一个你可以捕获并响应的Promise拒绝。

这是一个重要的细节,因为它有效地解决了另一种潜在的Zalgo时刻,也就是错误可能会产生一个同步的反应,而没有错误的部分还是异步的。Promise甚至将JS异常都转化为异步行为,因此极大地降低了发生竞合状态的可能性。

但是如果Promise完成了,但是在监听过程中(在一个then(..)上注册的回调上)出现了JS异常错误会怎样呢?即便是那些也不会丢失,但你可能会发现处理它们的方式有些令人诧异,除非你深挖一些:

var p = new Promise( function(resolve,reject){
    resolve( 42 );
} );

p.then(
    function fulfilled(msg){
        foo.bar();
        console.log( msg ); // 永远不会跑到这里 :(
    },
    function rejected(err){
        // 也永远不会跑到这里 :(
    }
);

等一下,这看起来foo.bar()发生的异常确实被吞掉了。不要害怕,它没有。但更深层次的东西出问题了,也就是我们没能成功地监听他。p.then(..)调用本身返回另一个promise,是 那个 promise将会被TypeError异常拒绝。

为什么它不能调用我们在这里定义的错误处理器呢?表面上看起来是一个符合逻辑的行为。但它会违反Promise一旦被解析就 不可变 的基本原则。p已经完成为值42,所以它不能因为在监听p的解析时发生了错误,而在稍后变成一个拒绝。

除了违反原则,这样的行为还可能造成破坏,假如说有多个在promisep上注册的then(..)回调,因为有些会被调用而有些不会,而且至于为什么是很明显的。

可信的Promise?

为了基于Promise模式建立信任,还有最后一个细节需要考察。

无疑你已经注意到了,Promise根本没有摆脱回调。它们只是改变了回调传递的位置。与将一个回调传入foo(..)相反,我们从foo(..)那里拿回 某些东西 (表面上是一个纯粹的Promise),然后我们将回调传入这个 东西

但为什么这要比仅使用回调的方式更可靠呢?我们如何确信我们拿回来的 某些东西 事实上是一个可信的Promise?这难道不是说我们相信它仅仅因为我们已经相信它了吗?

一个Promise经常被忽视,但是最重要的细节之一,就是它也为这个问题给出了解决方案。包含在原生的ES6Promise实现中,它就是Promise.resolve(..)

如果你传递一个立即的,非Promise的,非thenable的值给Promise.resolve(..),你会得到一个用这个值完成的promise。换句话说,下面两个promisep1p2的行为基本上完全相同:

var p1 = new Promise( function(resolve,reject){
    resolve( 42 );
} );

var p2 = Promise.resolve( 42 );

但如果你传递一个纯粹的Promise给Promise.resolve(..),你会得到这个完全相同的promise:

var p1 = Promise.resolve( 42 );

var p2 = Promise.resolve( p1 );

p1 === p2; // true

更重要的是,如果你传递一个非Promise的thenable值给Promise.resolve(..),它会试着将这个值展开,而且直到抽出一个最终具体的非Promise值之前,展开操作将会一直继续下去。

还记得我们先前讨论的thenable吗?

考虑这段代码:

var p = {
    then: function(cb) {
        cb( 42 );
    }
};

// 这工作起来没问题,但要靠运气
p
.then(
    function fulfilled(val){
        console.log( val ); // 42
    },
    function rejected(err){
        // 永远不会跑到这里
    }
);

这个p是一个thenable,但它不是一个纯粹的Promise。很走运,它是合理的,正如大多数情况那样。但是如果你得到的是看起来像这样的东西:

var p = {
    then: function(cb,errcb) {
        cb( 42 );
        errcb( "evil laugh" );
    }
};

p
.then(
    function fulfilled(val){
        console.log( val ); // 42
    },
    function rejected(err){
        // 噢,这里本不该运行
        console.log( err ); // evil laugh
    }
);

这个p是一个thenable,但它不是表现良好的promise。它是恶意的吗?或者它只是不知道Promise应当如何工作?老实说,这不重要。不管哪种情况,它都不那么可靠。

尽管如此,我们可以将这两个版本的p传入Promise.resolve(..),而且我们将会得到一个我们期望的泛化,安全的结果:

Promise.resolve( p )
.then(
    function fulfilled(val){
        console.log( val ); // 42
    },
    function rejected(err){
        // 永远不会跑到这里
    }
);

Promise.resolve(..)会接受任何thenable,而且将它展开直至非thenable值。但你会从Promise.resolve(..)那里得到一个真正的,纯粹的Promise,一个你可以信任的东西。如果你传入的东西已经是一个纯粹的Promise了,那么你会单纯地将它拿回来,所以通过Promise.resolve(..)过滤来得到信任没有任何坏处。

那么我们假定,我们在调用一个foo(..)工具,而且不能确定我们能相信它的返回值是一个行为规范的Promise,但我们知道它至少是一个thenable。Promise.resolve(..)将会给我们一个可靠的Promise包装器来进行链式调用:

// 不要只是这么做:
foo( 42 )
.then( function(v){
    console.log( v );
} );

// 相反,这样做:
Promise.resolve( foo( 42 ) )
.then( function(v){
    console.log( v );
} );

注意: 将任意函数的返回值(thenable或不是thenable)包装在Promise.resolve(..)中的另一个好的副作用是,它可以很容易地将函数调用泛化为一个行为规范的异步任务。如果foo(42)有时返回一个立即值,而其他时候返回一个Promise,Promise.resolve(foo(42)),将确保它总是返回Promise。并且使代码成为回避Zalgo效应的更好的代码。

信任建立了

希望前面的讨论使你现在完全理解了Promise是可靠的,而且更为重要的是,为什么信任对于建造强壮,可维护的软件来说是如此关键。

没有信任,你能用JS编写异步代码吗?你当然能。我们JS开发者在除了回调以外没有任何东西的情况下,写了将近20年的异步代码了。

但是一旦你开始质疑你到底能够以多大的程度相信你的底层机制,它实际上多么可预见,多么可靠,你就会开始理解回调的信任基础多么的摇摇欲坠。

Promise是一个用可靠语义来增强回调的模式,所以它的行为更合理更可靠。通过将回调的 控制倒转 反置过来,我们将控制交给一个可靠的系统(Promise),它是为了将你的异步处理进行清晰的表达而特意设计的。

链式流程

我们已经被暗示过几次,但Promise不仅仅是一个单步的 这个然后那个 操作机制。当然,那是构建块儿,但事实证明我们可以将多个Promise串联在一起来表达一系列的异步步骤。

使这一切能够工作的关键,是Promise的两个固有行为:

  • 每次你在一个Promise上调用then(..)的时候,它都创建并返回一个新的Promise,我们可以在它上面进行 链接
  • 无论你从then(..)调用的完成回调中(第一个参数)返回什么值,它都做为被链接的Promise的完成。

我们首先来说明一下这是什么意思,然后我们将会延伸出它是如何帮助我们创建异步顺序的控制流程的。考虑下面的代码:

var p = Promise.resolve( 21 );

var p2 = p.then( function(v){
    console.log( v );   // 21

    // 使用值`42`完成`p2`
    return v * 2;
} );

// 在`p2`后链接
p2.then( function(v){
    console.log( v );   // 42
} );

通过返回v * 2(也就是42),我们完成了由第一个then(..)调用创建并返回的p2promise。当p2then(..)调用运行时,它从return v * 2语句那里收到完成信号。当然,p2.then(..)还会创建另一个promise,我们将它存储在变量p3中。

但是不得不创建临时变量p2(或p3等)有点儿恼人。幸运的是,我们可以简单地将这些链接在一起:

var p = Promise.resolve( 21 );

p
.then( function(v){
    console.log( v );   // 21

    // 使用值`42`完成被链接的promise
    return v * 2;
} )
// 这里是被链接的promise
.then( function(v){
    console.log( v );   // 42
} );

那么现在第一个then(..)是异步序列的第一步,而第二个then(..)就是第二步。它可以根据你的需要延伸至任意长。只要持续不断地用每个自动创建的Promise在前一个then(..)末尾进行连接即可。

但是这里错过了某些东西。要是我们想让第2步等待第1步去做一些异步的事情呢?我们使用的是一个立即的return语句,它立即完成了链接中的promise。

使Promise序列在每一步上都是真正异步的关键,需要回忆一下当你向Promise.resolve(..)传递一个Promise或thenable而非一个最终值时它如何执行。Promise.resolve(..)会直接返回收到的纯粹Promise,或者它会展开收到的thenable的值——并且它会递归地持续展开thenable。

如果你从完成(或拒绝)处理器中返回一个thenable或Promise,同样的展开操作也会发生。考虑这段代码:

var p = Promise.resolve( 21 );

p.then( function(v){
    console.log( v );   // 21

    // 创建一个promise并返回它
    return new Promise( function(resolve,reject){
        // 使用值`42`完成
        resolve( v * 2 );
    } );
} )
.then( function(v){
    console.log( v );   // 42
} );

即便我们把42包装在一个我们返回的promise中,它依然会被展开并作为下一个被链接的promise的解析,如此第二个then(..)仍然收到42。如果我们在这个包装promise中引入异步,一切还是会同样正常的工作:

var p = Promise.resolve( 21 );

p.then( function(v){
    console.log( v );   // 21

    // 创建一个promise并返回
    return new Promise( function(resolve,reject){
        // 引入异步!
        setTimeout( function(){
            // 使用值`42`完成
            resolve( v * 2 );
        }, 100 );
    } );
} )
.then( function(v){
    // 在上一步中的100毫秒延迟之后运行
    console.log( v );   // 42
} );

这真是不可思议的强大!现在我们可以构建一个序列,它可以有我们想要的任意多的步骤,而且每一步都可以按照需要来推迟下一步(或者不推迟)。

当然,在这些例子中一步一步向下传递的值是可选的。如果你没有返回一个明确的值,那么它假定一个隐含的undefined,而且promise依然会以同样的方式链接在一起。如此,每个Promise的解析只不过是进行至下一步的信号。

为了演示更长的链接,让我们把推迟Promise的创建(没有解析信息)泛化为一个我们可以在多个步骤中复用的工具:

function delay(time) {
    return new Promise( function(resolve,reject){
        setTimeout( resolve, time );
    } );
}

delay( 100 ) // step 1
.then( function STEP2(){
    console.log( "step 2 (after 100ms)" );
    return delay( 200 );
} )
.then( function STEP3(){
    console.log( "step 3 (after another 200ms)" );
} )
.then( function STEP4(){
    console.log( "step 4 (next Job)" );
    return delay( 50 );
} )
.then( function STEP5(){
    console.log( "step 5 (after another 50ms)" );
} )
...

调用delay(200)创建了一个将在200毫秒内完成的promise,然后我们在第一个then(..)的完成回调中返回它,这将使第二个then(..)的promise等待这个200毫秒的promise。

注意: 正如刚才描述的,技术上讲在这个交替中有两个promise:一个200毫秒延迟的promise,和一个被第二个then(..)链接的promise。但你可能会发现将这两个promise组合在一起更容易思考,因为Promise机制帮你把它们的状态自动地混合到了一起。从这个角度讲,你可以认为return delay(200)创建了一个promise来取代早前一个返回的被链接的promise。

老实说,没有任何消息进行传递的一系列延迟作为Promise流程控制的例子不是很有用。让我们来看一个更加实在的场景:

与计时器不同,让我们考虑发起Ajax请求:

// 假定一个`ajax( {url}, {callback} )`工具

// 带有Promise的ajax
function request(url) {
    return new Promise( function(resolve,reject){
        // `ajax(..)`的回调应当是我们的promise的`resolve(..)`函数
        ajax( url, resolve );
    } );
}

我们首先定义一个request(..)工具,它构建一个promise表示ajax(..)调用的完成:

request( "http://some.url.1/" )
.then( function(response1){
    return request( "http://some.url.2/?v=" + response1 );
} )
.then( function(response2){
    console.log( response2 );
} );

注意: 开发者们通常遭遇的一种情况是,他们想用本身不支持Promise的工具(就像这里的ajax(..),它期待一个回调)进行Promise式的异步流程控制。虽然ES6原生的Promise机制不会自动帮我们解决这种模式,但是在实践中所有的Promise库会帮我们这么做。它们通常称这种处理为“提升(lifting)”或“promise化”或其他的什么名词。我们稍后再回头讨论这种技术。

使用返回Promise的request(..),通过用第一个URL调用它我们在链条中隐式地创建了第一步,然后我们用第一个then(..)在返回的promise末尾进行连接。

一旦response1返回,我们用它的值来构建第二个URL,并且发起第二个request(..)调用。这第二个promisereturn的,所以我们的异步流程控制的第三步将会等待这个Ajax调用完成。最终,一旦response2返回,我们就打印它。

我们构建的Promise链不仅是一个表达多步骤异步序列的流程控制,它还扮演者将消息从一步传递到下一步的消息管道。

要是Promise链中的某一步出错了会怎样呢?一个错误/异常是基于每个Promise的,意味着在链条的任意一点捕获这些错误是可能的,而且这些捕获操作在那一点上将链条“重置”,使它回到正常的操作上来:

// 步骤 1:
request( "http://some.url.1/" )

// 步骤 2:
.then( function(response1){
    foo.bar(); // 没有定义,错误!

    // 永远不会跑到这里
    return request( "http://some.url.2/?v=" + response1 );
} )

// 步骤 3:
.then(
    function fulfilled(response2){
        // 永远不会跑到这里
    },
    // 拒绝处理器捕捉错误
    function rejected(err){
        console.log( err ); // 来自 `foo.bar()` 的 `TypeError` 错误
        return 42;
    }
)

// 步骤 4:
.then( function(msg){
    console.log( msg );     // 42
} );

当错误在第2步中发生时,第3步的拒绝处理器将它捕获。拒绝处理器的返回值(在这个代码段里是42),如果有的话,将会完成下一步(第4步)的promise,如此整个链条又回到完成的状态。

注意: 就像我们刚才讨论过的,当我们从一个完成处理器中返回一个promise时,它会被展开并有可能推迟下一步。这对从拒绝处理器中返回的promise也是成立的,这样如果我们在第3步返回一个promise而不是return 42,那么这个promise就可能会推迟第4步。不管是在then(..)的完成还是拒绝处理器中,一个被抛出的异常都将导致下一个(链接着的)promise立即用这个异常拒绝。

如果你在一个promise上调用then(..),而且你只向它传递了一个完成处理器,一个假定的拒绝处理器会取而代之:

var p = new Promise( function(resolve,reject){
    reject( "Oops" );
} );

var p2 = p.then(
    function fulfilled(){
        // 永远不会跑到这里
    }
    // 如果忽略或者传入任何非函数的值,
    // 会有假定有一个这样的拒绝处理器
    // function(err) {
    //     throw err;
    // }
);

如你所见,这个假定的拒绝处理器仅仅简单地重新抛出错误,它最终强制p2(链接着的promise)用同样的错误进行拒绝。实质上,它允许错误持续地在Promise链上传播,直到遇到一个明确定义的拒绝处理器。

注意: 稍后我们会讲到更多关于使用Promise进行错误处理的细节,因为会有更多微妙的细节需要关心。

如果没有一个恰当的合法的函数作为then(..)的完成处理器参数,也会有一个默认的处理器取而代之:

var p = Promise.resolve( 42 );

p.then(
    // 如果忽略或者传入任何非函数的值,
    // 会有假定有一个这样的完成处理器
    // function(v) {
    //     return v;
    // }
    null,
    function rejected(err){
        // 永远不会跑到这里
    }
);

如你所见,默认的完成处理器简单地将它收到的任何值传递给下一步(Promise)。

注意: then(null,function(err){ .. })这种模式——仅处理拒绝(如果发生的话)但让成功通过——有一个缩写的API:catch(function(err){ .. })。我们会在下一节中更全面地涵盖catch(..)

让我们简要地复习一下使链式流程控制成为可能的Promise固有行为:

  • 在一个Promise上的then(..)调用会自动生成一个新的Promise并返回。
  • 在完成/拒绝处理器内部,如果你返回一个值或抛出一个异常,新返回的Promise(可以被链接的)将会相应地被解析。
  • 如果完成或拒绝处理器返回一个Promise,它会被展开,所以无论它被解析为什么值,这个值都将变成从当前的then(..)返回的被链接的Promise的解析。

虽然链式流程控制很有用,但是将它认为是Promise的组合方式的副作用可能最准确,而不是它的主要意图。正如我们已经详细讨论过许多次的,Promise泛化了异步处理并且包装了与时间相关的值和状态,这才是让我们以这种有用的方式将它们链接在一起的原因。

当然,相对于我们在第二章中看到的一堆混乱的回调,这种链条的顺序表达是一个巨大的改进。但是仍然要蹚过相当多的模板代码(then(..) and function(){ .. })。在下一章中,我们将看到一种极大美化顺序流程控制的表达模式,生成器(generators)。

术语: Resolve(解析),Fulfill(完成),和Reject(拒绝)

在你更多深入地学习Promise之前,在“解析(resolve)”,“完成(fulfill)”,和“拒绝(reject)”这些名词之间还有一些我们需要辨明的小困惑。首先让我们考虑一下Promise(..)构造器:

var p = new Promise( function(X,Y){
    // X() 给 fulfillment(完成)
    // Y() 给 rejection(拒绝)
} );

如你所见,有两个回调(标识为XY)被提供了。第一个 通常 用于表示Promise完成了,而第二个 总是 表示Promise拒绝了。但“通常”是什么意思?它对这些参数的正确命名暗示着什么呢?

最终,这只是你的用户代码,和将被引擎翻译为没有任何含义的东西的标识符,所以在 技术上 它无紧要;foo(..)bar(..)在功能性上是相等的。但是你用的词不仅会影响你如何考虑这段代码,还会影响你所在团队的其他开发者如何考虑它。将精心策划的异步代码错误地考虑,几乎可以说要比面条一般的回调还要差劲儿。

所以,某种意义上你如何称呼它们很关键。

第二个参数很容易决定。几乎所有的文献都使用reject(..)做为它的名称,因为这正是它(唯一!)要做的,对于命名来说这是一个很好的选择。我也强烈推荐你一直使用reject(..)

但是关于第一个参数还是有些带有歧义,它在许多关于Promise的文献中常被标识为resolve(..)。这个词明显地是与“resolution(解析)”有关,它在所有的文献中(包括本书)广泛用于描述给Promise设定一个最终的值/状态。我们已经使用“解析Promise(resolve the Promise)”许多次来意味Promise的完成(fulfilling)或拒绝(rejecting)。

但是如果这个参数看起来被用于特指Promise的完成,为什么我们不更准确地叫它fulfill(..),而是用resolve(..)呢?要回答这个问题,让我们看一下Promise的两个API方法:

var fulfilledPr = Promise.resolve( 42 );

var rejectedPr = Promise.reject( "Oops" );

Promise.resolve(..)创建了一个Promise,它被解析为它被给予的值。在这个例子中,42是一个一般的,非Promise,非thenable的值,所以完成的promisefulfilledPr是为值42创建的。Promise.reject("Oops")为了原因"Oops"创建的拒绝的promiserejectedPr

现在让我们来解释为什么如果“resolve”这个词(正如Promise.resolve(..)里的)被明确用于一个既可能完成也可能拒绝的环境时,它没有歧义,反而更加准确:

var rejectedTh = {
    then: function(resolved,rejected) {
        rejected( "Oops" );
    }
};

var rejectedPr = Promise.resolve( rejectedTh );

就像我们在本章前面讨论的,Promise.resolve(..)将会直接返回收到的纯粹的Promise,或者将收到的thenable展开。如果展开这个thenable之后是一个拒绝状态,那么从Promise.resolve(..)返回的Promise事实上是相同的拒绝状态。

所以对于这个API方法来说,Promise.resolve(..)是一个好的,准确的名称,因为它实际上既可以得到完成的结果,也可以得到拒绝的结果。

Promise(..)构造器的第一个回调参数既可以展开一个thenable(与Promise.resolve(..)相同),也可以展开一个Promise:

var rejectedPr = new Promise( function(resolve,reject){
    // 用一个被拒绝的promise来解析这个promise
    resolve( Promise.reject( "Oops" ) );
} );

rejectedPr.then(
    function fulfilled(){
        // 永远不会跑到这里
    },
    function rejected(err){
        console.log( err ); // "Oops"
    }
);

现在应当清楚了,对于Promise(..)构造器的第一个参数来说resolve(..)是一个合适的名称。

警告: 前面提到的reject(..) 不会resolve(..)那样进行展开。如果你向reject(..)传递一个Promise/thenable值,这个没有被碰过的值将作为拒绝的理由。一个后续的拒绝处理器将会受到你传递给reject(..)的实际的Promise/thenable,而不是它底层的立即值。

现在让我们将注意力转向提供给then(..)的回调。它们应当叫什么(在文献和代码中)?我的建议是fulfilled(..)rejected(..)

function fulfilled(msg) {
    console.log( msg );
}

function rejected(err) {
    console.error( err );
}

p.then(
    fulfilled,
    rejected
);

对于then(..)的第一个参数的情况,它没有歧义地总是完成状态,所以没有必要使用带有双重意义的“resolve”术语。另一方面,ES6语言规范中使用onFulfilled(..)onRejected(..) 来标识这两个回调,所以它们是准确的术语。

错误处理

我们已经看过几个例子,Promise拒绝——既可以通过有意调用reject(..),也可以通过意外的JS异常——是如何在异步编程中允许清晰的错误处理的。让我们兜个圈子回去,将我们一带而过的一些细节弄清楚。

对大多数开发者来说,最自然的错误处理形式是同步的try..catch结构。不幸的是,它仅能用于同步状态,所以在异步代码模式中它帮不上什么忙:

function foo() {
    setTimeout( function(){
        baz.bar();
    }, 100 );
}

try {
    foo();
    // 稍后会从`baz.bar()`抛出全局错误
}
catch (err) {
    // 永远不会到这里
}

能有try..catch当然很好,但除非有某些附加的环境支持,它无法与异步操作一起工作。我们将会在第四章中讨论generator时回到这个话题。

在回调中,对于错误处理的模式已经有了一些新兴的模式,最有名的就是“错误优先回调”风格:

function foo(cb) {
    setTimeout( function(){
        try {
            var x = baz.bar();
            cb( null, x ); // 成功!
        }
        catch (err) {
            cb( err );
        }
    }, 100 );
}

foo( function(err,val){
    if (err) {
        console.error( err ); // 倒霉 :(
    }
    else {
        console.log( val );
    }
} );

注意: 这里的try..catch仅在baz.bar()调用立即地,同步地成功或失败时才能工作。如果baz.bar()本身是一个异步完成的函数,它内部的任何异步错误都不能被捕获。

我们传递给foo(..)的回调期望通过预留的err参数收到一个表示错误的信号。如果存在,就假定出错。如果不存在,就假定成功。

这类错误处理在技术上是 异步兼容的,但它根本组织的不好。用无处不在的if语句检查将多层错误优先回调编织在一起,将不可避免地将你置于回调地狱的危险之中(见第二章)。

那么我们回到Promise的错误处理,使用传递给then(..)的拒绝处理器。Promise不使用流行的“错误优先回调”设计风格,反而使用“分割回调”的风格;一个回调给完成,一个回调给拒绝:

var p = Promise.reject( "Oops" );

p.then(
    function fulfilled(){
        // 永远不会到这里
    },
    function rejected(err){
        console.log( err ); // "Oops"
    }
);

虽然这种模式表面上看起来十分有道理,但是Promise错误处理的微妙之处经常使它有点儿相当难以全面把握。

考虑下面的代码:

var p = Promise.resolve( 42 );

p.then(
    function fulfilled(msg){
        // 数字没有字符串方法,
        // 所以这里抛出一个错误
        console.log( msg.toLowerCase() );
    },
    function rejected(err){
        // 永远不会到这里
    }
);

如果msg.toLowerCase()合法地抛出一个错误(它会的!),为什么我们的错误处理器没有得到通知?正如我们早先解释的,这是因为 这个 错误处理器是为ppromise准备的,也就是已经被值42完成的那个promise。ppromise是不可变的,所以唯一可以得到错误通知的promise是由p.then(..)返回的那个,而在这里我们没有捕获它。

这应当解释了:为什么Promise的错误处理是易错的。错误太容易被吞掉了,而这很少是你有意这么做的。

警告: 如果你以一种不合法的方式使用Promise API,而且有错误阻止正常的Promise构建,其结果将是一个立即被抛出的异常,而不是一个拒绝Promise。这是一些导致Promise构建失败的错误用法:new Promise(null)Promise.all()Promise.race(42)等等。如果你没有足够合法地使用Promise API来首先实际构建一个Promise,你就不能得到一个拒绝Promise!

绝望的深渊

几年前Jeff Atwood曾经写到:编程语言总是默认地以这样的方式建立,开发者们会掉入“绝望的深渊”(http://blog.codinghorror.com/falling-into-the-pit-of-success/ )——在这里意外会被惩罚——而你不得不更努力地使它正确。他恳求我们相反地创建“成功的深渊”,就是你会默认地掉入期望的(成功的)行为,而如此你不得不更努力地去失败。

毫无疑问,Promise的错误处理是一种“绝望的深渊”的设计。默认情况下,它假定你想让所有的错误都被Promise的状态吞掉,而且如果你忘记监听这个状态,错误就会默默地凋零/死去——通常是绝望的。

为了回避把一个被遗忘/抛弃的Promise的错误无声地丢失,一些开发者宣称Promise链的“最佳实践”是,总是将你的链条以catch(..)终结,就像这样:

var p = Promise.resolve( 42 );

p.then(
    function fulfilled(msg){
        // 数字没有字符串方法,
        // 所以这里抛出一个错误
        console.log( msg.toLowerCase() );
    }
)
.catch( handleErrors );

因为我们没有给then(..)传递拒绝处理器,默认的处理器会顶替上来,它仅仅简单地将错误传播到链条的下一个promise中。如此,在p中发生的错误,与在p之后的解析中(比如msg.toLowerCase())发生的错误都将会过滤到最后的handleErrors(..)中。

问题解决了,对吧?没那么容易!

要是handleErrors(..)本身也有错误呢?谁来捕获它?这里还有一个没人注意的promise:catch(..)返回的promise,我们没有对它进行捕获,也没注册拒绝处理器。

你不能仅仅将另一个catch(..)贴在链条末尾,因为它也可能失败。Promise链的最后一步,无论它是什么,总有可能,即便这种可能性逐渐减少,悬挂着一个困在未被监听的Promise中的,未被捕获的错误。

听起来像一个不可解的迷吧?

处理未被捕获的错误

这不是一个很容易就能完全解决的问题。但是有些接近于解决的方法,或者说 更好的方法

一些Promise库有一些附加的方法,可以注册某些类似于“全局的未处理拒绝”的处理器,全局上不会抛出错误,而是调用它。但是他们识别一个错误是“未被捕获的错误”的方案是,使用一个任意长的计时器,比如说3秒,从拒绝的那一刻开始计时。如果一个Promise被拒绝但没有错误处理在计时器被触发前注册,那么它就假定你不会注册监听器了,所以它是“未被捕获的”。

实践中,这个方法在许多库中工作的很好,因为大多数用法不会在Promise拒绝和监听这个拒绝之间有很明显的延迟。但是这个模式有点儿麻烦,因为3秒实在太随意了(即便它是实证过的),还因为确实有些情况你想让一个Promise在一段不确定的时间内持有它的拒绝状态,而且你不希望你的“未捕获错误”处理器因为这些误报(还没处理的“未捕获错误”)而被调用。

另一种常见的建议是,Promise应当增加一个done(..)方法,它实质上标志着Promise链的“终结”。done(..)不会创建并返回一个Promise,所以传递给done(..)的回调很明显地不会链接上一个不存在的Promise链,并向它报告问题。

那么接下来会发什么?正如你通常在未处理错误状态下希望的那样,在done(..)的拒绝处理器内部的任何异常都作为全局的未捕获错误抛出(基本上扔到开发者控制台):

var p = Promise.resolve( 42 );

p.then(
    function fulfilled(msg){
        // 数字没有字符串方法,
        // 所以这里抛出一个错误
        console.log( msg.toLowerCase() );
    }
)
.done( null, handleErrors );

// 如果`handleErrors(..)`自身发生异常,它会在这里被抛出到全局

这听起来要比永不终结的链条或随意的超时要吸引人。但最大的问题是,它不是ES6标准,所以不管听起来多么好,它成为一个可靠而普遍的解决方案还有很长的距离。

那我们就卡在这里了?不完全是。

浏览器有一个我们的代码没有的能力:它们可以追踪并确定一个对象什么时候被废弃并可以作为垃圾回收。所以,浏览器可以追踪Promise对象,当它们被当做垃圾回收时,如果在它们内部存在一个拒绝状态,浏览器就可以确信这是一个合法的“未捕获错误”,它可以信心十足地知道应当在开发者控制台上报告这一情况。

注意: 在写作本书的时候,Chrome和Firefox都早已试图实现这种“未捕获拒绝”的能力,虽然至多也就是支持的不完整。

然而,如果一个Promise不被垃圾回收——通过许多不同的代码模式,这极

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8