likes
comments
collection
share

我们也许并不了解Promise

作者站长头像
站长
· 阅读数 1

译者: 辣椒炒肉

原文地址:pouchdb.com/...

JavaScript 开发者们,承认一个事实吧:我们也许并不了解Promise。

众所周知,A+规范所定义的Promise,非常棒。

有个很大的问题是,在我过去使用Promise的一年中,看到很多开发者们,在用 PouchDB API或者是其他的Promise API,但是却不理解其中的原理。

不相信么?那请看我最近发布的这个推特。(可惜链接失效了,辣椒本人也没看到)

问题:这四个Promise有什么区别?

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

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

doSomething().then(doSomethingElse());

doSomething().then(doSomethingElse);

如果你知道答案,那么恭喜你!你是一个Promise大神!下面的内容可以不看了。

至于其他99.99%的人,也恭喜你们了,你们上对车了。我在推特上发布的那个问题,还没有人给我一个完美的回答。我对自己的#3回答也感到不可思议,即使我写了测试。

答案在这篇文章的最后。但首先,我想探讨一下,为什么Promise如此棘手?为什么这么多人都为它所困惑?我也将提供一些解释,相信会让Promise不那么难理解。

首先我们尝试一些假设。

所以Promise是什么?

如果你读过一些Promise的文章,你肯定会找到很多回调地狱的引用,它们稳定地延伸到屏幕的右边,这很糟糕!

Promise 确实可以解决这个问题,但它不仅仅是起到缩进的作用。正如它被盛誉的那样:回调地狱的救赎。回调函数带来的问题就是,剥夺了我们对return和throw的掌控,而且还有一个副作用,一个函数意外地调用了另外一个函数。

事实上,回调函数还做了更加让人讨厌的事情:它丢失了原来的栈,这是我们 在编程语言中通常认为理所当然的事情。编写代码丢失了对栈的掌控,就像驾驶一辆没有刹车的汽车那样,你不知道它会驶向哪里。

Promise的重点是,把异步所丢失的return, throw, 和栈还给我们。但是你必须知道如何正确使用promises才能利用它们。

新手的错误

有的人试图将Promise解释为卡通,或者这样形容:“哦,这个返回值就是异步回来的结果”

我觉得这种解释不是很有帮助。对我来说,Promises都是关于代码结构和流程的。所以我认为,最好回顾一些常见的错误并且想想怎么修复它们。我称之为“菜鸟错误”的意思是:“你现在是一个菜鸟,但你很快会成为一个职业选手”

Promise对很多人来说意味着不同的东西,但就本文而言,我只谈论官方规范,在现代浏览器中暴露为window.Promise

新手错误1: 厄运金字塔

基于Promise的PouchDB,看看下面一个糟糕的例子:

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) {
// ...

如果你认为这样的写法只限于初学者,那你错了。我在官方的BlackBerry开发者博客中发现了这样的代码!旧的回调习惯很难消亡。(致上面代码的作者:抱歉,但您的代码很有借鉴意义)

更好的例子是这样:

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);
});

这是Promise链式写法,只有前一个promise执行完后面一个才会执行,并将前一个的返回值作为参数。稍后会详细介绍。

新手错误2: 怎么把forEach和Promise一起使用?

这是大多数人对Promise的理解开始崩溃的地方。一旦用到他们熟悉的forEach和while循环时,他们就不知道怎么和Promise一起使用。所以他们这样写:

// 我想删除全部的doc
db.allDocs({include_docs: true}).then(function (result) {
  result.rows.forEach(function (row) {
    db.remove(row.doc);  
  });
}).then(function () {
  // 我天真地以为我删除了全部的doc
});

这段代码有什么问题?其实第一个函数返回undefined。这意味着第二个函数不会等待全部执行完db.remove(), 事实上它啥也不用等待。

这是一个很隐蔽的错误,因为PouchDB如果足够快删除这些文档并更新UI, 你可能不会注意到任何错误。这个错误可能会在奇怪的条件下或者某些浏览器中暴露。这时候来调试几乎是不可能的。

