ES6 Generators的非同步應用

Jaxu發表於2017-03-03

  ES6 Generators系列:

  1. ES6 Generators基本概念
  2. 深入研究ES6 Generators
  3. ES6 Generators的非同步應用
  4. ES6 Generators併發

  通過前面兩篇文章,我們已經對ES6 generators有了一些初步的瞭解,是時候來看看如何在實際應用中發揮它的作用了。

  Generators最主要的特點就是單執行緒執行,同步風格的程式碼編寫,同時又允許你將程式碼的非同步特性隱藏在程式的實現細節中。這使得我們可以用非常自然的方式來表達程式或程式碼的流程,而不用同時還要兼顧如何編寫非同步程式碼。

  也就是說,通過generator函式,我們將程式具體的實現細節從非同步程式碼中抽離出來(通過next(..)來遍歷generator函式),從而很好地實現了功能和關注點的分離。

  其結果就是程式碼易於閱讀和維護,在編寫上具有同步風格,但卻支援非同步特性。那如何才能做到這一點呢?

 

最簡單的非同步

  一個最簡單的例子,generator函式內部不需要任何非同步執行程式碼即可完成整個非同步過程的呼叫。

  假設你有下面這段程式碼:

function makeAjaxCall(url,cb) {
    // ajax請求
    // 完成時呼叫cb(result)
}

makeAjaxCall( "http://some.url.1", function(result1){
    var data = JSON.parse( result1 );

    makeAjaxCall( "http://some.url.2/?id=" + data.id, function(result2){
        var resp = JSON.parse( result2 );
        console.log( "The value you asked for: " + resp.value );
    });
} );

  如果使用generator函式來實現上面程式碼的邏輯:

function request(url) {
    // 這裡的非同步呼叫被隱藏起來了,
    // 通過it.next(..)方法對generator函式進行迭代,
    // 從而實現了非同步呼叫與main方法之間的分離
    makeAjaxCall( url, function(response){
        it.next( response );
    } );
    // 注意:這裡沒有return語句!
}

function *main() {
    var result1 = yield request( "http://some.url.1" );
    var data = JSON.parse( result1 );

    var result2 = yield request( "http://some.url.2?id=" + data.id );
    var resp = JSON.parse( result2 );
    console.log( "The value you asked for: " + resp.value );
}

var it = main();
it.next(); // 開始

  解釋一下上面的程式碼是如何執行的。

  方法request(..)是對makeAjaxCall(..)的封裝,確保回撥能夠呼叫generator函式的next(..)方法。請注意request(..)方法中沒有return語句(或者說返回了一個undefined值),後面我們會講到為什麼要這麼做。

  Main函式的第一行,由於request(..)方法沒有任何返回值,所以這裡的yield request(..)表示式不會接收任何值進行計算,僅僅暫停了main函式的執行,直到makeAjaxCall(..)在ajax的回撥中執行it.next(..)方法,然後恢復main函式的執行。那這裡yield表示式的結果到底是什麼呢?我們將什麼賦值給了變數result1?在Ajax的回撥中,it.next(..)方法將Ajax請求的返回值傳入,這個值會被yield表示式返回給變數result1

  是不是很酷!這裡,result1 = yield request(..)事實上就是為了得到ajax的返回結果,只不過這種寫法將回撥隱藏起來了,我們完全不用擔心,因為其中具體的執行步驟就是非同步呼叫。通過yield表示式的暫停功能,我們將程式的非同步呼叫隱藏起來,然後在另一個函式(ajax的回撥)中恢復對generator函式的執行,整個過程使得我們的main函式的程式碼看起來就像是在同步執行一樣

  語句result2 = yield result(..)的執行過程與上面一樣。程式碼執行過程中,有關generator函式的暫停和恢復完全是透明的,程式最終將我們想要的結果返回回來,而所有的這些都不需要我們將注意力放在非同步程式碼的編寫上。

  當然,程式碼中少不了yield關鍵字,這裡暗示著可能會有一個非同步呼叫。不過這和地獄般的巢狀回撥(或者promise鏈)比起來,程式碼看起來要清晰很多。

  注意上面我說的yield關鍵字的地方是“可能”會出現一個非同步呼叫,而不是一定會出現。在上面的例子中,程式每次都會去呼叫一個Ajax的非同步請求,但如果我們修改了程式,將之前Ajax響應的結果快取起來,情況會怎樣呢?又或者我們在程式的URL請求路由中加入某些邏輯判斷,使其立即就返回Ajax請求的結果,而不是真正地去請求伺服器,情況又會怎樣呢?

  我們將上面的程式碼改成下面這個版本:

var cache = {};

function request(url) {
    if (cache[url]) {
        // 延遲返回快取中的資料,以保證當前執行執行緒執行完成
        setTimeout( function(){
            it.next( cache[url] );
        }, 0 );
    }
    else {
        makeAjaxCall( url, function(resp){
            cache[url] = resp;
            it.next( resp );
        } );
    }
}

  注意上面程式碼中的setTimeout(..)語句,它會延遲返回快取中的資料。如果我們直接呼叫it.next(..)程式會報錯,這是因為generator函式目前還不是處於暫停狀態。主函式在呼叫完request(..)之後,generator函式才會處於暫停狀態。所以,我們不能在request(..)函式內部立即執行it.next(..),因為此時的generator函式仍然處於執行中(即yield表示式還沒有被處理)。不過我們可以稍後再呼叫it.next(..)setTimeout(..)語句將會在當前執行執行緒完成後立即執行,也就是在request(..)方法執行完後再執行,這正是我們想要的。下面我們會有更好的解決方案。

  現在,我們的main函式的程式碼依然是這樣:

var result1 = yield request( "http://some.url.1" );
var data = JSON.parse( result1 );
..

  瞧!我們的程式從不帶快取的版本改成了帶快取的版本,但是main函式卻不用做任何修改。*main()函式依然只是請求一個值,然後暫停執行,直到請求返回一個結果,然後再繼續執行。當前程式中,暫停的時間可能會比較長(實際Ajax請求大概會在300-800ms之間),但也可能是0(使用setTimeout(..0)延遲的情況)。無論是哪種情況,我們的主流程是不變的。

  這就是將非同步過程抽象為實現細節的真正力量!

 

改進的非同步

  以上方法僅適用於一些簡單非同步處理的generator函式,很快你就會發現在大多數實際應用中根本不夠用,所以我們需要一個更強大的非同步處理機制來匹配generator函式,使其能夠發揮更大的作用。這個處理機制是什麼呢?答案就是promises. 如果你對ES6 Promises還不瞭解,可以看看這裡的一篇文章: http://blog.getify.com/promises-part-1/

  在前面的Ajax示例程式碼中,無一例外都會遇到巢狀回撥的問題(我們稱之為回撥地獄)。到目前為止我們還有一些東西沒有考慮到:

  1. 有關錯誤處理。在前一篇文章中我們已經介紹過如何在generator函式中處理錯誤,我們可以在Ajax的回撥中判斷是否出錯,並通過it.throw(..)方法將錯誤傳遞給generator函式,然後在generator函式中使用try..catch語句來處理它。但這無疑會帶來許多工作量,而且如果程式中有很多generator函式的話,程式碼也不容易重用。
  2. 如果makeAjaxCall(..)函式不在我們的控制範圍內,並且它會多次呼叫回撥,或者同時返回success和error等等,那麼我們的generator函式將會陷於混亂(未處理的異常,返回意外的值等)。要解決這些問題,你可能需要做很多額外的工作,這顯然很不方便。
  3. 通常我們需要“並行”來處理多個任務(例如同時發起兩個Ajax請求),由於generator函式的yield只允許單個暫停,因此兩個或多個yield不能同時執行,它們必須按順序一個一個地執行。所以,在不編寫大量額外程式碼的前提下,很難在generator函式的單個yield中同時處理多個任務。

  上面的這些問題都是可以解決的,但是誰都不想每次都面對這些問題然後從頭到尾地解決一遍。我們需要一個功能強大的設計模式,能夠作為一個可靠的並且可以重用的解決方案,應用到我們的generator函式的非同步程式設計中。這種模式要能夠返回一個promises,並且在完成之後恢復generator函式的執行。

  回想一下上面程式碼中的yield request(..)表示式,函式request(..)沒有任何返回值,但實際上這裡我們是不是可以理解為yield返回了一個undefined呢?

  我們將request(..)函式改成基於promises的,這樣它會返回一個promise,所以yield表示式的計算結果也是一個promise而不是undefined

