原文:https://pouchdb.com/2015/05/1...

JavaScripts的朋友们,是时候承认了: we have a problem with promises。不,不是promises本身。正如A+ spec所定义的,promises是非常棒的。

在过去的一年里,当我看到许多程序员在PouchDB API和其他promise-heavy APIs上挣扎时,我发现了一个大问题:我们中的许多人使用promises 时没有真正理解它们

如果你觉得很难相信,想想我最近在Twitter上发布的这个谜题:

Q: What is the difference between these four promises?

doSomething().then(function () {
  return doSomethingElse();
});

doSomething().then(function () {
  doSomethingElse();
});

doSomething().then(doSomethingElse());

doSomething().then(doSomethingElse);


如果你知道答案,那么恭喜你:你是一个承诺忍者。我允许您停止阅读此日志。对于其他99.99%的人来说,你是一个很好的同伴。没有人回应我的推特,也没有人能解决这个问题,我自己对#3的答案感到惊讶。是的,即使我写了测验!

答案在这篇文章的最后,但首先,我想探讨一下为什么promises一开始就那么棘手,为什么我们中的许多人——新手和专家——会被promises绊倒。我还将提供我认为是独特见解的东西,一个奇异的把戏,它使promises很容易理解。是的,我真的相信在那之后他们不会那么难!

但首先,让我们挑战一些关于promises的常见假设。

Wherefore promises?

如果你读过有关promises的文献,你会经常发现对the pyramid of doom(https://medium.com/@wavded/managing-node-js-callback-hell-1fe03ba8baf)的引用,其中有一些可怕的callback-y代码稳步地向屏幕的右侧延伸。

promises确实解决了这个问题,但它不仅仅是缩进。正如"Redemption from Callback Hell"(http://youtu.be/hf1T_AONQJU)中所解释的,callbacks的真正问题是它们剥夺了我们return和throw这样的关键字。相反,我们的程序的整个流程基于side effects:一个函数偶然调用另一个函数。

事实上,callbacks 做了一些更险恶的事情:它们剥夺了我们的stack, stack在编程语言中我们通常认为是理所当然的。写没有stack的代码很像驾驶一辆没有刹车踏板的汽车:你不会意识到你有多么需要它,直到你伸手去拿它而它不在那里。

promises的全部要点是就是把异步时丢失的语言基础还给我们:return, throw, 和 stack。但是你必须知道如何正确地使用promises,才能利用它们。

Rookie mistakes

有些人试图把承诺解释成cartoon(https://www.andyshora.com/promises-angularjs-explained-as-cartoon.html),或者以一种非常面向名词的方式:“哦,正是你可以传递的东西代表了一个异步值。”

我觉得这样的解释没什么帮助。对我来说,promises都是关于代码结构和流程的。所以我认为最好是回顾一些常见的错误,并展示如何修复它们。我把这些叫做"rookie mistakes",意思是,“你现在是新手了,孩子,但你很快就会成为职业选手。”

Quick digression::“promises”对不同的人来说意味着很多不同的事情,但是在本文中,我将只讨论官方规范(https://promisesaplus.com/),就像window.Promise在现代浏览器中一样。并不是所有的浏览器都有window.Promise,因此,要想得到一个好的polyfill,请看一个名为Lie(https://github.com/calvinmetcalf/lie)的库,它是目前最小的符合规范的库。

Rookie mistake #1: the promisey pyramid of doom

看看人们是如何使用PouchDB的,PouchDB有一个很大程度上基于promise的API,我发现很多糟糕的promise模式。最常见的糟糕的做法是:

remotedb.allDocs({
  include_docs: true,
  attachments: true
}).then(function (result) {
  var docs = result.rows;
  docs.forEach(function(element) {
    localdb.put(element.doc).then(function(response) {
      alert("Pulled doc with id " + element.doc._id + " and added to local db.");
    }).catch(function (err) {
      if (err.name == 'conflict') {
        localdb.get(element.doc._id).then(function (resp) {
          localdb.remove(resp._id, resp._rev).then(function (resp) {
// et cetera...

是的,事实证明你可以像回调一样使用promises ,是的,这很像用电动砂光机锉指甲,但你可以做到。

如果你认为这类错误仅仅局限于绝对初学者,你会惊讶地发现我确实从官方的黑莓开发者博客中获取了上述代码!旧的回调习惯很难改变。(对开发人员说:很抱歉挑你的毛病,但你的例子很有启发性。)

A better style is this one:

remotedb.allDocs(...).then(function (resultOfAllDocs) {
  return localdb.put(...);
}).then(function (resultOfPut) {
  return localdb.get(...);
}).then(function (resultOfGet) {
  return localdb.put(...);
}).catch(function (err) {
  console.log(err);
});
这被称为composing promises,它是promises的great superpowers之一。每个函数只有在上一个Promise resolved后才会被调用,并且将使用该Promise的输出来调用它。更多的内容以后再谈。

Rookie mistake #2: WTF, how do I use forEach() with promises?

这就是大多数人对承诺的理解开始崩溃的地方。一旦他们到了熟悉的foreach()循环(或者for循环,或者while循环),他们就不知道如何让它与promises一起工作。所以他们写了这样的东西:
    // I want to remove() all docs
    db.allDocs({include_docs: true}).then(function (result) {
      result.rows.forEach(function (row) {
        db.remove(row.doc);  
      });
    }).then(function () {
      // I naively believe all docs have been removed() now!
    });
这个代码有什么问题?问题是第一个函数实际上返回undefined,这意味着第二个函数不等待对所有文档调用db.remove()。实际上,它不需要等待任何东西,并且可以在删除任意数量的文档后执行!
这是一个特别阴险的bug,因为您可能不会注意到任何错误,假设PouchDB删除这些文档的速度足以更新您的UI。这个bug可能只在odd race条件下出现,或者在某些浏览器中出现,此时几乎不可能进行调试。
所有这些的TLDR 都是forEach()/for/while 不是您要查找的构造。你需要Promise.all():
    db.allDocs({include_docs: true}).then(function (result) {
      return Promise.all(result.rows.map(function (row) {
        return db.remove(row.doc);
      }));
    }).then(function (arrayOfResults) {
      // All docs have really been removed() now!
    });

这是怎么回事?基本上Promise.all() 接受一个array of promises作为输入,然后它给您另一个promise,该promise只在其他所有的promise都resolved时才会解决。它是for循环的异步等价物。

Promise.all() 还将一个结果数组传递给下一个函数,这非常有用,例如,如果您试图从pouchdb去get()多个结果。如果它的任何一个sub-promises are rejected,那么all()承诺也会被拒绝,这更有用。

Rookie mistake #3: forgetting to add .catch()

这是另一个常见的错误。幸运的是,他们的promises永远不会抛出错误,许多开发人员忘记在代码中的所有地方添加.catch()。不幸的是,这意味着任何抛出的错误都将被吞没,您甚至不会在控制台中看到它们。这可能是调试真正的苦恼。
为了避免这种糟糕的情况,我养成了在我的promise chains中添加以下代码的习惯:
    somePromise().then(function () {
      return anotherPromise();
    }).then(function () {
      return yetAnotherPromise();
    }).catch(console.log.bind(console)); // <-- this is badass
即使您不期望出现错误,也要谨慎地添加catch()。如果你的假设被证明是错误的,这会让你的生活更轻松。

Rookie mistake #4: using "deferred"

这是一个错误 我看all the time,我甚至不愿意在这里重复它,因为我担心,像甲虫汁一样,仅仅调用它的名字就会引发更多的例子。简言之,promises 有着悠久的历史,而JavaScript社区花了很长时间才使其正确。早期,jQuery 和Angular在各地都使用这种“deferred”模式,现在已经被ES6 Promise规范所取代,由“good”库(如Q, When, RSVP, Bluebird, Lie, and others库)实现。

所以如果你在代码中写这个词(我不会第三次重复!)你做错了一些事。下面是如何避免它。
首先,大多数承诺库都为您提供了从第三方库“import”promises 的方法。例如,Angular的$q模块允许您使用$q.when()包装non-$q承诺。所以Angular用户可以这样包装PouchDB承诺:
    $q.when(db.put(doc)).then(/* ... */); // <-- this is all the code you need
另一种策略是使用revealing constructor pattern(https://blog.domenic.me/the-revealing-constructor-pattern/),这对于包装 non-promise的API很有用。例如,要包装基于回调的API,如Node的fs.readfile(),只需执行以下操作:
    new Promise(function (resolve, reject) {
      fs.readFile('myfile.txt', function (err, file) {
        if (err) {
          return reject(err);
        }
        resolve(file);
      });
    }).then(/* ... */)
Done! We have defeated the dreaded def... Aha, caught myself. :)
有关为什么这是anti-pattern的更多信息,请访问Bluebird wiki上的Promise anti-patterns页面(https://github.com/petkaantonov/bluebird/wiki/Promise-anti-patterns#the-deferred-anti-pattern)。

Rookie mistake #5: using side effects instead of returning

这个代码怎么了?
    somePromise().then(function () {
      someOtherPromise();
    }).then(function () {
      // Gee, I hope someOtherPromise() has resolved!
      // Spoiler alert: it hasn't.
    });

好吧,这是一个很好的观点,可以谈论关于promises的所有你需要知道的事情。说真的,这是一个one weird trick,一旦你理解了它,就会阻止我所说的所有错误。准备好了吗?
正如我之前所说,promises 的魔力在于,它们把我们宝贵的return 和 throw还给我们。但在实践中这到底是什么样子的呢?
每一个承诺都会给你一个then()方法(或catch(),它只是then(null, ...)的语法糖)。这里是then()函数的内部:
    somePromise().then(function () {
      // I'm inside a then() function!
    });
我们在这里能做什么?有三件事:
    1. return another promise
    2. return a synchronous value (or undefined)
    3. throw a synchronous error
就这样。一旦你理解了这个诀窍,你就明白了promises。所以,So let's go through each point one at a time.。

1. Return another promise
    这是您在promise文献中看到的常见模式,如上面的“composing promises”示例所示:
        getUserByName('nolan').then(function (user) {
          return getUserAccountById(user.id);
        }).then(function (userAccount) {
          // I got a user account!
        });
    请注意,我正在返回第二个promise—return是至关重要的。如果我没有说return,那么getUserAccountByID()实际上是一个side effect,下一个函数将接收undefined而不是userAccount。
2. Return a synchronous value (or undefined)

返回undefined通常是一个错误,但返回同步值实际上是将同步代码转换为Promisey代码的一种很棒的方法。例如,假设我们有一个用户的内存缓存。我们可以做到:
    getUserByName('nolan').then(function (user) {
      if (inMemoryCache[user.id]) {
        return inMemoryCache[user.id];    // returning a synchronous value!
      }
      return getUserAccountById(user.id); // returning a promise!
    }).then(function (userAccount) {
      // I got a user account!
    });

那不是太棒了吗?第二个函数不关心是同步还是异步获取用户帐户,第一个函数可以自由返回同步或异步值。
不幸的是,在JavaScript中,non-returning函数在技术上返回undefined结果是不方便的,这意味着当您打算返回某些内容时,很容易意外地引入side effects 。出于这个原因,我习惯于总是从then()函数内部返回或抛出。我建议你也这么做。
  1. Throw a synchronous error
    说到throw,这就是promises可以变得令人惊叹的地方。假设我们想要抛出一个同步错误,以防用户注销。这很容易:

    getUserByName('nolan').then(function (user) {
      if (user.isLoggedOut()) {
        throw new Error('user logged out!'); // throwing a synchronous error!
      }
      if (inMemoryCache[user.id]) {
        return inMemoryCache[user.id];       // returning a synchronous value!
      }
      return getUserAccountById(user.id);    // returning a promise!
    }).then(function (userAccount) {
      // I got a user account!
    }).catch(function (err) {
      // Boo, I got an error!
    });

    如果用户注销,我们的catch()将收到一个同步错误;如果任何promises被拒绝,它将收到一个异步错误。同样,函数不关心它得到的错误是同步的还是异步的。这尤其有用,因为它可以帮助识别开发过程中的编码错误。例如,如果在then()函数内的任何一点执行json.parse(),那么如果json无效,它可能会抛出一个同步错误。通过callbacks,这个错误会被忽略,但是通过promise,我们可以在catch() 函数中简单地处理它。

Advanced mistakes

好吧,既然你已经学会了一个让promises变得简单的诀窍,我们来谈谈边缘案例。因为当然,总是有边缘情况。
我将这些错误归类为“高级错误”,因为我只在那些已经相当擅长promises的程序员所犯的错误中见过。但是如果我们想解决我在本文开头提出的难题的话.我们需要讨论一下。

Advanced mistake #1: not knowing about Promise.resolve()

正如我上面所展示的,promises 对于将同步代码包装为异步代码非常有用。但是,如果你发现自己经常输入:
    new Promise(function (resolve, reject) {
      resolve(someSynchronousValue);
    }).then(/* ... */);
您可以使用promise.resolve()更简洁地表达这一点:
    Promise.resolve(someSynchronousValue).then(/* ... */);
这对于捕获任何同步错误也非常有用。它是如此有用,以至于我养成了一个习惯,几乎我所有的 promise-returning API方法都是这样的:
    function somePromiseAPI() {
      return Promise.resolve().then(function () {
        doSomethingThatMayThrow();
        return 'foo';
      }).then(/* ... */);
    }

只需记住:任何可能同步抛出的代码都是一个很好的candidate,因为它几乎不可能在一行中的某个地方调试吞没的错误。但是,如果您将所有内容都包装在promise.resolve()中,那么您以后总是可以确保catch() 。
同样,您可以使用promise.reject()返回一个立即被拒绝的承诺:
    Promise.reject(new Error('some awful error'));

Advanced mistake #2: then(resolveHandler).catch(rejectHandler) isn't exactly the same as then(resolveHandler, rejectHandler)

我在上面说,catch()只是语法糖。所以这两个片段是等效的:
    somePromise().catch(function (err) {
      // handle error
    });
    
    somePromise().then(null, function (err) {
      // handle error
    });
但是,这并不意味着以下两个片段是等效的:
    somePromise().then(function () {
      return someOtherPromise();
    }).catch(function (err) {
      // handle error
    });
    
    somePromise().then(function () {
      return someOtherPromise();
    }, function (err) {
      // handle error
    });
如果您想知道为什么它们不是等价的,那么考虑一下如果第一个函数抛出一个错误会发生什么:
    somePromise().then(function () {
      throw new Error('oh noes');
    }).catch(function (err) {
      // I caught your error! :)
    });
    
    somePromise().then(function () {
      throw new Error('oh noes');
    }, function (err) {
      // I didn't catch your error! :(
    });
事实证明,当使用then(resolveHandler, rejectHandler)格式时,如果resolveHandler本身抛出了错误,那么rejecthandler实际上不会捕获错误。出于这个原因,我已经习惯了永远不要使用then()的第二个参数,并且总是更喜欢catch()。例外情况是,当我在编写异步mocha测试时,我可能会编写一个测试来确保抛出一个错误:
    it('should throw an error', function () {
      return doSomethingThatThrows().then(function () {
        throw new Error('I expected an error!');
      }, function (err) {
        should.exist(err);
      });
    });
说到这一点,Mocha和Chai是测试Promise API的可爱组合。pouchdb-plugin-seed项目有一些示例测试可以让您开始。

Advanced mistake #3: promises vs promise factories

假设你想一个接一个地按顺序执行一系列的promises 。也就是说,您需要像promise.all()这样的东西,但它不能并行地执行promises。
你可能天真地写了这样的东西:
    function executeSequentially(promises) {
      var result = Promise.resolve();
      promises.forEach(function (promise) {
        result = result.then(promise);
      });
      return result;
    }

不幸的是,这不会像你想的那样奏效。您传递给executeSequentially()的promises 仍将并行执行。发生这种情况的原因是,你根本不想对一系列承诺进行操作。根据Promise规范,一旦创建了promise,它就开始执行。所以你真正想要的是一系列的promise factories:
    function executeSequentially(promiseFactories) {
      var result = Promise.resolve();
      promiseFactories.forEach(function (promiseFactory) {
        result = result.then(promiseFactory);
      });
      return result;
    }
我知道你在想:“这个Java程序员到底是谁,为什么他要谈论factories?”然而,Promise factories非常简单——它只是一个返回Promise的函数:
    function myPromiseFactory() {
      return somethingThatCreatesAPromise();
    }
为什么会这样?它起作用是因为promise factory在被要求之前不会创造promise。它与then函数的工作方式相同——事实上,它是相同的!
如果你看上面的executeSequentially() 函数,然后想象myPromiseFactory在result.then(...)中被替换了,那么希望一个灯泡会在你的大脑中发出咔嗒声。在那一刻,你将获得promise启发。

Advanced mistake #4: okay, what if I want the result of two promises?

通常情况下,一个promise 依赖于另一个promise ,但我们需要两个promises的输出。例如: 
    getUserByName('nolan').then(function (user) {
      return getUserAccountById(user.id);
    }).then(function (userAccount) {
      // dangit, I need the "user" object too!
    });
为了成为优秀的javascript开发人员并避免pyramid of doom,我们可能只将用户对象存储在一个更高范围的变量中:
    var user;
    getUserByName('nolan').then(function (result) {
      user = result;
      return getUserAccountById(user.id);
    }).then(function (userAccount) {
      // okay, I have both the "user" and the "userAccount"
    });
这是可行的,但我个人觉得有点笨拙。我建议的策略是:抛开你的先入之见,拥抱pyramid:
    getUserByName('nolan').then(function (user) {
      return getUserAccountById(user.id).then(function (userAccount) {
        // okay, I have both the "user" and the "userAccount"
      });
    });
…至少是暂时的。如果缩进变成了一个问题,那么您可以按照Javascript开发人员自古以来的做法,将函数提取到一个命名函数中:
    function onGetUserAndUserAccount(user, userAccount) {
      return doSomething(user, userAccount);
    }
    
    function onGetUser(user) {
      return getUserAccountById(user.id).then(function (userAccount) {
        return onGetUserAndUserAccount(user, userAccount);
      });
    }
    
    getUserByName('nolan')
      .then(onGetUser)
      .then(function () {
      // at this point, doSomething() is done, and we are back to indentation 0
    });
随着您的promise代码变得越来越复杂,您可能会发现自己正在将越来越多的函数提取到命名函数中。我发现这会产生非常美观的代码,看起来像这样:
putYourRightFootIn()
  .then(putYourRightFootOut)
  .then(putYourRightFootIn)  
  .then(shakeItAllAbout);
这就是promises的意义所在。

Advanced mistake #5: promises fall through

最后,这是我在介绍上述promise puzzle时提到的错误。这是一个非常深奥的用例,它可能永远不会出现在您的代码中,但它确实让我吃惊。
你觉得这个代码能打印出来吗?
    Promise.resolve('foo').then(Promise.resolve('bar')).then(function (result) {
      console.log(result);
    });
如果你认为它打印出了bar,你就错了。它实际上打印了foo!
发生这种情况的原因是,当您传递then()一个non-function (如promise)时,它实际上将其解释为then(null),这会导致前一个promise的结果失败。您可以自己测试:
    Promise.resolve('foo').then(null).then(function (result) {
        console.log(result);
    });

添加任意then(null)s;它仍将打印foo。
这实际上回到了我之前关于promises和promise factories的观点。简而言之,您可以将一个promise直接传递到then()方法中,但它不会执行您认为它正在执行的操作。then()应该接受一个函数,所以最有可能的情况是:
    Promise.resolve('foo').then(function () {
      return Promise.resolve('bar');
    }).then(function (result) {
      console.log(result);
    });
如我们所料,这将打印bar。
所以请提醒自己:总是向then()传递函数!

Solving the puzzle

既然我们已经了解了关于promises 的一切(或接近promises 的一切!)我们应该能够解决我最初在这篇文章开头提出的难题。以下是每个问题的答案,采用图形格式,以便更好地可视化:

Puzzle #1
    doSomething().then(function () {
      return doSomethingElse();
    }).then(finalHandler);
Answer:
    doSomething
    |-----------------|
                      doSomethingElse(undefined)
                      |------------------|
                                         finalHandler(resultOfDoSomethingElse)
                                         |------------------|
Puzzle #2
    doSomething().then(function () {
      doSomethingElse();
    }).then(finalHandler);
Answer:
    doSomething
    |-----------------|
                      doSomethingElse(undefined)
                      |------------------|
                      finalHandler(undefined)
                      |------------------|
Puzzle #3
    doSomething().then(doSomethingElse())
      .then(finalHandler);
Answer:
    doSomething
    |-----------------|
    doSomethingElse(undefined)
    |---------------------------------|
                      finalHandler(resultOfDoSomething)
                      |------------------|
Puzzle #4
    doSomething().then(doSomethingElse)
      .then(finalHandler);
Answer:
    doSomething
    |-----------------|
                      doSomethingElse(resultOfDoSomething)
                      |------------------|
                                         finalHandler(resultOfDoSomethingElse)
                                         |------------------|

    如果这些答案仍然没有意义,那么我建议您重新阅读文章,或者定义dosomething()和dosomethingelse()方法,并在浏览器中自己尝试。

    Clarification:对于这些示例,我假设doSomething()和doSomethingElse()都返回promises,并且这些promises表示在javascript事件循环之外所做的事情(例如IndexedDB, network, setTimeout),这就是为什么它们在适当的时候显示为并发的原因。这里有一个JSbin要演示。

为了更高级地使用promises,请查看我的承promise protips cheat sheet(https://gist.github.com/nolanlawson/6ce81186421d2fa109a4)。

Final word about promises

Promises是伟大的。如果你仍在使用callbacks,我强烈建议你转用promises。您的代码将变得更小、更优雅、更容易理解。如果你不相信我,这里有一个证据:a refactor of PouchDB's map/reduce module (https://t.co/hRyc6ENYGC),用promises替换callbacks。结果:290次插入,555次删除。
顺便说一下,写那个讨厌的回调代码的人是……我!因此,这是我在promises的原始力量方面的第一堂课,我感谢其他PouchDB贡献者在这一过程中对我的指导。
尽管如此,promises不完美。的确,他们比回调更好,但这很像是说,一拳打在肚子上总比一拳打在牙齿上好。当然,一个比另一个更好,但是如果你有选择的话,你可能会避开它们。

虽然优于callbacks,promises仍然很难理解和容易出错,这一点可以证明,我觉得有必要写这篇博文。新手和专家都会经常把事情搞得一团糟,事实上,这不是他们的错。问题是,虽然与我们在同步代码中使用的模式类似,但承诺是一个不错的替代品,但并不完全相同。事实上,您不必学习一堆神秘的规则和新的API来做一些事情,在同步的世界中,您可以很好地处理熟悉的模式,如 return, catch, throw, and for-loops。不应该有两个平行的系统,这个系统是你必须一直保持头脑中的直线。

Awaiting async/await

这就是我在 "Taming the asynchronous beast with ES7"(https://pouchdb.com/2015/03/05/taming-the-async-beast-with-es7.html),中提出的观点,在这里我研究了ES7 async/await关键字,以及它们如何将承诺更深入地集成到语言中。ES7不必编写伪同步代码(使用一个类似catch的fake catch()方法,但实际上不是),它允许我们使用真正的try/catch/return关键字,就像我们在CS 101中学习到的那样。
这对JavaScript作为一种语言来说是一个巨大的好处。因为最终,只要我们的工具不告诉我们什么时候出错,这些promise anti-patterns仍然会不断出现。
以javascript的历史为例,我认为可以公平地说,JSlint和JShint为社区提供了比JavaScript: The Good Parts更好的服务,即使它们实际上包含相同的信息。两者的区别是:告知你在代码中犯的错误,而不是读一本你试图理解别人错误的书。

ES7 Async/Await的优点是,在大多数情况下,您的错误将显示为语法/编译器错误,而不是细微的运行时错误。不过,在那之前,最好掌握promises的能力,以及如何在ES5和ES6中正确地使用它们。
所以,虽然我认识到,像JavaScript: The Good Parts,这个博客文章只能产生有限的影响,但希望你能在看到人们犯同样的错误时指出这些问题。因为我们中仍有太多人需要承认:"I have a problem with promises!"
Update:有人告诉我,Bluebird3.0会打印出警告,可以防止我在这篇文章中发现的许多错误。所以当我们等待ES7时,使用Bluebird是另一个很好的选择!

ocean
4 声望1 粉丝

引用和评论

0 条评论