【译】精通 JavaScript: 什么是 Promise?

原文:Master the JavaScript Interview: What is a Promise?

Photo by Kabun (CC BY NC SA 2.0)

“精通 JavaScript 面试” 是一个系列的文章,旨在帮助面试者准备他们在申请中高级职位时可能遇到的常见问题。这些是我在现实面试中经常提出的问题。

什么是 Promise?

Promise 是一个对象,在未来的某个时刻产生一个值:已完成 (resolved) 的值或者是未完成的原因(e.g. 网络错误)。Promise 可能是下面三种状态中的一种:fulfilled,rejected,pending 。Promise 提供回调来处理 fulfilled 返回的值或者 rejection 的原因。

Promise 会立即执行,这意味着一旦 promise 构造函数被调用,promise 就会开始执行你给它的任何任务。如果你需要 promise 懒加载,参考 observables 或者 tasks

Promise 的不完全历史

早在1980年代,promise 和 futures (类似/相关的想法) 的早期实现开始出现在 MultiLisp 和 Concurrent Prolog 等语言中。“promise” 这个概念是由 Barbara Liskov 和 Liuba Shrira 在1998年提出。

我第一次听说 JavaScript 中的 promise,Node 才刚刚出现,Node 社区正在讨论异步行为的最佳方案。社区实验了一段时间 promise,但最终选择了 error-first 回调作为 Node 标准。

大概在同一时间,Dojo 通过 Deferred API 添加了 promises 。不断增长的兴趣和活跃度最终导致了新的 Promises/A 规范的形成,提高了不同 promises 之间的互操作性。

jQuery 的异步操作是围绕 promises 的重构。jQuery 的 promise 和 Dojo 的 Deferred 非常相似,并且由于 jQuery 的流行,它一度在 JavaScript 中被广泛使用。但是 it did not support the two channel (fulfilled/rejected) chaining behavior & exception management 人们期望在 promise 上的构建工具。

尽管有这些缺点,jQuery 让 JavaScript promise 成为主流,并且一下更好的独立的 promise 库,例如 Q , When , 和 Bluebird 变得非常流行。jQuery 的实现的不兼容性促使 promise 规范中的一些重要说明,它被重写并重命名为 Promises/A+ specification

ES6 使用了遵循 Promises/A+ 兼容的 Promise 作为全局变量,并且一些非常重要的 API 构建在新的 Promise 标准之上:特别是 WHATWG Fetch 规范和 Async Functions 标准(第3阶段草案)

本文描述的是与 Promises/A+ 规范兼容的 promise,着重于基于 ECMAScript 标准的 Promise 实现。

Promises 是如何工作的

promise 是一个从异步函数同步返回的对象。它有3种可能的状态:

  • Fulfilled: 将会调用 onFulfilled() (e.g., 调用 resolve())

  • Rejected: 将会调用 onRejected() (e.g., 调用 reject())

  • Pending: 非 fulfilled 或者 rejected

如果 promise 不在 pending 状态,它就固定了(处于 fulfilled 或 rejected)。有时我们用 resolvedsettled 表示同一件事:not pending

promise 一旦被固定,就不能重新固定。调用 resolve()reject() 将没有效果。被固定的 promise 的不可变性是一个重要的特性。

原生 JavaScript promise 不暴露 promise 状态。相反,你应该把 promise 视为一个黑盒。只有创建这个 promise 的函数才知道 promise 的状态,或者说有 resolvereject 的权限。

这是一个返回一个 promise 的函数,并在制定的时间延迟后 resolve

调用 wait(3000) 会等待3000毫秒 (3秒) ,然后调用打印 'Hello!' 。所以与规范兼容的 promise 都定义了 .then() 方法,你可以传入一个函数来处理 resolvereject 返回的值。

ES6 promise 的构造函数需要一个函数。这个函数需要两个参数,resovle()reject() 。在上面的例子中,我们只使用了 resolve() ,将 reject() 从参数列表中删除了。我们调用 setTimeout() 来创建延迟,并在完成后调用 resolve()

你可以按需向 resolve()reject() 中传值,这个值会作为参数传入 .then() 的回调函数。

当我调用 reject() 并且传入一个值,我总是传入一个 Error 对象。一般来说,我想要两种可能的解决状态:正常的或异常的 (阻碍正常状态) 。传入一个 Error 对象来表示。

Promise 的重要规则

标准的 Promise 是由 Promises/A+ specification 社区定义的。有很多符合标准的实现,包括 JavaScript ECMAScript promise。