function request(url) {
    // 注意:現在返回的是一個promise!
    return new Promise( function(resolve,reject){
        makeAjaxCall( url, resolve );
    } );
}

  現在,request(..)函式會構造一個Promise物件,並在Ajax呼叫完成之後進行解析,然後返回一個promise給yield表示式。然後呢?我們需要一個函式來控制generator函式的迭代,這個函式會接收所有的這些yield promises然後恢復generator函式的執行(通過next(..)方法)。我們假設這個函式叫runGenerator(..)

// 非同步呼叫一個generator函式直到完成
// 注意:這是最簡單的情況,不包含任何錯誤處理
function runGenerator(g) {
    var it = g(), ret;

    // 非同步迭代給定的generator函式
    (function iterate(val){
        ret = it.next( val );

        if (!ret.done) {
            // 簡單測試返回值是否是一個promise
            if ("then" in ret.value) {
                // 等待promise返回
                ret.value.then( iterate );
            }
            // 立即執行
            else {
                // 避免同步遞迴呼叫
                setTimeout( function(){
                    iterate( ret.value );
                }, 0 );
            }
        }
    })();
}

  幾個關鍵的點:

  1. 程式會自動初始化generator函式(建立迭代器it),然後非同步執行直到完成(done:true)。
  2. 檢視yield是否返回一個promise(通過it.next(..)返回值中的value屬性來檢視),如果是,則等待promise中的then(..)方法執行完。
  3. 任何立即執行的程式碼(非promise型別)將會直接返回結果給generator函式,然後繼續執行。

  現在我們來看看如何使用它。

runGenerator( function *main(){
    var result1 = yield request( "http://some.url.1" );
    var data = JSON.parse( result1 );

    var result2 = yield request( "http://some.url.2?id=" + data.id );
    var resp = JSON.parse( result2 );
    console.log( "The value you asked for: " + resp.value );
} );

  等等!這不是和本文一開始的那個generator函式一樣嗎?是的。不過在這個版本中,我們建立了promises並返回給yield,等promise完成之後恢復generator函式繼續執行。所有這些操作都“隱藏”在實現細節中!不過不是真正的隱藏,我們只是將它從消費程式碼(這裡指的是我們的generator函式中的流程控制)中分離出去而已。

  Yield接受一個promise,然後等待它完成之後返回最終的結果給it.next(..)。通過這種方式,語句result1 = yield request(..)能夠得到和之前一樣的結果。

  現在我們使用promises來管理generator函式中非同步呼叫部分的程式碼,從而解決了在回撥中所遇到的各種問題:

  1. 擁有內建的錯誤處理機制。雖然我們並沒有在runGenerator(..)函式中顯示它,但是從promise監聽錯誤並非難事,一旦監聽到錯誤,我們可以通過it.throw(..)將錯誤丟擲,然後通過try..catch語句捕獲和處理這些錯誤。
  2. 我們通過promises來控制所有的流程。這一點毋庸置疑。
  3. 在自動處理各種複雜的“並行”任務方面,promises擁有十分強大的抽象能力。例如,yield Promise.all([..])接收一個“並行”任務的promises陣列,然後yield一個單個的promise(返回給generator函式處理),這個單個的promise會等待陣列中所有的promises全部處理完之後才會開始,但這些promises的執行順序無法保證。當所有的promises執行完後,yield表示式會接收到另外一個陣列,陣列中的值是每個promise返回的結果,按照promise被請求的順序依次排列。

  首先我們來看一下錯誤處理:

// 假設:`makeAjaxCall(..)` 是“error-first”風格的回撥(為了簡潔,省略了部分程式碼)
// 假設:`runGenerator(..)` 也具備錯誤處理的功能(為了簡潔,省略了部分程式碼)

