[译] 玩转 JavaScript 面试:何为 Promise ?

782 阅读12分钟

原文链接 Medium - Master the JavaScript Interview: What is a Promise?

开门见山,何为 Promise ?

一个promise指的是一个可能会在未来的某个时间点产生一个单一值的对象:不论是一个 resolved 值,还是一个未 resolved 值的原因(比如发生了网络错误)。一个promise可能为fulfilledrejectedpending三种状态中的一种。promise用户可以使用回调函数来处理fulfilledrejected状态。

Promise可以说是相当热心了,其构造器一旦被调用,promise就会立即开始做你给它的任何任务。

一个不太完整的 promise 发展史

早期对promisefutures(两个概念类似/相关)的实现始于如 MultiLispConcurrent Prolog语言于 20 世纪 80年代早期的出现。promise一词的使用是由 Barbara Liskov 和 Liuba Shrira 在 1988 年创造出来的1

我第一次在 JavaScript 中知道promise这个概念时,Node才刚刚出现,当时的社区也在积极的讨论实现异步行为的最佳方式。在一段时间里,社区使用过promises这个概念,但最终落实在了标准Node错误处理回调上。

几乎在同一时期,Dojo框架中通过Deferred API添加了promises。随着公众对此持续不断兴趣和活跃度的高涨,最终形成了一个新的promise/A规范使得多种promises的实现得以统一。

jQuery 的异步行为围绕 promises 被重构。jQuery 对 promise 的支持与 Dojo 的 Deferred极其类似,也很快因其大规模受众而成为 JavaScript 中最受欢迎的 promise 实现方式。然而,jQuery 不支持fulfilled/rejected两个通道的链式调用行为和异常处理,而这些特性也是用户赖以使用 promise 构建应用的基础。

尽管 jQuery 存在上述缺点,其依然成为当时 JavaScript promises 的主流实现,受喜爱程度遥遥领先于像是QWhen或者Bluebird这样的 promise 库。jQuery 实现的不兼容性催生了一些对 promise 的补充说明,从而形成了Promises/A+规范。

ES6 中的 Promise 带来了对 Promises/A+ 规范的完全兼容,另外还有一些非常重要的 API 也基于新的标准 Promise 而得到支持:最常见的有WHATWG Fetch规范和异步函数标准。

这里说明了 promises 是符合 Promises/A+ 规范的,也是 ECMAScript 标准Promise的实现。

Promise 是如何工作的

promise 是一个可以从一个异步函数中返回的异步对象,它可以处于以下三种状态中的一种:

  • Fulfilled:onFulfilled()会被调用(比如resolve()被调用)
  • Rejected:onRejected()会被调用(比如reject()被调用)
  • Pending: 暂时还未fulfilledrejected

promise 的状态只要不是 pending 即代表其已确定状态(resolved 或 rejected),有时人们会用 resolved 和 settled 来表示同一个意思:非 pending状态。

状态一旦确定,promise 的状态就不能再被改变,调用 resolve()reject()也不会产生任何影响。一个已确定状态的 promise 的不可变性是其一大重要特性。

原生 JS promise 不对外暴露状态。实际上你可能更希望把它当做一个黑盒机制来看待。只有当某函数的作用是创建一个 promise 时、或者是去访问 resolve 或 reject 时我们才需要深入 promise 的状态。

下面的函数会在指定时间后 resolve,然后返回一个 promise:

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

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

这里调用wait(3000)会在等待 3000ms 后打印出 Hello!。所有符合标准的 promises 都会定义一个.then()方法,可向该方法中传递一个句柄,从而拿到 resolve 或 reject 的值。

ES6 的 promise 构造函数接收一个函数作为参数。该函数接收两个参数分别为resolve()reject()。在上面的例子中,我们只用到了resolve(),然后调用了setTimeout()创建一个延迟函数,最后在延迟函数执行完后调用resolve()

你可以选择只传给resolve()reject()一个值,值会被传递到.then()中的回调函数中。

每当我向reject()中传一个值时,我都会传一个 Error 对象进去。一般来说我期望两种解决状态:正常的圆满结局,或者是抛出一个异常。传一个 Error 对象进去会使得结果更明朗。

几个重要的 Promise 规则

promises 的标准已经由Promises/A+ 规范社区定义好了。现存的很多种实现都遵守该规范,这其中就包括 JavaScript 标准 ECMAScript promises。

