ES6 Generators的異步應用


  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的強大功能。敬請關注!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM