JavaScript 異步編程之 jsdeferred 原理解析

danielzpy 8年前發布 | 8K 次閱讀 異步編程 JavaScript開發 JavaScript

1. 前言

最近在看司徒正美的《JavaScript框架設計》,看到異步編程的那一章介紹了jsdeferred這個庫,覺得很有意思,花了幾天的時間研究了一下代碼,在此做一下分享。

異步編程是編寫js的一個很重要的理念,特別是在處理復雜應用的時候,異步編程的技巧就至關重要。那么下面就來看看這個被稱為 里程碑式 的異步編程庫吧。

2. API源碼解析

2.1 構造函數

這里使用了 安全的構造函數 ,避免了在沒有使用new調用構造函數時出錯的問題,提供了兩個形式倆獲取Deferred對象實例。

function Deferred() {
    return (this instanceof Deferred) ? this.init() : new Deferred();
}

// 方式1 var o1 = new Deferred(); // 方式2 var o2 = Deferred();</code></pre>

2.2 Deferred.define()

這個方法可以包裝一個對象,指定對象的方法,或者將Deferred對象的方法直接暴露在全局作用域下,這樣就可以直接使用。

Deferred.methods = ["parallel", "wait", "next", "call", "loop", "repeat", "chain"];
/
    @Param obj 賦予該對象Deferred的屬性方法
    @Param list 指定屬性方法/
Deferred.define = function(obj, list){
    if(!list)list = Deferred.methods;
    // 獲取全局作用域的技巧,利用立即執行函數的作用域為全局作用域的技巧
    if(!obj) obj = (function getGlobal(){return this})();
    // 將屬性都掛載到obj上
    for(var i = 0; i < list.length; i++){
        var n = list[i];
        obj[n] = Deferred[n];
    }
    return Deferred;
}

this.Deferred = Deferred;</code></pre>

2.3 異步的操作實現

在JSDeferred中有許多異步操作的實現方式,也是作為這個框架最為出彩的地方,方法依次是:

  1. script.onreadystatechange(針對IE5.5~8)

  2. img.onerror/img.onload(針對現代瀏覽器的異步操作方法)

  3. 針對node環境的,使用process.nextTick來實現異步調用(已經過時)

  4. setTimeout(default)

