Javascript異步編程模型進化

jopen 9年前發布 | 24K 次閱讀 JavaScript開發 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/

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!