Promise的前世今生和妙用技巧 - 破狼

标签: promise 前世今生 技巧 | 发表时间:2015-10-22 19:43 | 作者:破狼
出处:

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

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“(回调地狱),使得这类代码及其不可读和难易维护。

asyncTask1(data, function (data1){

asyncTask2(data1, function (data2){

asyncTask3(data2, function (data3){
// .... 魔鬼式的金字塔还在继续
});

});

});

Promise的横空出世

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”状态时被调用。

如下面所示:

promises 流程图

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

asyncTask1(data)
.then(function(data1){
return asyncTask2(data1);
})
.then(function(data2){
return asyncTask3(data2);
})
// 仍然可以继续then方法

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的实现如下:

$http.get('/demo1')
.then(function(data){
console.log('demo1', data);
return $http.get('/demo2', {params: data.result});
})
.then(function(data){
console.log('demo2', data);
return $http.get('/demo3', {params: data.result});
})
.then(function(data){
console.log('demo3', data.result);
});

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

多个异步任务的并行处理

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

$q.all([$http.get('/demo1'),
$http.get('/demo2'),
$http.get('/demo3')
])
.then(function(results){
console.log('result 1', results[0]);
console.log('result 2', results[1]);
console.log('result 3', results[2]);
});

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

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

var getTemplatePromise = function(options) {
// ... 拼接所有template或者templateUrl
};

var getResolvePromises = function(resolves) {
// ... 拼接所有resolve
};

var controllerLoader = function(options, currentScope, tplAndVars, initLocals) {
// ...

ctrlInstance = $controller(options.controller, ctrlLocals);
if (options.controllerAs) {
currentScope[options.controllerAs] = ctrlInstance;
}

// ...

return currentScope;
};

var templateAndResolvePromise = $q.all([getTemplatePromise(options)].concat(getResolvePromises(options.resolve || {})));

return templateAndResolvePromise.then(function resolveSuccess(tplAndVars) {
var currentScope = currentScope || $rootScope.$new();
controllerLoader(options, currentScope, tplAndVars, initLocals);
// ... compile & append dom
});

对于这类路由机制的使用,在博主上篇博文 《自定义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接口,如下所示:

var data = $window.localStorage.getItem('data-api-key');
return $q.when(data);

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

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

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

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

$modal.open({
templateUrl: '/templates/modal.html',
controller: 'ModalController',
controllerAs: 'modal',
resolve: {
}
})
.result
.then(function ok(data) {
// 用户点击ok按钮事件
}, function cancel(){
// 用户点击cancel按钮事件
});

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

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

function open(data){
var defer = $q.defer();

// resolve or reject defer;

var promise = defer.promise;
promise.ok = function(func){
promise.then(func);
return promise;
};

promise.cancel = function(func){
promise.then(null, func);
return promise;
};

return promise;
};

则我们可以如下方式来访问它:

$modal.open(item)
.ok(function(data){
// ok逻辑
})
.cancel(function(data){
// cancel 逻辑
});

是不是感觉更具有语义呢?在Angular中$http的返回方法success、error也是同样逻辑的封装。将success的注册函数注册为.then方法的成功回调,error的注册方法注册为then方法的失败回调。所以success和error方法只是Angular框架为我们在Promise语法之上封装的一套语法糖而已。

Angular的success、error回调的实现代码:

promise.success = function(fn) {
promise.then(function(response) {
fn(response.data, response.status, response.headers, config);
});
return promise;
};

promise.error = function(fn) {
promise.then(null, function(response) {
fn(response.data, response.status, response.headers, config);
});
return promise;
};

利用Promise来实现管道式AOP拦截

在软件设计中,AOP是Aspect-Oriented Programming的缩写,意为:面向切面编程。通过编译时(Compile)植入代码、运行期(Runtime)动态代理、以及框架提供管道式执行等策略实现程序通用功能与业务模块的分离,统一处理、维护的一种解耦设计。 AOP是OOP的延续,是软件开发中的一个热点,也是很多服务端框架(如Java世界的Spring)中的核心内容之一,是函数式编程的一种衍生范型。 利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高开发效率。 AOP实用的场景主要有:权限控制、日志模块、事务处理、性能统计、异常处理等独立、通用的非业务模块。关于更多的AOP资料请参考 http://en.wikipedia.org/wiki/Aspect-oriented_programming

在Angular中同样也内置了一些AOP的设计思想,便于实现程序通用功能与业务模块的分离、解耦、统一处理和维护。$http中的拦截器(interceptors)和装饰器($provide.decorator)是Angular中两类常见的AOP切入点。前者以管道式执行策略实现,而后者则通过运行时的Promise管道动态实现的。

首先回顾一下Angular的拦截器实现方式:

