Generator:同步程式碼書寫非同步情懷

有贊前端發表於2017-08-21

編者按:看完本文,你能對ES6的Generator有一個很好的理解,輕鬆地以同步的方式寫非同步程式碼,也能初步理解到TJ大神的co框架的原理。

前言:ES6在2015年6月正式釋出,它帶給js帶來許多新特性,其中一個就是Generator,雖然其它語言如python早就有了,但js的Generator和它們的還是有點不一樣的,js的Generator重點在解決非同步回撥金字塔問題,巧妙的使用它可以寫出看起來同步的程式碼。

我們都知道js跟其它語言相比,最大的特性就是非同步,所以當我們要取非同步取一個檔案內容時,一般我們會這樣寫

$.get('http://youzan.com/test.txt', function(data){
    console.log(data);
})複製程式碼

如果取完A檔案後又要再取B檔案:

$.get('http://youzan.com/A.txt', function(a){
    $.get('http://youzan.com/B.txt', function(b){
        console.log(b);
    }
}複製程式碼

再取一個C檔案可能這樣寫:

$.get('http://youzan.com/A.txt', function(a){
    $.get('http://youzan.com/B.txt', function(b){
        $.get('http://youzan.com/C.txt', function(c){
            console.log(c);
        }
    }
}複製程式碼

當有更多的非同步操作,業務邏輯更為的複雜時,按上面的寫法維護時心中肯定要罵娘了。那有沒有更好一點的寫法呢?在Generator出來之前可以使用promise實現,雖然說promise也是es6的一部分,es6標準未出之前已經有很多ployfill出來了。

$.get('http://youzan.com/A.txt')
    .done(function(a){
        return $.get('http://youzan.com/B.txt');
    })
    .done(function(b){
        return $.get('http://youzan.com/C.txt');
    })
    .done(function(c){
        console.log(c);
    })複製程式碼

promise的實現要比上面巢狀回撥要優雅許多,但也可以一眼看出非同步回撥的身影。目前js有很多框架要致力解決js金字塔回撥,讓非同步程式碼書寫的邏輯更為清晰,如async, wind.js, promise, deffer。這些框架都有自己的一些約定,如async是以陣列形式來寫,promise是以回撥引數方式,但它們都不能做到像寫c或java那樣第一行open一個file,然後第二行馬上讀取,來看看最新的Generator是怎麼做的:

co(function* (){
    var a = yield $.get('http://youzan.com/A.txt');
    var b = yield $.get('http://youzan.com/B.txt');
    var c = yield $.get('http://youzan.com/C.txt');
    console.log(c);
})複製程式碼

上面程式碼使用了co框架包裹,裡面一個Generator,從書寫上看它已經和其它同步語言差不多。寫了多年的非同步看到上面程式碼是不是感覺不可思義呢?這就是Generator帶來的可喜之處,其實es6還更多的新東西等著你發現。下面來了詳細瞭解一下Generator。

Generator是什麼

Generator是生成器的意思,它是一種可以從中退出並在之後重新進入的函式。生成器的環境(繫結的變數)會在每次執行後被儲存,下次進入時可繼續使用。生成器其實在其它語言很早就有了,比如python、c#,但與python不同的是js的generator更多的是提供一種非同步解決方案。

Generator使用function*來定義,內部有yield關鍵字,next方法控制內部執行流程,每執行到一個yield語句就會中斷,並返回一個迭代值,下次執行時從yield的下一個語句繼續執行。一個生成器只能執行一次。

一個簡單的定義如下:

function* hello() {
   var a = 'b'
   yield 'a';
   return a;
}

var gen = hello();
console.log(gen);
// => hello {[[GeneratorStatus]]: "suspended", [[GeneratorReceiver]]: undefined}
console.log(gen.next())
// => {value: "a", done: false}
console.log(gen.next())
// => {value: "b", done: true}複製程式碼

上面程式碼通過呼叫hello(),產生了一個生成器,內部程式碼沒有執行。呼叫next方法執行到yield後暫停,內部環境被儲存,next執行返回一個物件,valueyield的執行結果,done表示迭代器是否完成。當迭代器完成後,done為true,value為return的值,繼續執行nextvalue將為undefined

Generator執行原理

回到開頭的例子,Generator給我們提供了直觀的寫法來處理非同步回撥,它讓程式碼邏輯非常清晰。來了解一下Generator內部的一些原理

function* hello() {
   yield 'a';
   return 'b';
}
var gen = hello();
console.log(gen);
// => hello {[[GeneratorStatus]]: "suspended", [[GeneratorReceiver]]: undefined}複製程式碼

使用chrome工具檢視物件內容,可發現裡面有nextthrow方法。

Snip20150909_1.png
Snip20150909_1.png

當我們執行next方法時,發現其返回了一個物件:

Snip20150909_2.png
Snip20150909_2.png

物件含有valuedone兩個欄位,value為yield 'a'的返回值,即為'a',done表示generator的狀態,為true時表示執行完成。再執行next方法時,可以看到done的值已經為true了,而且value的值為return的值

Snip20150909_3.png
Snip20150909_3.png

檢視gen自身內部的狀態,可以看到GeneratorStatus已經為closed了
Snip20150909_4.png
Snip20150909_4.png

綜上可以得出Generator是通過呼叫next方法來控制執行流程,當遇到yield語句時暫停執行。next方法返回一個對像{value: 'yield', done: false},value儲存的yield 執行結果,done表示迭代器是否執行完成。

通過上面的瞭解貌似generator並沒有太大卵用,不能如所說的用同步情懷書寫非同步程式碼。上面漏了很重要的一點就是yield的返回值next的引數。看下面一段程式碼:

function* hello() {
  var ret = yield 'a';
  console.log(ret);
}複製程式碼

然後過程如下一下:

Snip20150909_5.png
Snip20150909_5.png

從上面執行過程可以看到,ret與第二個next的引數值一樣,這是Generator的傳值方式。yield的返回值就是next的引數,第一個next由於執行到yield語句之前就暫停了,所以引數b沒有用。

這裡也提一下上面出現的throw方法。

在generator中使用gen.throw('error')來丟擲異常。當出現異常後,迭代中止,再次執行gen.next()時,將返回{value: undefined, done: true};

使用try catch可捕獲gen.throw出來的異常。

Generator自動執行封裝

至此對generator瞭解的也差不多了,但貌似使用它來寫程式碼感覺挺變扭的,因為你要不停的next,如果有一個函式能自動執行generator函式就好了。就像之前提到的程式碼:

co(function* (){
    var a = yield $.get('http://youzan.com/A.txt');
    var b = yield $.get('http://youzan.com/B.txt');
    var c = yield $.get('http://youzan.com/C.txt');
    console.log(c);
})複製程式碼

上面提到的Generator內部原理可以總結出,right這邊執行後的結果放到value裡,next的引數放到了left這邊。為了讓right這邊執行後的結果放到left,那right就得返回一個function,傳一個callback進行,然後在callback裡執行next方法。通過了角Generator的資料傳遞過程就可以寫出一個簡易版的co來自動執行next方法,以達到上面程式碼效果:

function co(genFunc) {
  var gen = genFunc();

  var next = function(value){
     var ret = gen.next(value);
     if (!ret.done) {
       ret.value(next);
     }
  }

  next();
}

function getAFromServer(url){
    /*
     *do something sync
     */
    return function(cb) {
       /*
        *do something async
        */ 
       var a = 'data A from server';
       cb(a);    // 返回讀到的內容
    }
}

function getBFromServer(url){
    /*
     *do something sync
     */
    return function(cb) {
       /*
        *do something async
        */ 
       var b = 'data B from server';
       cb(b);    // 返回讀到的內容
    }
}

co(function* (){
  var ret = yield getFromSever('url of A');
  console.log(ret);  // 輸出  data A from server
  var retB = yield getFromSever('url of B');
  console.log(retB);  // 輸出  data B from server
})複製程式碼

上面的co就是一個非常簡單的自動執行generator next的函式,且right這邊的值能正確傳到left,唯一的要求是getA的寫法必須trunk的寫法。像我們使用nodejs的一些非同步api,可使用trunkify來轉成trunk形式。

在co內部主要靠next來實現迴圈,靠外部cb()來驅動執行。大體流程如下:

Snip20150815_11.png
Snip20150815_11.png

瞭解了co原理,那就可以把它做得更強大一些,如支援Promise,支援nodejs寫法的異常處理。這個可以參考co, trunks的程式碼。

支援情況

根據這個ECMAScript 6 compatibility table的資料顯示,目前已經有如下平臺可以支援:

  • Chrome 35+ (about://flags中開啟)
  • Firefox 31+ (預設開啟)
  • nodejs harmony
  • nodejs 0.11+

參考資料

本文首發於有贊技術部落格: tech.youzan.com/es6-generat…

相關文章