遵循上述规范的 promises 必须包含以下几点规则:

  • 一个 promise/thenable 对象必须提供一个标准的兼容的 .then()方法;
  • 处于 pending 状态的 promise 可以改为 fulfilled 或 rejected 两种状态;
  • 一个处于 fulfilled 或 rejected 中的 promise 状态一旦确定,就再也不能改变为其他状态;
  • 一旦 promise 状态确定下来,它必须有一个值(即使是 undefined)。该值不可被改变。

每个 promise 必须提供一个具备如下特性的.then()方法:

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

.then()方法必须符合下面的规则:

  • onFulfilled()onRejected()皆为可选参数;
  • 如果所提供的参数不是函数,参数将被忽略;
  • onFulfilled()会在 promise 的状态变为 fulfilled 时调用,promise 返回的值会被作为第一个参数;
  • onRejected()会在 promise 的状态变为 rejected 时调用,被拒绝的原因会被作为第一个参数。原因可能会是任何有效的 JavaScript 值,但是由于被拒绝基本上等同于抛出异常,所以我建议使用 Error 对象;
  • onFulfilled()onRejected()都不会被多次调用;
  • .then()可能会在同一个 promise 上被调用多次。换句话说,promise 可被用来合并回调函数;
  • .then()必须返回一个新的 promise,可称之为promise2
  • 如果onFulfilled()onRejected()返回一个值为xx是一个 promise,promise2将用x锁定。否则,promise2会被值x fulfilled。
  • 如果onFulfilled()onRejected()抛出一个异常epromise2必须以e作为原因被 rejected;
  • 如果onFulfilled()不是函数,promise1被 fulfilled,那么promise2必须以相同的值被 fulfilled;
  • 如果onRejected()不是函数,promise1被 rejected,那么promise2必须以相同的原因被 rejected;

Promise 链式调用

由于.then()总是返回一个新的 promise,这样就可以实现对链式 promise 中的错误进行精确控制。Promises 允许我们模仿正常的同步代码行为(如 try...catch)。

就像同步代码一样,链式调用可以产生顺序执行的效果。比如下面这样:

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

假设上面的fetch()process()save()都返回 promises,process()会等待fetch()执行完毕后再开始执行,同理save()也要等待process()执行完毕才开始执行,handleErrors()当且仅当前面的任何一个 promises 运行出错才会执行。

下面给出一个复杂的例子:

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

wait(200)
  // onFulfilled() 可以返回一个新的 promise, `x`
  .then(() => new Promise(res => res('foo')))
  // 下一个 promise 会假设 `x`的状态
  .then(a => a)
  // 上面我们返回了未被包裹的`x`的值
  // 因此上面的`.then()`返回了一个 fulfilled promise
  // 有了上面的值之后:
  .then(b => console.log(b)) // 'foo'
  // 需要注意的是 `null` 是一个有效的 promise 返回值:
  .then(() => null)
  .then(c => console.log(c)) // null
  // 至此还未报错:
  .then(() => {throw new Error('foo');})
  // 相反, 返回的 promise 是 rejected
  // error 的原因如下:
  .then(
    // 由于上面的 error导致在这里啥都没打印:
    d => console.log(`d: ${ d }`),
    // 现在我们处理这个 error (rejection 的原因)
    e => console.log(e)) // [Error: foo]
  // 有了之前的异常处理, 我们可以继续:
  .then(f => console.log(`f: ${ f }`)) // f: undefined
  // 下面的代码未打印任何东西. e 已经被处理过了,
  // 所以该句柄并未被调用:
  .catch(e => console.log(e))
  .then(() => { throw new Error('bar'); })
  // 当一个 promise 被 rejected, success 句柄就被跳过.
  // 这里因为 'bar' 异常而不打印任何东西:
  .then(g => console.log(`g: ${ g }`))
  .catch(h => console.log(h)) // [Error: bar]
;

错误处理

需要注意的是 promise 同时具有成功和失败的句柄,所以下面代码的写法很常见:

save().then(
    handleSuccess,
    handleError
)

但是如果 handleSuccess()出错了怎么办?从.then()中返回的 promise 就会被 rejected,但是后续就没有能捕获该错误信息的函数了 —— 意思就是你 app 中的一个错误被吞掉了,这可有点儿糟糕。

