Promise的前世今生和妙用技巧

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

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的横空出世

因此很多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伪代码就可以转换为如下代码:

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有一定的理解,并能够”信手拈来“的运用于实际的项目之中,增强代码的可读性和可维护性。在本文中所用到的例子,你都可以在博主的jsbinhttp://jsbin.com/bayeva/edit?html,js,output中找到它们。

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

作者:破  狼 
出处:http://www.cnblogs.com/whitewolf/ 
本文版权归作者,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。该文章也同时发布在我的独立博客中-个人独立博客博客园--破狼51CTO--破狼。http://www.cnblogs.com/whitewolf/p/promise-best-practice.html

时间: 2024-10-27 02:31:09

Promise的前世今生和妙用技巧的相关文章

压缩软件日常操作的妙用技巧

提到好压压缩软件想必大家都不会陌生,除了压缩.解压缩之外,好压还提供了众多的实用工具和实用功能,下面介绍三则好压压缩软件在日常电脑操作中的妙用. 1.快速提取07版Word/Powerpoint/Excel中的图片 在2007版的Office中,用户不再能够像2003版一样直接把文档存储为Web格式提取图片.以07版的Word文档为例,07版的Word文档比03版的体积更小,实质上是一个压缩包,右键重命名,可以直接把扩展名改为zip,回车确定. 改名之后的Zip文件是可以用好压打开的,在文档名/

Mac使用小技巧:Fn键的妙用技巧

  大家有没有发现Mac的键盘上是没有Home, End, Page UP, Page DOWN这几个功能键?其实可以用Fn键来组合得到同样的功能,Mac键盘上的Fn键除了用来配合F1-F12实现一些特殊硬件功能外(如屏幕亮度.喇叭大小声.itunes的控制等),还有几个比较少人知道的用处,下面小编就给大家介绍几个关于Mac上Fn键的小技巧. 1.Home键=Fn+左方向 2.End键=Fn+右方向 3.PageUP=Fn+上方向 4.PageDOWN=Fn+下方向 5.向前Delete=Fn+

linux exec和文件描述符妙用技巧(转)

  最近在看<精通unix shell脚本编程>时,看到exec<$1 exec 1>$OUTFILE,一下看的我就蒙了.网上看了大半天,终于搞定,记录如下.对于 Linux 而言,所有对设备和文件的操作都使用文件描述符来进行的.文件描述符是一个非负的整数,它是一个索引值,并指向内核中每个进程打开文件的记录表.当打开一个现存文件或创建一个新文件时,内核就向进程返回一个文件描述符;当需要读写文件时,也需要把文件描述符作为参数传递给相应的函数.通常,一个进程启动时,都会打开 3 个文件

Foxmail另类妙用技巧两则

1.打造自己的"每日一帖" 在第一次启动Foxmail时,你看到过介绍使用技巧的"每日一帖"的提示吧,想把提示窗口中的内容换成你输入的信息吗?往下看. 用http://www.aliyun.com/zixun/aggregation/18444.html">文本编辑器(注:用Windows自带的"记事本"程序就可以了)打开安装目录下的Tips.Fox文件,这里虽然有一些英文,但那些汉字是不是很熟悉呢?呵呵,这些就是显示在"

javascript使用Promise对象实现异步编程_javascript技巧

Promise对象是CommonJS工作组为异步编程提供的统一接口,是ECMAScript6中提供了对Promise的原生支持,Promise就是在未来发生的事情,使用Promise可以避免回调函数的层层嵌套,还提供了规范更加容易的对异步操作进行控制.提供了reject,resolve,then和catch等方法. 使用PROMISE Promise是ES6之后原生的对象,我们只需要实例化Promise对象就可以直接使用. 实例化Promise: var promise = new Promis

JavaScript单线程和浏览器事件循环简述

JavaScript单线程 在上篇博客<Promise的前世今生和妙用技巧>的开篇中,我们曾简述了JavaScript的单线程机制和浏览器的事件模型.应很多网友的回复,在这篇文章中将继续展开这一个话题.当然这里是博主的一些理解,如果还存在什么纰漏的话,请不吝指教. JavaScript这门语言运行在浏览器中,是以单线程的方式运行的.说到单线程,就得从操作系统进程开始说起.进程和线程都是操作系统的概念.进程是应用程序的执行实例,每一个进程都是由私有的虚拟地址空间.代码.数据和其它系统资源所组成:

JavaScript多线程之HTML5 Web Worker

在博主的前些文章Promise的前世今生和妙用技巧和JavaScript单线程和浏览器事件循环简述中都曾提到了HTML5 Web Worker这一个概念.在JavaScript单线程和浏览器事件循环简述中讲述了JavaScript出于界面元素访问安全的考虑,所以JavaScript运行时一直是被实现为单线程执行的:这也意味着我们应该尽量的避免在JavaScript中执行较长耗时的操作(如大量for循环的对象diff等)或者是长时间I/O阻塞的任务,特别是对于CPU计算密集型的操作. 例如在Jav

HTML5 Web Worker 中的 JavaScript多线程讲解

JavaScript单线程 JavaScript这门语言运行在浏览器中,是以单线程的方式运行的.说到单线程,就得从操作系统进程开始说起.进程和线程都是操作系统的概念.进程是应用程序的执行实例,每一个进程都是由私有的虚拟地址空间.代码.数据和其它系统资源所组成;进程在运行过程中能够申请创建和使用系统资源(如独立的内存区域等),这些资源也会随着进程的终止而被销毁.而线程则是进程内的一个独立执行单元,在不同的线程之间是可以共享进程资源的,所以在多线程的情况下,需要特别注意对临界资源的访问控制.在系统创

node.js异步API与其局限性详解分析

为何要用异步API 异步的概念之所以首先在Web2.0中火起来,是因为在浏览器中Javascript在单线程上执行,而且他还与UI渲染公用一个线程.这意味着Javascript在执行的时候UI渲染和响应是处于停滞状态的.为了用户体验更好而采取异步的方式(当然,这在所谓的单线程语言中)不阻塞主线程继续响应用户操作.这属于用户体验的范畴. 同样的,如果有其他语言经验的工程师当然也明白,CPU在线程间切换是需要消耗大量的时间的(主要为上下文之间的切换和缓存),所以提高效率也是使用异步API的理由. 当