JavaScript異步Generator

上一篇介紹了Promise異步編程,可以很好地回避回調地獄。但Promise的問題是,不管什么樣的異步操作,被Promise一包裝,看上去都是一堆then,語義方面還不夠清晰。因此更好的異步編程解決方案是ES6的Generator。你可以從GitHub上獲取本篇代碼。

  • 基本概念和語法
  • next方法
  • return方法
  • throw方法
  • 嵌套Generator
  • 作為對象屬性
  • this
  • 例子

基本概念和語法
Promise只是一種代碼組織結構,但Generator具有全新的語法,參照MDN

function* gen() { 
    yield 1;
    yield 2;
    yield 3;
}
var g = gen();

console.log(g);         // Generator {}
console.log(g.next());  // { value: 1, done: false }
console.log(g.next());  // { value: 2, done: false }
console.log(g.next());  // { value: 3, done: true }
console.log(g.next());  // { value: undefined, done: true }

上面是一個最簡單的Generator函數,和普通函數有兩個區別:

首先是函數名前需要有個*星號,JS解釋器讀到function關鍵字后面接*星號就知道它是一個Generator函數了。

其次函數體內有yield關鍵字,有點像return關鍵字,都會返回后面的表達式的值。區別是return了函數就終止了,但yield表示函數暫時運行到這里,稍后繼續運行。究其本質,yield是用來定義函數的內部狀態的,調用Generator函數后,會返回得到一個遍歷器對象,可以依次遍歷Generator函數的內部狀態。(Generator在英語中是生成器的意思,意味著將異步操作打包生成一個生成器)

注意上面說的,調用Generator只會得到一個遍歷器對象,僅此而已。并不會執行Generator函數。上例中var g = gen();,變量g是一個遍歷器對象,即一個指向內部狀態的指針對象,用于之后遍歷yield定義的內部狀態。

有了遍歷器對象g之后,就可以使用next方法使指針依次移向下一個狀態,即讓函數從開頭或上一次暫停的地方開始執行,執行到下一個yield或return語句為止。雖然yeild和next本質上是遍歷器對象和操作指針,但你使用時可以將它們簡單地理解為:

Generator是分段執行的函數。yeild是暫停的標記。next用于繼續執行。

看上面執行的結果:

console.log(g.next());  // { value: 1, done: false }
console.log(g.next());  // { value: 2, done: false }
console.log(g.next());  // { value: 3, done: true }
console.log(g.next());  // { value: undefined, done: true }

next方法返回的是一個對象,有兩個屬性,分別是value和done。

value屬性就是內部狀態的值,即yield關鍵字后面的表達式結果。yield相當于return,都會返回后面的表達式結果。如果yield后面沒有語句,那會和return一樣默認返回undefined。它倆的區別是,return返回后函數調用就結束了,但yield返回后,只是暫停函數執行,等待下一次調用next方法來恢復執行剩余的函數代碼。一個函數里,只能執行一次return,但可以執行多次yield。

done屬性會檢查遍歷器對象指針是否已經指到最后,即是否已經遍歷結束,結束了為true,尚未結束為false。如果遍歷結束,done為true后,再執行next的話(如上面最后一行),value會得到undefined,done保持不變。對照著上面的例子代碼,很容易理解。

上面說了,調用Generator只會得到一個遍歷器對象,這就提供了兩個特性:惰性求值,自動遍歷。

惰性求值

function* add(n1, n2) {
    yield  n1 + n2;
}
var a = add(3, 4);
console.log(a.next());  // { value: 7, done: false }

如果是普通函數,執行var a = add(3, 4);語句后,變量a會賦值為7。但此處變量a只是一個遍歷器對象,不會執行函數。直到調用next方法將指針移到yield語句處時才會去求值。

利用這個特性,你可以在Generator函數里不定義yield,來實現一個單純的暫緩執行函數:

function* f() {console.log('執行了')}

var generator = f();
setTimeout(function () {
    generator.next();
}, 2000);

