第三章: Promise - Promise的信任

优质
小牛编辑
124浏览
2023-12-01

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”),而且没有任何在这些回调中发生的事情可以影响/推迟其他回调的调用。

举例来说:

  1. p.then( function(){
  2. p.then( function(){
  3. console.log( "C" );
  4. } );
  5. console.log( "A" );
  6. } );
  7. p.then( function(){
  8. console.log( "B" );
  9. } );
  10. // A B C

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

Promise排程的怪现象

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

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

  1. var p3 = new Promise( function(resolve,reject){
  2. resolve( "B" );
  3. } );
  4. var p1 = new Promise( function(resolve,reject){
  5. resolve( p3 );
  6. } );
  7. var p2 = new Promise( function(resolve,reject){
  8. resolve( "A" );
  9. } );
  10. p1.then( function(v){
  11. console.log( v );
  12. } );
  13. p2.then( function(v){
  14. console.log( v );
  15. } );
  16. // A B <-- 不是你可能期望的 B A

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

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

根本不调回调

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

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

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

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

  1. // 一个使Promise超时的工具
  2. function timeoutPromise(delay) {
  3. return new Promise( function(resolve,reject){
  4. setTimeout( function(){
  5. reject( "Timeout!" );
  6. }, delay );
  7. } );
  8. }
  9. // 为`foo()`设置一个超时
  10. Promise.race( [
  11. foo(), // 尝试调用`foo()`
  12. timeoutPromise( 3000 ) // 给它3秒钟
  13. ] )
  14. .then(
  15. function(){
  16. // `foo(..)`及时地完成了!
  17. },
  18. function(err){
  19. // `foo()`不是被拒绝了,就是它没有及时完成
  20. // 那么可以考察`err`来知道是哪种情况
  21. }
  22. );

这种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变为拒绝。

举例来说:

  1. var p = new Promise( function(resolve,reject){
  2. foo.bar(); // `foo`没有定义,所以这是一个错误!
  3. resolve( 42 ); // 永远不会跑到这里 :(
  4. } );
  5. p.then(
  6. function fulfilled(){
  7. // 永远不会跑到这里 :(
  8. },
  9. function rejected(err){
  10. // `err`将是一个来自`foo.bar()`那一行的`TypeError`异常对象
  11. }
  12. );

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

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

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

  1. var p = new Promise( function(resolve,reject){
  2. resolve( 42 );
  3. } );
  4. p.then(
  5. function fulfilled(msg){
  6. foo.bar();
  7. console.log( msg ); // 永远不会跑到这里 :(
  8. },
  9. function rejected(err){
  10. // 也永远不会跑到这里 :(
  11. }
  12. );

等一下,这看起来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的行为基本上完全相同:

  1. var p1 = new Promise( function(resolve,reject){
  2. resolve( 42 );
  3. } );
  4. var p2 = Promise.resolve( 42 );

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

  1. var p1 = Promise.resolve( 42 );
  2. var p2 = Promise.resolve( p1 );
  3. p1 === p2; // true

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

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

考虑这段代码:

  1. var p = {
  2. then: function(cb) {
  3. cb( 42 );
  4. }
  5. };
  6. // 这工作起来没问题,但要靠运气
  7. p
  8. .then(
  9. function fulfilled(val){
  10. console.log( val ); // 42
  11. },
  12. function rejected(err){
  13. // 永远不会跑到这里
  14. }
  15. );

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

  1. var p = {
  2. then: function(cb,errcb) {
  3. cb( 42 );
  4. errcb( "evil laugh" );
  5. }
  6. };
  7. p
  8. .then(
  9. function fulfilled(val){
  10. console.log( val ); // 42
  11. },
  12. function rejected(err){
  13. // 噢,这里本不该运行
  14. console.log( err ); // evil laugh
  15. }
  16. );

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

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

  1. Promise.resolve( p )
  2. .then(
  3. function fulfilled(val){
  4. console.log( val ); // 42
  5. },
  6. function rejected(err){
  7. // 永远不会跑到这里
  8. }
  9. );

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

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

  1. // 不要只是这么做:
  2. foo( 42 )
  3. .then( function(v){
  4. console.log( v );
  5. } );
  6. // 相反,这样做:
  7. Promise.resolve( foo( 42 ) )
  8. .then( function(v){
  9. console.log( v );
  10. } );

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

信任建立了

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

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

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

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