使用ES6的Generator代替回調(diào)函數(shù)

2018-06-09 16:16 更新

英文原文: Replacing callbacks with ES6 Generators

目前,已經(jīng)有很多文章討論過了如何使用ES6 generators來取代JavaScript中經(jīng)常遇到的回調(diào)金字塔。但是,其中提到的絕大多數(shù)方法都需要依賴于某個庫,而對于其中的原理卻提及甚少。

在本文中,我們將一步一步的將一個基于回調(diào)函數(shù)的例子修改為一個基于generator的例子。本文的目標是讓你透徹地理解使用generator替代回調(diào)函數(shù)的原理。

Generator是JavaScript中一個新概念,但在編程語言中已經(jīng)存在已久。你可能已經(jīng)在其他的編程語言例如Python使用過它。如果沒有,也不要害怕,我們在后面已經(jīng)為你準備了一個簡單明了的入門介紹。

如何運行例子

在我們開始之前,你需要安裝Node 0.11.*來運行文章中的例子。當(dāng)你在運行這些例子時,你需要告訴Node使用ES6(也就是Harmony)來運行:node -harmony example.js。

什么是generator?

在我們深入講述如何使用generator替代回調(diào)函數(shù)之前,我們先來說說什么是generator。

Generator很像是一個函數(shù),但是你可以暫停它的執(zhí)行。你可以向它請求一個值,于是它為你提供了一個值,但是余下的函數(shù)不會自動向下執(zhí)行直到你再次向它請求一個值。

取號機也許是對generator的一個絕佳的比喻。你可以通過取一張票來向機器請求一個號碼。你接收了你的號碼,但是機器不會自動為你提供下一個。換句話說,取票機暫停直到有人請求另一個號碼,此時它才會向后運行。

ES6中的generator

Generator在ES6中像一個函數(shù)一樣被聲明,除了在之前有一個*的差別外:


function* ticketGenerator() {
}

當(dāng)你想要一個generator提供一個值然后暫停時,你需要使用yield關(guān)鍵字。yield有點像是return關(guān)鍵字,因為它們都返回一個值,但是函數(shù)在yield之后會進入暫停狀態(tài)。


function* ticketGenerator() {
    yield 1;
    yield 2;
    yield 3;
}

在我們的例子中,我們定義了一個叫做ticketGenerator的迭代器。如果你向它請求一個值,它會返回1然后暫停。如果你再次向它發(fā)出一個請求,我們將得到2,然后是3。

當(dāng)你調(diào)用一個generator時,它將返回一個迭代器對象。這個迭代器對象擁有一個叫做next的方法來幫助你重啟generator函數(shù)并得到下一個值。

next方法不僅返回值,它返回的對象具有兩個屬性:donevalue。value是你獲得的值,done用來表明你的generator是否已經(jīng)停止提供值。

現(xiàn)在我們從我們的取號機中取一些號碼,


var takeANumber = ticketGenerator();
takeANumber.next();
// > { value: 1, done: false }
takeANumber.next();
// > { value: 2, done: false }
takeANumber.next();
// > { value: 3, done: false }
takeANumber.next();
// > { value: undefined, done: true }

現(xiàn)在我們的取號系統(tǒng)只能提供最多到3的號碼,這實在是沒什么用。我們想要讓它無線增加下去,因此我們來創(chuàng)建一個循環(huán)。


function* ticketGenerator() {
    for(var i=0; true; i++) {
        yield i;
    }
}

現(xiàn)在,如果這是一個普通的函數(shù),我們每次只會得到0。但是使用generator卻不一樣:


var takeANumber = ticketGenerator();
console.log(takeANumber.next().value); //0
console.log(takeANumber.next().value); //1
console.log(takeANumber.next().value); //2
console.log(takeANumber.next().value); //3
console.log(takeANumber.next().value); //4

每一次當(dāng)我們調(diào)用next()時,generator執(zhí)行下一個循環(huán)迭代然后暫停。這意味著我們擁有一個可以無限向下運行的generator。因為這個generator只是發(fā)生了暫停,你并沒有凍結(jié)你的程序。事實上,generator是一個創(chuàng)建無限循環(huán)的好方法。

影響generator的狀態(tài)

進一步探究迭代迭代generator對象的話,next()實際上還有另一個用途。如果你給next傳遞一個值,它會被視為generator中的一個yield語句的結(jié)果來對待。

因此next是一個在generator運行過程中向其傳遞信息的方式。我們將以此來修改我們的取號generator以便它能夠被重置到0。我們希望能在任何時間點來重置取號機。


function* ticketGenerator() {
    for(var i=0; true; i++) {
        var reset = yield i;
        if(reset) {
            i = -1;
        }
    }
}

正如你所看到的,如果yield返回了一個true然后我們將i設(shè)置為-1。那么for循環(huán)將會在循環(huán)的結(jié)尾將i增加1,因此下一次返回的i變成了0。

我們來看看實際情況如何:


var takeANumber = ticketGenerator();
console.log(takeANumber.next().value); //0
console.log(takeANumber.next().value); //1
console.log(takeANumber.next().value); //2
console.log(takeANumber.next(true).value); //0
console.log(takeANumber.next().value); //1

用generator替代回調(diào)函數(shù)

既然我們已經(jīng)學(xué)到了一些關(guān)于generator的知識,現(xiàn)在讓我們來談?wù)刧enerator和回調(diào)函數(shù)。正如你所知道的,當(dāng)我們調(diào)用例如AJAX請求這樣的異步代碼時我們會使用回調(diào)函數(shù)。為了簡單起見我們在例子中定義一個delay函數(shù)。

我們的delay函數(shù)將會是異步的 – 在指定的時間過后我們提供給delay的回調(diào)函數(shù)才會被執(zhí)行,然后delay會給你的回調(diào)函數(shù)傳遞一個字符串告訴它究竟沉睡了多久。

在此期間你的其余代碼將會繼續(xù)執(zhí)行下去。這就好像是進行一個AJAX請求一樣 – 你發(fā)出請求,你的代碼繼續(xù)執(zhí)行,當(dāng)服務(wù)器返回一個結(jié)果時你的回調(diào)函數(shù)才執(zhí)行。

現(xiàn)在,我們來定義delay函數(shù),


function delay(time, callback){
    setTimeout(function(){
        callback("Slept for "+time);
    },time);
}

到目前為止,還沒有什么特別的東西。現(xiàn)在我們來使用它來delay兩次。首先我們將delay 1000ms,然后當(dāng)delay結(jié)束后我們再另外delay 1200ms


delay(1000,function(msg){
    console.log(msg);
    delay(1200,function(msg){
        console.log(msg);
    });
});
//...waits 1000ms
// > "Slept for 1000"
//...waits another 1200ms
// > "Slept for 1200

確保我們的兩個delay依次被調(diào)用的唯一方法就是確保第二個delay在第一個delay的回調(diào)函數(shù)中。

如果我們要依次delay 12次,我們將需要嵌套的調(diào)用12次delay函數(shù)。這時你就會碰到回調(diào)金字塔,代碼也變得丑陋不堪。

引入generator

Generator是解決回調(diào)地獄的有效方法之一。異步調(diào)用是很困難的事情,因為我們的函數(shù)不會等待異步調(diào)用完成,因此我們需要回調(diào)函數(shù)。

使用generator,我們可以讓我們的代碼進行等待。無需嵌套回調(diào)函數(shù),我們可以使用generator確保當(dāng)異步調(diào)用在我們的generator函數(shù)運行一下行代碼之前完成時暫停函數(shù)的執(zhí)行。

因此,如果我們可以在一個異步調(diào)用完成時暫停執(zhí)行,這就意味著我們可以依次調(diào)用delay函數(shù) – 就像delay函數(shù)是同步執(zhí)行的一樣。

我們應(yīng)該怎么做

首先,我們知道我們進行異步調(diào)用的代碼需要在一個generator而不是一個一般的函數(shù)中進行,因此我們來定義一個generator,


function* myDelayedMessages() {
    /* delay 1000 ms and print the result */
    /* delay 1200 ms and print the result */
}

接下來我們需要在我們的generator中調(diào)用delay。記住,delay接收一個回調(diào)函數(shù)。這個回調(diào)函數(shù)需要繼續(xù)我們的generator,但是我們現(xiàn)在還沒有一個generator因此我們先放上一個空函數(shù)。


function* myDelayedMessages() {
    console.log(delay(1000, function(){}));
    console.log(delay(1200, function(){}));
}

我們代碼依然是異步的。這是因為我們還沒有將放入任何的yield語句。Generator只是在它們看大一個yield語句時才暫停。


function* myDelayedMessages() {
    console.log(yield delay(1000, function(){}));
    console.log(yield delay(1200, function(){}));
}

我們現(xiàn)在已經(jīng)更接近了一點了。然而,如果我們運行我們的generator什么也不會發(fā)生。因為沒有什么東西告訴它要向下運行。

在這里你需要理解的最重要的概念是:generator需要在delay中的回調(diào)函數(shù)運行完成后繼續(xù)往下運行,這就是它們?nèi)绾沃罆和?yīng)該結(jié)束了的原因。