上例中,如果函數f是一個普通函數,變量generator賦值時就會執行。但是,將函數f寫成Generator函數后,只有在顯示地調用next方法后才會執行。

自動遍歷
遍歷器對象意味著,Generator函數的返回值可以被實現了遍歷器接口的各種方法調用,例如for…of,Array.form,擴展運算符(…),解構賦值等。例如:

function* numbers () {
    yield 1;
    yield 2;
    return 3;
    yield 4;
}

//for...of
var gen1 = numbers();
for (let n of gen1) {
    console.log(n);    //1  2
}

//Array.from
console.log(Array.from(numbers()));  // [1, 2]

//擴展運算符(…)
console.log([...numbers()]);    // [1, 2]

//解構賦值
let [x, y] = numbers();
console.log(x);   //1
console.log(y);   //2

上面各方法都可以自動遍歷Generator函數生成的遍歷器對象,就不用你顯示地寫next語句了。隱式自動調用next方法,遍歷到done為true為止結束。因此上例中numbers函數里的3和4不會被打印出來。

自動遍歷的特性很重要,因為通常現實中我們不太顯示地調用next方法,而是靠for…of來迭代它進行異步處理。

next方法

Generator.prototype.next()方法用于恢復執行。函數聲明:gen.next(value)。參照MDN

返回值是包含value和done屬性的對象,上面有介紹,不贅述。

上面介紹的next方法均沒有參數,其實可以為它提供一個參數。參數會被當作上一個yield語句的返回值。如果沒有參數,那默認上一個yield語句的返回值為undefined。

該參數意義重大。普通函數執行期間上下文是不變的,Generator函數也不例外,因此從暫停到下一次恢復都具有相同上下文。現在通過給next方法設參數,可以給Generator函數的上下文注入值。即,允許程序員在Generator函數運行的不同階段,從外部向內部注入不同的值,來調整函數行為。例如:

function* myAdd(n) {
    var n1 = yield (n + 1);
    var n2 = yield (n1 * 2);
    return n1 + n2;
}

var a1 = myAdd(5);
console.log(a1.next());    // Object{value:6, done:false}
console.log(a1.next());    // Object{value:NaN, done:false}
console.log(a1.next());    // Object{value:NaN, done:true}

var a2 = myAdd(5);
console.log(a2.next());    // Object{value:6, done:false}
console.log(a2.next(10));  // Object{value:20, done:false}
console.log(a2.next(50));  // Object{value:60, done:true}

第一次調用next,n為5,所以結果value均為6。第二次調用next時,a1和a2的差異如下:

a1時,由于next方法沒有參數,默認上一次yield的表達式值為undefined,即n1為undefined,導致n2為undefined * 2 = NaN。同理下一次再調用無參的next方法,n1和n2均為undefined,導致return了NaN。

a2時,由于next方法參數為10,表示上一次yield的表達式值為10,即n1為10,所以n2為10 * 2 = 20。同理下一次調用next(50),導致n2被改為50,所以return 10 + 50 = 60。

從語義上講,next的參數表示上一次yield的表達式值,因此第一次調用next方法時,傳遞參數是沒有意義的。如果第一次next方法帶上參數,瀏覽器會直接無視該參數。

return方法

Generator.prototype.return()方法用于立即結束遍歷,并返回給定的值。函數聲明:gen.return(value)。參照MDN

Generator函數的返回值是遍歷器對象,但你可以用return方法指定返回的值。參數就是返回值的value屬性。用return方法后,done屬性會被設為true,所以會立即終結遍歷Generator函數。例如:

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

var g = gen();
console.log(g.next());         // { value: 1, done: false }
console.log(g.return('foo'));  // { value: "foo", done: true }
console.log(g.next());         // { value: undefined, done: true }

注意兩個小細節:如果遍歷尚未結束,即done為false的情況下,調用無參的return(),會將value設為undefined。如果已經遍歷結束,即done已經為true的情況下,調用return(value)是沒有意義的,參數也不會生效,value會固定為undefined。

throw方法

Generator.prototype.throw()方法用于拋出錯誤,然后在Generator函數體內捕獲。函數聲明:gen.throw(exception)。參照MDN

