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


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

一、回調函數

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

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

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

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

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

二、事件發布/訂閱模式(觀察者模式)

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

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

我們也可以自己實現一個事件發射器,代碼實現參考了《JavaScript設計模式與開發實踐》

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可以自行設計一個拷貝函數如下:

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

上述的函數就能給任意對象添加上事件發布-訂閱功能。下面我們測試一下,假如你家里養了一只喵星人,現在它餓了。

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

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

三、使用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對象的具體用法還有很多知識點,建議查找相關資料深入閱讀,這里僅介紹它作為異步編程的一種解決方案。

四、使用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);
}

五、使用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的方法,使得異步編程與同步編程看起來相差無幾了。

六、借助流程控制庫

隨着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);
});

可以看到使用流程控制避免了嵌套。

七、使用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版》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM