JavaScript異步編程的主要解決方案—對不起,我和你不在同一個頻率上

衆所周知(這也忒誇張了吧?),Javascript經過事件驅動機制,在單線程模型下,以異步的形式來實現非阻塞的IO操做。這種模式使得JavaScript在處理事務時很是高效,但這帶來了不少問題,好比異常處理困難、函數嵌套過深。下面介紹幾種目前已知的實現異步操做的解決方案。
(操蛋,不支持TOC)html

1、回調函數

這是最古老的一種異步解決方案:經過參數傳入回調,將來調用回調時讓函數的調用者判斷髮生了什麼。
直接偷懶上阮大神的例子:
假定有兩個函數f1和f2,後者等待前者的執行結果。
若是f1是一個很耗時的任務,能夠考慮改寫f1,把f2寫成f1的回調函數。前端

function f1(callback){
    setTimeout(function () {
      // f1的任務代碼
      callback();
    }, 1000);
  }

執行代碼就變成下面這樣:
f1(f2);
採用這種方式,咱們把同步操做變成了異步操做,f1不會堵塞程序運行,至關於先執行程序的主要邏輯,將耗時的操做推遲執行。
回調函數的優勢是簡單、容易理解和部署,缺點是不利於代碼的閱讀和維護,各個部分之間高度耦合,流程會很混亂.也許你以爲上面的流程還算清晰。那是由於我等初級菜鳥還沒見過世面,試想在前端領域打怪升級的過程當中,遇到了下面的代碼:java

doA(function(){
    doB();
    doC(function(){
        doD();
    })
    doE();
});
doF();

要想理清上述代碼中函數的執行順序,還真得停下來分析好久,正確的執行順序是doA->doF->doB->doC->doE->doD.
回調函數的優勢是簡單、容易理解和部署,缺點是不利於代碼的閱讀和維護,程序的流程會很混亂,並且每一個任務只能指定一個回調函數。node

2、事件發佈/訂閱模式(觀察者模式)

事件監聽模式是一種普遍應用於異步編程的模式,是回調函數的事件化,任務的執行不取決於代碼的順序,而取決於某個事件是否發生。這種設計模式常被成爲發佈/訂閱模式或者觀察者模式。
瀏覽器原生支持事件,如Ajax請求獲取響應、與DOM的交互等,這些事件天生就是異步執行的。在後端的Node環境中也自帶了events模塊,Node中事件發佈/訂閱的模式及其簡單,使用事件發射器便可,示例代碼以下:jquery

//訂閱
emitter.on("event1",function(message){
  console.log(message);
});
//發佈
emitter.emit('event1',"I am message!");

咱們也能夠本身實現一個事件發射器,代碼實現參考了《JavaScript設計模式與開發實踐》git

var event={
    clientList:[],
    listen:function (key,fn) {
        if (!this.clientList[key]) {
            this.clientList[key]=[];
        }
        this.clientList[key].push(fn);//訂閱的消息添加進緩存列表
    },
    trigger:function(){
        var key=Array.prototype.shift.call(arguments),//提取第一個參數爲事件名稱
        fns=this.clientList[key];
        if (!fns || fns.length===0) {//若是沒有綁定對應的消息
            return false;
        }
        for (var i = 0,fn;fn=fns[i++];) {
            fn.apply(this,arguments);//帶上剩餘的參數
        }
    },
    remove:function(key,fn){
        var fns=this.clientList[key];
        if (!fns) {//若是key對應的消息沒人訂閱,則直接返回
            return false;
        }
        if (!fn) {//若是沒有傳入具體的回調函數,表示須要取消key對應消息的全部訂閱
            fns&&(fns.length=0);
        }else{
            for (var i = fns.length - 1; i >= 0; i--) {//反向遍歷訂閱的回調函數列表
                var _fn=fns[i];
                if (_fn===fn) {
                    fns.splice(i,1);//刪除訂閱者的回調函數
                }
            }
        }
    }
};

