用 Generator 實現 JS 異步流程控制

natural92 7年前發布 | 8K 次閱讀 JavaScript開發 JavaScript ECMAScript

前言

當初, JavaScript 引入異步(Asynchonrous)主要是為了解決瀏覽器端 同步IO 造成的UI假死現象,但是主流的編程語言和Web服務器都采取 同步IO 的模式,原因無非是:

  1. 采用 同步IO 編寫的代碼符合人和直覺,代碼容易編寫和維護。
  2. 對于 同步IO 造成的線程阻塞可以通過創建多線程(進程)的方式,通過增加服務器數量進行橫向擴展來解決。

但是,在很多情況下,這種方式并不能很好地解決問題。比如對于靜態資源服務器(CDN服務器)來說,每時每刻要處理大量的文件請求,如果對于每個請求都新開一個線程(進程),可想而知,性能開銷是很大的,而且有種殺雞用牛刀的感覺。所以 Nginx 采用了和 JavaScript 相同的策略來解決這個問題——單線程、非阻塞、異步IO。這樣,當一個 IO 操作開始的時候, Nginx 不會等待操作完成就會去處理下一個請求,等到某個 IO 操作完成后, Nginx 再回過頭去處理(回調)這次 IO 的后續工作。

然后2009年 NodeJS 的發布,又極大的推進了 異步IO 在服務端的應用,據聞, NodeJS 在處理阿里巴巴“雙十一”的海量請求高并發中發揮了很大的作用。

所以, 異步IO 真是個好東西,但是,編寫異步代碼卻有一個無法回避的問題——回調函數嵌套太多、過多的回調層級造成閱讀和維護上的困難——俗稱“回調地獄(callback hell)”。

為了解決這個難題,出現了各種各樣的解決方案。最先出來的方案是利用任務隊列控制異步流程,著名的代表有 Async 。然后 Promises/A+ 規范出來了,人們根據這個規范實現了 Q 。那時候 Async 和 Q 各占邊壁江山,兩邊都有不少忠實的擁躉, 雖然它們解決問題的思路不同,但是都很好的解決了地獄回調的問題。隨著 ES6(ECMA Script 6) 將 Promise 標準納入旗下, Promise 成了真正意義上解決地獄回調的最佳解決方案(在支持 ES6 的環境中,開箱即用,不用引入第三方庫)。

但是,雖然 Async 和 Promise 之流都在代碼層面避免了地獄回調,但是代碼組織結構上并沒有完全擺脫異步的影子,和純同步的寫法相比,還是有很大的不同,寫起來還是略麻煩。幸好, ES6 引入了 Generator 的概念,利用 Generator 就可以很好的解決這個問題了。

可以看到,經過Generator重寫后,代碼形式上和我們熟悉的同步代碼沒什么二樣了。?

下面我們就來介紹這種神奇的黑魔法!

Generator 簡介

Generator 是 ES6 新引進的關鍵字,它用來定義一個 Generator ,用法和定義一個普通的函數(function)幾乎一樣,只是在 function 關鍵字和函數名之前加入了星號 * 。 Generator 最大的特點就是定義的函數可以被暫停執行,很類似我們打斷點調試代碼:點 Run 按鈕代碼就自動執行當前語句直到遇到下一個斷點并暫停,不同的是 Generator 的這種暫停態和執行態是由代碼來定義和控制的。

在 Generator 里, yield 關鍵字用來定義代碼暫停的地方,類似于給代碼打斷點(但不是真的打斷點,不要和 debugger 關鍵字混淆),而 generator.next(value) 則用來控制代碼的運行并處理輸入輸出。下面用代碼來說明:

/**
*@description 獲取自然數
*/
function *getNaturalNumber(){
  var seed = 0;
  while(true) {
    yieldseed ++;
  }
}
var gen = getNaturalNumber();// 實例化一個Generator
/* 啟動Generator */
console.log(gen.next()) //{value: 0, done: false}
console.log(gen.next()) //{value: 1, done: false}
console.log(gen.next()) //{value: 2, done: false}

這是一個利用 Generator 實現的自然數生成器。通過實例化一個 Generator ,然后每次通過 gen.next() 取得一個自然數,此過程可以無限進行下去。不過值得注意的是,通過 gen.next() 取得的輸出是一個對象,包含 value 和 done 兩個屬性,其中 value 是真正返回的值,而 done 則用來標識 Generator 是否已經執行完畢。因為自然數生成器是一個無限循環,所以不存在 done: true 的情況。

