JavaScript非同步編程解決方案

來源:互聯網
上載者:User

最近看了一些javascript非同步編程方面文章, 也反覆讀了幾遍薄薄的 << Async JavaScript >>。總結一下, 供自己後續學習使用, 並分享給大家。

首先, 有幾個問題:

什麼是非同步編程/非同步函數?
非同步函數和回呼函數有什麼關係?
為什麼非同步編程經常與javascript同時出現?
javascript中的非同步函數的機制是怎樣的?
那麼現在非同步編程有什麼解決文案?
未來的javascript非同步編程是什麼樣子?

什麼是非同步函數?

對一個jser而言,學習和使用javascrtip的過程中, 非同步編程出現頻率應該是極高的,或許僅次於事件驅動/單線程。那麼什麼是非同步編程呢?什麼是非同步函數呢?

言簡意賅的說:非同步函數就是會導致將來運行 一個取自事件隊列的函數 的函數。這是的重點是取自事件隊列,關於這個概念,暫且按下不表,將在後面進行分析,我們現在只需要知道非同步函數是會導致將來某個時刻運行另外一個函數的函數。

非同步函數 VS 回呼函數

又是一個高頻詞彙,回呼函數 。再次,我覺得有必要區分一下回呼函數和非同步函數的概念,雖然在很多人看來,在這一點上的區分不必太過糾結,可是借用老羅的話,我不是為了輸贏,我就是認真,對概念的精確理解和把握,往往是我們深入學習的第一個台階。

所謂回函函數:

In computer programming, a callback is a piece of executable code that is passed as an argument to other code, which is expected to call back (execute) the argument at some convenient time. The invocation may be immediate as in a synchronous callback or it might happen at later time, as in an asynchronous callback. In all cases, the intention is to specify a function or subroutine as an entity that is, depending on the language, more or less similar to a variable.(from wikipedia)

從wikipedia的說法中我們可以清晰的看到:

首先,回呼函數作為參數傳入到另外一段代碼中的一段可執行代碼,也就是它所強調的是回呼函數是需要被當作參數傳入到其它代碼中的;

其次,回呼函數可以是同步的,也可以是非同步,這取決於使用者。

如果我們進入到wikipedia的頁面,我們能額外發現一些其它的知識,比如回呼函數會出現在擁有某些特性的語言中,那麼函數是一等會民的javascript當然也就完美支援回呼函數了。

那麼,現在這兩個概念應該比較清晰了,我們舉個例子比較一下。比如:

function callbackFunc() {
     console.log("callback executed!");
}

setTimeout(callbackFunc, 10000);

function syncFunc(callbackFunc) {
    callbackFunc();
}

在上面的程式碼片段中,setTimeou是一個非同步函數, 因為它導致 了大約1秒後callbackFunc的運行。而callbackFunc對於setTimeout來說,它是一個回呼函數。同時,callbackFunc對於syncFunc來說,它也是一個回呼函數,但是被同步執行(在同一個事件迴圈裡被執行),那麼syncFunc不能被稱為非同步函數

另外,在網上的一些文章中都能看到,很多人將回呼函數作為了非同步編程的一個解決文案進行總結,包括阮一峰老師的javascript非同步編程的4種方法。對於此,我認為這種分類是不恰當的。如果將回呼函數看做非同步編程的一種解決文案,那麼我們後面講到的分布式事件、Promise以及強大的工作流程控制庫都是藉助回呼函數的形式來實現,豈不是都能看作是同一種解決?所以,我認為,回呼函數並不能簡單地被當做非同步編程的一種解決文案。

javascript中的非同步機制

每一個jser都應該瞭解,javascript是單線程的,所謂單線程,就是同一時刻只能執行一個任務,或者說只能有一個函數一個程式碼片段在執行。那麼我們就很容易產生疑問,如果是單線程,那非同步是如何?的?

一句話回答:事件驅動(event-driven)。

首先,javascript是單線程執行的, 但是javscript引擎的平台(瀏覽器或者nodejs)等是擁有若干線程的。比如,對於一個瀏覽器而言,有一條線程做渲染,有一條線程記錄事件(click)等,有一條線程執行javascript等等,這些線程在瀏覽器核心的協調控制下執行(javascript線程執行期間,不能進行ui渲染)。這是單線程實現的非同步基礎。

