wenkai

征服 JavaScript 面试: 什么是 Promise?

wenkai · 2017-06-06翻译 · 546阅读 原文链接

Photo by Kabun (CC BY NC SA 2.0)

“征服 JavaScript 面试”是我写的一系列文章,来帮助面试者准备他们在面试 JavaScript 中、高级职位中将可能会遇到的一些问题。这些问题我自己在面试中也经常会问。

什么是 Promise?

Promise 是一个对象,它在未来的某时会生成一个值:已完成(resolved)的值、或者一个没有完成的理由(例如网络错误)。一个 promise 会有 3 种可能的状态:fulfilled(已完成)、rejected(已拒绝)、pending(等待中)。Promise 的使用者可以附上回调函数来处理已完成的值或者拒绝的原因。

Promise 是热切的,一旦 promise 的构造函数被调用,它就会开始执行你交给它的任务。如果你需要一个懒惰的,请看 observables 或者 tasks

Promise 的不完全历史

早在 1980 年代,早期的 promise 和 future(类似/相关概念) 实现开始出现。单词 “promise” 的使用由 Barbara Liskov 和 Liuba Shrira 在 1988 年创造[1]。

我第一次在 JavaScript 中听说 promise 时,Node 还刚刚出现,社区中正在讨论处理异步行为的最好方式。社区试验了一下 promise,但最终选定 error-first 型的回调作为 Node 标准。

大约在同一时间,Dojo 通过 Deferred API 添加了 promise。随着人们对 promise 的兴趣及其活跃度的快速增长,最终产生了新的 Promises/A 标准,提高了不同的 promise 的互操作性。

JQuery 的异步行为围绕 promise 做了重构。JQuery 的 promise 与 Dojo 的 Deferred 非常相似。由于在那段时间 JQuery 极其流行,它迅速成为了使用最广泛的 promise 实现。然而,人们想要通过两通道(已完成/已拒绝)链接 & 异常管理来在 promise 之上创造工具,它却不支持。

不管这些缺点,jQuery 正式使得 JavaScript promise 成为主流。一些更好的独立 promise 库如 Q、When、Bulebird 变得非常流行。jQuery的一些不一致的实现驱使 promise 标准做了一些重要的阐明,重写并更名为 Promises/A+ 标准

ES6 遵从 Promises/A+ 带来了 Promise 全局变量,和一些构建在新 Promise 标准之上的重要 API:例如 WHATWG Fetchasync 函数(写此文时正处于第三阶段草案)。

本文使用的 promise 遵守 Promises/A+ 标准,着重于 ECMAScript 标准 Promise 实现。

Promises 如何运行

Promise 是一个对象,它可以由一个异步函数同步地返回。它会处于这三种可能的状态之一:

  • Fulfilled(已完成): 会调用onFulfilled()(例如 resolve()

  • Rejected(已拒绝): 会调用onRejected()(例如 reject()

  • Pending(等待中): 还没有完成或者拒绝

如果一个 promise 没有在等待中(处于已解决或者已拒绝),那么它是 settled(已解决) 的。有时候人们用 resolvedsettled 表达同一件事:not pending

一旦被解决,promise 就不会被再次解决。再次调用 resolve()reject() 不会起作用。已解决的 promise 的不变性是一个重要的特性。

原生 JavaScript 的 promise 不会暴露 promise 的状态。相反地,你需要把 promise 当做一个黑盒。只有负责创建 promise 的函数才清楚 promise 的状态,或者说有解决/拒绝的入口。

下面的函数返回一个 promise,它会在一段时间之后解决。

const wait = time => new Promise((resolve) => setTimeout(resolve, time));

wait(3000).then(() => console.log('Hello!')); // 'Hello!'

CodePen 上 wait — promise 的例子

调用 wait(3000) 会等待 3000 毫秒(3 秒),然后打印 'Hello!'。所有兼容标准的 promise 都会定义一个 .then() 方法, 你可以传入一个处理函数拿到已完成或者已拒绝的值。

ES6 promise 的构造函数需要一个函数。这个函数需要两个参数,resolve()reject()。在上面的例子中,我们只用了 resolve(),所以我省去了参数列表中的 reject()。然后调用 setTimeout() 创建延时,在完成后调用 resolve()

你可以视需要地给 resolve()reject() 传值,这个值会被传入 .then() 参数中的回调函数中。

当我给 reject() 传入一个值,我永远会传一个 Error 对象。一般我想要两种解决状态:正常的,或者一个异常 — 阻碍正常状态出现的。传一个 Error 对象使之更清楚。

重要的 Promise 规则

Primise/A+ 社区定义了一个 promise 标准。包括标准 ECMAScript promise 在内的很多标准都遵循它。

Promise 必须满足如下几个规则:

  • Promise 或者 “thenable”需要支持标准的 .then() 方法。

  • 等待中的 promise 可以转化到已完成或已拒绝状态。

  • 处于已完成和已拒绝状态的 promise 是“被解决”的,不会再转化到其它状态。

  • promise 一旦被解决,必须有个值(可以是 undefined),这个值不能改变。

这里“改变”指恒等(===)。当对象作为值时,它的属性是可以修改的。

Promise 必须提供一个 .then() 方法,签名如下:

promise.then(
  onFulfilled?: Function,
  onRejected?: Function
) => Promise

.then() 方法遵循以下规则:

  • onFulfilled()onRejected() 都是可选的。

  • 如果提供的参数不是函数,必须忽略参数。

  • onFulfilled() 会在 promise 完成后调用,把 promise 的值作为第一个参数。

  • onRejected() 会在 promise 拒绝后调用,把拒绝的原因作为第一个参数。这个原因可以是任何可用的 JavaScript 值,不过因为拒绝本质上是异常,我推荐使用 Error 对象。

  • onFulfilled()onRejected() 只会调用一次。

  • .then() 必须返回一个新的 promise,promise2

  • 如果 onFulfilled()onRejected() 返回一个值 x,并且 x 是个 promise,那么 promise2 的状态和值就会与 x 相同。否则,promise2 会成为已完成状态,值是 x

  • 如果 onFulfilledonRejected 抛出一个异常 epromise2 会被拒绝,原因是 e

  • 如果 onFulfilled 不是函数,且 promise1 已完成,那么 promise2 必须是已完成,值和 promise1 的相同。

  • 如果 onRejected 不是函数,且 promise1 已拒绝,那么 promise2 必须是已拒绝,原因和 promise1 的相同。

Promise 的链式调用

因为 .then() 永远返回一个新的 promise,所以在链式调用中,可以精确地控制错误的处理方式和处理位置。Promise 使你可以模仿普通同步代码的 try/catch 行为。

链式调用会形成一个连续的序列,就像同步代码一样。换句话说,你可以这样:

fetch(url)
  .then(process)
  .then(save)
  .catch(handleErrors)
;

假设这些函数fetch(), process(), save()都返回 promise,process() 在开始前会等待 fetch()完成,save()在开始前会等待process()完成,handleErrors()只有在前面的 promise 中任意一个被拒绝时才会执行。

下面是一个有多个拒绝的复杂 promise 链式调用的例子:

Here’s an example of a complex promise chain with multiple rejections:

const wait = time => new Promise(
  res => setTimeout(() => res(), time)
);

wait(200)
  // onFulfilled() can return a new promise, x
  .then(() => new Promise(res => res('foo')))
  // the next promise will assume the state of x
  .then(a => a)
  // Above we returned the unwrapped value of x
  // so .then() above returns a fulfilled promise
  // with that value:
  .then(b => console.log(b)) // 'foo'
  // Note that null is a valid promise value:
  .then(() => null)
  .then(c => console.log(c)) // null
  // The following error is not reported yet:
  .then(() => {throw new Error('foo');})
  // Instead, the returned promise is rejected
  // with the error as the reason:
  .then(
    // Nothing is logged here due to the error above:
    d => console.log(d: ${ d }),
    // Now we handle the error (rejection reason)
    e => console.log(e)) // [Error: foo]
  // With the previous exception handled, we can continue:
  .then(f => console.log(f: ${ f })) // f: undefined
  // The following doesn't log. e was already handled,
  // so this handler doesn't get called:
  .catch(e => console.log(e))
  .then(() => { throw new Error('bar'); })
  // When a promise is rejected, success handlers get skipped.
  // Nothing logs here because of the 'bar' exception:
  .then(g => console.log(g: ${ g }))
  .catch(h => console.log(h)) // [Error: bar]
;

CodePen 上 primise 链式调用的例子

错误处理

请注意 primise 可以同时有成功和异常的处理函数,像这样的代码很常见:

save().then(
  handleSuccess,
  handleError
);

handleSuccess() 抛出异常时会怎样呢?由 .then() 返回的 promise 会被拒绝,但是没有捕获这个异常—意味着你的应用里有一个错误被吞掉了!

因此,有些人把上面的代码当做是反模式的,推荐下面的代码:

save()
  .then(handleSuccess)
  .catch(handleError)
;

两者的差别很微妙,也很重要。第一个例子里,来自 save() 的错误会被捕获,但是 handleSuccess() 里的会被吞掉。

没有.catch()时,success handler 里的错误不会被捕获

第二个例子里,对于 save()handleSuccess() 里的异常,.catch() 都会处理。

.catch() 可以应对两种不同的错误来源(diagram source)

当然,save() 可能是一个网络错误,而 handleSuccess() 的错误可能因为开发者忘记处理某个状态码。如何对它们做不同的处理?你可以选择这样做:

save()
  .then(
    handleSuccess,
    handleNetworkError
  )
  .catch(handleProgrammerError)
;

无论你喜欢哪个,我都推荐使用 .catch() 作为所有 promise 链的结尾。重复一遍:

我推荐使用 .catch() 作为所有 promise 链的结尾。

如何取消一个 promise?

promise 新人经常想知道如何取消 promise。提供一种思路:拒绝这个 pormise,并使用 “Cancelled” 作为原因。如果你想区分“普通”错误,就在错误处理函数中添加新的分支。

下面人们自己实现取消 promise 时一些常见的错误:

把 .cancel() 加到 promise 上

标准的 promise 没有 .cancel(),而且也违反了其他的规则:只有创建 promise 的函数才可以完成、拒绝或取消这个 promise。把 .cancel() 暴露出来破坏了封装性,鼓励代码在不应该知道 promise 的地方操作它。请避免意大利面式的代码和坏掉的 promise。

忘记清理

有些聪明的人想出利用 Promise.race() 来做取消。问题在于取消的控制来自于创建 promise 的函数,这个函数是唯一你能做清理工作的地方,例如清空 timeout 或者清空对数据的引用来释放内存等等。

忘记处理拒绝状态的取消的 promise

你知道吗?当你忘记处理拒绝状态的 promise 时,Chrome 会在控制台到处抛出警告消息!

过度复杂

已撤回的 TC39 提案提出了使用独立的消息通道实现 promise 取消。它还使用了以一种新的概念叫做取消标记。在我看来,这种解决方案会让 promise 特性变得相当臃肿,而它唯一提供的只是拒绝和取消的分离,我认为这是没有必要的。

你想实现异常和取消的转换吗?非常想。那是 promise 的工作吗?我认为不是。

重新考虑 promise 的取消

在 promise 创建的时候传入所有信息,来决定如何完成/拒绝/取消一个 promise。这样的话,promise 上就不需要 .cancel() 方法。你可能会有疑问,在 promise 创建的时候怎么可能知道是否要取消。

在我还不知道要不要取消的时候,在创建 promise 时传入什么呢?

要是有一种对象可以代表未来可能的一个值,那该多好...哦,等等。

我们传入的那个代表是否取消的值可以是一个 promise 本身。可能是这样的:

const wait = (
  time,
  cancel = Promise.reject()
) => new Promise((resolve, reject) => {
  const timer = setTimeout(resolve, time);
  const noop = () => {};

  cancel.then(() => {
    clearTimeout(timer);
    reject(new Error('Cancelled'));
  }, noop);
});

const shouldCancel = Promise.resolve(); // Yes, cancel
// const shouldCancel = Promise.reject(); // No cancel

wait(2000, shouldCancel).then(
  () => console.log('Hello!'),
  (e) => console.log(e) // [Error: Cancelled]
);

我们用一个默认参数赋值来告诉它默认不取消。cancel 参数是可选的。然后和之前一样设定了延时,这次我们保存了延时的 ID,这样就可以在之后清除它。

我们用 cancel.then() 来处理取消和资源清理。这只会在 promise 被解决之前取消了才会执行。如果你取消太晚了,就会错过取消的机会。那列火车已经离开了车站。

注:你可能有疑问:noop()函数是做什么的?noop 代表 no-op,意味着一个什么都不做的函数。没有它,V8 就会抛出一个警告:UnhandledPromiseRejectionWarning: Unhandled promise rejection永远处理 promise 的拒绝是个好主意,即使你的处理函数是个 noop()

抽象 Promise 的取消

对于一个定时器来说 wait() 已经很好了,但我们还可以把这个想法进一步抽象:

  1. 默认拒绝 promise - 如果没有取消的 promise 传入,就不取消,并且不报错。

  2. 记得在取消后执行清理工作

  3. 记得 onCancel 的清理工作本身也可能抛出错误,那个错误也要处理。(上边的例子中遗漏了 - 很容易忘!)

让我们创造一个可取消 promise 的工具,用来包裹任何 promise。例如,处理网络请求等。函数签名是这样的:

speculation(fn: SpecFunction, shouldCancel: Promise) => Promise

SpecFunction 就像你传给 Promise 构造器的函数,不同的是,它还有一个 onCancel() 处理函数:

SpecFunction(resolve: Function, reject: Function, onCancel: Function) => Void
// HOF Wraps the native Promise API
// to add take a shouldCancel promise and add
// an onCancel() callback.
const speculation = (
  fn,
  cancel = Promise.reject() // Don't cancel by default
) => new Promise((resolve, reject) => {
  const noop = () => {};

  const onCancel = (
    handleCancel
  ) => cancel.then(
      handleCancel,
      // Ignore expected cancel rejections:
      noop
    )
    // handle onCancel errors
    .catch(e => reject(e))
  ;

  fn(resolve, reject, onCancel);
});

注意这个例子只是像你解释它如何工作。还有一些其它的边缘情况你需要考虑。例如,这里,如果你取消了一个已解决的 promise ,handleCancel 依然会被调用。

我实现了一个产品版本,作为开源库,它覆盖了一些边缘情况,Speculation

让我们用增强的抽象库来重写前面的 wait() 工具函数。首先安装 speculation:

npm install --save speculation

现在你可以导入使用了:

import speculation from 'speculation';

const wait = (
  time,
  cancel = Promise.reject() // By default, don't cancel
) => speculation((resolve, reject, onCancel) => {
  const timer = setTimeout(resolve, time);

  // Use onCancel to clean up any lingering resources
  // and then call reject(). You can pass a custom reason.
  onCancel(() => {
    clearTimeout(timer);
    reject(new Error('Cancelled'));
  });
}, cancel); // remember to pass in cancel!

wait(200, wait(500)).then(
  () => console.log('Hello!'),
  (e) => console.log(e)
); // 'Hello!'

wait(200, wait(50)).then(
  () => console.log('Hello!'),
  (e) => console.log(e)
); // [Error: Cancelled]

这使事情变得简单了,因为你不用担心 noop() 的问题,不用捕获 onCancel() 里的错误,也不用考虑其它的边缘情况。尽管在真实项目中使用吧。

原生 JS Promise 的的一些附加信息

原生 Promise 对象有一些额外的东西,你可能会感兴趣:

  • Promise.reject() 返回一个已拒绝的 promise

  • Promise.resolve() 返回一个已完成的 promise

  • Promise.race() 接收一个数组(或任何迭代器),返回一个完成或拒绝的 promise,值是迭代器中第一个完成的 promise 的值,或者第一个拒绝的 promise 的原因。

  • Promise.all() 接收一个数组(或任何迭代器),返回一个完成或拒绝的 promise,当迭代器中的全部 promise 完成时,这个 promise 会完成,当任意一个 promise 拒绝时,返回一个拒绝的 promise,值是拒绝的原因。

结论

Promise 已经成为几个 JavaScript 语法的重要部分,如 WHATWG Fetch,用于更现代的 ajax 请求、Async 函数,用于使异步代码看起来是同步的。

Async 函数在写这篇文章时还处于第三阶段,但我预测它会很快成为 JS 异步变成中一种非常流行,普遍使用的解决方案,意味着对于 JavaScript 开发者,学习 promise 会在不远的将来更加重要。

例如,如果你在使用 Redux,我建议你看看redux-saga:一个用来管理 Redux 中副作用的库,在它的文档中到处是异步函数。

我甚至希望那些有经验的 promise 使用者,对于 promise 是什么,如何工作,怎么更好的使用,在读了这篇文章后能有更好的理解。

译者wenkai尚未开通打赏功能

相关文章