返回值是帶有value和done屬性的對象。參數是異常信息。例如:

function* gen() {
    try {
        yield;
    } catch (e) {
        console.log('內部捕獲', e);
    }
    yield console.log('end');
};

try {
    var g = gen();
    g.next();
    
    if(true) {
        g.throw('a');
    }
    g.throw('b');
} catch (e) { 
    console.log('外部捕獲', e);
}
// 內部捕獲 a
// end
// 外部捕獲 b

第一次throw被Generator函數內的catch語句捕獲。需要注意的是,Generator函數內catch到異常后,JS認為異常已經被處理了,Generator函數仍舊會繼續運行到yield為止,所以打印出end。由于Generator函數內的catch語句已經執行過了,第二次throw將沒機會再次執行catch語句,于是錯誤就被拋到了Generator函數外,被外部catch語句捕獲。

注意本節說的throw,本質上是Generator.prototype.throw()方法,需要由遍歷器對象來調用。如果沒用遍歷器對象調用的話,是JS原生的throw,只會被Generator函數體外的catch捕捉。

例如上例中的g.throw(‘a’);,錯寫成throw(‘a’);的話,結果會打印出一行“外部捕獲a”。后面的g.throw(‘b’);語句將沒有機會被執行到。

還有一種情況,雖然寫了g.throw(‘a’);,但Generator函數內部沒有try…catch語句,那拋出的錯誤會被Generator函數體外的catch捕捉。與上面在內部被catch到的不同之處在于:在Generator函數內被catch到的話,JS認為異常已經被處理了,會繼續運行到yield為止。但如果在Generator函數內未被catch到的話,JS認為函數出現故障,后續代碼將不會繼續執行下去了,如果繼續調用next方法會得到value為undefined,done為true的對象。

例如上例中gen函數內將try…catch語句去掉,結果只會打印出一行“外部捕獲a”。 后面的g.throw(‘b’);語句將沒有機會被執行到。

那如果Generator函數內部和外部均沒有try…catch語句呢?那throw出的錯誤將一直冒泡,直到瀏覽器報錯。

在回調函數實現異步操作里,要捕捉錯誤,你不得不給每個函數內部都寫一個錯誤處理語句。用Promise的話,讓多個then進行異步操作,最后用一個catch來捕捉所有錯誤。用Generator的話,可以用一個try…catch把多個yield語句包起來,簡化了錯誤處理語句。

嵌套Generator

在Generater函數內部,調用另一個Generator函數的話,需要用yield*,即yield指針來實現,否則的話是沒有效果的。例如:

function* foo() {
    yield 'a';
    yield 'b';
}
function* bar() {
    yield '1';
    foo();      //沒有用yield*
    yield '2';
}
for (let v of bar()){
    console.log(v);
}
//1
//2

上面代碼本意是想在bar內部調用另一個Generator函數foo,但由于沒有用yield*,所以壓根沒效果。原因很簡單,foo();返回的是一個遍歷器對象,然后呢?就沒然后了,所以沒效果。

我們真正想要的是,讓外層bar返回的遍歷器對象,內部指針指向foo返回的遍歷器對象,這樣才能實現遍歷。因此需要加上yield*,如下:

function* foo() {
    yield 'a';
    yield 'b';
}
function* bar() {
    yield '1';
    yield* foo();    //加上yield*,讓指針指向內部Generator函數返回的遍歷器對象
    yield '2';
}
for (let v of bar()){
    console.log(v);
}
//1
//a
//b
//2

yield*其實是個廣義的概念,不是非要指向Generator函數的返回值,指向任意遍歷器對象均可。例如指向數組:

function* gen(){
    yield* ["a", "b", "c"];
}
console.log(gen().next());    // { value:"a", done:false }

上面代碼中,如果yield后面沒有星號,得到的value是整個數組,加了星號就表示返回的是數組的遍歷器對象。

作為對象屬性

普通函數可以作為對象屬性,Generator函數也不例外:

let obj = {
    gen: function* () { 
        yield 1; 
    }
};
let g = obj.gen();
console.log(g.next());    // { value:1, done:false }

你也可以簡寫成下面這種形式,效果是一樣的:

let obj = {
    * gen() { 
        yield 1; 
    }
};

this

Generator函數返回的不是this對象,而是遍歷器對象,內含指向內部狀態的指針。因此它不是構造函數,不能用new來調用:

function* gen() {  
    yield this.a = 1;
}
let g = new gen();  //TypeError: gen is not a constructor

同理,由于返回的不是this對象,在Generator函數內部的this其實指向window。因此不建議在Generator函數內部使用this:

function* gen() {  
    yield this.a = 1;
}

let g = gen();
console.log(g.next());  //{value=1,  done=false}
console.log(g.a);       //undefined
console.log(window.a);  //1

上例中this指向的是全局對象window,結果給window添加了一個屬性a。

總之,Generator函數的本意是用于流程控制,返回的是指向各流程步驟的指針。它并不是構造函數,不要試圖像普通函數一樣使用this

當然我經驗尚淺,見識少。可能確實有需要用到this對象的場景,該怎么處理呢?可以像上面介紹的next,return,throw方法一樣,在prototype上使用this:

function* gen() {}
gen.prototype.add1 = function () {
    if (typeof this.a === "undefined") {
        this.a = 1;
    } else {
        this.a++;
    }
};

let g = gen();
console.log(g.a);       //undefined
g.add1();
console.log(g.a);       //1
g.add1();
console.log(g.a);       //2
console.log(window.a);  //undefined

如果就是要在Generator函數內部使用this,且有期待的效果呢?似乎沒什么好辦法,只有一個變通的方法。先生成一個空對象,使用bind方法綁定Generator函數內部的this。這樣,這個空對象就成Generator函數的實例對象了:

function* gen() {
    this.a = 1;
    yield this.b = 2;
    yield this.c = 3;
}
var obj = {};
var g = gen.call(obj);
g.next();
g.next();
g.next();

console.log(obj.a);      //1
console.log(obj.b);      //2
console.log(obj.c);      //3

console.log(g.a);       //undefined
console.log(g.b);       //undefined
console.log(g.c);       //undefined
console.log(window.a);  //undefined
console.log(window.b);  //undefined
console.log(window.c);  //undefined

上面代碼可以看出,用call將空對象綁定到Generator函數內部的this上后,通過this添加的屬性都被添加到了空對象obj上。之后可以通過obj正確取到屬性值。

Generator函數的返回值本身,即遍歷器對象g,仍舊沒有屬性。包括原本this應該指向的window也同樣沒有屬性。

例子

用Generator可以用同步的組織代碼的方式,寫出比Promise更清晰的異步操作代碼。例如Promise一文中最后舉的例子,我們要做兩次異步操作(用最簡單的異步操作setTimeout為例),第一次異步操作成功后執行第二次異步操作:

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

delay(1000,function(msg){
    console.log(msg);
    delay(2000,function(msg){
        console.log(msg);
    });
});
//1秒后打印出:sleep 1000
//再過2秒打印出:sleep 2000

上面用回調嵌套很容易實現,但也發現才兩層(還沒加上異常處理),代碼就比較難看了。改成Promise后,雖然異步操作扁平化了,但一眼看過去都是then。

使用Generator,我們可以在異步處理時,暫停函數運行,等異步處理完成,再恢復函數運行。這樣就能用同步化的方式組織代碼。代碼流程看起來更加清晰。

用Generator函數寫出來的同步化的代碼,應該是這樣的(為簡單起見delay的回調函數參數暫時為空):

function* delayedMsg (){
    console.log(delay(1000,function(){}));
    console.log(delay(2000,function(){}));
}

上面這個還不完整,需要加上yield,實現進行異步操作時,暫停函數運行:

function* delayedMsg () { 
    console.log(yield delay(1000, function(){})); 
    console.log(yield delay(2000, function(){}));
}