其次,每一個非同步函數都會對應至少一個event-handler,而上面提到的 事件隊列 便是 event-handler在被處理的時候該放在的地方。javascript引擎的線程會在適當的時機處理一系列的 event-handler,適當的時機需要滿足兩個條件:

該事件已經滿足觸發條件(比如:setTimeout(func, 1000)後大約1000ms);
javascript的線程空閑(比如:setTimeout註冊的回調延時條件已經滿足,但是此時的javascript引擎正在做一個複雜的for迴圈耗時3秒,那麼setTimeout的回呼函數也能只能等待for迴圈執行完成後,再執行)。
這是再提到event-loop的存在,每一次迴圈都是一個tick,它的作用就是在不斷迴圈檢測事件隊列中是否有event-handler,如果有便會取出執行。我們可以這樣理解event-loop:

while (true) {
    if (atLeasetOneEventIsQueued) {
        fireNextQueuedEvent();
    }
}
最後,事件滿足觸發條件(上文中適當的時機條件1)是如何判斷的?

不同的事件的觸發條件可能由不同的線程監控。比如,我們發送一個ajax請求,應該是瀏覽器有一個獨立的線程發送http請求並在請求返回的時候通知javascript引擎線程滿足觸發條件;而click一個button,應該是瀏覽器的GUI線程通知javascript引擎,然後適時執行相應的event-handler。

我們舉個例子說明,假設:我們處在一個頁面,這個頁面上有一個setTimeout正在執行延時1000ms執行的某段代碼 ;而在這個200ms的時候,我們點擊了一個按鈕,因為此時已經滿足事件觸發條件,且javscript線程空閑,所以按照我們指令碼,瀏覽器會立即執行與這個事件綁定的另外某段代碼;點擊事件觸發的某段代碼會做兩件事,

一件是註冊一個setinterval要求每隔700ms執行某段代碼
另一件是發送一個ajax請求,並要求請求返回後執行某段代碼,這個請求會在1500ms後返回。在這之後可能還會有其它事件被觸發。
上文中,每一個某段代碼都是一個event-handler,而event-handler被觸發的時機可能受前面event-handler的影響。我們按照每個event-handler的執行時間都非常短來處理。可以提到下圖(上文標示event-hanlder對應的非同步函數,下方標示大致的時間):

從圖中我們能看到事件的執行順序,這個很容易理解。現在想一下,如果時間點事件的event-handler先執行一個while迴圈耗時了100ms,然後再去setInterval和ajax請求,那麼執行的順序又是怎樣的呢?如果理解了javascript的事件驅動機制,這個就很容易了。留下一段代碼,大家自己嘗試一下。是不是跟大家想的一樣?

var obj = {"num": 1}, start = new Date;
setTimeout(function() {obj.num = 2}, 0);

while(new Date - start < 1000) {}

alert(JSON.stringify(obj));

或許還可以想到我們平時遇到的一些問題背後的原因:

為什麼大多情況下setInterval執行間隔會小於setTimeout?
為什麼setTimeout會有最小間隔?whatwg和w3c的HTML5規範都規定4ms
為什麼建議耗時的函數分多次執行?比如,process.nextTick。

現在主要的非同步編程的方案有3種:

PubSub模式(分布式事件)
Promise對象
工作流程控制庫

下面我們將逐個進行分析,在這些非同步方案之前,我們經常看到所謂的金字塔厄運

asyncFunc1(function(result1){
    //some codes
    asyncFunc2(function(result2){
        //some codes
        asyncFunc3(function(result3){
            //other codes
        });
    });
});

那麼,我們的解決方案就是使我們能更加方便的組織非同步代碼,規避像上面那樣的問題。

PubSub模式(分布式事件)

所謂的PubSub模式其實很簡單, 比如我們平時使用的dom.addEventListener就是一個PubSub模式鮮活的例子。在2000年DOM Level 2發布之前, 我們可能 需要使用類似於dom.onclick的方式去綁定事件。這樣很容易產生問題,如果沒有分布式事件的話,我們不能:

