美文网首页
你不知道JS:异步(翻译)系列3-2

你不知道JS:异步(翻译)系列3-2

作者: purple_force | 来源:发表于2016-08-24 18:11 被阅读0次

你不知道JS:异步

第三章:Promises

接上篇3-1

错误处理(Error Handling)

在异步编程中,关于Promise rejection--可能是通过主动的reject(..)调用,也可能是通过偶然的JS异常--是如何实现健全的错误处理,我们已经看了几个例子了。

对绝大多数开发者而言,错误处理最自然的形式就是同步的try..catch结构了。不幸的是,它只是同步的,因此无法在异步代码模式中奏效:

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

try {
    foo();
    // later throws global error from `baz.bar()`
}
catch (err) {
    // never gets here
}

try..catch固然很好,但是在异步操作中不起作用。也就是说,除非有一些附加的环境支持,我们会在第四章的生成器中讨论。

在回调中,一些标准中已经出现了模式化错误处理,绝大多数是“错误优先回调(error-first callback)”类型的:

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

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

注意: 此处的try..catch只在baz.bar()调用是同步的、立即成功或者失败时才起作用。如果baz.bar()本身就是异步完成函数,则其中的任何异步错误都无法捕获。

我们传给foo(..)的回调通过保留第一个参数err,希望接收一个错误信号。如果存在,则假定有错误发生。如果不存在,则假定成功。

此种错误处理在技术上称为async capable,但这一点都不好。多级错误优先回调和无处不在的if语句检查交织在一起,不可避免地将你置于回调地狱的危险之中(见第二章)。

让我们回到Promise的错误处理中来,采用传给then(..)的rejection处理函数的方式。Promise并没有采用流行的“错误优先回调”的设计方式,而是采用“分隔回调”的方式,一个是fulfillment回调,一个是rejection回调:

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

p.then(
    function fulfilled(){
        // never gets here
    },
    function rejected(err){
        console.log( err ); // "Oops"
    }
);

尽管这种模式的错误处理表面上看起来很好理解,但是Promise错误处理的细节通常更难完全掌握。

考虑如下代码:

var p = Promise.resolve( 42 );

p.then(
    function fulfilled(msg){
        // numbers don't have string functions,
        // so will throw an error
        console.log( msg.toLowerCase() );
    },
    function rejected(err){
        // never gets here
    }
);

如果msg.toLowerCase()正常的抛出一个错误(确实会),为什么我们的错误处理函数没收到通知呢?正如早先解释的一样,因为那个错误处理是为p promise,已经由值42变为fulfilled状态了。p promise 是不可改变的,因此,唯一一个能被通知到错误的是p.then(..)返回的promise,而在该例中我们没有去捕获。

这向我们描述了一幅很清楚的画面,即为什么Promise的错误处理容易出错(双关语)。错误太容易被掩盖了,很少是出于你的意愿。

警告: 如果以非法的方式使用Promise API,并且错误阻止了正常的Promise构建,则会立即抛出异常,而不是一个rejected Promise。一些不正确的使用导致Promise构建失败的例子有:new Promise(null)Promise.all()Promise.race(42)等。如果不首先采用Promise API构建一个合法的Promise,你无法得到一个rejected Promise!

绝望的深渊(Pit of Despair)