它會視瀏覽器選擇最快的API。

  1. 使用script的onreadystatechange事件來進行,需要注意的是由于瀏覽器對并發請求數有限制,(IE5.5~8為2~3,IE9+和現代瀏覽器為6),當并發請求數大于上限時,會讓請求的發起操作排隊執行,導致延時更嚴重。代碼的思路是以150ms為一個周期,每個周期以通過setTimeout發起的異步執行為起始,周期內的其他異步執行操作通過script請求實現,如果此方法被頻繁調用的話,說明達到并發請求數上限的可能性越高,因此可以下調一下周期時間,例如設為100ms,避免因排隊導致的高延時。

    Deferred.next_faster_way_readystatechange = ((typeof window === "object") && 
    (location.protocol == "http:") && 
    !window.opera &&
    /\bMSIE\b/.test(navigator.userAgent)) &&
    function (fun) {
    var d = new Deferred();
    var t = new Date().getTime();
    if(t - arguments.callee._prev_timeout_called < 150){
    var cancel = false; // 因為readyState會一直變化,避免重復執行
    var script = document.createElement("script");
    script.type = "text/javascript";
    // 發送一個錯誤的url,快速觸發回調,實現異步操作
    script.src = "data:text/javascript,";
    script.onreadystatechange = function () {
        if(!cancel){
            d.canceller();
            d.call();
        }
    };

    d.canceller = function () { if(!cancel){ cancel = true; script.onreadystatechange = null; document.body.removeChild(script);// 移除節點 } };

    // 不同于img,需要添加到文檔中才會發送請求 document.body.appendChild(script); } else { // 記錄或重置起始時間 arguments.callee._prev_timeout_called = t; // 每個周期開始使用setTimeout var id = setTimeout(function (){ d.call()}, 0); d.canceller = function () {clearTimeout(id)}; } if(fun)d.callback.ok = fun; return d; }</code></pre> </li> </ol>

    1. 使用img的方式,利用src屬性報錯和綁定事件回調的方式來進行異步操作

      Deferred.next_faster_way_Image = ((typeof window === "object") &&
      (typeof Image != "undefined") && 
      !window.opera && document.addEventListener) && 
      function (fun){
      var d = new Deffered();
      var img = new Image();
      var hander = function () {
      d.canceller();
      d.call();
      }
      img.addEventListener("load", handler, false);
      img.addEventListener("error", handler, false);

      d.canceller = function (){ img.removeEventListener("load", handler, false); img.removeEventListener("error", handler, false); } // 賦值一個錯誤的URL img.src = "data:imag/png," + Math.random(); if(fun) d.callback.ok = fun; return d; }</code></pre> </li>

    2. 針對Node環境的,使用process.nextTick來實現異步調用

      Deferred.next_tick = (typeof process === 'object' &&
      typeof process.nextTick === 'function') && 
      function (fun) {
      var d = new Deferred();
      process.nextTick(function() { d.call() });
      if (fun) d.callback.ok = fun;
      return d;
      };
    3. setTimeout的方式,這種方式有一個觸發最小的時間間隔,在舊的IE瀏覽器中,時間間隔可能會稍微長一點(15ms)。

      Deferred.next_default = function (fun) {
      var d = new Deferred();
      var id = setTimeout(function(){
      clearTimeout(id);
      d.call(); // 喚起Deferred調用鏈
      }, 0)
      d.canceller = function () {
      try{
          clearTimeout(id);
      }catch(e){}
      };
      if(fun){
      d.callback.ok = fun;
      }
      return d;
      }
    4. </ol>

      默認的順序為

      Deferred.next = 
          Deferred.next_faster_way_readystatechange || // 處理IE
          Deferred.next_faster_way_Image || // 現代瀏覽器
          Deferred.next_tick || // node環境
          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回調,只有在喚起調用鏈之前調用才有效。(調用鏈是單向的,執行之后就不可返回)

      Deferred.prototype = {
          _id : 0xe38286e381ae, // 用于判斷是否是實例的標識位
          init : function () {
              this._next = null; // 一種鏈表的實現思路
              this.callback = {
                  ok : Deferred.ok, // 默認的ok回調
                  ng : Deferred.ng  // 出錯時的回調
              };
              return this;
          },
          next : function (fun) {
              return this._post("ok", fun); // 調用_post建立鏈表
          },
          error : function (fun) {
              return this._post("ng", fun); // 調用_post建立鏈表
          },
          call : function(val) {
              return this._fire("ok", val); // 喚起next調用鏈
          },
          fail : function (err) {
              return this._fire("ng", err); // 喚起error調用鏈
          },
          cancel : function () {
              (this.canceller || function () {}).apply(this);
              return this.init(); // 進行重置
          },
          _post : function (okng, fun){ // 建立鏈表
              this._next = new Deferred();
              this._next.callback[okng] = fun;
              return this._next;
          },
          _fire : function (okng, fun){
              var next = "ok";
              try{
                  // 注冊的回調函數中,可能會拋出異常,用try-catch進行捕捉
                  value = this.callback[okng].call(this, value); 
              } catch(e) {
                  next = "ng";
                  value = e; // 傳遞出錯信息
                  if (Deferred.onerror) Deferred.onerror(e); // 發生錯誤的回調
              }
              if (Deferred.isDeferred(value)) { // 判斷是否是Deferred的實例
                  // 這里的代碼就是給Deferred.wait方法使用的,
                  value._next = this._next;
              } else { // 如果不是,則繼續執行
                  if (this._next) this._next._fire(next, value);
              }
              return this;
          }
      }

      2.5 輔助靜態方法

      上面的代碼中,可以看到一些Deferred對象的方法(靜態方法),下面簡單介紹一下:

      // 默認的成功回調
      Deferred.ok = function (x) {return x};

      // 默認的失敗回調 Deferred.ng = function (x) {throw x};

      // 根據_id判斷實例的實現 Deferred.isDeferred = function (obj) { return !!(obj && obj._id === Deferred.prototype._id); }</code></pre>

      2.6 簡單小結

      看到這里,我們需要停下來,看看一個簡單的例子,來理解整個流程。

      Defferred對象自身有 next 屬性方法,在原型上也定義了 next 方法,需要注意這一點,例如以下代碼:

      var o = {};
      Deferred.define(o);
      o.next(function fn1(){
          console.log(1);
      }).next(function fn2(){
          console.log(2);
      });
      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變成異步執行,代碼如下:

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

      wait方法很有意思,在Deferred的原型上并沒有wait方法,而是在靜態方法上找到了。

      Deferred.wait = function (n) {
          var d = new Deferred(),
              t = new Date();
          // 使用定時器來變成異步操作
          var id = setTimeout(function () {
              d.call((new Date()).getTime() - t.getTime());
          }, n * 1000);

      d.canceller = function () {
          clearTimeout(id);
      }
      return d;
      

      }</code></pre>

      那么這個方法是怎么放到原型上的?原來是通過 Deferred.register 進行函數轉換,綁定到原型上的。

      Deferred.register = function (name, fun){
          this.prototype[name] = function () { // 柯里化
              var a = arguments;
              return this.next(function(){
                  return fun.apply(this, a);
              });
          }
      };

      // 將方法注冊到原型上 Deferred.register("wait", Deferred.wait);</code></pre>

      我們需要思考 為什么要用這種方式將wait方法register到Deferred的原型對象上去? ,因為明顯這種方式有點難以理解。

      結合例子,我們進行討論,便能夠徹底地理解上述的問題。

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

      這段代碼首先會建立一個調用鏈

      之后,執行的過程為(如圖所示)

      我們來看看執行過程的幾個關鍵點

      1. 圖中的d1、d2、d3、d_wait表示在調用鏈上生成的Deferred對象的實例

      2. 在調用了d2的callback.ok即包裝了wait()方法的匿名函數之后,返回了在wait()方法中生成的Deferred對象的實例d_wait,保存在變量value中,在_fire()方法中有一個if判斷

        if(Deferred.isDeferred(value)){
            value._next = this._next;
        }
      在這里并沒有繼續往下執行調用鏈的函數,而是重新建立了一個調用鏈,此時鏈頭為d_wait,在wait()方法中使用setTimeout,使其異步執行,使用d.call()重新喚起調用鏈。

      理解了整個過程,就比較好回到上面的問題了。之所以使用register的方式是因為原型上的wait方法并非直接使用Deferred.wait,而是把Deferred.wait方法作為參數,對原型上的next()方法進行curry化,然后返回一個柯里化之后的next()方法。而Deferred.wait()其實和Deferred.next()的作用很類似,都是異步執行接下來的操作。

      2.8 并歸結果 parallel

      設想一個場景,我們需要多個異步網絡查詢任務,這些任務沒有依賴關系,不需要區分前后,但是需要等待所有查詢結果回來之后才能進一步處理,那么你會怎么做?在比較復雜的應用中,這個場景經常會出現,如果我們采用以下的方式(見偽代碼)

      var result = [];
      $.ajax("task1", function(ret1){
          result.push(ret1);
          $.ajax("task2", function(ret2){
              result.push(ret2);
              // 進行操作
          });
      });

      這種方式可以,但是卻無法同時發送 task1 和 task2 (從代碼上看還以為之間有依賴關系,實際上沒有)。那怎么解決?這就是Deferred.parallel()所要解決的問題。

      我們先來個簡單的例子感受一下這種并歸結果的方式。

      Deferred.parallel(function () {
          return 1;
      }, function () {
          return 2;
      }, function () {
          return 3;
      }).next(function (a) {
          console.log(a); // [1,2,3]
      });

      在parallel()方法執行之后,會將結果合并為一個數組,然后傳遞給next()中的callback.ok中。可以看到parallel里面都是同步的方法,先來看看parallel的源碼是如何實現,再來看看能不能結合所學來改造實現我們所需要的ajax的效果。

      Deferred.parallel = function (dl) {
          /* 
              前面都是對參數的處理,可以接收三種形式的參數

          1. parallel(fn1, fn2, fn3).next()
          2. parallel({
                  foo : $.get("foo.html"),
                  bar : $.get("bar.html")
              }).next(function (v){
                  v.foo // => foo.html data
                  v.bar // => bar.html data
              });
          3. parallel([fn1, fn2, fn3]).next(function (v) {
                  v[0] // fn1執行的結果
                  v[1] // fn2執行的結果
                  v[3] // fn3執行返回的結果
              });
      */
      var isArray = false;
      // 第一種形式
      if (arguments.length > 1) {
          dl = Array.prototype.slice.call(arguments);
          isArray = true;
      // 其余兩種形式,數組,類數組
      } else if (Array.isArray && Array.isArray(dl) 
                  || typeof dl.length == "number") {
          isArray = true;
      }
      var ret = new Deferred(), // 用于歸并結果的Deferred對象的實例
          value = {}, // 收集函數執行的結果
          num = 0 ; // 計數器,當為0時說明所有任務都執行完畢
      
      // 開始遍歷,這里使用for-in其實效率不高
      for (var i in dl) {
          // 預防遍歷了所有屬性,例如toString之類的
          if (dl.hasOwnProperty(i)) {
              // 利用閉包保存變量狀態
              (function (d, i){
                  // 使用Deferred.next()開始一個異步任務,并且執行完成之后,收集結果
                  if (typeof d == "function") dl[i] = d = Deferred.next(d);
                  d.next(function (v) {
                      values[i] = v;
                      if( --num <= 0){ // 計數器為0說明所有任務已經完成,可以返回
                          if(isArray){ // 如果是數組的話,結果可以轉換成數組
                              values.length = dl.length;
                              values = Array.prototype.slice.call(values, 0);
                          }
                          // 調用parallel().next(function(v){}),喚起調用鏈
                          ret.call(values);
                      }
                  }).error(function (e) {
                      ret.fail(e);
                  });
                  num++; // 計數器加1
              })(d[i], i);
          } 
      }
      
      // 當計算器為0的時候,處理可能沒有參數或者非法參數的情況
      if (!num) {
          Deferred.next(function () { 
              ret.call();
          });
      } 
      
      ret.canceller = function () {
          for (var i in dl) {
              if (dl.hasOwnProperty(i)) {
                  dl[i].cancel();
              }
          }
      };
      return ret; // 返回Deferred實例
      

      };</code></pre>

      結合上述知識,我們可以在parallel中使用異步方法,代碼如下

      Deferred.parallel(function fn1(){
          var d = new Deferred();
          $.ajax("task1", function(ret1){
              d.call(ret1);
          });
          return d;
      }, function () {
          var d = new Deferred();
          $.ajax("task2", function fn2(ret2) {
              d.call(ret2)
          });
          return d;
      }).next(function fn3(ret) {
          ret[0]; // => task1返回的結果
          ret[1]; // => task2返回的結果
      });

      為什么可以這樣?我們來圖解一下,加深一下理解。

      我們使用了_fire中的if判斷,建立了新的調用鏈,獲得去統計計數函數(即parallel中--num)的控制權,從而使得在parallel執行異步的方法。

      問題解決!

      考慮到篇幅問題,其他的源碼分析放在了我自己的 gitbook 上,歡迎交流探討。

      參考資料

      1. jsdeferred.js

      2. jsDeferred API

      3. JavaScript框架設計

       

      來自:https://segmentfault.com/a/1190000007216755

       

 本文由用戶 danielzpy 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!