针对上述原因,有人就将上面的代码称为一种反模式(anti-pattern),并建议使用如下写法替代:

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

其中的差异很微妙,但却很重要。在头一个例子中,来自save()中的错误会被捕获,但是来自handleSuccess()中的错误就会被吞掉。

在第二个例子中,.catch()会处理来自不论是save()还是handleSuccess()中的错误。
当然了,来自于save()的错误还有可能是网络错误,而handleSuccess()中的错误可能来自于开发者忘记处理一个错误的状态码,要是你想对这两种错误进行不同的处理该怎么办?那就可以选择下面这种处理方式了:

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

无论你倾向于哪种方式,我都推荐你在所有的 promises 后面带上 .catch()

要如何取消/中断一个 Promise

刚学会使用 promise 的用户总是有很多疑问,其中最多的就是关于如何取消/中断一个 promise。思路是这样的:直接去 reject 想要取消/中断的 promise,原因就写「Cancelled」即可。但如果你要将它与常规错误处理方式区分开来的话,那就去开发自己的错误处理分支。

下面列出了几种人们在写取消/中断 promise 时常犯的错误:

给 promise 添加了一个.cancel()

添加.cancel()使得 promise 非标准化了,同时也违背了 promise 的另一个规定:只有创建了 promise 的函数才有能力去 resolve、reject 或 取消/中断该 promise。传播这种写法只会破坏函数的封装特性,怂恿人们在不恰当的地方操作 promise 代码,破坏了 promise。

忘记清理

有些聪明的人搞清楚了使用promise.race()的方式来取消/中断 promise。这种方式的问题在于中断控制的操作是由创建该 promise 的函数发起的,这也是唯一一处恰当的进行清理动作的位置,比如说清理定时器或者通过解除对数据的引用来释放内存等等。

忘记处理一个被 reject 的中断 promise

你知道当你忘记处理一个 promise 的拒绝状态时 Chrome 抛出的满控制台的警告信息吗?

重新思考 promise 取消/中断

一般来说,我会在一个 promise 创建时就把 promise 所有需要的信息都传给它,以便 promise 决定如何进行 resolve/reject/cancel。这种方式并不需要一个 .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()来处理取消/中断和资源的清理。它的运行条件是在 resolve 之前让 promise 取消。如果你取消的过晚,你就错过了取消的时机。

你可能比较好奇noop函数的作用是啥,noop 一词表示空操作,意指啥都不做。要是不指定这个函数,V8 引擎会抛出警告:UnhandledPromiseRejectionWarning: Unhandled promise rejection,所以总是记得去处理 promise 的 rejection 是个好习惯,即使你的句柄为 noop。

抽象的 promise 取消/中断

上面的wait()计时器当然是极好的,但我们要继续将上面这种思路做进一步的抽象,来封装所有你需要知道的东西:

  1. 默认 reject 需要中断的 promise
  2. 记得要清理被 reject 过的 promise
  3. 保持警惕,onCancel的清理操作本身也有可能抛异常,该异常也需要处理。

让我们来创建一个可中断的 promise 工具函数吧,这样你就可以用来包裹任何 promise 了。形式如下:

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

SpecFunction就像你传入 Promise 构造器中的函数一样,唯一的不同在于它有一个onCancel句柄:

SpecFunction(resolve: Function, reject: Function, onCancel: Function) => Void
const speculation = (
  fn,
  cancel = Promise.reject() 
) => new Promise((resolve, reject) => {
  const noop = () => {};

  const onCancel = (
    handleCancel
  ) => cancel.then(
      handleCancel,
      noop
    )
    .catch(e => reject(e))
  ;

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

上例只是其作用要旨,其实还有需要边界情况需要你去考虑。我自己写了一个完整的版本供大家参考,speculation

结语

文章太长,翻译到后半段着实翻译不下去了,主要还是自身对 promise 的理解还不够深,后面就看不懂了,但还是觉得要有始有终,把这件事做完,后面懂了再回头完善,never giveup!

参考文献

[1] Barbara Liskov; Liuba Shrira (1988). “Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems”. Proceedings of the SIGPLAN ’88 Conference on Programming Language Design and Implementation; Atlanta, Georgia, United States, pp. 260–267. ISBN 0–89791–269–1, published by ACM. Also published in ACM SIGPLAN Notices, Volume 23, Issue 7, July 1988.