現狀
目前我們對異步回調的解決方案有這么幾種:回調,deferred/promise和事件觸發。回調的方式自不必說,需要硬編碼調用,而且有可能會出現復雜的嵌套關系,造成“回調黑洞”;deferred/promise方式則對使用者而言簡潔明了,在執行異步函數之前就已經構造好了執行鏈--then鏈,而且實現也很靈活,具體可參考Promise的實現;事件機制則是一種觀察者模式的實現,但也必須硬編碼在異步執行的函數中,當異步函數執行完畢后再trigger相關事件,而觀察者則相應執行事件處理函數。
注意,剛剛提到了一個詞--硬編碼,依賴這種方式僅實現回調局限性很大,如在node中,對fs.readFile('file1','utf-8')完成之后再進行fs.readFile('file2','utf-8'),使用回調和事件觸發則必須在第一個異步的回調函數中進行調用trigger,增強了這兩個操作的強依賴,使用deferred/promise則會很好的避免。
現在,隨着ECMAScript6的逐漸普及,我們可以在chrome和node端嘗試一種新的異步流程控制--generator。通過generator,我們可以控制函數內部的執行階段,進而可以利用高階函數的特性進行擴展,完成對異步流程的控制。
特性及兼容性
由於隸屬於ECMAScript6規范,因此兼容性是一個大問題,不過我們在最新版的chrome和node --harmony下使用該功能,所以做node端開發的小伙伴們可以大膽的使用。
那么,什么是generator呢?
function* (){}
這就是一個匿名的generator。通過function關鍵字和函數名或者括號之間添加“*”定義一個generator函數,我們也可以這樣判斷一個函數是否為generator:
typeof fn == 'function' && fn.constructor.name == 'GeneratorFunction'
在generator中我們可以關鍵字yield,java程序員對yield肯定不陌生,yield在java中是線程調度的一種方式,可以釋放時間片讓同級別的線程執行,然而在js中,yield卻大不相同,因為js的執行線程是單線程,所以調度就不存在,yield我們可以理解為函數執行的一個斷點,每次只能執行到yield處,這樣原本順序或者異步執行的函數邏輯都可以通過某種方式使他們以順序的方式呈現在我們眼前,在這里需要強調下,通過yield只能斷點執行generator函數中的邏輯,在函數之外並不會阻塞,否則整個主線程就會掛掉。
一個generator函數執行到yield處,我們通過調用generator object的next()繼續進行,generator object(下文簡寫為GO)就是generator函數的返回對象,調用GO的next方法會返回一個{value: '',done: false}這樣的對象,value為yield關鍵字后面的表達式的值,done則表示generator函數是否執行完畢。
這就是基本的generator所有的數據結構,很簡單明了。
實例
function * fn(){
var a = yield 1;
console.log(a);
var b = yield 2;
console.log(b);
}
var go = fn(); // 這是一個generator object
go.next(); // 執行到第一個 yield ,執行表達式 1
go.next(); //執行到第二個yield,輸出console.log(a)為undefined,執行表達式 2
go.next(); //執行console.log(b),輸出 undefined
上面的demo很容易理解,可能唯一有點疑問的就是console.log的輸出。這里強調,每次next,只執行yield后面的表達式,這樣對於前面的賦值操作就無能為力,那么如何對a進行賦值呢?可以通過第二個next進行傳值。通過對第二個go.next(2),這樣a的值就被賦為2,同理b的值也可以這樣傳遞。
但是,這對於異步流程控制有什么用呢?其實,還是通過分段執行異步操作來完成。每個yield async1()執行完畢,將結果作為參數傳給下一個yield async2(),這樣我們只需判斷GO.done是否為true來終止這個流程。
異步流程控制
我們的目標是實現這種方式的流程控制:
flow(function *(){
var readFile = helper(fs.readFile);
var t1 = yield readFile('./files/f1', 'utf8');
var t2 = yield readFile(t1, 'utf8');
console.log(t2);
});
其中flow是流程控制函數,參數為一個generator,helper函數則是一個包裝函數,負責針對異步操作進行處理,下面我們看看helper函數的邏輯。
var helper = function(fn) {
var feed; // 用於存儲回調函數,該函數復用於所有用於helper處理的異步函數
/**
* 執行次序分析:
* helper的參數fn是一個異步函數,通過helper的處理,返回一個含有內部處理邏輯
* 的函數,該函數封裝了所需參數和可能的回調函數feed,並且返回一個設置feed的函數。
*
* 在具體的使用中,通過helper函數封裝fs.readFile,獲取readFile。當執行第一個
* 片段時,首先將所有的參數(包括feed)合並到args,並執行異步調用返回處理函數;此時
* 我們用獲取的返回函數設置回調函數,進而影響到args中的最后一項的函數
*/
return function(){
var args = [].slice.call(arguments);
args.push(function(){
if(feed) {
feed.apply(null,arguments);
}
console.log(feed)
});
fn.apply(null,args);
// 返回一個函數,用於給yield之前的變量賦值
return function(fn){
feed = fn;
}
};
helper函數的作用就是重新包裝異步函數,返回的包裝函數也會返回一個函數,用於給回調函數feed賦值。
所有的異步函數都需要用helper進行封裝,已傳遞必要的回調,最后按照flow分發的流程“依次執行”。
下面我們實現flow的控制邏輯:
var flow = function(gfn) {
var generator = gfn();
next();
function next(data){
generator.ret = generator.next(data);
if(generator.ret.done){
return;
}
generator.ret.value(
function(error,d){
if(error)
throw error;
next.apply(null,[].slice.call(arguments,1));
}
);
}
};
邏輯依舊很簡單,針對傳入的generator生產generator object,最后進入next遞歸。在遞歸中,首先執行next邏輯並判斷是否到了generator的終點,如果沒有則調用generator object的value方法(此處為“被helper處理過得函數的返回值,即function(fn){feed = fn}”)對回調進行賦值,在回調中則遞歸執行next函數,直至generator結束邏輯。
通過這樣的方式,我們制定了flow流程,可以將多個異步操作順序執行,而不影響generator函數之外的其余邏輯,這樣避免了硬編碼,沒有了回調黑洞,我們只需在異步函數前加yield即可,省時省事。
flow(function *(){
var readFile = helper(fs.readFile);
var nt = helper(process.nextTick);
var t1 = yield readFile('./files/f1', 'utf8');
var t2 = yield readFile(t1, 'utf8');
yield nt(function(){console.log(t2)});
// console.log(t2);
});
可以用helper封裝各種異步回調,在具體的業務邏輯中傳入其余回調返回值作為參數,從而達到目的。
並行異步執行
yield 后面不僅僅可以放置表達式,也可以放置數組。數組的每項為表達式,這樣每次執行到yield時,會並行執行這些異步操作,返回對象的value屬性也是一個數組,我們依舊可以對value數組的每項進行賦值,從而完成回調的賦值。
var length = generator.ret.value.length,
ret = [];
generator.ret.value.forEach(function(item,i){
item(function(err,data) {
--length;
if (err) {
console.log(err.message);
// throw err;
}
ret.push(data);
if(0 == length){
generator.next(ret);
}
});
});
對value值進行遍歷,並判斷並行的異步操作是否都已完成,若完成則傳遞ret數組給變量。
throw特性
這塊throw語法糖是后來添加的,之所以提到它是因為它的表現有點獨特:
var gen = function* gen() {
try {
yield console.log('hello');
yield console.log('world');
}
catch (e) {
console.log(e);
yield console.log('error...');
}
yield console.log('end');
}
var g = gen();
g.next();
g.throw('a');
g.next();
第一個next后,輸出‘hello’;
throw后,輸出‘a’、‘error...’
第二個next后,輸出‘end’
可以發現gen.throw后,不僅執行到catch代碼塊,而且還會執行下一個yield表達式,在這里需要注意下!
應用
目前generator的兼容性要求其只能在node平台上使用,目前express框架的后繼者koa采用了generator實現中間件的方式,中間件處理完每個請求都會通過yield *next的方式進行分發,此處的next也是一個generator object,通過yield *next的方式可以嵌套多層generator鏈,這樣next()就會到下一個generator的yield處。
分解函數的執行,這種方式確實讓人耳目一新,我們有理由相信js的未來,我們要堅信js未來的能量,我們要自豪我們處在前端開發這個領域內。