function request(url) {
    return new Promise( function(resolve,reject){
        // 傳入一個error-first風格的回撥函式
        makeAjaxCall( url, function(err,text){
            if (err) reject( err );
            else resolve( text );
        } );
    } );
}

runGenerator( function *main(){
    try {
        var result1 = yield request( "http://some.url.1" );
    }
    catch (err) {
        console.log( "Error: " + err );
        return;
    }
    var data = JSON.parse( result1 );

    try {
        var result2 = yield request( "http://some.url.2?id=" + data.id );
    } catch (err) {
        console.log( "Error: " + err );
        return;
    }
    var resp = JSON.parse( result2 );
    console.log( "The value you asked for: " + resp.value );
} );

  在request(..)函式中,makeAjaxCall(..)如果出錯,會返回一個promise的rejection,並最終對映到generator函式的error(在runGenerator(..)函式中通過it.throw(..)方法丟擲錯誤,這部分細節對於消費端來說是透明的),然後在消費端我們通過try..catch語句最終捕獲錯誤。

  下面我們來看一下複雜點的使用promises非同步呼叫的情況:

function request(url) {
    return new Promise( function(resolve,reject){
        makeAjaxCall( url, resolve );
    } )
    // 在ajax呼叫完之後獲取返回值,然後進行下一步操作
    .then( function(text){
        // 檢視返回值中是否包含URL
        if (/^https?:\/\/.+/.test( text )) {
            // 如果有則繼續呼叫這個新的URL
            return request( text );
        }
        // 否則直接返回撥用的結果
        else {
            return text;
        }
    } );
}

runGenerator( function *main(){
    var search_terms = yield Promise.all( [
        request( "http://some.url.1" ),
        request( "http://some.url.2" ),
        request( "http://some.url.3" )
    ] );

    var search_results = yield request(
        "http://some.url.4?search=" + search_terms.join( "+" )
    );
    var resp = JSON.parse( search_results );

    console.log( "Search results: " + resp.value );
} );

  Promise.all([...])構造了一個promise物件,它接收三個子promises,當所有的子promises都完成之後,將返回的結果通過yield表示式傳遞給runGenerator(..)函式並恢復執行。在request(..)函式中,每個子promise通過鏈式操作對response的值進行解析,如果其中包含另一個URL則繼續請求這個URL,如果沒有則直接返回response的值。有關promise的鏈式操作可以檢視這篇文章: http://blog.getify.com/promises-part-5/#the-chains-that-bind-us

  任何複雜的非同步處理,你都可以通過在generator函式中使用yield promise來完成(或者promise的promise鏈式操作),這樣程式碼具有同步風格,看起來更加簡潔。這是目前最佳的處理方式。

 

runGenerator(..)工具庫

  我們需要定義我們自己的runGenerator(..)工具來實現上面介紹的generator+promises模式。為了簡單,我們甚至可以不用實現所有的功能,因為這其中有很多的細節需要處理,例如錯誤處理的部分。

  但是你肯定不想親自來寫runGenerator(..)函式吧?反正我是不想。

  其實有很多的開源庫提供了promise/async工具,你可以免費使用。這裡我就不去一一介紹了,推薦看看Q.spawn(..)co(..)等。

  這裡我想介紹一下我自己寫的一個工具庫:asynquence的外掛runner。因為我認為和其它工具庫比起來,這個外掛提供了一些獨特的功能。我寫過一個系列文章,是有關asynquence的,如果你有興趣的話可以去讀一讀。

  首先,asynquence提供了一系列的工具來自動處理“error-first”風格的回撥函式。看下面的程式碼:

function request(url) {
    return ASQ( function(done){
        // 這裡傳入了一個error-first風格的回撥函式 - done.errfcb
        makeAjaxCall( url, done.errfcb );
    } );
}

  看起來是不是會好很多?

  接下來,asynquence的runner(..)外掛消費了asynquence序列(非同步呼叫序列)中的generator函式,因此你可以從序列的從上一步中傳入訊息,然後generator函式可以將這個訊息返回,繼續傳到下一步,並且這其中的任何錯誤都將自動向上丟擲,你不用自己去管理。來看看具體的程式碼:

// 首先呼叫`getSomeValues()`建立一個sequence/promise,
// 然後將sequence中的async鏈起來
getSomeValues()

