Promise的前世今生和妙用技巧

489 查看

浏览器事件模型和回调机制

JavaScript作为单线程运行于浏览器之中,这是每本JavaScript教科书中都会被提到的。同时出于对UI线程操作的安全性考虑,JavaScript和UI线程也处于同一个线程中。因此对于长时间的耗时操作,将会阻塞UI的响应。为了更好的UI体验,应该尽量的避免JavaScript中执行较长耗时的操作(如大量for循环的对象diff等)或者是长时间I/O阻塞的任务。所以在浏览器中的大多数任务都是异步(无阻塞)执行的,例如:鼠标点击事件、窗口大小拖拉事件、定时器触发事件、Ajax完成回调事件等。当每一个异步事件完成时,它都将被放入一个叫做”浏览器事件队列“中的事件池中去。而这些被放在事件池中的任务,将会被javascript引擎单线程处理的一个一个的处理,当在此次处理中再次遇见的异步任务,它们也会被放到事件池中去,等待下一次的tick被处理。另外在HTML5中引入了新的组件-Web Worker,它可以在JavaScript线程以外执行这些任务,而不阻塞当前UI线程。

浏览器中的事件循环模型如下图所示:

由于浏览器的这种内部事件循环机制,所以JavaScript一直以callback回调的方式来处理事件任务。因此无所避免的对于多个的JavaScript异步任务的处理,将会遇见”callback hell“(回调地狱),使得这类代码及其不可读和难易维护。

 

Promise的横空出世

因此很多JavaScript牛人开始寻找解决这回调地狱的模式设计,随后Promise(jQuery的deferred也属于Promise范畴)便被引入到了JavaScript的世界。Promise在英语中语义为:”承诺“,它表示如A调用一个长时间任务B的时候,B将返回一个”承诺“给A,A不用关心整个实施的过程,继续做自己的任务;当B实施完成的时候,会通过A,并将执行A之间的预先约定的回调。而deferred在英语中语义为:”延迟“,这也说明promise解决的问题是一种带有延迟的事件,这个事件会被延迟到未来某个合适点再执行。

Promise/A+规范

  • Promise 对象有三种状态: Pending – Promise对象的初始状态,等到任务的完成或者被拒绝;Fulfilled – 任务执行完成并且成功的状态;Rejected – 任务执行完成并且失败的状态;
  • Promise的状态只可能从“Pending”状态转到“Fulfilled”状态或者“Rejected”状态,而且不能逆向转换,同时“Fulfilled”状态和“Rejected”状态也不能相互转换;
  • Promise对象必须实现then方法,then是promise规范的核心,而且then方法也必须返回一个Promise对象,同一个Promise对象可以注册多个then方法,并且回调的执行顺序跟它们的注册顺序一致;
  • then方法接受两个回调函数,它们分别为:成功时的回调和失败时的回调;并且它们分别在:Promise由“Pending”状态转换到“Fulfilled”状态时被调用和在Promise由“Pending”状态转换到“Rejected”状态时被调用。

如下面所示:

根据Promise/A+规范,我们在文章开始的Promise伪代码就可以转换为如下代码:

 

Promise将原来回调地狱中的回调函数,从横向式增加巧妙的变为了纵向增长。以链式的风格,纵向的书写,使得代码更加的可读和易于维护。

Promise在JavaScript的世界中逐渐的被大家所接受,所以在ES6的标准版中已经引入了Promise的规范了。现在通过Babel,可以完全放心的引入产品环境之中了。

另外,对于解决这类异步任务的方式,在ES7中将会引入async、await两个关键字,以同步的方式来书写异步的任务,它被誉为”JavaScript异步处理的终极方案“。这两个关键字是ES6标准中生成器(generator)和Promise的组合新语法,内置generator的执行器的一种方式。当然async、await的讲解并不会在本文中展开,有兴趣的读者可以参见MDN资料

Promise的妙用

如上所说Promise在处理异步回调或者是延迟执行任务时候,是一个不错的选择方案。下面我们将介绍一些Promise的使用技巧(下面将利用Angular的$q$http为例,当然对于jQuery的deferred,ES6的Promise仍然实用):