dom.onclick = eventHandler1;
dom.onclick = eventHandler2;

很明顯,onclick只是dom的一個屬性,同一個key不能對應多個value,第一個會被第二個覆蓋掉,所以我們只能:

dom.onclick = function(){
    eventHandler1.apply(this,arguments);
    eventHandler1.apply(this,arguments); 
};
`
這樣的壞處很多,比如不夠靈活,代碼冗長,不利於維護等等。

現在開始學習前端,可能已經沒有老師或者書籍講解這樣的用法了。dom.addEventListener標準化之後,我們可以:

dom.addEventListener('click',eventHandler1);
dom.addEventListener('click',eventHandler2);

而像jquery這樣的類庫,也自然磨平了不同瀏覽器的差異,提供了類似於$dom.on()的方法。如今,幾乎所有的前端dom相關的類庫都會提供類似的API。當然,在javascript世界的另一端,nodejs也有核心模組Events提供的EventEmitter對象,從而很容易實現分布式事件:

var Emitter = require("events").EevntEmitter;

var emitter = new Emitter();

emitter.on('someEvent',function(stream){
    console.log(stream + 'from eventHandler1');
});

emitter.on('someEvent',function(stream){
    console.log(stream + 'from eventHandler2');
});

emitter.emit('someEvent','I am a stream!');

我們用DOM舉例並不說明PubSub模式就是事件監聽,而是因為事件監聽是一個典型的分布式事件的樣本,只是我們的訂閱和發布依託的對象不是一個常規的對象,而且是一個瀏覽器的DOM對象,而在jQuery中這個對象就是jQuery對象了,下面,我們用簡單的代碼實現一個PubSub模式:

var PubSub = {handler: {}};

PubSub.sub = function(evnt, handler) {
   var handlers = this.handlers;

   !(event in handlers) && handlers(event) = [];

   handers[event].push(handler);
}

PubSub.pub = function(event) {
   var handlers = (handlers[event] || []);

   var handlerArgs = [].slice.call(arguments, 1);

   for(var i = 0, item; item = handlers[i]; i++) {
        item.apply(this, handlerArgs);
    }

    return this;
}

如同我們看到的,上面的代碼只是一個最簡單甚至不安全的實現。在生產環境中,有很多成熟的架構,比如PubSubJS這樣純粹的PubSub模式的實現。同時,從上面的實現中,我們能發現,所有的event-handler都是同步執行的,這與我們瀏覽器中真實點擊事件的事件處理時機還是有差異的,真實的點擊事件的handler會在後續的event-loop中觸發,同樣,我們手動的dom.click()或者jQuery的$dom.click()都是同步執行的(大家可以測試一下)。

PubSub模式是大家最常用的一種方式,相對容易理解。基於這種事件化對象,實現了代碼的分層次化,像大名如雷貫耳的Backbone.js也是使用了這樣的技術。這是PubSub模式的好處。但是,事件不是萬金油,有一些情況不適合用事件來處理,比如一些一次性轉化且只有成功或者失敗結果的流程,使用PubSub模式就有一些不合適。而這種情景下,Promise就顯得更加適合我們。

Promise對象

Promise在很多語言中都有各自的實現,而其與javascript的結緣要歸功於javascript發展曆史上有裡程碑意義的Dojo架構。2007年Dojo的開發人員Twisted的啟發,為Dojo添加了一個dojo.Deferred對象。2009年,Kris Zyp在CommmonJS社區提出了Promiser/A規範。之後,風雲變幻,nodejs異軍突起(2010年初,nodejs 放棄了對Promise的原生支援),2011年jQuery1.5攜帶著叛逆的Promise實現以及嶄新的ajax風火出世,從此Promise真正被javascript開發人員所熟知。

如今,更多的實現早已關注羽翼更加豐滿的Promise/A+規範,jQuery對Promise的實現也對標準有所妥協,同時像Q.js的出現,也使得javascript世界有了通吃用戶端和服務端的直觀且純粹的實現。

就在不遠的(2014年12月)將來,javascript發展史上有了一個重大的時刻將會到來,ES6將成為正式標準,在眾多奪人眼球的特性中,對Promise的原生支援仍然不乏矚目,如果再配以Generator將是如虎添翼。

稍遠的將來,ES7會提供一個async關鍵字引導聲明的函數,支援 await, 而此番花樣將會如何讓我們拭目以待。

CommonJS社區的Promise/A規範相對簡潔,而Promise/A+規範規範對其作了一些補充,我們後面將以Promise/A+規範配以執行個體學習Promise。

什麼是Promise?Promise是一個對象,它代表非同步函數的返回結果。用代碼錶示也就是:

var promise = asyncFunction();

如果具象一點,我們常見的一個jQuery的ajax調用就是這樣:

var ajaxPromise = $.ajax('mydata');
ajaxPromise.done(successFunction);
ajaxPromise.fail(errorFunction);
ajaxPromise.always(completeFunction);

從上面的代碼中,我們看到jQuery返回的Promise對象擁有若干方法,比如done、fail和always分別對應了ajax成功、失敗以及無論成功失敗都應該執行的回調,這些方法可以看做是規範之上的具體實現帶給我們的文法糖。那麼,真實的Promise規範是什麼樣?(其實,規範相對簡短,大家可以稍花時間閱讀,在此我們做一下主幹介紹)

Promise的狀態能且只能是下面三種的某一種:pending, fulfilled, rejected。這三種狀態之間的關係:

pending:可以轉變到fulfilled狀態或者rejected狀態
fulfilled:不可以轉變到其他任何狀態,而且必須有一個不可改變的value
rejected:不可以轉變到其他任何狀態,而且必須有一個不可改變的reason
關於value和reason,我們可以分別理解為fulfilled的結果和rejected的原因。

Promise必須要擁有一個then方法,用以訪問當前或者最終的value或reason。then方法擁有兩個參數,而且這兩個參數都是可選的,用promise.then(onFulfilled, onRejected)。分析如下:

onFulfilled: :如果不是函數,將被忽略。
如果是函數,只有且必須在promise狀態轉換為fulfilled之後被觸發一次,並且只傳遞promise的value作為第一個參數。

onRejected:如果不是函數,將被忽略。
如果是函數,只有且必須在promise狀態轉換為rejected之後被觸發一次,並且只傳遞promise的reason作為第一個參數。

另外:多次調用then綁定的回呼函數,在fulfilled或rejected的時候,執行順序與綁定順序相對應。規範要求,調用需要在then之後的event loop中執行。
Promise的then方法必須返回一個promise對象,以供鏈式調用,如果onFulfilled或者onRejected有throw,那麼後產生的Promise對象應該以拋出內容為reason轉化為rejected狀態。

在淺析Promise規範之後,我們可以完善一下本章節的第一段代碼:

var promise = asyncFunction();
promise = promise.then(onFulfilled1, onRejected1)
                 .then(onFulfilled2, onRejected2);

promise.then(onFulfilled3, onRejected3);
Promise/A規範的實現眾多,在我們的實際生產中,我們應該選擇哪個實現呢?這個只能說因地制宜。

當然,現在應該有很多人和我一樣,期待著ES6的原生Promise實現。ES標準化的Promise看上去是這樣的:

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async, then…

  if (/* everything turned out fine */) {
    resolve("Stuff worked!");
  }
  else {
    reject(Error("It broke"));
  }
});

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}, function(err) {
  console.log(err); // Error: "It broke"
});

接下來,我們順帶提及一下Generator吧,如果你還不知道Generator是什嗎?看這裡。簡潔一點描述就是Generator函數可以通過特定的yield關鍵字中斷函數執行,並與外界共用執行內容。Generator函數基於這一特性,可以跟非同步函數配合,等待非同步函數的執行(結果),然後通過特定的介面(next)將非同步結果注入到Generator自己的上下文中,然後繼續執行後面的代碼。

這樣結合後,我們便能用同步的方式書寫非同步代碼。能與Generator配合的實現有很多,其中就有Promise對象,而express的主人TJ大神給我們提供了一個非常成熟的方案–co。個人感覺,基於Generator最佳化非同步代碼的方式會是未來的最受歡迎的方式。在此,推薦幾篇比較優秀的文章,我也就不班門弄斧了。

樸靈大大的還熱乎的Generator與非同步編程,不知道是哪位老師的Harmony Generator, yield, ES6, co架構學習,屈屈大大的ES6中的產生器函數介紹。如果想學習這一“不遠未來”的技術,請點擊進入上述連結吧。

另外,Google和Mozilla分別給了一些自己的解決方案:traceur 和 taskjs。

基於Promise,我們可以實現各種串列並行的非同步作業,但是,這個串列並行的控制,需要我們手動去維護,而flow-control類的方案,恰恰滿足了我們這方面的需求,下面我們就從這裡說起吧。

工作流程控制庫

所謂的工作流程控制庫(flow-control),我用自己的語言描述便是通過固有的模式(庫提供相關的api)組織任務(代碼/函數)的執行,從而輕鬆實現並行串列等需求。那麼,比如我們有一個需求,需要讀取三個檔案,而三個檔案是有順序依賴關係的,那麼我們需要做的就是順序讀取,可能代碼原始是這樣的:

fs.readFile('originalFile',function(err,data1){
  fs.readFile(data1,function(err,data2){
    fs.readFile(data2,function(err,data3){
      //operate with data3
    });
  });
});

我們看到了一個“美麗的”金字塔。那麼,如果用久負盛名的async後,會是怎麼樣呢?

async.waterfall([
  function(cb){
    fs.readFile('originalFile',cb);
  },function(data1,cb){
    fs.readFile(data1,cb);
  },function(data2,cb){
    fs.readFile(data2,cb);
  }
],function(err,result){
  //result now equals data3 & operate with data3
});

而同樣的需求,用極簡主義的step實現,代碼又是如何呢?

step(function(){
  fs.readFile('originalFile',this);
},function(err,data1){
  fs.readFile(data1,this);
},function(err,data2){
  fs.readFile(data2,this);
},function(err,data3){
  //operate with data3
});

關於原始方案非同步函數嵌套非同步函數,我們可以一目瞭然就不做解釋了。下面,我們對比一下async和step兩者:

最明顯的區別便是,async對外暴露一個對象,對象之下有實現若干特定流程的api。比如,我們需求中,由上而下有順序依賴關係,async會給我們提供一個很文藝的api叫waterfall,而沒有依賴關係只有順序要求,我們就可以使用async.series,並行推進任務可以用async.parallel等等。

相比async,step就顯得簡潔很多,step給我們只提供了一個函數,它接受一個系列函數作為參數,並根據函數中對this的調用區分實現不同類型的流程式控制制。上面的樣本中,非同步函數在完成之後將結果傳入step的回呼函數執行時的this(如你所想,這時候this是一個函數),而正是通過this實現了將非同步作業的結果傳入到下一個step的回呼函數,從而實現流程式控制制。通過this,我們實現其它的流程式控制制,比如要求多個任務並行:

Step(function loadStuff() {
    fs.readFile('file-1', this.parallel());
    fs.readFile('file-2', this.parallel());
    fs.readFile('file-3', this.parallel());
  },function showStuff(err, f1, f2, f3) {
    //operate with f1, f2, f3
  }
);

另外,async還為我們提供了一些流程式控制制之外的非常易用集合操作的方法以及一些工具函數。比如,類似於數組的map操作,我們看下面的函數:

async.map(['file1','file2','file3'], fs.stat, function(err, results){
  // results is now an array of stats for each file
});

而當我們需要用step實作類別似需求的時候怎麼辦呢?因為step是極簡主義,源碼也總共寥寥百餘行,但是,我們完全可以藉助既有的函數和方法,類比出一個stepMap:

function stepMap(arr, iterator, callback){
  step(function(){
    var group = this.group();
    for(var i = 0, l = arr.length; i < l; i++){
      iterator(arr[i], group());
    }
  },callback);
}

總之,關於這一類型的解決方案,async和step是兩個比較福士的實現,哪個更優,我覺得各有利弊,就像我們權衡express和connect一樣。如果你喜歡便捷易用,又對api天生敏感,async是不錯的選擇;如果你像我一樣,喜歡簡潔,而且喜歡自己折騰,又不想死記api,那不妨嘗試一下step。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.