// 使用generator函式來處理獲取到的values
.runner( function*(token){
    // token.messages陣列將會在前一步中賦值
    var value1 = token.messages[0];
    var value2 = token.messages[1];
    var value3 = token.messages[2];

    // 並行呼叫3個Ajax請求,並等待它們全部執行完(以任何順序)
    // 注意:`ASQ().all(..)`類似於`Promise.all(..)`
    var msgs = yield ASQ().all(
        request( "http://some.url.1?v=" + value1 ),
        request( "http://some.url.2?v=" + value2 ),
        request( "http://some.url.3?v=" + value3 )
    );

    // 將message傳送到下一步
    yield (msgs[0] + msgs[1] + msgs[2]);
} )

// 現在,將前一個generator函式的最終結果傳送給下一個請求
.seq( function(msg){
    return request( "http://some.url.4?msg=" + msg );
} )

// 所有的全部執行完畢!
.val( function(result){
    console.log( result ); // 成功,全部完成!
} )

// 或者,有錯誤發生!
.or( function(err) {
    console.log( "Error: " + err );
} );

  Asynquence runner(..)從sequence的上一步中接收一個messages(可選)來啟動generator,這樣在generator中可以訪問token.messages陣列中的元素。然後,與我們上面演示的runGenerator(..)函式一樣,runner(..)負責監聽yield promise或者yield asynquence(一個ASQ().all(..)包含了所有並行的步驟),等待完成之後再恢復generator函式的執行。當generator函式執行完之後,最終的結果將會傳遞給sequence中的下一步。此外,如果這其中有錯誤發生,包括在generator函式體內產生的錯誤,都將會向上丟擲或者被錯誤處理程式捕捉到。

  Asynquence試圖將promises和generator融合到一起,使程式碼編寫變得非常簡單。只要你願意,你可以隨意地將任何generator函式與基於promise的sequence聯絡到一起。

ES7 async

  在ES7的計劃中,有一個提案非常不錯,它建立了另外一種function:async function。有點像generator函式,它會自動包裝到一個類似於我們的runGenerator(..)函式(或者asynquence的runner(..)函式)的utility中。這樣,就可以自動地傳送promisesasync function並在它們執行完後恢復執行(甚至都不需要generator函式遍歷器了!)。

  程式碼看起來就像這樣:

async function main() {
    var result1 = await request( "http://some.url.1" );
    var data = JSON.parse( result1 );

    var result2 = await request( "http://some.url.2?id=" + data.id );
    var resp = JSON.parse( result2 );
    console.log( "The value you asked for: " + resp.value );
}

main();

  Async function可以被直接呼叫(上面程式碼中的main()語句),而不用像我們之前那樣需要將它包裝到runGenerator(..)或者ASQ.runner(..)函式中。在函式內部,我們不需要yield,取而代之的是await(另一個新加入的關鍵字),它會告訴async function等待promise完成之後才會繼續執行。將來我們會有更多的generator函式庫都支援本地語法。

  是不是很酷?

  同時,像asynquence runner這樣的庫一樣,它們會給我們在非同步generator函式程式設計方面帶來極大的便利。

 

總結

  一句話,generator + yield promise(s)模式功能是如此強大,它們一起使得對同步和非同步的流程控制變得行運自如。伴隨著使用一些包裝庫(很多現有的庫都已經免費提供了),我們可以自動執行我們的generator函式直到所有的任務全部完成,並且包含了錯誤處理!

  在ES7中,我們很可能將會看到async function這種型別的函式,它使得我們在沒有第三方庫支援的情況下也可以做到上面說的這些(至少對於一些簡單情況來說是可以的)。

  JavaScript的非同步在未來是光明的,而且只會越來越好!我堅信這一點。

  不過還沒完,我們還有最後一個東西需要探索:

  如果有兩個或多個generators函式,如何讓它們獨立地並行執行,並且各自傳送自己的訊息呢?這或許需要一些更強大的功能,沒錯!我們管這種模式叫“CSP”(communicating sequential processes)。我們將在下一篇文章中探討和揭祕CSP的強大功能。敬請關注!

相關文章