上一篇介紹了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有了更深入的了解。如果看過本篇覺得還行,推薦購買該書,比我寫的好多了。