所有这些for/forEach/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) {
  // 现在这些doc真的全部被删除了!
});

这里发生了什么?Promise.all接受一个promise数组作为参数,然后当每个promsie都resolve了,返回一个新的promise,包括了每个promise的resolve结果。它是for循环的异步等价物。

Promise.all()还将一个结果数组传递给下一个函数,这可能非常有用。例如,当你试图从PouchDB中获取多个东西, 如果任何一个子promise被rejected,那么all()的promise也会被拒绝,这更有用。

新手错误3: 忘记catch()

这也是一个常见的错误。自信地认为他们的代码不会发生异常。不幸的是,这意味着任何的错误都会被吞下,你甚至都不会在控制台中看到它们,这才是最痛苦的。

为了避免这种情况,我已经习惯在promise链中添加这样的代码:

somePromise().then(function () {
  return anotherPromise();
}).then(function () {
  return yetAnotherPromise();
}).catch(console.log.bind(console)); // <-- this is badass

即使你非常确定不会发生任何错误,最好还是添加一个catch(),让生活更美好。

新手错误4: 使用“deferred”

【辣椒没看懂这段。大概是,用Promise封装异步的操作吧(这不是很常规的操作么)】

 new Promise(function (resolve, reject) {
  fs.readFile('myfile.txt', function (err, file) {
    if (err) {
      return reject(err);
    }
    resolve(file);
  });
}).then(/** */);

【辣椒不喜欢上面这样写。我自己会封装起来这段,return这个promise在别的地方await 这个promise获取返回。我知道我在说es7的async/await, 我就是看不惯这种写法。】

新手错误5:“using side effects instead of returning”

下面这段代码有什么问题?

somePromise().then(function () {
  someOtherPromise();
}).then(function () {
  // 哎呀,我希望someOtherPromise()已经resolved了!
  // 剧透:并没有
});

正如我之前所说,Promise的魔力在于它们将我们的return和throw带回来。 但这在实践中实际上是什么样的?

每个promise都会给你一个then()方法(或者catch(),是语法糖,可以在then的第二个参数处理错误then(null,...))。 这里我们在then()函数内:

somePromise().then(function () {
  // 我在then里面
});

我们在这儿可以做三件事:

  1. 返回另一个promise

  2. 返回一个同步值(或者是undefined)

  3. 抛出一个同步异常

一旦你理解了这个技巧,你就理解了Promise。 下面我们具体说说这三点:

1. 返回另一个promise

getUserByName('nolan').then(function (user) {
  return getUserAccountById(user.id);
}).then(function (userAccount) {
  // 我拿到了一个用户账号!
});

请注意,我正在返回第二个promise。 return至关重要!! 如果我没有写return,那么getUserAccountById()实际上是effect,而下一个函数将接收undefined而不是userAccount。

2.返回一个同步值(或者是undefined)

getUserByName('nolan').then(function (user) {
  if (inMemoryCache[user.id]) {
    return inMemoryCache[user.id];    // 返回一个同步值!
  }
  return getUserAccountById(user.id); // 返回一个promise!
}).then(function (userAccount) {
  // 我拿到了一个userAccount!
});

是不是很棒!第二个函数不关心userAccount是同步还是异步获取的。第一个函数可以自由返回同步或异步值。

不幸的是,有一个事实是,JavaScript中的非返回函数在技术上返回undefined,这意味着当你想要返回一些内容时,很容易意外地引入effect。

出于这个原因,我总是习惯在then()函数内return或throw,建议你也这样做。

3.抛出一个同步异常

getUserByName('nolan').then(function (user) {
  if (user.isLoggedOut()) {
    throw new Error('user logged out!'); // 抛出一个同步异常!
  }
  if (inMemoryCache[user.id]) {
    return inMemoryCache[user.id];       // 返回一个同步值!
  }
  return getUserAccountById(user.id);    // 返回一个promise!
}).then(function (userAccount) {
  // 我拿到了userAccount!
}).catch(function (err) {
  // 砰! 我拿到一个异常!
});