Promise 必须遵循下面的规则:

  • Promise 或 “thenable” 需要提供标准的 .then() 方法。

  • pending 状态的 promise 可以转换为 fulfilled 或 rejected 状态。

  • fulfilled 或 rejected 状态的 promise 是固定的,不能转换为其他状态。

  • 一旦 promise 固定,一定有一个值 (可以是 undefined) 。这个值不可变。

在这个上下午的变更指的是恒等 (===) 。fulfilled 的值可以是一个对象,这个对象的属性是可变的。

所以的 promise 都必须实现具有下面方法签名的 .then() 方法:

1
2
3
4
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() 的返回值为 x ,并且 x 是一个 promise , promise2 会被锁定 (假设和 x 有相同的状态和值) 。 否则, promise2 将会转换为 fulfilled 状态并且值为 x

  • 不论 onFulfilledonRejected 跑出一个异常 epromise2 一定会 rejected 并且 e 作为原因。

  • 如果 onFulfilled 不是一个函数并且 promise1 处于 fulfilled 状态, promise2 一定是 fulfilled 状态并且和 promise1 有同样的值。

  • 如果 onRejected 不是一个函数并且 promise1 处于 rejected 状态, promise2 一定是 rejected 状态并且和 promise1 的原因相同。

Promise 链

因为 .then() 总是返回一个新的 promise ,所以可以链接 promises 并精确控制错误的处理方式和位置。Promises 允许你模仿同步代码的 try/catch 方式。

链式调用会形成一个有序的序列,类似于同步代码。换句话说,你可以这么做:

1
2
3
4
fetch(url)
.then(process)
.then(save)
.catch(handleErrors)

假设 fetch() process() save() 每个函数都返回 promises , fetch() 完成后 process() 才开始执行, process() 完成后 save() 才开始执行。任何一个 promise reject 后 handleErrors() 才会执行。

这是一个复杂的 promise 链 的例子,并具有多个 rejection:

Error 处理

注意 promise 同时有成功和错误的 handler ,下面的代码很常见:

1
2
3
4
save().then(
handleSuccess,
handleError
)

但是 handleSuccess() 抛出错误应该怎么做?从 .then() 返回的 promise 将会 rejected ,但是没有捕捉到这个 rejection 。意味着你的 app 的这个错误被吞了。

出于这个原因,有些人认为上面的代码是反模式的,并推荐下面的代码:

1
2
3
save()
.then(handleSuccess)
.catch(handleError)

差异很微妙但是很重要。在第一个示例中, 源自 save() 的错误将会被捕获, 但源自 handleSuccess() 的错误将会被吞掉。

Without .catch(), an error in the success handler is uncaught.

在第二个示例中,.catch() 可以处理 save()handleSuccess() 两者的 rejection 。

With .catch(), both error sources are handled.

当然, save() 的错误可能是网络错误,但 handleSuccess() 的错误可能是开发人员忘记处理特定的状态码。如果你想以不同的方式处理它们,你可以选择这样处理:

1
2
3
4
5
6
save()
.then(
handleSuccess,
handleNetworkError
)
.catch(handleProgrammerError)

无论你喜欢哪一种方式,我都建议在所有的 promise 链都是以 .catch() 结尾。重复这么做是值得的:

I recommend ending all promise chains with a .catch().

如何取消一个 Promise?

promises 萌新经常想知道的第一件事是如何取消一个 promise。这是一个想法:以 “Cancelled” 为原因 reject promise 。如果你想和 “normal” 错误以不同的处理方式,在 error handler 中添加一个处理分支。

以下是人们在取消 promise 时所犯的一些常见错误:

在 promise 中添加 .cancel()

添加 .cancel() 使 promise 不符合标准,而且也违反了一些 promises 的规则:只有创建 promise 的函数才能 resolve, reject,或者 cancel 这个 promise 。暴露出来会破坏 promise 的封装,并且会鼓励人们在不应该知道 promise 的地方编写操作 promise 的代码。避免意大利面式的代码以及破坏 promises 。

忘记清理

有一些聪明的人发现可以使用 Promise.race() 组委取消机制。问题是使用了创建 promise 的函数来做取消控制,只有这个函数可以清理活动内容,例如清理 timeouts 或者清理数据引用的内存空间等等。

忘记处理 rejected 状态下取消 promise

你知道吗当你忘记处理 promise rejection , Chrome 控制台会抛出一个警告信息。

过度复杂

withdrawn TC39 proposal 提出了使用独立的消息通道实现取消 promise 。它提出了一个叫做 cancellation token 的新概念。在我看来,这个解决方案会让 promise 规范变得臃肿,它的唯一作用是分离了 rejections 和 cancellations ,这是没有必要的。

