JavaScript异步编程之jsdeferred原理解析

1. 前言

最近在看司徒正美的《JavaScript框架设计》,看到异步编程的那一章介绍了jsdeferred这个库,觉得很有意思,花了几天的时间研究了一下代码,在此做一下分享。

异步编程是编写js的一个很重要的理念,特别是在处理复杂应用的时候,异步编程的技巧就至关重要。那么下面就来看看这个被称为里程碑式的异步编程库吧。

2. API源码解析

2.1 构造函数

这里使用了安全的构造函数,避免了在没有使用new调用构造函数时出错的问题,提供了两个形式俩获取Deferred对象实例。


  1. function Deferred() { 
  2.     return (this instanceof Deferred) ? this.init() : new Deferred(); 
  3.  
  4. // 方式1  
  5. var o1 = new Deferred(); 
  6. // 方式2 
  7. var o2 = Deferred();  

2.2 Deferred.define()

这个方法可以包装一个对象,指定对象的方法,或者将Deferred对象的方法直接暴露在全局作用域下,这样就可以直接使用。


  1. Deferred.methods = ["parallel""wait""next""call""loop""repeat""chain"]; 
  2. /* 
  3.     @Param obj 赋予该对象Deferred的属性方法 
  4.     @Param list 指定属性方法 
  5. */ 
  6. Deferred.define = function(obj, list){ 
  7.     if(!list)list = Deferred.methods; 
  8.     // 获取全局作用域的技巧,利用立即执行函数的作用域为全局作用域的技巧 
  9.     if(!obj) obj = (function getGlobal(){return this})(); 
  10.     // 将属性都挂载到obj上 
  11.     for(var i = 0; i < list.length; i++){ 
  12.         var n = list[i]; 
  13.         obj[n] = Deferred[n]; 
  14.     } 
  15.     return Deferred; 
  16.  
  17. this.Deferred = Deferred;  

2.3 异步的操作实现

在JSDeferred中有许多异步操作的实现方式,也是作为这个框架最为出彩的地方,方法依次是:

  • script.onreadystatechange(针对IE5.5~8)
  • img.onerror/img.onload(针对现代浏览器的异步操作方法)
  • 针对node环境的,使用process.nextTick来实现异步调用(已经过时)
  • setTimeout(default)

它会视浏览器选择最快的API。

1).使用script的onreadystatechange事件来进行,需要注意的是由于浏览器对并发请求数有限制,(IE5.5~8为2~3,IE9+和现代浏览器为6),当并发请求数大于上限时,会让请求的发起操作排队执行,导致延时更严重。代码的思路是以150ms为一个周期,每个周期以通过setTimeout发起的异步执行为起始,周期内的其他异步执行操作通过script请求实现,如果此方法被频繁调用的话,说明达到并发请求数上限的可能性越高,因此可以下调一下周期时间,例如设为100ms,避免因排队导致的高延时。


  1. Deferred.next_faster_way_readystatechange = ((typeof window === "object") &&  
  2. (location.protocol == "http:") &&  
  3. !window.opera && 
  4. /\bMSIE\b/.test(navigator.userAgent)) && 
  5. function (fun) { 
  6. var d = new Deferred(); 
  7. var t = new Date().getTime(); 
  8. if(t - arguments.callee._prev_timeout_called < 150){ 
  9. var cancel = false; // 因为readyState会一直变化,避免重复执行 
  10. var script = document.createElement("script"); 
  11. script.type = "text/javascript"
  12. // 发送一个错误的url,快速触发回调,实现异步操作 
  13. script.src = "data:text/javascript,"
  14. script.onreadystatechange = function () { 
  15.     if(!cancel){ 
  16.         d.canceller(); 
  17.         d.call(); 
  18.     } 
  19. }; 
  20.  
  21. d.canceller = function () { 
  22.     if(!cancel){ 
  23.         cancel = true
  24.         script.onreadystatechange = null
  25.         document.body.removeChild(script);// 移除节点 
  26.     } 
  27. }; 
  28.  
  29. // 不同于img,需要添加到文档中才会发送请求 
  30. document.body.appendChild(script); 
  31. else { 
  32. // 记录或重置起始时间 
  33. arguments.callee._prev_timeout_called = t;  
  34. // 每个周期开始使用setTimeout 
  35. var id = setTimeout(function (){ d.call()}, 0); 
  36. d.canceller = function () {clearTimeout(id)}; 
  37. if(fun)d.callback.ok = fun; 
  38. return d; 

2).使用img的方式,利用src属性报错和绑定事件回调的方式来进行异步操作


  1. Deferred.next_faster_way_Image = ((typeof window === "object") && 
  2. (typeof Image != "undefined") &&  
  3. !window.opera && document.addEventListener) &&  
  4. function (fun){ 
  5. var d = new Deffered(); 
  6. var img = new Image(); 
  7. var hander = function () { 
  8. d.canceller(); 
  9. d.call(); 
  10. img.addEventListener("load", handler, false); 
  11. img.addEventListener("error", handler, false); 
  12.  
  13. d.canceller = function (){ 
  14. img.removeEventListener("load", handler, false); 
  15. img.removeEventListener("error", handler, false); 
  16. // 赋值一个错误的URL 
  17. img.src = "data:imag/png," + Math.random(); 
  18. if(fun) d.callback.ok = fun; 
  19. return d; 
  20. }  

3).针对Node环境的,使用process.nextTick来实现异步调用


  1. Deferred.next_tick = (typeof process === 'object' && 
  2. typeof process.nextTick === 'function') &&  
  3. function (fun) { 
  4. var d = new Deferred(); 
  5. process.nextTick(function() { d.call() }); 
  6. if (fun) d.callback.ok = fun; 
  7. return d; 
  8. };  

4).setTimeout的方式,这种方式有一个触发最小的时间间隔,在旧的IE浏览器中,时间间隔可能会稍微长一点(15ms)。


  1. Deferred.next_default = function (fun) { 
  2. var d = new Deferred(); 
  3. var id = setTimeout(function(){ 
  4. clearTimeout(id); 
  5. d.call(); // 唤起Deferred调用链 
  6. }, 0) 
  7. d.canceller = function () { 
  8. try{ 
  9.     clearTimeout(id); 
  10. }catch(e){} 
  11. }; 
  12. if(fun){ 
  13. d.callback.ok = fun; 
  14. return d; 
  15. }  

默认的顺序为


  1. Deferred.next =  
  2.     Deferred.next_faster_way_readystatechange || // 处理IE 
  3.     Deferred.next_faster_way_Image || // 现代浏览器 
  4.     Deferred.next_tick || // node环境 
  5.     Deferred.next_default; // 默认行为  

根据JSDeferred官方的数据,使用next_faster_way_readystatechange和next_faster_way_Image这两个比原有的setTimeout异步的方式快上700%以上。

看了一下数据,其实对比的浏览器版本都相对比较旧,在现代的浏览器中性能提升应该就没有那么明显了。

2.4 原型方法

Deferred的原型方法中实现了

  1. _id 用来判断是否是Deferred的实例,原因好像是Mozilla有个插件也叫Deferred,因此不能通过instanceof来检测。cho45于是自定义标志位来作检测,并在github上提交fxxking Mozilla。
  2. init 初始化,给每个实例附加一个_next和callback属性
  3. next 用于注册调用函数,内部以链表的方式实现,节点为Deferred实例,调用的内部方法_post
  4. error 用于注册函数调用失败时的错误信息,与next的内部实现一致。
  5. call 唤起next调用链
  6. fail 唤起error调用链
  7. cancel 执行cancel回调,只有在唤起调用链之前调用才有效。(调用链是单向的,执行之后就不可返回)

  1. Deferred.prototype = { 
  2.     _id : 0xe38286e381ae, // 用于判断是否是实例的标识位 
  3.     init : function () { 
  4.         this._next = null; // 一种链表的实现思路 
  5.         this.callback = { 
  6.             ok : Deferred.ok, // 默认的ok回调 
  7.             ng : Deferred.ng  // 出错时的回调 
  8.         }; 
  9.         return this; 
  10.     }, 
  11.     next : function (fun) { 
  12.         return this._post("ok", fun); // 调用_post建立链表 
  13.     }, 
  14.     error : function (fun) { 
  15.         return this._post("ng", fun); // 调用_post建立链表 
  16.     }, 
  17.     call : function(val) { 
  18.         return this._fire("ok", val); // 唤起next调用链 
  19.     }, 
  20.     fail : function (err) { 
  21.         return this._fire("ng", err); // 唤起error调用链 
  22.     }, 
  23.     cancel : function () { 
  24.         (this.canceller || function () {}).apply(this); 
  25.         return this.init(); // 进行重置 
  26.     }, 
  27.     _post : function (okng, fun){ // 建立链表 
  28.         this._next = new Deferred(); 
  29.         this._next.callback[okng] = fun; 
  30.         return this._next; 
  31.     }, 
  32.     _fire : function (okng, fun){ 
  33.         var next = "ok"
  34.         try{ 
  35.             // 注册的回调函数中,可能会抛出异常,用try-catch进行捕捉 
  36.             value = this.callback[okng].call(this, value);  
  37.         } catch(e) { 
  38.             next = "ng"
  39.             value = e; // 传递出错信息 
  40.             if (Deferred.onerror) Deferred.onerror(e); // 发生错误的回调 
  41.         } 
  42.         if (Deferred.isDeferred(value)) { // 判断是否是Deferred的实例 
  43.             // 这里的代码就是给Deferred.wait方法使用的, 
  44.             value._next = this._next; 
  45.         } else { // 如果不是,则继续执行 
  46.             if (this._next) this._next._fire(next, value); 
  47.         } 
  48.         return this; 
  49.     } 
  50. }  

2.5 辅助静态方法

上面的代码中,可以看到一些Deferred对象的方法(静态方法),下面简单介绍一下:


  1. // 默认的成功回调 
  2. Deferred.ok = function (x) {return x}; 
  3.  
  4. // 默认的失败回调 
  5. Deferred.ng = function (x) {throw x}; 
  6.  
  7. // 根据_id判断实例的实现 
  8. Deferred.isDeferred = function (obj) { 
  9.     return !!(obj && obj._id === Deferred.prototype._id); 

2.6 简单小结

看到这里,我们需要停下来,看看一个简单的例子,来理解整个流程。

Defferred对象自身有next属性方法,在原型上也定义了next方法,需要注意这一点,例如以下代码:


  1. var o = {}; 
  2. Deferred.define(o); 
  3. o.next(function fn1(){ 
  4.     console.log(1); 
  5. }).next(function fn2(){ 
  6.     console.log(2); 
  7. });  
  1. o.next()是Deffered对象的属性方法,这个方法会返回一个Defferred对象的实例,因此下一个next()则是原型上的next方法。
  2. 第一个next()方法将后续的代码变成异步操作,后面的next()方法实际上是注册调用函数。
  3. 在第一个next()的异步操作里面唤起后面next()的调用链(d.call()),开始顺序的调用,换句话说就是,fn1和fn2是同步执行的。

那么,如果我们希望fn1和fn2也是异步执行,而不是同步执行的,这就得借助Deferred.wait方法了。

2.7 wait & register

我们可以使用wait来让fn1和fn2变成异步执行,代码如下:


  1. Deferred.next(function fn1() { 
  2.     console.log(1) 
  3. }).wait(0).next(function fn2() { 
  4.     console.log(2) 
  5. });  

wait方法很有意思,在Deferred的原型上并没有wait方法,而是在静态方法上找到了。


  1. Deferred.wait = function (n) { 
  2.     var d = new Deferred(), 
  3.         t = new Date(); 
  4.     // 使用定时器来变成异步操作 
  5.     var id = setTimeout(function () { 
  6.         d.call((new Date()).getTime() - t.getTime()); 
  7.     }, n * 1000); 
  8.  
  9.     d.canceller = function () { 
  10.         clearTimeout(id); 
  11.     } 
  12.     return d; 
  13. }  

那么这个方法是怎么放到原型上的?原来是通过Deferred.register进行函数转换,绑定到原型上的。


  1. Deferred.register = function (name, fun){ 
  2.     this.prototype[name] = function () { // 柯里化 
  3.         var a = arguments; 
  4.         return this.next(function(){ 
  5.             return fun.apply(this, a); 
  6.         }); 
  7.     } 
  8. }; 
  9.  
  10. // 将方法注册到原型上 
  11. Deferred.register("wait", Deferred.wait);  

我们需要思考为什么要用这种方式将wait方法register到Deferred的原型对象上去?,因为明显这种方式有点难以理解。

结合例子,我们进行讨论,便能够彻底地理解上述的问题。


  1. Deferred.next(function fn1(){ // d1 
  2.     console.log(1); 
  3. }) 
  4. .wait(1) // d2 
  5. .next(function fn2(){ // d3 
  6.     console.log(2); 
  7. });  

这段代码首先会建立一个调用链

JavaScript异步编程之jsdeferred原理解析

之后,执行的过程为(如图所示)

JavaScript异步编程之jsdeferred原理解析

我们来看看执行过程的几个关键点

  1. 图中的d1、d2、d3、d_wait表示在调用链上生成的Deferred对象的实例
  2. 在调用了d2的callback.ok即包装了wait()方法的匿名函数之后,返回了在wait()方法中生成的Deferred对象的实例d_wait,保存在变量value中,在_fire()方法中有一个if判断

  1. if(Deferred.isDeferred(value)){ 
  2.     value._next = this._next; 
  3. }  

在这里并没有继续往下执行调用链的函数,而是重新建立了一个调用链,此时链头为d_wait,在wait()方法中使用setTimeout,使其异步执行,使用d.call()重新唤起调用链。

理解了整个过程,就比较好回到上面的问题了。之所以使用register的方式是因为原型上的wait方法并非直接使用Deferred.wait,而是把Deferred.wait方法作为参数,对原型上的next()方法进行curry化,然后返回一个柯里化之后的next()方法。而Deferred.wait()其实和Deferred.next()的作用很类似,都是异步执行接下来的操作。

2.8 并归结果 parallel

设想一个场景,我们需要多个异步网络查询任务,这些任务没有依赖关系,不需要区分前后,但是需要等待所有查询结果回来之后才能进一步处理,那么你会怎么做?在比较复杂的应用中,这个场景经常会出现,如果我们采用以下的方式(见伪代码)


  1. var result = []; 
  2. $.ajax("task1"function(ret1){ 
  3.     result.push(ret1); 
  4.     $.ajax("task2"function(ret2){ 
  5.         result.push(ret2); 
  6.         // 进行操作 
  7.     }); 
  8. });  

这种方式可以,但是却无法同时发送task1和task2(从代码上看还以为之间有依赖关系,实际上没有)。那怎么解决?这就是Deferred.parallel()所要解决的问题。

我们先来个简单的例子感受一下这种并归结果的方式。


  1. Deferred.parallel(function () { 
  2.     return 1; 
  3. }, function () { 
  4.     return 2; 
  5. }, function () { 
  6.     return 3; 
  7. }).next(function (a) { 
  8.     console.log(a); // [1,2,3] 
  9. });  

在parallel()方法执行之后,会将结果合并为一个数组,然后传递给next()中的callback.ok中。可以看到parallel里面都是同步的方法,先来看看parallel的源码是如何实现,再来看看能不能结合所学来改造实现我们所需要的ajax的效果。


  1. Deferred.parallel = function (dl) { 
  2.     /*  
  3.         前面都是对参数的处理,可以接收三种形式的参数  
  4.         1. parallel(fn1, fn2, fn3).next() 
  5.         2. parallel({ 
  6.                 foo : $.get("foo.html"), 
  7.                 bar : $.get("bar.html"
  8.             }).next(function (v){ 
  9.                 v.foo // => foo.html data 
  10.                 v.bar // => bar.html data 
  11.             }); 
  12.         3. parallel([fn1, fn2, fn3]).next(function (v) { 
  13.                 v[0] // fn1执行的结果 
  14.                 v[1] // fn2执行的结果 
  15.                 v[3] // fn3执行返回的结果 
  16.             }); 
  17.     */ 
  18.     var isArray = false
  19.     // 第一种形式 
  20.     if (arguments.length > 1) { 
  21.         dl = Array.prototype.slice.call(arguments); 
  22.         isArray = true
  23.     // 其余两种形式,数组,类数组 
  24.     } else if (Array.isArray && Array.isArray(dl)  
  25.                 || typeof dl.length == "number") { 
  26.         isArray = true
  27.     } 
  28.     var ret = new Deferred(), // 用于归并结果的Deferred对象的实例 
  29.         value = {}, // 收集函数执行的结果 
  30.         num = 0 ; // 计数器,当为0时说明所有任务都执行完毕 
  31.      
  32.     // 开始遍历,这里使用for-in其实效率不高 
  33.     for (var i in dl) { 
  34.         // 预防遍历了所有属性,例如toString之类的 
  35.         if (dl.hasOwnProperty(i)) { 
  36.             // 利用闭包保存变量状态 
  37.             (function (d, i){ 
  38.                 // 使用Deferred.next()开始一个异步任务,并且执行完成之后,收集结果 
  39.                 if (typeof d == "function") dl[i] = d = Deferred.next(d); 
  40.                 d.next(function (v) { 
  41.                     values[i] = v; 
  42.                     if( --num <= 0){ // 计数器为0说明所有任务已经完成,可以返回 
  43.                         if(isArray){ // 如果是数组的话,结果可以转换成数组 
  44.                             values.length = dl.length; 
  45.                             values = Array.prototype.slice.call(values, 0); 
  46.                         } 
  47.                         // 调用parallel().next(function(v){}),唤起调用链 
  48.                         ret.call(values); 
  49.                     } 
  50.                 }).error(function (e) { 
  51.                     ret.fail(e); 
  52.                 }); 
  53.                 num++; // 计数器加1 
  54.             })(d[i], i); 
  55.         }  
  56.     } 
  57.      
  58.     // 当计算器为0的时候,处理可能没有参数或者非法参数的情况 
  59.     if (!num) { 
  60.         Deferred.next(function () {  
  61.             ret.call(); 
  62.         }); 
  63.     }  
  64.  
  65.     ret.canceller = function () { 
  66.         for (var i in dl) { 
  67.             if (dl.hasOwnProperty(i)) { 
  68.                 dl[i].cancel(); 
  69.             } 
  70.         } 
  71.     }; 
  72.     return ret; // 返回Deferred实例 
  73. };  

结合上述知识,我们可以在parallel中使用异步方法,代码如下


  1. Deferred.parallel(function fn1(){ 
  2.     var d = new Deferred(); 
  3.     $.ajax("task1"function(ret1){ 
  4.         d.call(ret1); 
  5.     }); 
  6.     return d; 
  7. }, function () { 
  8.     var d = new Deferred(); 
  9.     $.ajax("task2"function fn2(ret2) { 
  10.         d.call(ret2) 
  11.     }); 
  12.     return d; 
  13. }).next(function fn3(ret) { 
  14.     ret[0]; // => task1返回的结果 
  15.     ret[1]; // => task2返回的结果 
  16. });  

为什么可以这样?我们来图解一下,加深一下理解。

JavaScript异步编程之jsdeferred原理解析

我们使用了_fire中的if判断,建立了新的调用链,获得去统计计数函数(即parallel中--num)的控制权,从而使得在parallel执行异步的方法。

问题解决!

考虑到篇幅问题,其他的源码分析放在了我自己的gitbook上,欢迎交流探讨。

参考资料

  1. jsdeferred.js
  2. jsDeferred API
  3. JavaScript框架设计
作者:Gut
来源:51CTO
上一篇:面试官:线程池多余的线程是如何回收的?


下一篇:使用TensorFlow的卷积神经网络识别自己的单个手写数字,填坑总结