這意味著回調(diào)函數(shù)中的東西需要知道如何向前推動generator。我們在其中傳遞一個叫做resume的函數(shù)來為我們做這件事。記住我們現(xiàn)在還依然沒有定義resume


function* myDelayedMessages(resume) {
    console.log(yield delay(1000, resume));
    console.log(yield delay(1200, resume));
}

OK,現(xiàn)在我們的generator將會接收一個resume函數(shù),這個函數(shù)將會向前推動generator。

現(xiàn)在到了關(guān)鍵步驟了,我們?nèi)绾蝸砭帉?code>resume,它又是怎么來了解我們的generator的。

如果你看看其他使用generator代替回調(diào)函數(shù)的例子,你會看到generator函數(shù)總是被另一個函數(shù)包裹著 – 通常是一個叫做run或者execute的函數(shù)。這些函數(shù)的目的有以下幾個:

  • 接收一個generator作為參數(shù)
  • 使用這個generator來創(chuàng)建一個新的generator迭代器對象,我們將調(diào)用它的next方法
  • 創(chuàng)建一個resume函數(shù)來使用這個generator迭代器對象來推進generator
  • resume函數(shù)傳遞給這個generator以便generator能夠訪問resume
  • 在最開始時調(diào)用next()函數(shù),以便我們的代碼在碰到第一個yield之前開始執(zhí)行

現(xiàn)在我們來創(chuàng)建run函數(shù),


function run(generatorFunction) {
    var generatorItr = generatorFunction(resume);
    function resume(callbackValue) {
        generatorItr.next(callbackValue);
    }
    generatorItr.next()
}

現(xiàn)在我們有了一個能夠接收一個generator函數(shù)的函數(shù),并為它傳遞了一個了解如何推進generator迭代器對象的函數(shù)。

注意到我們在resume函數(shù)中用到了next的第二個特性。resume是被傳遞給delay的回調(diào)函數(shù),因此它接收delay函數(shù)提供的值。resume將這個值傳遞給next,因此yield語句的結(jié)果實際上是我們異步函數(shù)的結(jié)果!

我們現(xiàn)在要做的只是用run包裹上我們的generator函數(shù),然后我們就能看到以下結(jié)果,


run(function* myDelayedMessages(resume) {
    console.log(yield delay(1000, resume));
    console.log(yield delay(1200, resume));
});
//...waits 1000ms
// > "Slept for 1000"
//...waits 1200ms
// > "Slept for 1200"

現(xiàn)在,你能看到我們調(diào)用delay兩次,并沒有使用嵌套回調(diào)函數(shù)。如果你依然看到疑惑,我們現(xiàn)在概括的來講述以下究竟發(fā)生了什么:

  • run接收了我們的generator并創(chuàng)建了一個resume函數(shù)
  • run創(chuàng)建了一個generator迭代器對象(我們在它上面調(diào)用next方法),提供了resume函數(shù)。接著它推動了generator迭代器向前運行。
  • 我們的generator碰到了第一個yield語句并且調(diào)用delay。接著這個generator暫停。
  • delay在1000ms之后完成然后調(diào)用resume
  • resume告訴我們的generator進行下一步。它將結(jié)果傳遞給delay以便console能夠?qū)⑺蛴〕鰜怼?/li>
  • 我們的generator碰到了第二個yield,調(diào)用delay然后再次暫停。 delay等待1200ms之后調(diào)用resume回調(diào)函數(shù)。
  • resume再次推進generator。
  • 再也沒有yield的調(diào)用,這個generator完成執(zhí)行。

結(jié)論

我們已經(jīng)成功的使用generator替代了回調(diào)嵌套方法??偨Y(jié)一下,使用generator替代回調(diào)函數(shù)要包含以下幾個步驟:

  • 創(chuàng)建一個run函數(shù)來接受一個generator,并為這個generator提供resume函數(shù)。
  • 創(chuàng)建一個resume函數(shù)來推進generator,然后在resume被異步函數(shù)調(diào)用時將這個resume函數(shù)傳遞給generator。
  • resume作為回調(diào)傳遞給我們所有的異步回調(diào)函數(shù)。這些異步函數(shù)在完成時執(zhí)行resume,這使得我們的generator在每個異步調(diào)用完成之時僅僅向前一步。

雖然generator究竟是不是一個處理回調(diào)地獄的好方法還在討論之中,但是它確實是一個加強你對ES6中g(shù)enerator和迭代器理解的練習(xí)。如果你在尋找一些不需要用到ES6的處理嵌套回調(diào)函數(shù)的方法,可以考慮promises。

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號