只有這個事件訂閱發佈對象沒有多大做用,咱們要作的是給任意的對象都能添加上發佈-訂閱的功能:
在ES6中可使用Object.assign(target,source)方法合併對象功能。若是不支持ES6能夠自行設計一個拷貝函數以下:es6

var installEvent=function(obj){
 for(var i in event){
     if(event.hasOwnProperty(i))
   obj[i]=event[i];
 }
};

上述的函數就能給任意對象添加上事件發佈-訂閱功能。下面咱們測試一下,假如你家裏養了一隻喵星人,如今它餓了。github

var Cat={};
//Object.assign(Cat,event);
installEvent(Cat);
Cat.listen('hungry',function(){
  console.log("鏟屎的,快把朕的小魚乾拿來!")
});
Cat.trigger('hungry');//鏟屎的,快把朕的小魚乾拿來!

自定義發佈-訂閱模式介紹完了。
這種方法的優勢是比較容易理解,能夠綁定多個事件,每一個事件能夠指定多個回調函數。缺點是整個程序都要變成事件驅動型,運行流程會變得很不清晰。web

3、使用Promise對象

ES6標準中實現的Promise是異步編程的一種解決方案,比傳統的解決方案——回調函數和事件——更合理和更強大。
所謂Promise,就是一個對象,用來傳遞異步操做的消息。它表明了某個將來纔會知道結果的事件,而且這個事件提供統一的API,各類異步操做均可以用一樣的方法進行處理。

Promise對象有如下兩個特色。
(1)對象的狀態不受外界影響。Promise對象表明一個異步操做,有三種狀態:Pending(進行中)、Resolved(已完成,又稱Fulfilled)和Rejected(已失敗)。只有異步操做的結果,能夠決定當前是哪種狀態,任何其餘操做都沒法改變這個狀態
(2)一旦狀態改變,就不會再變,任什麼時候候均可以獲得這個結果。Promise對象的狀態改變,只有兩種可能:從Pending變爲Resolved和從Pending變爲Rejected。只要這兩種狀況發生,狀態就凝固了,不會再變了,會一直保持這個結果。就算改變已經發生了,你再對Promise對象添加回調函數,也會當即獲得這個結果。這與事件(Event)徹底不一樣,事件的特色是,若是你錯過了它,再去監聽,是得不到結果的。
有了Promise對象,就能夠將異步操做以同步操做的流程表達出來,避免了層層嵌套的回調函數。
下面以一個Ajax請求爲例,Cnode社區的API中有這樣一個流程,首先根據accesstoken獲取用戶名,而後能夠根據用戶名獲取用戶收藏的主題,若是咱們想獲得某個用戶收藏的主題數量就要進行兩次請求。若是不使用Promise對象,以Jquery的ajax請求爲例:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Promise</title>
</head>
<body>    

</body>
<script type="text/javascript" src="http://apps.bdimg.com/libs/jquery/1.7.2/jquery.min.js"></script>
<script type="text/javascript">
    $.post("https://cnodejs.org/api/v1/accesstoken",{
        accesstoken:"XXXXXXXXXXXXXXXXXXXXXXXXXXX"
    },function (res1) {
        $.get("https://cnodejs.org/api/v1/topic_collect/"+res1.loginname,function(res2){
            alert(res2.data.length);
        });
    });
</script>
</html>

從上述代碼中能夠看出,兩次請求相互嵌套,若是改爲用Promise對象實現:

function post(url,para){
        return new Promise(function(resolve,reject){
            $.post(url,para,resolve);            
        });
    }

    function get(url,para){
        return new Promise(function(resolve,reject){
            $.get(url,para,resolve);
        });
    } 

    var p1=post("https://cnodejs.org/api/v1/accesstoken",{
         accesstoken:"XXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    });
    var p2=p1.then(function(res){
        return get("https://cnodejs.org/api/v1/topic_collect/"+res.loginname,{});
    });
    p2.then(function(res){
        alert(res.data.length);
    });

能夠看到前面代碼中的嵌套被解開了,(也許有人會說,這代碼還變長了,坑爹嗎這是,請不要在乎這些細節,這裏僅舉例說明)。關於Promise對象的具體用法還有不少知識點,建議查找相關資料深刻閱讀,這裏僅介紹它做爲異步編程的一種解決方案。

4、使用Generator函數

關於Generator函數的概念能夠參考阮大神的ES6標準入門,Generator能夠理解爲可在運行中轉移控制權給其餘代碼,並在須要的時候返回繼續執行的函數,看下面一個簡單的例子:

function* helloWorldGenerator(){
    yield 'hello';
    yield 'world';
    yield 'ending';
}
var hw=helloWorldGenerator();
console.log(hw.next());
console.log(hw.next());
console.log(hw.next());
console.log(hw.next());
// { value: 'hello', done: false }
// { value: 'world', done: false }
// { value: 'ending', done: false }
// { value: undefined, done: true }

Generator函數的調用方法與普通函數同樣,也是在函數名後面加上一對圓括號。不一樣的是,調用Generator函數後,該函數並不執行,返回的也不是函數運行結果,而是一個遍歷器對象(Iterator Object)。
下一步,必須調用遍歷器對象的next方法,使得指針移向下一個狀態。也就是說,每次調用next方法,內部指針就從函數頭部或上一次停下來的地方開始執行,直到遇到下一個yield語句(或return語句)爲止。換言之,Generator函數是分段執行的,yield語句是暫停執行的標記,而next方法能夠恢復執行。
Generator函數的暫停執行的效果,意味着能夠把異步操做寫在yield語句裏面,等到調用next方法時再日後執行。這實際上等同於不須要寫回調函數了,由於異步操做的後續操做能夠放在yield語句下面,反正要等到調用next方法時再執行。因此,Generator函數的一個重要實際意義就是用來處理異步操做,改寫回調函數。
若是有一個多步操做很是耗時,採用回調函數,可能會寫成下面這樣。

step1(function (value1) {
  step2(value1, function(value2) {
    step3(value2, function(value3) {
      step4(value3, function(value4) {
        // Do something with value4
      });
    });
  });
});

採用Promise改寫上面的代碼。(下面的代碼使用了Promise的函數庫Q)

Q.fcall(step1)
  .then(step2)
  .then(step3)
  .then(step4)
  .then(function (value4) {
    // Do something with value4
  }, function (error) {
    // Handle any error from step1 through step4
  })
  .done();

上面代碼已經把回調函數,改爲了直線執行的形式,可是加入了大量Promise的語法。Generator函數能夠進一步改善代碼運行流程。

function* longRunningTask() {
  try {
    var value1 = yield step1();
    var value2 = yield step2(value1);
    var value3 = yield step3(value2);
    var value4 = yield step4(value3);
    // Do something with value4
  } catch (e) {
    // Handle any error from step1 through step4
  }
}

若是隻有Generator函數,任務並不會自動執行,所以須要再編寫一個函數,按次序自動執行全部步驟。

scheduler(longRunningTask());
function scheduler(task) {
  setTimeout(function() {
    var taskObj = task.next(task.value);
    // 若是Generator函數未結束,就繼續調用
    if (!taskObj.done) {
      task.value = taskObj.value
      scheduler(task);
    }
  }, 0);
}

5、使用async函數

在ES7(還未正式標準化)中引入了Async函數的概念,async函數的實現就是將Generator函數和自動執行器包裝在一個函數中。若是把上面Generator實現異步的操做改爲async函數,代碼以下:

async function longRunningTask() {
  try {
    var value1 = await step1();
    var value2 = await step2(value1);
    var value3 = await step3(value2);
    var value4 = await step4(value3);
    // Do something with value4
  } catch (e) {
    // Handle any error from step1 through step4
  }
}

正如阮一峯在博客中所述,異步編程的語法目標,就是怎樣讓它更像同步編程,使用async/await的方法,使得異步編程與同步編程看起來相差無幾了。

6、藉助流程控制庫

隨着Node開發的流行,NPM社區中出現了不少流程控制庫能夠供開發者直接使用,其中很流行的就是async庫,該庫提供了一些流程控制方法,注意這裏所說的async並非標題五中所述的async函數。而是第三方封裝好的庫。其官方文檔見http://caolan.github.io/async/docs.html
async爲流程控制主要提供了waterfall(瀑布式)、series(串行)、parallel(並行)

  • 若是須要執行的任務緊密結合。下一個任務須要上一個任務的結果作輸入,應該使用瀑布式
  • 若是多個任務必須依次執行,並且之間沒有數據交換,應該使用串行執行
  • 若是多個任務之間沒有任何依賴,並且執行順序沒有要求,應該使用並行執行
    關於async控制流程的基本用法能夠參考官方文檔或者Async詳解之一:流程控制
    下面我舉一個例子說明:假設咱們有個需求,返回100加1再減2再乘3最後除以4的結果,並且每一個任務須要分解執行。
    1.使用回調函數
function add(fn) {
    var num=100;
    var result=num+1;
    fn(result)
}
function  minus(num,fn){
    var result=num-2;
    fn(result);
}
function  multiply(num,fn){
    var result=num*3;
    fn(result);
}
function  divide(num,fn){
    var result=num/4;
    fn(result);
}
add(function (value1) {
  minus(value1, function(value2) {
    multiply(value2, function(value3) {
      divide(value3, function(value4) {
        console.log(value4);
      });
    });
  });
});

從上面的結果能夠看到回調嵌套很深。
2.使用async庫的流程控制
因爲後面的任務依賴前面的任務執行的結果,因此這裏要使用watefall方式。

var async=require("async");
function add(callback) {
    var num=100;
    var result=num+1;
    callback(null, result);
}
function  minus(num,callback){
    var result=num-2;
    callback(null, result);
}
function  multiply(num,callback){
    var result=num*3;
    callback(null, result);
}
function  divide(num,callback){
    var result=num/4;
    callback(null, result);
}
async.waterfall([
    add,
    minus,
    multiply,
    divide
], function (err, result) {
    console.log(result);
});

能夠看到使用流程控制避免了嵌套。

7、使用Web Workers

Web Worker是HTML5新標準中新添加的一個功能,Web Worker的基本原理就是在當前javascript的主線程中,使用Worker類加載一個javascript文件來開闢一個新的線程,起到互不阻塞執行的效果,而且提供主線程和新線程之間數據交換的接口:postMessage,onmessage。其數據交互過程也相似於事件發佈/監聽模式,異能實現異步操做。下面的示例來自於紅寶書,實現了一個數組排序功能。
頁面代碼:

<!DOCTYPE html>
<html>
<head>
    <title>Web Worker Example</title>
</head>
<body>
    <script>
        (function(){
        
            var data = [23,4,7,9,2,14,6,651,87,41,7798,24],
                worker = new Worker("WebWorkerExample01.js");                              
            worker.onmessage = function(event){
                alert(event.data);
            };         
            worker.postMessage(data);            
        
        })();        
    </script>
</body>
</html>

Web Worker內部代碼

self.onmessage = function(event){
    var data = event.data;
    data.sort(function(a, b){
        return a - b;
    });
    
    self.postMessage(data);
};

把比較消耗時間的操做,轉交給Worker操做就不會阻塞用戶界面了,遺憾的是Web Worker不能進行DOM操做。

參考文獻
Javascript異步編程的4種方法-阮一峯 《You Don't Know JS:Async&Performance》 《JavaScript設計模式與開發實踐》-曾探 《深刻淺出NodeJS》-樸靈 《ES6標準入門-第二版》-阮一峯 《JavaScript Web 應用開發》-Nicolas Bevacqua 《JavaScript高級程序設計第3版》

相關文章
相關標籤/搜索