Jeff Atwood 多年前提过:编程语言通常是这样设置的,即默认开发者会掉进”绝望的深渊“(http://blog.codinghorror.com/falling-into-the-pit-of-success/)--会遭受惩罚--并且你不得不花更大的力气去修正它。他恳求我们创建”成功之坑(pit of success)“,即默认你会成功,并且必须花大力气才会失败。

Promise 的错误处理毫无疑问是”Pit of Despair“式的设计。默认情况下,假定你想让Promise状态掩盖任何错误,并且如果你忘记监听那个状态,那么错误就会静默地消逝--通常令人绝望。

为了避免丢掉那个错误,已经有一些开发人员声称Promise链的”最佳实践“是总以一个catch(..)结尾,比如:

var p = Promise.resolve( 42 );

p.then(
    function fulfilled(msg){
        // numbers don't have string functions,
        // so will throw an error
        console.log( msg.toLowerCase() );
    }
)
.catch( handleErrors );

因为没给then(..)传递rejection处理函数,所以就替换为默认的处理函数,即简单的将错误传播到链中的下一个promise。这样,在解析时,p中的错误和p之后的错误(如msg.toLowerCase())会被过滤到最终的handleErrors(..)中。

问题解决了,是吗?没那么快!

如果handleErrors(..)本身也有错误,会发生什么呢?谁来捕获那个错误?仍然有一个未参与的promise:catch(..)返回的,我们没有捕获,也没有为其注册rejection处理函数。

你不能简单地在链尾再接个catch(..),因为同样有可能失败。在任何Promise链的最后一步,无论是什么,总可能在未监听的promise中悬着一个未捕获的错误。

听起来似乎是个不可能的难题?

未捕获处理(Uncaught Handling)

这并不是个容易完全解决的问题。有些其它方法,许多人说可能更好一点。

一些Promise库已经添加了一些方法,用来注册类似于”全局的未处理rejection“处理函数的东西,它会被调用,而不是全局地抛出异常。但对于如何识别一个错误是未处理的,他们的解决方案是用一个随机长度的定时器,比如3秒,从rejection时开始运行。如果在定时器触发前,一个Promise被rejected了,但是没有注册错误处理函数,之后就会假定你不会给它注册处理函数,因此它是”未捕获的(uncaught)“。

在实际开发中,对许多库而言,这种做法很奏效。因为绝大多数使用模式的Promise rejection和监听rejection之间不需要太长的延时。但是这种模式有些问题,因为3秒太随意了(即使是从经验上来看),并且因为有些情况下,确实需要Promise在一定时间内保持rejected状态,你并不希望所有误报(false positives)(还没处理的”未捕获错误“)发生时都调用”uncaught“处理函数。

另一个更常见的建议是Promise应该添加个done(..)方法,本质上是标记Promise链”结束了“。done(..)不会创建也不会返回一个Promise,因此传入done(..)中的回调不会向一个不存在的链式Promise报告问题。

那么会发生什么呢?在未捕获错误(uncaught error)情况下,它会按照你预期的那样得到处理:done(..)内的rejection处理函数中的任何异常,都会被当做全局的未捕获错误抛出(通常在开发者控制台):

var p = Promise.resolve( 42 );

p.then(
    function fulfilled(msg){
        // numbers don't have string functions,
        // so will throw an error
        console.log( msg.toLowerCase() );
    }
)
.done( null, handleErrors );

// if `handleErrors(..)` caused its own exception, it would
// be thrown globally here

似乎听起来比永不终止的链或者随机超时更具吸引力。但最大的问题是它并不是ES6标准的一部分,因此不论听起来有多好,离称为一个可信赖并且普遍的解决方案还有更长的路。

那么我们就这么被困住了吗?不全是。

浏览器有一个我们代码不具备的独特能力:它们能够追踪并且很确定的知道任一个对象被丢弃和垃圾回收的时间。因此,浏览器可以追踪Promise对象,一旦被垃圾回收,如果其中有一个rejection,浏览器能很确定的知道这是个正当的”未捕获错误“,并且很自信地将其报告给开发者控制台。

注意: 写到这时,Chrome和Firefox在此种”uncaught rejection“能力方面有一些早期的尝试,尽管支持的不完全。

然而,如果一个Promise没有被垃圾回收--通过各种不同的编程模式,这种情况很容易发生--浏览器的垃圾回收嗅探就无法帮你诊断出有一个静默的rejected Promise。

还有其它办法吗?是的。

成功之坑(Pit of Success)

关于Promise的行为将来可能变成什么样,接下来所说的只是理论性的。我认为这远远优于我们当前所拥有的。并且我认为这种改变在后ES6中是可能的,因为它不会打破浏览器对ES6 Promise的兼容。此外,如果细心一点的话,这种改变可以polyfill,让我们看下:

  • 如果在那个时刻Promise上没有注册错误处理函数,Promise能够在下一个作业(Job)或者事件轮询tick时,默认地(向开发者控制台)报告任何rejection。
  • 你想让一个rejected Promise在被监听前的一段不确定时间内保持rejected状态,此时你可以调用defer(),它可以阻止该Promise的自动错误上报。

如果一个Promise被rejected了,它会默认将之上报给开发者控制台(而不是默认静默)。你可以选择隐式地(在rejection前注册一个错误处理函数)或者显式地(采用defer())退出错误上报。无论哪一种情况,都是由你控制误报(false positives)。

考虑如下:

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

// `foo(..)` is Promise-aware
foo( 42 )
.then(
    function fulfilled(){
        return p;
    },
    function rejected(err){
        // handle `foo(..)` error
    }
);
...

当我们创建p时,我们打算等一会再使用/监听它的rejection,因此我们调用了defer()。--因此没有全局上报。为实现链式,defer()只是简单地返回同样的promise。

foo(..)返回的promise立刻附上了一个错误处理函数,因此隐式地选择退出,并且也没有错误上报。

then(..)调用返回的promise没有附上defer(..)或者错误处理函数,因此,如果它reject(因内部的任一个解析处理函数),就会被当作未捕获异常上报至开发者控制台。

这种设计就是成功之坑。 默认情况下,所有错误,都会被处理或者上报--这是多数开发者在绝大多数情况下希望的那样。你既可以注册一个处理函数,也可以选择退出,表明你想推迟到以后处理异常,只在那种特定情况下你选择额外的责任(译者注:指自己处理异常)。

这种方法唯一的危险就是,如果你defer()一个Promise,但之后无法监听/处理rejection。

但是你必须主动调用defer()来选择进入绝望深渊--默认是成功之坑--因此,对于你自己的错误,我们所能做的不多。

我认为Promise错误处理仍然有希望(后ES6)。我希望当权者(译者注:此处指ES规范的制定者)重新思考下这种情况并且考虑这个方案。同时,你可以自己实现(对读者而言是个不小的挑战!),或者使用一个更精简的库!

注意: 错误处理/上报的精确模型在我的异步队列Promise 抽象库中实现了,会在本书的附录A中讨论。

Promise模式(Promise Patterns)

我们已经见识了采用Promise链(this-then-this-then-that流控制)实现的序列模式,但在Promise外,构建在异步模式上的抽象,还有许多变体。这些模式用来简化异步流控制的表示--这使得我们的代码更合理,更易于维护--即使是在程序中最复杂的部分。

有两种这样的模式直接被编进了原生ES6 Promise实现中,因此我们可以很方便地使用它们,来作为其它模式的构建块。

Promise.all([])

在异步序列中(Promise链),在任一给定时刻只能协调一个异步任务--step 2严格地跟在step 1后面,step 3严格地跟在step 2后面。但要是同时进行两步或更多步呢(即”并行“)?

在传统编程术语中,”门(gate)“机制是指在继续之前,需要等待两个或更多的并行/并发任务完成。完成的顺序并不重要,只需要它们都完成,进而打开门,让流控制通过。

在Promise API中,我们称这种模式为all([ .. ])

假设你想同时发两个Ajax请求,并且在发第三个请求前,需要等到两个都完成,顺序不重要。考虑如下:

/ `request(..)` is a Promise-aware Ajax utility,
// like we defined earlier in the chapter

var p1 = request( "http://some.url.1/" );
var p2 = request( "http://some.url.2/" );

Promise.all( [p1,p2] )
.then( function(msgs){
    // both `p1` and `p2` fulfill and pass in
    // their messages here
    return request(
        "http://some.url.3/?v=" + msgs.join(",")
    );
} )
.then( function(msg){
    console.log( msg );
} );

Promise.all([ .. ])接收单个参数,即一个array,通常由Promise实例组成。Promise.all([ .. ])返回的promise会接收一个fulfillment信号(代码中的msgs),它是一个由所有传入的promise生成的fulfillment信号组成的array,顺序与指定的一致(与达到fulfillment状态的时间顺序无关)。

注意: 从技术来讲,传入Promise.all([ .. ])array值可以是Promise、thenable或者甚至是立即(immediate)值。每一个值都会传入到Promise.resolve(..)中,确保最终都是真正的Promise,因此立即值会以该值被标准化为Promise。如果array是空的
,则主Promise会立即fulfilled。

当且仅当所有的成员promise都fulfilled时,Promise.all([ .. ])返回的主promise才fulfilled。如果任一个promise被rejected了,主Promise.all([ .. ])promise立即被rejected,不管其它promise的结果。

记住,在每个promise后都要附个rejection/error处理函数,尤其是Promise.all([ .. ])返回的promise。

Promise.race([])

尽管Promise.all([ .. ])能够同时协调多个Promise,并且假定所有的promise都需要fulfillment,但有时你只想响应”第一个跨过终点线的Promise“,而让其它Promise离开。

这种模式通常称为”闩(latch)“,但在Promise中,称为'race'。

警告: ”只有第一个跨过终点线的赢“,尽管这一比喻很恰当,但是,"race"有点被过度使用了,因为在程序中,”竞态(race condition)“通常被认为是bug(见第一章)。不要把Promise.race([ .. ])和”race condition“弄混淆了。

Promise.race([ .. ])也接收单个array参数,包括一个或多个Promise、thenable或者立即值。包含立即值并没有太多的实际意义,因为第一个列出来的立即值很明显会赢--就像赛跑中,一个站在终点线开始跑的人一样!

类似于Promise.all([ .. ]),当任一个Promise解析是fulfillment时,Promise.race([ .. ])即fulfill,当任一个Promise解析是rejection时,Promise.race([ .. ])即reject。

警告: 一个”race“至少需要一个”runner“,因此,如果你传入一个空array,主race([..])Promise永远不会解析。这是个footgun(译者注:没查到这是个什么鬼)!ES6本该指定它要么fulfill,要么reject,或者抛出某种同步的错误。不幸的是,由于Promise库早于ES6 Promise,他们只好将之放至一边,因此千万不要传一个空array

让我们重新看一下前面的并发Ajax例子,但是以p1p2竞争的形式:

// `request(..)` is a Promise-aware Ajax utility,
// like we defined earlier in the chapter

var p1 = request( "http://some.url.1/" );
var p2 = request( "http://some.url.2/" );

Promise.race( [p1,p2] )
.then( function(msg){
    // either `p1` or `p2` will win the race
    return request(
        "http://some.url.3/?v=" + msg
    );
} )
.then( function(msg){
    console.log( msg );
} );

因为只有一个promise胜出,所以fulfillment值是单个信息值,不是如Promise.all([ .. ])array

超时竞赛(Timeout Race)

我们早先看过这个例子,用来说明如何使用Promise.race([ .. ])表示”promise timeout“模式:

// `foo()` is a Promise-aware function

// `timeoutPromise(..)`, defined ealier, returns
// a Promise that rejects after a specified delay

// setup a timeout for `foo()`
Promise.race( [
    foo(),                  // attempt `foo()`
    timeoutPromise( 3000 )  // give it 3 seconds
] )
.then(
    function(){
        // `foo(..)` fulfilled in time!
    },
    function(err){
        // either `foo()` rejected, or it just
        // didn't finish in time, so inspect
        // `err` to know which
    }
);

多数情况下,这种超时模式很管用。但有些细节需要考虑,坦白来说,这些细节对Promise.race([ .. ])Promise.all([ .. ])同等适用。

”最后“(”Finally“)

关键问题是,”被废弃/忽略的promise发生了什么?“我们不是从性能角度来问的--它们通常最终会被垃圾回收--而是从行为角度(副作用等等)。Promise不能取消--也不应该取消,否则会破坏外部不可变性信任,这会在本章的”Promise Uncancelable“一节中讨论--因此,只能静默忽略这些promise。

但要是前面例子中的foo()保留着某种资源,但是超时首先触发,造成那个promise被忽略了呢?在超时后,这种模式能够主动释放保留的资源?或者取消可能造成的任何副作用吗?要是你想要的只是记录foo()超时呢?

一些开发者已经提议,Promise需要一个finally(..)回调注册,总是在Promise解析完后调用,允许你指定任何必要的清理工作。目前并不存在于规范中,但可能出现在ES7+中。让我们拭目以待。

它看起来可能会是这样:

var p = Promise.resolve( 42 );

p.then( something )
.finally( cleanup )
.then( another )
.finally( cleanup );

注意: 在各种Promise库中,finally(..)仍然会创建并返回一个新的Promise(为了保证链式)。如果cleanup(..)函数返回一个Promise,它会被链接到链中,这意味着你仍然可能有我们之前讨论的未处理rejection问题。

同时,我们可以实现个静态辅助函数,允许我们监听(不会影响)Promise的解析结果:

// polyfill-safe guard check
if (!Promise.observe) {
    Promise.observe = function(pr,cb) {
        // side-observe `pr`'s resolution
        pr.then(
            function fulfilled(msg){
                // schedule callback async (as Job)
                Promise.resolve( msg ).then( cb );
            },
            function rejected(err){
                // schedule callback async (as Job)
                Promise.resolve( err ).then( cb );
            }
        );

        // return original promise
        return pr;
    };
}

在之前超时例子中使用该监听函数:

Promise.race( [
    Promise.observe(
        foo(),                  // attempt `foo()`
        function cleanup(msg){
            // clean up after `foo()`, even if it
            // didn't finish before the timeout
        }
    ),
    timeoutPromise( 3000 )  // give it 3 seconds
] )

Promise.observe(..)辅助函数只是为了说明如何在不影响它们的情况下,监听Promise的完成。其它Promise库有自己的解决方案。无论你怎么实现,都要确保你的Promise不会意外地被静默忽略。

all([..])和race([..])的变体(Variations on all([ .. ]) and race([ .. ]))

尽管原生ES6只有内建的Promise.all([ .. ])Promise.race([ .. ]),但基于这些,还可以实现一些常用的模式:

  • none([..])有点像all([ .. ]),但是fulfillment和rejection是相反的,所有的Promise都应当被rejected--rejection变为fulfillment值,反之亦然。
  • any([ .. ])有点像all([ .. ]),但是它会忽略任何rejection,因此只需要一个fulfill,而不是全部。
  • 'first([ .. ])'有点像any([ .. ])的竞争版,即忽略任何rejection,一旦第一个Promise fulfill,该Promise就fulfill了。
  • last([ .. ])有点像'first([ .. ])',但是最后一个Promise获胜。

有些Promise抽象库实现了以上这些,但是你也可以利用Promise的机制(race([ .. ])all([ .. ]))自己定义。

例如,这是我们定义的first([..]):

// polyfill-safe guard check
if (!Promise.first) {
    Promise.first = function(prs) {
        return new Promise( function(resolve,reject){
            // loop through all promises
            prs.forEach( function(pr){
                // normalize the value
                Promise.resolve( pr )
                // whichever one fulfills first wins, and
                // gets to resolve the main promise
                .then( resolve );
            } );
        } );
    };
}

注意: 如果所有promise都reject,这个first(..)实现中并没有reject,只是简单的挂起,就像Promise.race([])一样。如果愿意,你可以另外添加逻辑来跟踪每个promise 的rejection,如果所有promise均reject,在主promise上调用reject()。我们将之留给读者作为练习。

并行遍历(Concurrent Iterations)

有时,你想遍历一列Promise,并针对所有这些Promise执行某些任务,就像对同步的array一样(比如,forEach(..)map(..)some(..)every(..))。如果对每个promise执行的任务是严格同步的,这几个方法就可以了,正如我们之前代码中用到的forEach(..)一样。

但如果任务是异步的,或者应该并发执行,那么你可以使用库提供的这些utility的异步版本。

例如,考虑一个异步的map(..)utility,它接受一个array值(可能是Promise,也可能是其它)和一个针对每个值的执行函数(任务)。map(..)函数本身返回一个promise,它的fulfillment值是一个array,保存着(以同样的映射顺序)每个任务返回的fulfillment值:

if (!Promise.map) {
    Promise.map = function(vals,cb) {
        // new promise that waits for all mapped promises
        return Promise.all(
            // note: regular array `map(..)`, turns
            // the array of values into an array of
            // promises
            vals.map( function(val){
                // replace `val` with a new promise that
                // resolves after `val` is async mapped
                return new Promise( function(resolve){
                    cb( val, resolve );
                } );
            } )
        );
    };
}

注意: 在这个map(..)实现中,你无法发出异步rejection信号,但是如果在映射回调(cb(..))内部发生同步异常/错误,Promise.map(..)返回的主promise就会reject。

让我们举例说明一下多个Promise(而不是简单值)时的map(..)用法:

var p1 = Promise.resolve( 21 );
var p2 = Promise.resolve( 42 );
var p3 = Promise.reject( "Oops" );

// double values in list even if they're in
// Promises
Promise.map( [p1,p2,p3], function(pr,done){
    // make sure the item itself is a Promise
    Promise.resolve( pr )
    .then(
        // extract value as `v`
        function(v){
            // map fulfillment `v` to new value
            done( v * 2 );
        },
        // or, map to promise rejection message
        done
    );
} )
.then( function(vals){
    console.log( vals );    // [42,84,"Oops"]
} );

Promise API 回顾(Promise API Recap)

让我们回顾一下本章中零零碎碎展开的ES6 Promise API。

注意: 下面的API只是原生ES6才有的,但仍有一些兼容规范的polyfill(不只是简单地扩展Promise库),它们可以定义Promise及其所有相关行为,以便于在pre-ES6(ES6前)的浏览器中使用原生的Promise。其中一个polyfill是“Native Promise Only”(http://github.com/getify/native-promise-only),我写的!

new Promise(..) Constructor

暴露构造函数( revealing constructor )(见 Promise "Events" 一节)必须配合new使用,必须提供一个同步/立即调用的回调。这个函数传入两个回调充当promise的解析功能。我们通常将它们标为resolve(..)reject(..)

var p = new Promise( function(resolve,reject){
    // `resolve(..)` to resolve/fulfill the promise
    // `reject(..)` to reject the promise
} );

reject(..)只是简单地reject该promise,但resolve(..)根据传入的值,既可以fulfill该promise,也可以reject该promise。如果传入resolve(..)的是个立即的,非Promise,非thenable值,之后就会以该值fulfill该promise。

但是如果resolve(..)传入的是个真正的Promise或者thenable值,那么该值就会被递归拆析,promise会接收最终的解析结果/状态。

Promise.resolve(..) and Promise.reject(..)

创建一个已经rejected的Promise的简写形式为Promise.reject(..),因此这两个promise是等价的:

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

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

类似于Promise.reject(..)Promise.resolve(..)通常用来创建一个已经fulfilled的Promise。然而,Promise.resolve(..)同样也拆析thenable值(正如多次讨论的那样)。在那种情况下,返回的Promise接收传入的thenable的最终解析结果,既可能是fulfillment,又可能是rejection:

var fulfilledTh = {
    then: function(cb) { cb( 42 ); }
};
var rejectedTh = {
    then: function(cb,errCb) {
        errCb( "Oops" );
    }
};

var p1 = Promise.resolve( fulfilledTh );
var p2 = Promise.resolve( rejectedTh );

// `p1` will be a fulfilled promise
// `p2` will be a rejected promise

记住,如果你传入一个真正的Promise,Promise.resolve(..)什么也不会做;它只会直接返回该值。因此如果恰巧是个真正的Promise,而你不知道是何种值时,调用Promise.resolve(..)不会有开销。

then(..) and catch(..)

每个Promise实例(不是 Promise API命名空间)都有then(..)catch(..)方法,允许为Promise注册fulfillment和rejection处理函数。一旦Promise解析完,其中一个就会被调用,并且是异步调用的(见第一章的"Jobs")。

then(..)接收一个或两个参数,第一个作为fulfillment回调,第二个作为rejection回调。如果省略任一个或者传入一个非函数值,就会用相应的默认函数代替。默认的fulfillment回调只是简单的传递信息,而默认的rejection回调简单地重新抛出(传播)接收到的错误原因。

catch(..)只接收rejection回调作为参数,会自动替换上默认fulfillment回调。换句话说,等价于then(null,..)

p.then( fulfilled );

p.then( fulfilled, rejected );

p.catch( rejected ); // or `p.then( null, rejected )`

then(..)catch(..)同样会创建并返回一个新的promise,可用来表示Promise的链式流控制。

如果fulfillment或者rejection回调有异常抛出,则返回的promise就被reject了。如果任一个回调返回一个立即的,非Promise,非thenable值,则那个值就被设为返回promise的fulfillment。如果fulfillment处理函数指定返回了一个promise或者thenable值,则那个值会被拆析并成为返回promise的解析结果。

Promise.all([ .. ]) and Promise.race([ .. ])

ES6 Promise API中的静态函数Promise.all([ .. ])Promise.race([ .. ]),都创建了一个Promise作为返回值。那个promise的解析结果完全决定于你传入的promise数组。

对于Promise.all([ .. ])而言,若要返回的promise fulfill,则传入的所有promise必须都fulfill。如果任一个promise被reject了,则主promise也立即被reject(忽略其它promise的结果)。对于fulfillment而言,你传入的array必须都是fulfillment的promise。对于rejection而言,只要有一个rejection的promise即可。这种模式通常称为“门(gate)”:在门打开前,所有人必须都到场。

对于Promise.race([ .. ]),只有第一个解析的promise(fulfillment或者rejection)“胜出”,解析结果无论是什么,都会成为返回的promise的解析结果。这种模式通常称为“闩(latch)”:第一个打开闩的人通过。考虑如下代码:

var p1 = Promise.resolve( 42 );
var p2 = Promise.resolve( "Hello World" );
var p3 = Promise.reject( "Oops" );

Promise.race( [p1,p2,p3] )
.then( function(msg){
    console.log( msg );     // 42
} );

Promise.all( [p1,p2,p3] )
.catch( function(err){
    console.error( err );   // "Oops"
} );

Promise.all( [p1,p2] )
.then( function(msgs){
    console.log( msgs );    // [42,"Hello World"]
} );

警告: 如果空array传入Promise.all([ .. ]),则立即fulfill,但是Promise.race([ .. ])会永远挂起,从不解析。

ES6 的Promise相当简单直接。基本上足够满足绝大多数异步需求了,在重排代码,使之从地狱回调转向其它更好的方式时,是个不错的选择。

但是在某些应用中,有些复杂的异步需求,Promise处理的不是很好。在下一节,我们将会仔细探究这些局限性,并以此作为Promise库跟进的动力。

Promise的局限(Promise Limitations)

本节讨论的所有细节在本章中都略微提及了,让我们专门回顾一下这些不足。

序列化错误处理(Sequence Error Handling)

本章早些时候,我们已经仔细讨论过Promise式的错误处理了。Promise设计方式的不足--尤其是成链的方式--很容易造成陷阱,即Promise链中的错误可能被静默忽略。

但是,关于Promise 错误,还需要考虑其它一些东西。因为Promise链只是将成员promise连在一起,没有实体(entity)能够将整个链视作单个个体,这意味着没有外部方法能够监听可能发生的错误。

如果你创建了一个没有错误处理的Promise链,那么链中的任何一个错误都会沿着链无限传播下去,直至被监听(通过在某步注册rejection处理函数)。因此,在那种特定情况下,有一个指向链中的最后一个promise就行了(以下代码中是p)的引用,因为可以在那注册一个rejection处理函数,如果有任何错误传过来,就会被通知到。

// `foo(..)`, `STEP2(..)` and `STEP3(..)` are
// all promise-aware utilities

var p = foo( 42 )
.then( STEP2 )
.then( STEP3 );

尽管看起来有点迷糊,此处的p并不指向链中的第一个promise(foo(42)调用返回的),而是指向最后一个promise,then(STEP3)调用返回的。

另外,该promise链中没有一步监听错误处理,意味着你可以在p上注册一个rejection错误处理函数,如果链中发生任何错误,rejection注册函数就会被通知到:

p.catch( handleErrors );

但是如果链中的每一步有自己的错误处理函数(或许被隐藏/抽象,你看不到),你的handleErrors(..)就不会被通知到。这可能是你想要的--毕竟,它是一个“rejection处理函数”--也可能不是你想要的。完全丧失接收通知的能力是个不足之处,在某些情况下限制了功能实现。

这一不足和现有的try..catch是一样的,它能够捕获异常并简单地掩盖异常。因此,这不是Promise独有的不足,但我们希望有一些解决方案。

不幸的是,在Promise链中,无法保持对中间步骤的引用,因此,没有这些引用的话,就无法附上错误处理函数来可靠地监听错误。

单个值(Single Value)

从定义上来说,Promise只有单个fulfillment值或者rejection原因短语,在简单例子中,这不是个大问题。但在更复杂的场景中,你就会觉得捉襟见肘了。

通常的建议是构建一个值包装器(比如一个object或者array)来包含多个值。这种方法有效,但是在每步中包装、拆析信息相当别扭和繁琐。

分离值(Splitting Values)

有时你可以把这当做一个信号,即应该将问题分解为多个Promise。

假设有一个utilityfoo(..),它异步生成两个值(xy)。

function getY(x) {
    return new Promise( function(resolve,reject){
        setTimeout( function(){
            resolve( (3 * x) - 1 );
        }, 100 );
    } );
}

function foo(bar,baz) {
    var x = bar * baz;

    return getY( x )
    .then( function(y){
        // wrap both values into container
        return [x,y];
    } );
}

foo( 10, 20 )
.then( function(msgs){
    var x = msgs[0];
    var y = msgs[1];

    console.log( x, y );    // 200 599
} );

首先,让我们重排一下foo(..)的返回值,以便我们在传递值时,不需要将xy包进一个array中。可以将每个值包到自己的promise中:

function foo(bar,baz) {
    var x = bar * baz;

    // return both promises
    return [
        Promise.resolve( x ),
        getY( x )
    ];
}

Promise.all(
    foo( 10, 20 )
)
.then( function(msgs){
    var x = msgs[0];
    var y = msgs[1];

    console.log( x, y );
} );

一个promise array真的比通过单个promise传递的array值好吗?从句法结构上来看,并没有多少改善。

但这种方式更符合Promise的设计理念。将xy的计算分到不同的函数中去,这样将来更容易重构。

让调用代码决定如何编排这两个promise,这种方式更清晰灵活--此处采用Promise.all([ .. ]),但当然不是唯一的选项--而不是把foo(..)内的细节抽离出来。

打开/展开参数(Unwrap/Spread Arguments)

var x = ..var y = ..赋值操作仍然是很别扭的开销。我们可以在辅助函数中采用一些小的功能性手段(致敬Reginald Braithwaite, @raganwald on Twitter):

function spread(fn) {
    return Function.apply.bind( fn, null );
}

Promise.all(
    foo( 10, 20 )
)
.then(
    spread( function(x,y){
        console.log( x, y );    // 200 599
    } )
)

这种更好一点!当然,你可以写成内联函数样式,避免额外的辅助函数:

Promise.all(
    foo( 10, 20 )
)
.then( Function.apply.bind(
    function(x,y){
        console.log( x, y );    // 200 599
    },
    null
) );

这种把戏可能很简洁,但是ES6有个更好的方案:解构。数组解构赋值的形式看起来是这样子的:

Promise.all(
    foo( 10, 20 )
)
.then( function(msgs){
    var [x,y] = msgs;

    console.log( x, y );    // 200 599
} );

但最好的是,ES6提供数组参数解构形式:

Promise.all(
    foo( 10, 20 )
)
.then( function([x,y]){
    console.log( x, y );    // 200 599
} );

现在,我们已经奉行了一个Promise一个值(one-value-per-Promise)的准则,但同时将支持样板减到最少!

注意: 要想了解更多关于ES6解构形式的信息,请看本系列的ES6 & Beyond

单次解析(Single Resolution)

Promise最固有的行为之一就是Promise只能被解析一次(fulfillment或者rejection)。对于许多异步使用场景而言,你只需获取一次值,因此这种形式效果很好。

但有许多异步场景符合另一种不同的模型--即更类似于事件或者数据流。从表面上看,就算可以,也并不清楚Promise能够在多大程度上适用于这些使用场景。在Promise之外,没有重大的抽象,因而完全缺乏处理多次值解析的能力。

假设有个场景,为响应一个激励(比如一个事件),它可能发生很多次,比如按钮点击,你想触发一系列的异步操作。

这可能不是你想要的方式:

// `click(..)` binds the `"click"` event to a DOM element
// `request(..)` is the previously defined Promise-aware Ajax

var p = new Promise( function(resolve,reject){
    click( "#mybtn", resolve );
} );

p.then( function(evt){
    var btnID = evt.currentTarget.id;
    return request( "http://some.url.1/?id=" + btnID );
} )
.then( function(text){
    console.log( text );
} );

这段代码只在你的应用要求按钮只点击一次时有效。如果再点一次按钮,p promise已经解析了,因此第二次resolve(..)调用就会被忽略。

反而,你可能需要转换一下方案,每次事件触发时创建一个全新的Promise链。

click( "#mybtn", function(evt){
    var btnID = evt.currentTarget.id;

    request( "http://some.url.1/?id=" + btnID )
    .then( function(text){
        console.log( text );
    } );
} );

这种方法就会奏效,因为按钮的每一次"click"事件都会生成一个全新的Promise序列。

但是,在事件处理函数中定义整个Promise链,除了难看以外,从某种角度来说,这种设计违反了关注/功能分离(separation of concerns/capabilities,SoC)的理念。你可能想在代码的其它地方定义事件处理函数,不同于定义事件响应(Promise链)所在的位置。没采用辅助机制的话,这种模式相当别扭。

注意: 另一种阐述这种局限的方式是,要是我们能构建一种让Promise链订阅的“监听器(observable)”就好了。已经有库实现了这些抽象(比如RxJS--http://rxjs.codeplex.com/),但是这些抽象很臃肿,以至于你再也看不清Promise的本质了。这些臃肿的抽象带来了一些需要注意的问题,比如这些机制(不包括Promise)是否和Promise设计的那样值得信赖。我们会在附录B中再讨论“Observable”模式。

惯性(Inertia)

在代码中开始使用Promise的一个具体障碍是,现存的代码都不是基于Promise(Promises-aware )。如果有许多基于回调的代码,那么以同样方式编程更容易。

“运转中(采用回调)的代码库会继续运转(采用回调),除非聪明的、具有Promise意识的开发者采取行动”。

Promise提供了一种不同的模式,正因如此,编码方式可能有些不同,某些情况下,完全不同。你必须刻意这样做,因为Promise本身无法将你从早已习惯的编码方式中脱离出来。

考虑以下一个基于回调的场景:

function foo(x,y,cb) {
    ajax(
        "http://some.url.1/?x=" + x + "&y=" + y,
        cb
    );
}

foo( 11, 31, function(err,text) {
    if (err) {
        console.error( err );
    }
    else {
        console.log( text );
    }
} );

是否立刻就能看出第一步应该干什么,即如何将这个基于回调的代码转为基于Promise的代码?取决于你的经验。你使用Promise的实践越多,就会越觉得自然。Promise并没有宣称具体该怎么做--没有放之四海皆准的答案--因此责任在你。

正如我们之前讨论过的一样,我们确实需要一个基于Promise,而不是回调的Ajax utility,我们可以称之为request(..)。你可以像我们一样实现自己的方法。但是为每个基于回调的utility手动定义Promise式的包装器开销很大,你就更不会选择基于Promise的重构了。

对于这一不足,Promise没有直接的答案。然而,绝大多数Promise库确实提供这样的一个辅助函数。但就算没有这样的库,辅助函数也可能像这样:

// polyfill-safe guard check
if (!Promise.wrap) {
    Promise.wrap = function(fn) {
        return function() {
            var args = [].slice.call( arguments );

            return new Promise( function(resolve,reject){
                fn.apply(
                    null,
                    args.concat( function(err,v){
                        if (err) {
                            reject( err );
                        }
                        else {
                            resolve( v );
                        }
                    } )
                );
            } );
        };
    };
}

OK,这仅仅是个小小的实验程序。然而,尽管看起来有点吓人,但它并不是你想得那么糟。它接收一个函数,该函数期望一个错误优先式的回调作为最后一个参数,返回一个自动创建并返回promise的新函数
,通过将其连到Promise fulfillment/rejection上来替换掉你的回调函数。

不要再浪费时间讨论Promise.wrap(..)辅助函数是如何工作的,让我们看下如何使用吧:

var request = Promise.wrap( ajax );

request( "http://some.url.1/" )
.then( .. )
..

哇哦,相当简单!

Promise.wrap(..)并不生成一个Promise。它返回一个能生成promise的函数。从某种意义上来说,Promise生成函数可以被视作“Promise工厂”。我提议采用“promisory”来为其命名("Promise" + "factory")。

将一个期望回调的函数包装成一个Promise式的函数的行为有时称为“提升(lifting)”或者“promise化(promisifying)”。但对于结果函数,除了叫“提升函数(lifted function)”,似乎没有一个标准的术语来称呼它,因此我更喜欢“promisory”,因为它更具描述性。

注意: Promisory并不是个拼凑的术语。它是个真正的词,定义是包含或者传递一个promise。那正是这些函数所做的,因此这是一个完美匹配的术语!

因此,Promise.wrap(ajax)生成了称为request(..)ajax(..) promisory,那个promisory为Ajax响应生成Promise。

那么回到早先的例子,我们需要为ajax(..)foo(..)创建promisory:

// make a promisory for `ajax(..)`
var request = Promise.wrap( ajax );

// refactor `foo(..)`, but keep it externally
// callback-based for compatibility with other
// parts of the code for now -- only use
// `request(..)`'s promise internally.
function foo(x,y,cb) {
    request(
        "http://some.url.1/?x=" + x + "&y=" + y
    )
    .then(
        function fulfilled(text){
            cb( null, text );
        },
        cb
    );
}

// now, for this code's purposes, make a
// promisory for `foo(..)`
var betterFoo = Promise.wrap( foo );

// and use the promisory
betterFoo( 11, 31 )
.then(
    function fulfilled(text){
        console.log( text );
    },
    function rejected(err){
        console.error( err );
    }
);

当然,尽管我们重构了foo(..)来使用新的request(..) promisory,但是我们也可以只将foo(..)本身变成一个promisory ,而不是保持基于回调的状态并且需要创建和使用随后的betterFoo(..) promisory。这仅取决于foo(..)是否需要保持在基于回调的状态来兼容代码库的其它部分。

考虑如下代码:

// `foo(..)` is now also a promisory because it
// delegates to the `request(..)` promisory
function foo(x,y) {
    return request(
        "http://some.url.1/?x=" + x + "&y=" + y
    );
}

foo( 11, 31 )
.then( .. )
..

尽管ES6 Promise没有原生提供诸如promisory包装的辅助函数,但绝大多数库提供,或者也可自己实现。不论哪一种,Promise的这一不足可以在没有太大痛苦(当然是相比于地狱回调的痛苦)的情况下得到解决。

Promise不可取消(Promise Uncancelable)

一旦创建了一个Promise并为其注册一个fulfillment和/或rejection处理函数,如果由于某些其它原因,使得任务突然变得无意义了,你无法从外部阻止信息的传播。

注意: 许多Promise抽象库提供工具来取消Promise,但这是个很糟糕的主意!许多开发者希望Promise要是原生就具有外部取消功能就好了,但问题是这会让一个Promise 解析者/监听者影响其它解析者对同一个Promise的的监听。这违背了未来值可信原则(即外部不可变性),更是“超距作用(action at a distance)”反模式(anti-pattern)(http://en.wikipedia.org/wiki/Action_at_a_distance_%28computer_programming%29)的具体体现。不管看起来如何有用,它会使你直接回到回调一样的噩梦中。

考虑早先的超时Promise场景:

var p = foo( 42 );

Promise.race( [
    p,
    timeoutPromise( 3000 )
] )
.then(
    doSomething,
    handleError
);

p.then( function(){
    // still happens even in the timeout case :(
} );

"超时"是在promise p的外部的,因此超时后,p本身会继续运行,这不是我们想要的。

一种选择是侵入性地(invasively)定义解析回调:

var OK = true;

var p = foo( 42 );

Promise.race( [
    p,
    timeoutPromise( 3000 )
    .catch( function(err){
        OK = false;
        throw err;
    } )
] )
.then(
    doSomething,
    handleError
);

p.then( function(){
    if (OK) {
        // only happens if no timeout! :)
    }
} );

这很难看,虽然有效,但是远不理想。一般来说,需要避免这种情况。

但如果不这么做的话,这种丑陋的办法会让你认识到,可取消性(cancelation)是个在Promise之外的,一种属于更高级抽象的功能。我建议你转向Promise抽象库寻求帮助,而不是自己实现。

注意: 我的“异步队列(asynquence)”Promise抽象库正好提供了这样一个抽象和队列的abort()功能。将会在附录A中讨论。

单个Promise并不是真正的流控制机制(至少从是否有意义方面来说不是),这正是可取消性(cancelation)所指的那样;这就是Promise 可取消性让人觉得别扭的原因。

与此相反,多个Promise链在一起--我称为一个“序列”--才是流控制的表示形式,因此,在这个抽象层面定义可取消性才比较合适。

单个Promise不应该被取消,但取消序列(sequence)是有意义的,因为你不会像Promise那样将序列当做单个不可变值传递。

Promise性能(Promise Performance)

这一局限既简单又复杂。

基本的基于回调的异步任务链对比Promise链,各有多少代码运行,相比于此,很清楚的一点是Promise运行的相对多一点,这意味着Promise天生就慢一点。简单回想下Promise提供的信任保证,相比于为了达到同样的效果,不得不在回调之外再布一层专门的解决方案代码。

需要做更多工作,更多防护,这意味着相比于裸奔的、不可信任的回调,Promise更慢一些。这很明显,很简单就能想到。

但是慢多少呢?呃。。很难全面地回答这个难题。

坦白来说,这就像是苹果和橘子的比较,因此问的问题可能就是错的。你应该问的是,手动部署同样效果防御代码的回调,是否比Promise实现更快些。

如果Promise有合理的性能局限,更多的在于Promise不提供是否需要信任保护的选项--Promise总是提供信任保护。

然而,如果我们承认Promise通常比对等的non-Promise、non-trustable回调稍微慢一点--假设某些地方你觉得缺乏信任是合理的--那不就意味着应当完全避免使用Promise,犹如你的整个应用完全由尽可能快的代码驱动吗?

总结一下:如果你的代码是那样的话,那么JavaScript是完成这些任务的恰当语言吗?可以优化JavaScript,使之能够高性能地运行应用(见第五、六章)。但是,鉴于提供的种种好处,沉迷于Promise的一点点性能折中,真的合适吗?

另一个微妙的问题是,Promise让一切都变成异步了,这意味着一些立即(同步)完成的步骤的下步进展仍然被推迟到Job中(见第一章)。也就是说很可能一系列Promise任务可能比相同的采用回调串联起来的序列完成的慢。

当然,此处的问题是:这些性能方面的潜在不足真的抵得上整篇文章所提到的Promise的优点吗?

在我看来,所有你认为Promise性能不好的情况,实际上都是一种反模式
,即通过避免使用Promise来优化掉Promise的可信任和可组合的优点。

反而,你应该在整个代码库中默认使用Promise,之后简述和分析应用的热(重要)路径(译者注:指应用中使用频率最高的部分)。Promise真的是性能瓶颈,或者只是理论上的性能低下?只有之后,有了有效地基准(见第六章),才能谨慎负责地在确定的关键领域中评价Promise。

Promise有点慢,但作为交换,你获得了信任、非Zalgo可预测性和构建的可组合性。或许缺陷不是性能,而是你对Promise优点的缺乏认识?

回顾(Review)

Promise很棒,使用它吧。它解决了控制权反转问题,这个问题在只有回调的代码中一直困扰我们。

Promise并没有舍弃回调,只是将那些回调重新编排,成为一个站在我们和第三方实用程序间可信任的中间机制。

Promise也开始以序列化的方式(尽管并不完美)更好地表示异步流,这能帮助我们的大脑更好地规划和维护异步JS代码。我们会在下一章看到一个更好的表示异步流的方案!

相关文章

网友评论

      本文标题:你不知道JS:异步(翻译)系列3-2

      本文链接:https://www.haomeiwen.com/subject/knjosttx.html