原文链接 Medium - Master the JavaScript Interview: What is a Promise?
开门见山,何为 Promise ?
一个promise
指的是一个可能会在未来的某个时间点产生一个单一值的对象:不论是一个 resolved 值,还是一个未 resolved 值的原因(比如发生了网络错误)。一个promise
可能为fulfilled
、 rejected
或 pending
三种状态中的一种。promise
用户可以使用回调函数来处理fulfilled
和 rejected
状态。
Promise
可以说是相当热心了,其构造器一旦被调用,promise
就会立即开始做你给它的任何任务。
一个不太完整的 promise 发展史
早期对promise
和futures
(两个概念类似/相关)的实现始于如 MultiLisp
和Concurrent 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 的主流实现,受喜爱程度遥遥领先于像是Q
、When
或者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: 暂时还未
fulfilled
或rejected
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()
返回一个值为x
,x
是一个 promise,promise2
将用x
锁定。否则,promise2
会被值x
fulfilled。 - 如果
onFulfilled()
或onRejected()
抛出一个异常e
,promise2
必须以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()
计时器当然是极好的,但我们要继续将上面这种思路做进一步的抽象,来封装所有你需要知道的东西:
- 默认 reject 需要中断的 promise
- 记得要清理被 reject 过的 promise
- 保持警惕,
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.