上面說了Generator是惰性函數,我們需要給它一個原動力,推它一把,讓它開始運行。通常例子里都會將Generator函數包裝在名叫run或execute函數里,就是為了給它一個原動力,讓懶漢開始工作:

function run(genFunc) { 
    var g = genFunc();
    g.next();
} 

run(function* delayedMsg () { 
    console.log(yield delay(1000, function(){})); 
    console.log(yield delay(2000, function(){}));
});

光給原動力還不夠,還需要Generator函數內每個異步操作后調用next方法,執行下一步。由于next方法需要遍歷器對象才能調用,因此定義到run方法內部,新建一個名叫resume的函數:

function run(genFunc) { 
    var g = genFunc(); 
    function resume(value) { 
        g.next(value);
    } 
    g.next();
}

resume函數的參數是上一次異步執行的結果,傳遞給next方法。resume函數就像一個推進器,推進著Generator函數前進。現在需要將resume和定義的Generator函數關聯起來,完整代碼如下:

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

function run(genFunc) { 
    var g = genFunc(resume); 
    function resume(value) { 
        g.next(value);
    } 
    g.next();
} 

run(function* delayedMsg(resume) { 
    console.log(yield delay(1000, resume)); 
    console.log(yield delay(2000, resume));
});
//1秒后打印出:sleep 1000
//再過2秒打印出:sleep 2000

是不是有點復雜?總結一下:

首先,創建一個run函數,參數是自定義Gerenator函數。run函數內調用next方法提供原始推動力,讓Generator函數運作起來。

其次,run函數內部創建一個resume函數,用于恢復yield暫停。參數為上一次異步操作的結果,傳遞給next方法。

最后,自定義Generator函數內,為每一個異步操作加上yield關鍵字和resume作為回調函數。這樣異步操作完成后會調用resume,讓Generator函數再推進一步。

通常run(有的庫起名execute)和resume方法定義好后就一勞永逸,不用變了。如果上述過程實在搞不清,也不影響我們開發。因為我們只需根據業務需求,自定義Generator函數(即上面的delayedMsg),異步操作前加上yield,異步操作的回調函數設為resume,異步操作的結果傳給resume做參數,就行了。

一個node.js用Generator讀取多個文件的例子:

var fs = require('fs');

function run(gen) {
    var gen_obj = gen(resume);
    function resume() {
        var err = arguments[0];
        if (err && err instanceof Error) {
            return gen_obj.throw(err);
        }
        var data = arguments[1];
        gen_obj.next(data);
    }
    gen_obj.next();
}

run(function* gen(resume) {
    var ret;
    try {
        ret = yield fs.readFile('./apples.txt','utf8', resume);
        console.log(ret);
        ret = yield fs.readFile('./oranges.txt','utf8', resume);
        console.log(ret);
    } catch (e) {
        console.log(e);
    } finally {
        console.log('finally');
    }
});

總結

感謝阮一峰寫的《ES6標準入門》一書,讓我對Generator有了更深入的了解。如果看過本篇覺得還行,推薦購買該書,比我寫的好多了。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 簡介 基本概念 Generator函數是ES6提供的一種異步編程解決方案,語法行為與傳統函數完全不同。本章詳細介紹...
    呼呼哥閱讀 1,090評論 0 4
  • 在此處先列下本篇文章的主要內容 簡介 next方法的參數 for...of循環 Generator.prototy...
    醉生夢死閱讀 1,463評論 3 8
  • 異步編程對JavaScript語言太重要。Javascript語言的執行環境是“單線程”的,如果沒有異步編程,根本...
    呼呼哥閱讀 7,333評論 5 22
  • 官方中文版原文鏈接 感謝社區中各位的大力支持,譯者再次奉上一點點福利:阿里云產品券,享受所有官網優惠,并抽取幸運大...
    HetfieldJoe閱讀 6,399評論 9 19
  • 白天不能看到你 夜里我總夢見你 你是我心里的幽靈 出沒夜晚,白天也來遇見 真實的晚上,虛空的白天幻象 我失掉重量,...
    糧食和花圈閱讀 309評論 0 1