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上的回调之间的相对顺序,是不能可靠预测的。
如果两个promisep1
和p2
都准备好被解析了,那么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异常错误发生的话,比如TypeError
或ReferenceError
,这个异常将会被捕获,并且强制当前的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。换句话说,下面两个promisep1
和p2
的行为基本上完全相同:
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),它是为了将你的异步处理进行清晰的表达而特意设计的。