如果用户注销,我们的catch()将收到同步错误,如果任何promise被拒绝,它将收到异步错误。 同样,该函数不关心它获得的错误是同步还是异步。

这特别有用,因为它可以帮助识别开发过程中的编码错误。 例如,如果在then()函数内部的任何一点,我们执行JSON.parse(),如果JSON无效,它可能会抛出同步错误。 有了回调,这个错误就会被吞噬,但是使用promise,我们可以在catch()函数中简单地处理它。

高级一点的错误

好的,现在你已经学会了一些基本的promise技巧,那我们就聊聊边缘情况吧。

我将这些错误归类为“高级”,因为我只看到了那些已经相当擅长Promise的程序员犯的错误。 但是,如果我们希望能够解决我在本文开头提出的问题,我们还需要继续讨论一下。

高级错误1:不知道Promise.resolve()

上面我已经讲过,promises对于将异步代码包装为同步代码非常有用。 但是,如果你发现自己会这样写:

new Promise(function (resolve, reject) {
  resolve(/** 同步值*/);
}).then(/* ... */);

你可以使用Promise.resolve()更简洁地这样写:

Promise.resolve(/** 同步值*/).then(/* ... */);

这对于捕获任何同步错误也非常有用。 它非常有用,我养成了几乎所有promise-api都写return的习惯:

function somePromiseAPI() {
  return Promise.resolve().then(function () {
    doSomethingThatMayThrow();
    return 'foo';
  }).then(/* ... */);
}

请记住,任何可能throw同步错误的代码,都可能会发生“难以调试”的吞噬错误。如果你将所有的代码都包装在Promise.resolve()中,那么就总是可以确保稍后捕获到。

类似地,有一个Promise.reject()可用于返回立即拒绝的promise:

Promise.reject(new Error('some awful error'));

高级错误2:then(resolveHandler).catch(rejectHandler) 和 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) {
  // 我捕获了一个异常
});

somePromise().then(function () {
  throw new Error('oh noes');
}, function (err) {
  // 我没有捕获到异常
});

事实证明,当您使用then(resolveHandler,rejectHandler)格式时,如果由resolveHandler本身抛出,则rejectHandler实际上不会捕获错误。【辣椒个人os:这好理解,resolveHandler和rejectHandler是同一级的,捕获不到应该是合理的,rejectHandler只能捕获somePromise发生的异常。所以,你可以用catch啊!】

出于这个原因,我已经习惯于永远不要使用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项目有一些示例测试可以帮助你入门。

高级错误3:Promise与Promise工厂

假设你想按顺序依次执行一系列的promises。 也就是说,你想要像Promise.all()这样的东西,但它不会并行执行promises。

你可能天真地写这样的东西:

function executeSequentially(promises) {
  var result = Promise.resolve();
  promises.forEach(function (promise) {
    result = result.then(promise);
  });
  return result;
}

不幸的是,这不会按照你的想法执行。 传递给executeSequentially()的promise仍然会并行执行。

发生这种情况的原因是你根本不想操作数组里的promise。 根据promise规范,一旦创建了promise,它就会开始执行。 所以你需要的是数组promise工厂:

function executeSequentially(promiseFactories) {
  var result = Promise.resolve();
  promiseFactories.forEach(function (promiseFactory) {
    result = result.then(promiseFactory);
  });
  return result;
}

我知道你在想什么:“这个Java程序员到底是谁,为什么他在谈论工厂呢?” promise工厂很简单,它只是一个返回promise的函数:

function myPromiseFactory() {
  return somethingThatCreatesAPromise();
}

为什么这样写就有用?它起作用是因为promise工厂在被调用之前不会创建promise。 它的工作方式与当时的功能相同,实际上,它是一个东西!

如果你看一下上面的executeSequentially()函数,然后想象myPromiseFactory在result.then(...)中被替换,那么希望你会灵光一闪,得到promise启蒙。