這個例子比較簡單,下面來個稍微復雜點的例子(涉及到輸入和輸出)。

/**

  • @description 處理輸入和輸出 / function input(){     letarray = [];     while(i) {         array.push(yieldarray);     } } var gen = input(); console.log(gen.next("西")) // { value: [], done: false } console.log(gen.next("部")) // { value: [ '部' ], done: false } console.log(gen.next("世")) // { value: [ '部', '世' ], done: false } console.log(gen.next("界")) // { value: [ '部', '世', '界' ], done: false } </code></pre>

    有人可能會對執行結果有疑問,不清楚外部的數據是如何傳到 Generator 內部的,可能會猜想是通過 gen.next("西") 這句話傳進去的,但是問題又來了,為什么 '部', '世', '界' 都傳進去了, '西' 去哪了?別急,且聽我慢慢道來。

    首先我們要明白 yield 其實由兩個動作組成, 輸入 + 輸出 (輸入在輸出前面),每次執行 next ,代碼會暫停在 yield 輸出 執行后,其它的語句不再執行( 很重要 )。其次對于上面的例子來說,兩次 next() 才真正執行完一次while循環。比如上面的例子里,為什么第一次輸出的是 [] ,而不是 ['西'] 呢?那是因為第一次執行 gen.next("西") 的時候,首先會將 '西' 傳進去,但是并沒有接受的對象,雖然 西 確實是被傳進來了,但是最后被丟棄了;然后代碼執行完 yield array 輸出之后就暫停。然后第二次執行 gen.next("部") 的時候,會先執行輸入操作,執行 array.push('部') , 然后進行第二次循環,執行輸出操作。

    現在總結一下:

    1. 每個 yield 將代碼分割成兩個部分,需要執行兩次 next 才能執行完。
    2. yield 其實由兩個動作組成, 輸入 + 輸出 (輸入在輸出前面),每次執行 next ,代碼會暫停在 yield 輸出 執行后,其它的語句不再執行( 很重要 )。

    如何利用Generator進行異步流程控制?

    利用 Generaotr 可以暫停代碼執行的特性,我們通過將異步操作用 yield 關鍵字進行修飾,每當執行異步操作的時候,代碼便在此暫停執行了。異步操作結束后,通過在回調函數里利用 next(data) 來控制 Generator 的執行流程,并順便將異步操作的結果 data 回傳給 Generator ,執行下一步。到此,整個異步流程得到了完美的控制,我們可以看一個小例子

    可以看到,Generator確實可以幫助我們來控制異步流程,但是上面的代比較很raw,存在以下兩個問題:

    • 不能自動運行,需要手動啟動。
    • 不能流程控制的代碼需要自己寫在異步回調函數里,且沒有通用性。

    所以我們需要構造一個運行器,自動處理上面提到的兩個問題。

    TJ大神的 co 就是用來解決這個問題的。

    下面我來詳細說一下解決此問題的兩種方法:利用 Thunk 和 Promise

    利用Thunk來構造generator自動運行器

    這里引入了一個新的概念——thunk( 讀音 [θ??k] ),為了幫助理解,下面單獨來介紹一下thunk。

    Thunk

    維基百科上的介紹如下:

    In computer programming , a thunk is a subroutine that is created, often automatically, to assist a call to another subroutine. Thunks are primarily used to represent an additional calculation that a subroutine needs to execute, or to call a routine that does not support the usual calling mechanism. They have a variety of other applications to compiler code generation ) and in modular programming .

    可以簡單理解為,thunk就是為了滿足函數(子程序)調用的 特殊需要 ,對原函數(子程序)進行了特殊的改造,主要用在編譯器的代碼生成(傳名調用)和模塊化編程中。

    在 JavaScript 中的 thunk 化指的是將多參數函數,將其替換成單參數的版本,且只接受回調函數作為參數,比如 NodeJs 的 fs.readFile 函數, tnunk 化為:

    var fs = require("fs");
    var readFile = function(filename){ // 包裝為高階函數
      return function(cb){
        fs.readFile(filename, cb);
      }
    }
    

    為了接下來的方便,我們在這里先構造一個 thunkify 函數,專門對函數進行 thunk 化:

    function thunkify(fn){
      return function(){
        var args = [].slice.call(arguments);
        var pass; 
        args.push(function(){
          if(pass) pass.apply(null, arguments);
    }); // 植入回調函數,里面包含控制邏輯
        fn.apply(null, args);
        return function(fn) {
          pass = fn; // 外部可注入的控制邏輯
        }
      }
    }
    

    運行器

    現在開始構造我們的運行器。思路也很簡單,運行器接受一個 Generator 函數,實例化一個 Generator 對象,然后啟動任務,通過 next() 取得返回值,這個返回值其實是一個函數,提供了一個入口可以讓我們可以方便的注入控住邏輯,包括:控制 Generator 向下執行、將異步執行的結果返回給 Generator 。

    function run(generator){
      var gen = generator();
      function next(data) {
        var ret = gen.next(data); // 將數據傳回Generator
        if(ret.done) return;
        ret.value(function(err, data){
          if(err) throw(err);
          next(data);
    });
        next(); // 啟動任務
      }
    }
    

    測試

    下面我們來測試一下代碼是否按照我們的預期運行。

    可以看到,完全符合我們的預期!

    缺陷

    雖然以上Thunk函數能完美實現我們對異步流程的控制,但是對于同步任務卻不能正確的做出反應,比如我寫一個同步版的 readFileSync :

    function _readFileSync(filename, cb) {
      cb(null, file[filename]);
    }
    

    然后將 var readFile = thunkify(_readFile); // 將_readFile thunk化 改為 var readFile = thunkify(_readFileSync) 其余均保持不變,運行代碼會發現執行不成功。什么原因造成的呢?其實只要仔細分析就會發現,主要問題主要出現在 thunkify 函數上面,在 流程控制函數 注入之前,任務函數就已經執行了,如果這個任務是異步的,那沒問題,因為異步任務回調函數只會等主線程空閑了才會執行,所以異步任務能確保控制函數能夠被成功注入。但是如果這個任務是同步的,那就不一樣了。傳給同步任務的回調函數會被立刻執行,之后給它注入控制邏輯已經沒用了,因為同步任務早已執行完。

    改進

    為了改進 thunkify 函數,讓它能適應同步的情況,可以考慮將 任務函數 的執行延后到控制邏輯注入后執行,這樣就能確保無論任務函數異步也好,同步也罷,都能注入控制邏輯。

    /**
  • 重寫thunkify函數,使其能兼容同步任務 */ function thunkify(fn) {   return function(){     var args = [].slice.call(arguments);     var ctx = this;     return function(done) {       var called;       args.push(function(){         if(called) return ;         called = true;         done.apply(null, arguments);       })       try{         fn.apply(ctx, args); // 將任務函數置后運行       } catch(ex) {         done(ex);       }     }   } } </code></pre>

    改進后的:

    利用Promise來構造generator自動運行器

    有了上面的基礎,基于Promise就會容易理解多了。

    toPromise

    首先,我們應該將異步任務改造成Promsie的形式,為了兼容同步任務,我們先對任務進行thunkify統一化,然后再轉化為Promise。

    function toPromise(fn) {
        return function() {
            var thunkify_fn = thunkify(fn).apply(this, arguments); // 先thunkify化
            return new Promise(function(resolve, reject) { // 返回Promise
                thunkify_fn(function(err, data) {
                    if (err)  reject(err);
                    resolve(data);
                });
            });
        }
    }
    

    運行器

    因為Promise是標準化的,所以構造Promise的運行器比較簡單,我就直接show code了:

    function run(generator) {
      var gen = generator();
      function next(data) {
        var ret = gen.next(data);
        if(ret.done) return Promise.resolve("done");
      return Promise.resolve(ret.value)
        .then(data => next(data))
        .catch(ex => gen.throw(ex));
      }
      
      try{
        return next();
      } catch(ex) {
        return Promise.reject(ex);
      }
      
    }
    

    測試

    經測試,完全符合要求。

    小結

    異步流程的控制一直是 JavaScript 比較令人頭疼的一點, Generator 的出現無疑是一件囍事,相信隨著ES6的普及以及ES7的推進(ES 7的 async , await ),異步代碼那反直覺的編寫方式將一去不復返,編寫和維護異步代碼將會越來越容易,JavaScript也將會越來越成熟,受到越來越多人的喜愛。

    參考文獻

     

    來自:http://web.jobbole.com/89650/

     

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