多个异步任务的串行处理

在上文中提到的回调地狱案例,就是一种试图去将多个异步的任务串行处理的结果,使得代码不断的横向延伸,可读性和维护性急剧下降。当然我们也提到了Promise利用链式和延迟执行模型,将代码从横向延伸拉回了纵向增长。使用Angular中$http的实现如下:

 

因为Promise是可以传递的,可以继续then方法延续下去,也可以在纵向扩展的途中改变为其他Promise或者数据。所以在例子中的$http也可以换为其他的Promise(如$timeout$resource…)。

多个异步任务的并行处理

在有些场景下,我们所要处理的多个异步任务之间并没有像上例中的那么强的依赖关系,只需要在这一系列的异步任务全部完成的时候执行一些特定逻辑。这个时候为了性能的考虑等,我们不需要将它们都串行起来执行,并行执行它们将是一个最优的选择。如果仍然采用回调函数,则这是一个非常恼人的问题。利用Promise则同样可以优雅的解决它:

 

这样就可以等到一堆异步的任务完成后,在执行特定的业务回调了。在Angular中的路由机制ngRouteuiRoute的resolve机制也是采用同样的原理:在路由执行的时候,会将获取模板的Promise、获取所有resolve数据的Promise都拼接在一起,同时并行的获取它们,然后等待它们都结束的时候,才开始初始化ng-viewui-view指令的scope对象,以及compile模板节点,并插入页面DOM中,完成一次路由的跳转并且切换了View,将静态的HTML模板变为动态的网页展示出来。

Angular路由机制的伪代码如下:

 

对于这类路由机制的使用,在博主上篇博文《自定义Angular插件 – 网站用户引导》中的ng-trainning插件中也采用了它。关于这段代码的具体分析和应用将在后续单独的文章中,敬请大家期待。

对于同步数据的Promise处理,统一调用接口

有了Promise的处理,因为在前端代码中最多的异步处理就是Ajax,它们都被包装为了Promise .then的风格。那么对于一部分同步的非异步处理呢?如localStorage、setTimeout、setInterval之类的方法。在大多数情况下,博主仍然推荐使用Promise的方式包装,使得项目Service的返回接口统一。这样也便于像上例中的多个异步任务的串行、并行处理。在Angular路由中对于只设置template的情况,也是这么处理的。

对于setTimeout、setInterval在Angular中都已经为我们内置了$timeout和$interval服务,它们就是一种Promise的封装。对于localStorage呢?可以采用$q.when方法来直接包装localStorage的返回值的为Promise接口,如下所示:

 

整个项目的Service层的返回值都可以被封装为统一的风格使用了,项目变得更加的一致和统一。在需要多个Service同时并行或者串行处理的时候,也变得简单了,一致的使用方式。

对于延迟任务的Promise DSL语义化封装

在前面已经提到Promise是延迟到未来执行某些特定任务,在调用时候则给消费者返回一个”承诺“,消费者线程并不会被阻塞。在消费者接受到”承诺“之后,消费者就不用再关心这些任务是如何完成的,以及督促生产者的任务执行状态等。直到任务完成后,消费者手中的这个”承诺“就被兑现了。

对于这类延迟机制,在前端的UI交互中也是极其常见的。比如模态窗口的显示,对于用户在模态窗口中的交互结果并不可提前预知的,用户是点击”ok“按钮,或者是”cancel“按钮,这是一个未来将会发生的延迟事件。对于这类场景的处理,也是Promise所擅长的领域。在Angular-UI的Bootstrap的modal的实现也是基于Promise的封装。

 

这是因为modal在open方法的返回值中给了我们一个Promise的result对象(承诺)。等到用户在模态窗口中点击了ok按钮,则Bootstrap会使用$qdeferresolve来执行ok事件;相反,如果用户点击了cancel按钮,则会使用$qdeferreject执行cancel事件。

这样就很好的解决了延迟触发的问题,也避免了callback的地狱。我们仍然可以进一步将其返回值语义化,以业务自有的术语命名而形成一套DSL API。