高级错误4:好的,如果我想要两个promise的结果怎么办?

通常,一个promise将取决于另一个promise,但我们希望得到两个promise的输出。 例如:

getUserByName('nolan').then(function (user) {
  return getUserAccountById(user.id);
}).then(function (userAccount) {
  // 我也需要“user”对象!
});

想要成为优秀的JavaScript开发人员并避免厄运的金字塔,我们可能只是将用户对象存储在更高范围的变量中:

var user;
getUserByName('nolan').then(function (result) {
  user = result;
  return getUserAccountById(user.id);
}).then(function (userAccount) {
  // 好的,我拿到了user和userAccount
});

这是可以的,但我个人觉得它有点笨拙。 我推荐的策略:放下你的先入之见,使用金字塔写法:

getUserByName('nolan').then(function (user) {
  return getUserAccountById(user.id).then(function (userAccount) {
    // 好的,我拿到了user和userAccount
  });
});

或者你这样写:

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 () {
  // 在这一点上,doSomething()完成了,我们又回到了缩进0
});

随着你的promise代码变得越来越复杂,可能会发现自己将越来越多的函数提取到命名函数中。 我发现这样的代码非常美观,像这样:

putYourRightFootIn()
  .then(putYourRightFootOut)
  .then(putYourRightFootIn)  
  .then(shakeItAllAbout);

这就是promise的全部。

高级错误5:promise失败

最后,当我介绍上面的promise难题时,这就是我提到的错误。 这是一个非常深奥的用例,你可能永远不会遇到,但它确实让我感到惊讶。

你认为此代码打印出来的是什么?

Promise.resolve('foo').then(Promise.resolve('bar')).then(function (result) {
  console.log(result);
});

如果你认为它打印出来bar,你就错了。 它实际上打印出foo!

发生这种情况的原因是因为当你传递then()一个非函数(例如一个promise)时,它实际上将它解释为then(null),这会导致前一个promise的结果失败。 你可以自己测试一下:

Promise.resolve('foo').then(null).then(function (result) {
  console.log(result);
});

它仍会打印foo。

这实际上回到了我之前关于promise与promise工厂的观点。 简而言之,你可以将promise直接传递给then()方法,但它不会按照您的想法执行。 then()应该接受一个函数,所以很可能你打算这样做:

Promise.resolve('foo').then(function () {
  return Promise.resolve('bar');
}).then(function (result) {
  console.log(result);
});

这会如我们所期望的那样,打印bar。

所以只需提醒自己:将函数传递给then()!

解决难题

现在我们已经学会了所有关于promise的知识,我们应该能够解决我在本文开头提出的难题。【就是推特那个】

难题1

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

答案:

doSomething
|-----------------|
                  doSomethingElse(undefined)
                  |------------------|
                                     finalHandler(resultOfDoSomethingElse)
                                     |------------------|

难题2

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

答案:

doSomething
|-----------------|
                  doSomethingElse(undefined)
                  |------------------|
                  finalHandler(undefined)
                  |------------------|

难题3

doSomething().then(doSomethingElse())
  .then(finalHandler);

答案:

doSomething
|-----------------|
doSomethingElse(undefined)
|---------------------------------|
                  finalHandler(resultOfDoSomething)
                  |------------------|

难题4

doSomething().then(doSomethingElse)
  .then(finalHandler);

答案

doSomething
|-----------------|
                  doSomethingElse(resultOfDoSomething)
                  |------------------|
                                     finalHandler(resultOfDoSomethingElse)
                                     |------------------|

如果这些答案仍然没有起到作用,那么我建议你重新阅读帖子,或者定义doSomething()和doSomethingElse()方法,并在浏览器中自行尝试。

【文章年代久远,那时候es7还没出,但是依然有些参考意义。现在异步编程的解决办法,大多是Promise+async/await, 即:用Promise封装异步api(比如fs.readFile),在外部的async方法中await刚才封装好的方法,爽爽的!这将重新审视这篇文章的作者提出的一些异步写法】