Javascript異步編程模型進化
Javascript語言是單線程的,沒有復雜的同步互斥;但是,這并沒有限制它的使用范圍;相反,借助于Node,Javascript已經在某些場景下具備通吃前后端的能力了。近幾年,多線程同步IO的模式已經在和單線程異步IO的模式的對決中敗下陣來,Node也因此得名。接下來我們深入介紹一下Javascript的殺手锏,異步編程的發展歷程。
讓我們假設一個應用場景:一篇文章有10個章節,章節的數據是通過XHR異步請求的,章節必須按順序顯示。我們從這個問題出發,逐步探求從粗糙到優雅的解決方案。
1.回憶往昔之callback
在那個年代,javascript僅限于前端的簡單事件處理,這是異步編程的最基本模式了。 比如監聽dom事件,在dom事件發生時觸發相應的回調。 javascript
element.addEventListener('click',function(){
//response to user click
}); 比如通過定時器執行異步任務。
setTimeout(function(){
//do something 1s later
}, 1000); 但是這種模式注定無法處理復雜的業務邏輯的。假設有N個異步任務,每一個任務必須在上一個任務完成后觸發,于是就有了如下的代碼,這就產生了回調黑洞。
doAsyncJob1(function(){
doAsyncJob2(function(){
doAsyncJob3(function(){
doAsyncJob4(function(){
//Black hole
});
})
}); });
2.活在當下之promise
針對上文的回調黑洞問題,有人提出了開源的promise/A+規范,具體規范見如下地址: https://promisesaplus.com/ 。promise代表了一個異步操作的結果,其狀態必須符合下面幾個要求:
一個Promise必須處在其中之一的狀態:pending, fulfilled 或 rejected.
如果是pending狀態,則promise可以轉換到fulfilled或rejected狀態。
如果是fulfilled狀態,則promise不能轉換成任何其它狀態。
如果是rejected狀態,則promise不能轉換成任何其它狀態。
2.1 promise基本用法
promise有then方法,可以添加在異步操作到達fulfilled狀態和rejected狀態的處理函數。
promise.then(successHandler,failedHandler);
而then方法同時也會返回一個promise對象,這樣我們就可以鏈式處理了。
promise.then(successHandler,failedHandler).then().then();
MDN上的一張圖,比較清晰的描述了Pomise各個狀態之間的轉換。
假設上文中的doAsyncJob都返回一個promise對象,那我們看看如何用promise處理回調黑洞:
doAsyncJob1().then(function(){
return doAsyncJob2();;
}).then(function(){
return doAsyncJob3();
}).then(function(){
return doAsyncJob4();
}).then(//......); 這種編程方式是不是清爽多了。我們最經常使用的jQuery已經實現了promise規范,在調用$.ajax時可以寫成這樣了:
var options = {type:'GET',url:'the-url-to-get-data'};
$.ajax(options).then(function(data){
//success handler
},function(data){
//failed handler
}); 我們可以使用ES6的Promise的構造函數生成自己的promise對象,Promise構造函數的參數為一個函數,該函數接收兩個函數(resolve,reject)作為參數,并在成功時調用resolve,失敗時調用reject。如下代碼生成一個擁有隨機結果的promise。
var RandomPromiseJob = function(){
return new Promise(function(resolve,reject){
var res = Math.round(Math.random()*10)%2;
setTimeout(function(){
if(res){
resolve(res);
}else{
reject(res);
}
}, 1000)
});
}
RandomPromiseJob().then(function(data){
console.log('success');
},function(data){
console.log('failed');
}); jsfiddle演示地址: http://jsfiddle.net/panrq4t7/
promise錯誤處理也十分靈活,在promise構造函數中發生異常時,會自動設置promise的狀態為rejected,從而觸發相應的函數。
new Promise(function(resolve,reject){
resolve(JSON.parse('I am not json'));
}).then(undefined,function(data){
console.log(data.message);
}); 其中then(undefined,function(data)可以簡寫為catch。
new Promise(function(resolve,reject){
resolve(JSON.parse('I am not json'));
}).catch(function(data){
console.log(data.message);
}); jsfiddle演示地址: http://jsfiddle.net/x696ysv2/
2.2 一個更復雜的例子
promise的功能絕不僅限于上文這種小打小鬧的應用。對于篇頭提到的一篇文章10個章節異步請求,順序展示的問題,如果使用回調處理章節之間的依賴邏輯,顯然會產生回調黑洞; 而使用promise模式,則代碼形式優雅而且邏輯清晰。假設我們有一個包含10個章節內容的數組,并有一個返回promise對象的getChaper函數:
var chapterStrs = [
'chapter1','chapter2','chapter3','chapter4','chapter5',
'chapter6','chapter7','chapter8','chapter9','chapter10',
];
var getChapter = function(chapterStr) {
return get('<p>' + chapterStr + '</p>', Math.round(Math.random()*2));
}; 下面我們探討一下如何優雅高效的使用promise處理這個問題。
(1). 順序promise
順序promise主要是通過對promise的then方法的鏈式調用產生的。
//按順序請求章節數據并展示
chapterStrs.reduce(function(sequence, chapterStr) {
return sequence.then(function() {
return getChapter(chapterStr);
}).then(function(chapter) {
addToPage(chapter);
});
}, Promise.resolve()); 這種方法有一個問題,XHR請求是串行的,沒有充分利用瀏覽器的并行性。網絡請求timeline和顯示效果圖如下:
查看jsfiddle演示代碼: http://jsfiddle.net/81k9nv6x/1/
(2). 并發promise,一次性
Promise類有一個all方法,其接受一個promise數組:
Promise.all([promise1,promise2,...,promise10]).then(function(){}); 只有promise數組中的promise全部兌現,才會調用then方法。使用Promise.all,我們可以并發性的進行網絡請求,并在所有請求返回后在集中進行數據展示。
//并發請求章節數據,一次性按順序展示章節
Promise.all(chapterStrs.map(getChapter)).then(function(chapters){
chapters.forEach(function(chapter){
addToPage(chapter);
});
}); 這種方法也有一個問題,要等到所有數據加載完成后,才會一次性展示全部章節。效果圖如下:
查看jsfiddle演示代碼: http://jsfiddle.net/7ops845a/
(3). 并發promise,漸進式
其實,我們可以做到并發的請求數據,盡快展示滿足順序條件的章節:即前面的章節展示后就可以展示當前章節,而不用等待后續章節的網絡請求。基本思路是:先創建一批并行的promise,然后通過鏈式調用then方法控制展示順序。
chapterStrs.map(getChapter).reduce(function(sequence, chapterStrPromise) {
return sequence.then(function(){
return chapterStrPromise;
}).then(function(chapter){
addToPage(chapter);
});
}, Promise.resolve()); 效果如下:
查看jsfiddle演示代碼:http://jsfiddle.net/fuog1ejg/
這三種模式基本上概括了使用Pormise控制并發的方式,你可以根據業務需求,確定各個任務之間的依賴關系,從而做出選擇。
2.3 promise的實現
ES6中已經實現了promise規范,在新版的瀏覽器和node中我們可以放心使用了。對于ES5及其以下版本,我們可以借助第三方庫實現,q( https://github.com/kriskowal/q )是一個非常優秀的實現,angular使用的就是它,你可以放心使用。下一篇文章準備實現一個自己的promise。
3.憧憬未來之generater
異步編程的一種解決方案叫做"協程"(coroutine),意思是多個線程互相協作,完成異步任務。隨著ES6中對協程的支持,這種方案也逐漸進入人們的視野。Generator函數是協程在 ES6 的實現.
3.1 Generator三大基本特性
讓我們先從三個方面了解generator。
(1) 控制權移交
在普通函數名前面加*號就可以生成generator函數,該函數返回一個指針,每一次調用next函數,就會移動該指針到下一個yield處,直到函數結尾。通過next函數就可以控制generator函數的執行。如下所示:
function *gen(){
yield 'I';
yield 'love';
yield 'Javascript';
}
var g = gen();
console.log(g.next().value); //I
console.log(g.next().value); //love
console.log(g.next().value); //Javascript next函數返回一個對象{value:'love',done:false},其中value表示yield返回值,done表示generator函數是否執行完成。這樣寫有點low?試試這種語法。
for(var v of gen()){
console.log(v);
} (2) 分步數據傳遞
next()函數中可以傳遞參數,作為yield的返回值,傳遞到函數體內部。這里有點tricky,next參數作為上一次執行yeild的返回值。理解“上一次”很重要。
function* gen(x){
var y = yield x + 1;
yield y + 2;
return 1;
}
var g = gen(1);
console.log(g.next()) // { value: 2, done: false }
console.log(g.next(2)) // { value: 4, done: true }
console.log(g.next()); //{ value: 1, done: true } 比如這里的g.next(2),參數2為上一步yield x + 1 的返回值賦給y,從而我們就可以在接下來的代碼中使用。這就是generator數據傳遞的基本方法了。
(3) 異常傳遞
通過generator函數返回的指針,我們可以向函數內部傳遞異常,這也使得異步任務的異常處理機制得到保證。
function* gen(x){
try {
var y = yield x + 2;
} catch (e){
console.log(e);
}
return y;
}
var g = gen(1);
console.log(g.next()); //{ value: 3, done: false }
g.throw('error'); //error 3.2 用generator實現異步操作
仍然使用本文中的getChapter方法,該方法返回一個promise,我們看一下如何使用generator處理異步回調。gen方法在執行到yield指令時返回的result.value是promise對象,然后我們通過next方法將promise的結果返回到gen函數中,作為addToPage的參數。
function *gen(){
var result = yield getChapter('I love Javascript');
addToPage(result);
}
var g = gen();
var result = g.next();
result.value.then(function(data){
g.next(data);
}); gen函數的代碼,和普通同步函數幾乎沒有區別,只是多了一條yield指令。
jsfiddle地址如下: http://jsfiddle.net/fhnc07rq/3/
3.3 使用co進行規范化異步操作
雖然gen函數本身非常干凈,只需要一條yield指令即可實現異步操作。但是我卻需要一堆代碼,用于控制gen函數、向gen函數傳遞參數。有沒有更規范的方式呢?其實只需要將這些操作進行封裝,co庫為我們做了這些( https://github.com/tj/co )。那么我們用generator和co實現上文的逐步加載10個章節數據的操作。
function *gen(){
for(var i=0;i<chapterStrs.length;i++){
addToPage(yield getChapter(chapterStrs[i]));
}
}
co(gen); jsfiddle演示地址: http://jsfiddle.net/0hvtL6e9/
這種方法的效果類似于上文中提到“順序promise”,我們能不能實現上文的“并發promise,漸進式”呢?代碼如下:
function *gen(){
var charperPromises = chapterStrs.map(getChapter);
for(var i=0;i<charperPromises.length;i++){
addToPage(yield charperPromises[i]);
}
}
co(gen); jsfiddle演示地址: http://jsfiddle.net/gr6n3azz/1/
經歷過復雜性才能達到簡單性。我們從最開始的回調黑洞到最終的generator,越來越復雜也越來越簡單。
本文同時發表在我的博客 積木村の研究所 : http://foio.github.io/javascript-asyn-pattern/