给我一个承诺,还你一个未来
二次元中有一个词叫做立flag,暗示的剧情的发展。比如如果那个角色说了一句“我保证会活着回来”,基本上这个角色就离死不远了。Javascript 最近几年也发展出类似的玩意儿,表示将来的某种状态,叫做 Promises (当然这里没有死这么严重),早期 jQuery 的 Deferred 就是类似于 Promises 的实现(当然jQuery的Deferred不完全符合Promises现在的标准)。
聊聊 Promises 的历史
所以,为什么Promises会出现?Promises最原始的是由 Daniel P. Friedman 和 David Wise 提出的。后来有出现了类似的概念叫做Futures。Futures和Promises的出现是为了解决并行编程中同步的问题。有关他们的介绍可以看这里 Futures and Promises。
虽然是搬运 wikipedia 的,还是要说一下一般我们会看到几个词 future, promise, deferred, delay 一般来说这几个词可以等价。但是按照原始的定义,或者更确切的从原始的意义上来解释 Futures 和 Promises 还是有细微的不同的。Futures 指的一个只读的变量的占位符,意思就是说 Futures 作为一个异步操作的符号表示,表示这个地方会有一个异步操作的返回。而 Promises 指一个可以对 Futures 进行设置或者操作的容器。 这在单词的字面的意思也能理解,未来是一种代指之后的某一时刻,而承诺本身就隐性地包含了未来。
异步编程与回调
Futures 和 Promises 的提出就是用来解决异步编程的,所以先来看看javascript是如何解决异步编程问题的。
异步编程是我们执行一个函数的时候,可能本身这个函数的执行是费时间的,可是我们不希望这个操作的执行阻塞了当前的线程,希望这个函数能够立即返回,让这个操作在不影响当前线程的情况下运行,然后在将来的某一时刻,操作完成之后通知当前线程,更新状态。
一般来说,javascript 的异步编程基本上依靠的回调函数,不管是最原始的回调,还是 event-drive的方式,或者基于pub-sub的方式,还是我们现在正在讨论的 Promises,本质上都是依靠了回调函数。只是在方式上简化了原始回调的操作,努力去避免了一些在代码组织上的问题。
原始回调
1 | op1(function(){ |
1 | // more simple way |
这是最原始的方法,把函数当作参数传递给异步操作,等到异步操作完成之后,调用回调函数。典型的例子就是setTimeout。
但是原始的回调函数有一个缺点是,当我们的程序越来越复杂的时候,我们的回调的层数会越来越多,代码的耦合性高,在代码的可维护性上就出现问题。想象一下如果我们有5个ajax请求顺序执行,这样就有五层回调,这样如果当我们突然说不行 我们要去掉中间的两个回调,这样带来的代码量的修改是非常大的。
事件驱动
事件驱动可以很好的解决掉这个原始回调带来的问题,考虑如下代码:
1 | function async1(){ |
我们看到这里的代码的耦合程度,从代码的可维护性来说,显然这里的事件驱动会明显好于前一种。
pub-sub(发布订阅)
发布订阅是在事件驱动的基础上,把能触发事件和发布事件统一在一起,便于对事件的管理,这样避免在纯事件驱动中的事件种类不可控性。
1 | function async1(){ |
这里我们还能对Eventbus限制,比如只能触发规定事件,或者只能监听规定事件,这样对面对逐渐扩大的项目,不会出现事件混乱的情况出现。
Promises 解决方案
其实不能说Promises更优于上面所说的事件驱动的异步方案或者基于订阅发布的方案。但是Promises带来的更优雅的方式。
Promises 在2007年第一次被Dojo所实现,称为dojo.Deferred。之后CommonJS一直致力于标准化Promises行为,现在最被广泛接受的是Promises/A+。所以这里以Promises/A+为例说明Promises。上面的代码如过用Promises可以表述成:
1 | function async1(resolve, reject) { |
这里先说明一下, 在Promise中一共只有三个状态pending, fullfilled, rejected. pending 表示异步操作还在进行,fullfilled表示这个异步操作已经成功,rejected表示这个异步操作失败了。
我们在实现async1时我们传入了两个参数resolve和reject,他们都是函数。resolve表示这个操作成功,他接受一个result作为参数。reject表示这个操作失败,接受一个reason参数。然后在这个Promise的then方法我们会传入两个函数,第一个会在resolve调用的时候触发,表示操作成功之后的回调。第二个会在reject调用的时候触发表示这个操作失败之后处理错误。介绍到这里就算是promise的基本用法了。
但是并不是Promise强大的地方。
同一异步操作的链式调用
promise允许在同一个异步调用上反复的使用then,考虑上面代码第二个then,即使在对于async1这个操作的结果进行链式的调用。第一个then中成功回调接受到async1的返回之后处理,然后再进入到第二个then中的成功回调中。这个第二个回调中的参数就是第一个回调的返回。这样我们可以一步一步的对异步操作的原始结果做链式调用,一步一步的处理数据。大大清晰了整个过程。
1 | new Promise(asyncOperation).then(processData1).then(processData2).then(processData3) |
注意这里,每次then对象的调用实际上是生成的一个新的Promise对象,并不能将这里的链式调用等同于jQuery中的链式调用,应为jQuery的链式调用每次都返回的是同一个对象。
不同异步操作的链式调用
上面的代码的第三个then显示了对于多个异步流程的处理。现在我们的流程是async1->async2->async3, 在pub-sub那节中已经给出了传统的实现。然后考虑上面Promise的处理。Promise前两个then是对于第一个回调的处理,这个已经提到了。第三个then,我们可以看到这个地方在最后返回了一个async2的Promise对象。然后在第四个then中的success回调中我们就能得到async2的结果,然后再返回async3的Promise对象。这样我们就实现了async1->async2->async3的异步流程的链式操作。如果中间比如async2我们不需要了,则只需要将第三个then删除,其他的地方完全不用修改代码。这样的写法必然要比用pub-sub或者event-drive的方式要简单明了。也一眼能看出数据流的方向。
错误异常处理
你可以直接在异步操作的过程中或者在对结果的处理过程中,抛出任何的错误。Promise都会帮你传递到下一个then的错误处理中。对异常和错误的处理十分的方便
1 | new Promise(function(resolve, reject){ |
注意,这里的错误处理不会一直传递下去,只会错误出现的下一个then中的错误的处理中被接收到。再下一个then的那个回调会被触发完全取决于你的返回。
回调如何触发
在异步函数中resolve触发success,reject触发fail。但是如果有多个then,那么then的触发取决于上一个回调的返回。
如果是返回值不是没有then方法的函数或者对象,就会触发success的回调,参数就是这个返回值
如果是throw exception 则会触发fail的回调。
如果返回是个Promise对象则取决于Promise的自身的状态,Promise是fullfill的则触发succes,如果是rejected,则触发fail的回调。然后他们的参数就是这个Promise的value或者reason。
如果返回值是有then方法的函数或者对象,这个就取决于then中的处理。这里then方法有两个参数 resolvePromise和rejectPromise,如果执行resovlePromise(x),则下一个then会触发success,如果是执行rejectPromise(reason)则会触发下一个then的fail回调。
自己实现一个Promise
这里限于篇幅我可能会在之后加入如果根据Promise/A+的规范实现一个Promise,详情可以参考我的这个简单实现future.js