// 注册一个拦截器服务
$provide.factory('myHttpInterceptor', function($q, dependency1, dependency2) {
return {
// 可选方法
'request': function(config) {
// 请求成功后处理
return config;
},

// 可选方法
'requestError': function(rejection) {
// 请求失败后的处理
if (canRecover(rejection)) {
return responseOrNewPromise
}
return $q.reject(rejection);
},



// 可选方法
'response': function(response) {
// 返回回城处理
return response;
},

// 可选方法
'responseError': function(rejection) {
// 返回失败的处理
if (canRecover(rejection)) {
return responseOrNewPromise
}
return $q.reject(rejection);
}
};
});

// 将服务注册到拦截器链中
$httpProvider.interceptors.push('myHttpInterceptor');


// 同样也可以将拦截器注册为一个工厂方法。 但上一中方式更为推荐。
$httpProvider.interceptors.push(['$q', function($q) {
return {
'request': function(config) {
// 同上
},

'response': function(response) {
// 同上
}
};
}]);

这样就可以实现对Angular中的 $http或者是 $resource的Ajax请求拦截了。但在Angular内部是是如何实现这种拦截方式的呢?Angular使用的就是Promise机制,形成异步管道流,将真实的Ajax请求放置在request、requestError和response、responseError的管道中间,因此就产生了对Ajax请求的拦截。

其源码实现如下:

var interceptorFactories = this.interceptors = [];

var responseInterceptorFactories = this.responseInterceptors = [];