你是否希望基于 exception 或者 cancellation 来进行切换?这确实是 promise 的工作?我认为不是。

重新思考 Promise Cancellation

通常,在 promise 创建时传入所有需要的信息以确定如何 resolve / reject / cancel 这个promise 。这样就没有必要在 promise 上添加 .cancel() 函数了。你可能会有疑问,在 promise 创建的时候怎么知道是否可以取消。

“If I don’t yet know whether or not to cancel, how will I know what to pass in when I create the promise?”

如果只有某种对象可以代替未来的潜在的值…等一下。

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

我们使用的默认参数默认是不取消的。cancel 参数是可选的。像之前一样设定一个 settimeout ,不过会保存这个 timeout 的 ID 以便于在之后清理。

我们使用 cancel.then() 方法来处理 cancellation 和资源清理。这个方法只会在 promise resolve 之前取消了才会执行。如果你太晚取消,你就错过了你的机会。火车已经驶离了车站。

注意:你可能想知道 noop() 函数是做什么的。单词 noop 代表了 no-op,意思是空函数什么都不做。如果没有这个函数,V8 引擎会抛出警告:UnhandledPromiseRejectionWarning: Unhandled promise rejection 。永远都要处理 promise rejections 是一个好主意,即使是 noop() 函数。

Note: You may be wondering what the noop() function is for. The word noop stands for no-op, meaning a function that does nothing. Without it, V8 will throw warnings: UnhandledPromiseRejectionWarning: Unhandled promise rejection. It’s a good idea to always handle promise rejections, even if your handler is a noop().

抽象 Promise Cancellation

这对于 wait() 计时器是很好的,但是我们可以进一步抽象这个想法,封装所有你需要的:

  1. 默认拒绝 cancel promise – 如果没有传入 cancel 到 promise ,我们不希望 cancel 或抛错。

  2. 记住因 cancellations 而 reject 时要进行清理。

  3. 记住 onCancel 的清理工作本身也会抛错,这个错误也需要处理。(注意上面的示例中省略了错误处理 – 很容易忘记!)

让我们来创建一个可取消的 promise 工具,你可以使用它来包装任何 promise 。例如,处理网络请求等… 方法前面如下所示:

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

SpecFunction 就像你传入 Promis 的构造函数一样,有一点不同 – 它有一个 onCancel() handler:

1
SpecFunction(resolve: Function, reject: Function, onCancel: Function) => void

注意这个例子只是向你解释它是如何工作的。还有一些其他边界问题需要考虑。例如,这个版本的代码,promise 已经固定了,取消 promise 依然会调用 handleCancel 函数。

我实现了一个生产版本,处理了边界问题的开源库,Speculation .

让我们使用这个改进的抽象库来重写之前的 wait() 工具函数。首先安装 speculation:

1
npm install --save speculation

现在你可以导入和使用它:

这简化了一些,因为你不需要关心 noop() , 捕获 onCancel() ,函数或其他边界问题的错误。这些细节都被抽象到 speculation() 中了。随时在真实项目中使用它。

原生 JS Promise 的附加内容

原生 Promise 对象有一些你可能感兴趣的附加内容:

  • Promise.reject() 返回一个 rejected promise 。

  • Promise.resolve() 返回一个 resolved promise 。

  • Promise.race() 接收一个 promise 数组 (或任何迭代器) 并且返回第一个 resolved (值) 或 rejected (原因) 的 promise 。

  • Promise.all() 接收一个 promise 数组 (或任何迭代器) 并且当迭代器中所有 promises 都 resolved 后返回一个 promise ,或者返回第一个 rejected (原因) 的 promise。

总结

Promises 已称为 JavaScript 语法的重要部分,包括用于 ajax 请求的 WHATWG Fetch 标准,用于让异步代码看起来像同步代码的 Async Functions 标准。

在撰写本文时 (2017.1.23),Async functions 还处于第3阶段,但我预测它们很快将成为 JavaScript 中异步编程的一种非常流行,非常有效的解决方案 – 这意味着在不久的将来,学习 promises 对 JavaScript 开发人员来说将变得更加重要。

例如,如果你正在使用 Redux ,我建议你看下 redux-saga:一个用于管理 Redux 中副作用的库,整个文档都依赖于异步函数。

我希望即使是有着丰富 promise 使用经验的开发人员在阅读完本文之后,能够对 promise 有更好的理解和 promise 是如何工作的,以及更好地去使用 promise 。

探索 ‘Master the JavaScript Interview’ 系列