Javascript非同步程式設計模型進化,從promise到generator

積木村の研究所發表於2015-12-11

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各個狀態之間的轉換。

Javascript非同步程式設計模型進化,從promise到generator

假設上文中的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和顯示效果圖如下: Javascript非同步程式設計模型進化,從promise到generator Javascript非同步程式設計模型進化,從promise到generator

檢視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);
    });
});

這種方法也有一個問題,要等到所有資料載入完成後,才會一次性展示全部章節。效果圖如下:

Javascript非同步程式設計模型進化,從promise到generator Javascript非同步程式設計模型進化,從promise到generator

檢視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());

效果如下: Javascript非同步程式設計模型進化,從promise到generator Javascript非同步程式設計模型進化,從promise到generator

檢視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/

相關文章