this.$get = ['$httpBackend', '$browser', '$cacheFactory', '$rootScope', '$q', '$injector',
function($httpBackend, $browser, $cacheFactory, $rootScope, $q, $injector) {

var defaultCache = $cacheFactory('$http');

var reversedInterceptors = [];

forEach(interceptorFactories, function(interceptorFactory) {
reversedInterceptors.unshift(isString(interceptorFactory) ? $injector.get(interceptorFactory) : $injector.invoke(interceptorFactory));
});

forEach(responseInterceptorFactories, function(interceptorFactory, index) {
var responseFn = isString(interceptorFactory) ? $injector.get(interceptorFactory) : $injector.invoke(interceptorFactory);

reversedInterceptors.splice(index, 0, {
response: function(response) {
return responseFn($q.when(response));
},
responseError: function(response) {
return responseFn($q.reject(response));
}
});
});
...

function $http(requestConfig) {
...

var chain = [serverRequest, undefined];
var promise = $q.when(config);

// apply interceptors
forEach(reversedInterceptors, function(interceptor) {
if (interceptor.request || interceptor.requestError) {
chain.unshift(interceptor.request, interceptor.requestError);
}
if (interceptor.response || interceptor.responseError) {
chain.push(interceptor.response, interceptor.responseError);
}
});

while (chain.length) {
var thenFn = chain.shift();
var rejectFn = chain.shift();

promise = promise.then(thenFn, rejectFn);
}

promise.success = function(fn) {
promise.then(function(response) {
fn(response.data, response.status, response.headers, config);
});
return promise;
};

promise.error = function(fn) {
promise.then(null, function(response) {
fn(response.data, response.status, response.headers, config);
});
return promise;
};

return promise;
};

在上面紧接着在 $get注入方法之后,Angular会将 interceptorsresponseInterceptors反转合并到一个 reversedInterceptors的拦截器内部变量中保存。最后在$http函数中以 [serverRequest, undefined]serverRequest是Ajax请求的Promise操作)为中心,将 reversedInterceptors中的所有拦截器函数依次加入到chain链式数组中。如果是request或requestError,那么就放在链式数组起始位置;相反如果是response或responseError,那么就放在链式数组最后。

注意添加在chain的request和requestError或者response和responseError都一定是成对的,换句话说可能注册一个非空的request与一个为undefined的requestError,或者是一个为undefined的request与非空的requestError。就像chain数组的声明一样 (var chain = [serverRequest, undefined];),成对的放入serverRequest和undefined对象到数组中。因为后面的代码将利用Promise的机制注册这些拦截器函数,实现管道式AOP拦截机制。

在Promise中需要两个函数来注册回调,分别为成功回调和失败回调。在这里request和response会被注册成Promise的成功回调,而requestError和responseError则会注册成Promise的失败回调。所以在chain中添加的request和requestError,response或responseError都是成对出现的,这是为了能在接下来的循环中简洁地注册Promise回调函数。 这些被注册的拦截器链,会通过 $q.when(config) Promise启动,它会首先传入 $http的config对象,并执行所有的request拦截器,依次再到 serverRequest这个Ajax请求,此时将挂起后边所有的response拦截器,直到Ajax请求响应完成,再依次执行剩下的response拦截器回调; 如果在request过程中有异常失败则会执行后边的requestError拦截器,对于Ajax请求的失败或者处理Ajax的response拦截器的异常也会被后面注册的responseError拦截器捕获。

从最后两段代码也能了解到关于 $http服务中的success方法和error方法,是Angular为大家提供了一种Promise的便捷写法。success方法是注册一个传入的成功回调和为undefined的错误回调,而error则是注册一个为null的成功回调和一个传入的失败回调。

总结

写到这里,本文也进入了尾声。希望大家能够对Promise有一定的理解,并能够”信手拈来“的运用于实际的项目之中,增强代码的可读性和可维护性。在本文中所用到的例子,你都可以在博主的jsbin http://jsbin.com/bayeva/edit?html,js,output中找到它们。

另外,同时也欢迎关注博主的微信公众号[破狼](微信二维码位于博客右侧),这里将会为大家地时间推送博主的最新博文,谢谢大家的支持和鼓励。


本文链接: Promise的前世今生和妙用技巧,转载请注明。

相关 [promise 前世今生 技巧] 推荐:

Promise的前世今生和妙用技巧 - 破狼

- - 博客园_首页
JavaScript作为单线程运行于浏览器之中,这是每本JavaScript教科书中都会被提到的. 同时出于对UI线程操作的安全性考虑,JavaScript和UI线程也处于同一个线程中. 因此对于长时间的耗时操作,将会阻塞UI的响应. 为了更好的UI体验,应该尽量的避免JavaScript中执行较长耗时的操作(如大量for循环的对象diff等)或者是长时间I/O阻塞的任务.

kissy1.3的Promise精讲

- - ria之家--RIA三部曲:jquery、ext、flex
promise是commonJs的规范中的内容,目的是利用更为简洁良好的AP让异步处理过程更为通俗易懂,同时更为易用. 明河对promise价值理解是:promise消除反人类的多层回调嵌套,提高代码的可阅读性和可维护性. promise典型场景应用. 场景:需要发送三个异步请求,且三个异步请求存在依赖,写出来的代码可能如下:.

inline-blcok 前世今生

- - Taobao UED Team
曾几何时,display:inline-block 已经深入「大街小巷」,随处可见 「display:inline-block; *display:inline; *zoom:1; 」这样的代码. 如今现代浏览器已经全面支持这个属性值了,上面的代码只是为了兼容 IE6、7 而已. 那么你真的了解 inline-block 了吗.

NB-IoT 的 “前世今生”

- - DiyCode - 致力于构建开发工程师高端交流分享社区社区
作者:个推B2D研发工程师 海晏. 根据《爱立信2018移动报告》(Ericsson Mobility Report,June 2018)的预测,蜂窝物联网设备连接数将在2023年达到35亿,年增长率达到30%. 图片来源:《爱立信2018移动报告》(Ericsson Mobility Report,June 2018).

HTTP的前世今生

- - 酷 壳 – CoolShell
HTTP (Hypertext transfer protocol) 翻译成中文是超文本传输协议,是互联网上重要的一个协议,由欧洲核子研究委员会CERN的英国工程师 Tim Berners-Lee v发明的,同时,他也是WWW的发明人,最初的主要是用于传递通过HTML封装过的数据. 0.9和1.0这两个版本,就是最传统的 request – response的模式了,HTTP 0.9版本的协议简单到极点,请求时,不支持请求头,只支持 GET 方法,没了.

我和Android的前世今生缘

- - 我爱水煮鱼
老实说我是 Google Android 手机的忠实粉丝,我从2009年9月份开始买了第一台 Android 手机 HTC Magic,也就是俗称的 G2 开始,就一直使用 Android 手机,期间用过 Motorola Milestone,HTC Legend,现在正在用的是 Samsung I9000.

从事件驱动到observable的异步编程——PubSub+Promise+Rx的JS事件库

- Kejun - YY in Limbo 混沌海狂想
你上当叻,虽然从外面看标题很有气势,传达出一种宏大叙事的赶脚,其实我只是刚刚把一个阿尔法城的JS模块提交到github,想顺便介绍一下,但我连API文档都懒得写,就别指望能深入浅出的讲一遍来龙去脉了⋯⋯. 所以就直接帖几个前置阅读的链接罢. 这些潮流的外部起源:(技术也有外源论/exogenesis⋯⋯).

从“古”到今 回顾Mac OS的前世今生

- will - cnBeta.COM
Mac OS是指运行于苹果Macintosh系列电脑上的操作系统,一直以来都被业界用来和微软的Windows进行相互比较. Mac OS是首个在商用领域成功的图形用户界面.当年Mac OS推出图形界面的时候,微软还只停留在DOS年代,Windows尚在襁褓之中. 在Mac OS X Lion即将推出之际,我们一起来回顾一下Mac OS的“前世今生”吧.

不确定性原理的前世今生 · 数学篇(一)

- 李隆 - 科学松鼠会
在现代数学中有一个很容易被外行误解的词汇:信号 (signal). 当数学家们说起「一个信号」的时候,他们脑海中想到的并不是交通指示灯所发出的闪烁光芒或者手机屏幕顶部的天线图案,而是一段可以具体数字化的信息,可以是声音,可以是图像,也可是遥感测量数据. 简单地说,它是一个函数,定义在通常的一维或者多维空间之上.