JavaScript非同步流程控制全攻略

nightZing發表於2018-01-22

一.js非同步流程的由來

      眾所周知,Javascript語言的執行環境是單執行緒(single thread),作為瀏覽器指令碼語言,JavaScript的主要用途是與使用者互動,以及操作DOM。若以多執行緒的方式操作這些DOM,則可能出現操作的衝突。假設有兩個執行緒同時操作一個DOM元素,執行緒1要求瀏覽器刪除DOM,而執行緒2卻要求修改DOM樣式,這時瀏覽器就無法決定採用哪個執行緒的操作。當然,我們可以為瀏覽器引入“鎖”的機制來解決這些衝突,但這會大大提高複雜性,所以JavaScript從誕生開始就選擇了單執行緒執行。
      而單執行緒就是指一次只能完成一件任務。如果有多個任務,就必須排隊,前面一個任務完成,再執行後面一個任務。因為javascript 設計之初是為瀏覽器設計的GUI程式語言,GUI程式設計的特性之一是保證UI執行緒一定不能阻塞,否則效能不好,可能會介面卡死,因為JavaScript是單執行緒的,有一個致命問題是在某一時刻內只能執行特定的一個任務,並且會阻塞其它任務執行,為了解決這個問題,Javascript語言將任務的執行模式分成同步(Synchronous)和非同步(Asynchronous),在遇到類似I/O等耗時的任務時js會採用非同步操作,而此時非同步操作不進入主執行緒、而進入"任務佇列",只有"任務佇列"通知主執行緒,某個非同步任務可以執行了,該任務才會進入主執行緒執行,這時就不會阻塞其它任務執,而這種模式稱為js的事件迴圈機制(Event Loop)。

  • 同步:呼叫者發出呼叫後,在沒有得到結果之前,該呼叫就不返回。後一個任務等待前一個任務結束,然後再執行,程式的執行順序與任務的排列順序是一致的、同步的,具有同步關係的一組任務相互傳送的資訊稱為訊息或事件。
  • 非同步:呼叫者發出呼叫後不會立刻得到結果,該呼叫就返回了。每一個任務有一個或多個回撥函式(callback),前一個任務結束後,不是執行後一個任務,而是執行回撥函式,後一個任務則是不等前一個任務結束就執行,所以程式的執行順序與任務的排列順序是不一致的、非同步的,執行緒就是實現非同步的一個方式,非同步是讓呼叫方法的主執行緒不需要同步等待另一執行緒的完成,從而可以讓主執行緒幹其它的事情。
  • 阻塞:指呼叫結果返回之前,呼叫者會進入阻塞狀態等待。只有在得到結果之後才會返回。
  • 非阻塞:指在不能立刻得到結果之前,該函式不會阻塞當前執行緒,而會立刻返回。
  • 事件迴圈機制: (1)所有同步任務都在主執行緒上執行,形成一個執行棧(execution context stack)。 (2)主執行緒之外,還存在一個"任務佇列"(task queue)。只要非同步任務有了執行結果,就在"任務佇列"之中放置一個事件。 (3)一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務佇列",看看裡面有哪些事件。那些對應的非同步任務,於是結束等待狀態,進入執行棧,開始執行。 (4)主執行緒不斷重複上面的第三步,形成一個事件的迴圈。
    事件迴圈機制示意圖
  • 阻塞非阻塞和同步非同步的主要區別在於前者是相對於呼叫者來說,後者是相對於被呼叫者來說。舉個栗子,把js比作一個老公的話,有一天上班的時候老公在微信約她老婆今天晚上去吃飯,如果老婆看到訊息後馬上同意或者拒絕,對老婆來說這就是同步(老公的訊息被老婆返回了,同時也得到了結果),如果老婆看到訊息後回覆說我晚上可能會加班還不確定,過段時間確定了我再來發條訊息通知你結果(可以理解為回撥函式),對老婆來說這就是非同步(老公的訊息被老婆返回了,但是還沒得到結果,需要等待)。而在老婆還沒有給出最終通知結果時(不管是同步回覆還是非同步回覆),如果此時老公開啟另一個微信視窗約小三明天晚上去吃飯,此時對老公來說就是非阻塞的,而如果老公在老婆沒有最終通知結果之前一直在那等著而沒幹其他事情,對老公來說這就是阻塞的。顯而易見,在這裡老公是呼叫者,老婆是被呼叫者。
  • 還是上面那個栗子,如果老婆說要過段時間才能通知老公最後結果(也就是非同步的時候),此時老公也不能在老婆通知前什麼都不幹就待在那裡,老公沒有分身,也就是說老公不是多執行緒的,他會把這個非同步事件先擱置(也就是放到任務佇列裡) ,作為單執行緒的他只能親自去處理其他事情(主執行緒中處理執行棧),等老婆通知後再來處理這件事情(把這個非同步事件從任務佇列中取回來在主執行緒中執行)。所以當js採用非同步模式的時候js就是非阻塞了,這也就是為什麼說node.js是非阻塞非同步I/O了,因為非同步和事件迴圈機制的特性使它是非阻塞的。

二.js為什麼要演進非同步流程控制

      "非同步模式"非常重要。在瀏覽器端,耗時很長的操作都應該非同步執行,避免瀏覽器失去響應,最好的例子就是Ajax操作。在伺服器端,"非同步模式"甚至是唯一的模式,因為執行環境是單執行緒的,如果允許同步執行所有http請求,伺服器效能會急劇下降,很快就會失去響應。最早非同步模式採用的是回撥函式的方法,但是這種方法不利於程式碼的閱讀和維護,各個部分之間高度耦合,流程會很混亂,而且每個任務只能指定一個回撥函式,這樣就很容易陷入回撥地獄,所以非同步流程控制模式慢慢衍生出許多方式,下面主要來介紹這些方式有哪些。

三.js非同步流程控制的幾種主要方式

1.回撥函式
有兩個任務函式taskFun1和taskFun2,如果按同步方式寫

taskFun1();
taskFun2();
複製程式碼

taskFun1()如果是一個很耗時的任務,會嚴重阻塞taskFun2()的執行,用回撥函式可以這樣寫:

function taskFun1(callbackFun){
    setTimeout(function () {
        // do something
        callbackFun();
    }, 2000);   
}
taskFun1(taskFun2);
複製程式碼
  • 優點:簡單、容易理解和部署,
  • 缺點:不利於程式碼的閱讀和維護,各個部分之間高度耦合,流程會很混亂,而且每個任務只能指定一個回撥函式。

2.事件監聽

另一種思路是採用事件驅動模式。任務的執行不取決於程式碼的順序,而取決於某個事件是否發生。

taskFun1.on("event", taskFun2);
function taskFun1(){
    setTimeout(function () {
    // taskFun1的任務程式碼
    taskFun1.trigger('event');    
    }, 2000);
}
/* taskFun1.trigger('event')表示執行完成後,立即觸發事件,從而開始執行taskFun2。*/
複製程式碼
  • 優點:比較容易理解,可以繫結多個事件,每個事件可以指定多個回撥函式,耦合度很低,有利於實現模組化
  • 缺點:整個程式都要變成事件驅動型,事件不能得到流程控制,執行流程會變得很不清晰。

3.釋出/訂閱

上一節的"事件",完全可以理解成"訊號"。假定,存在一個"訊號中心",某個任務執行完成,就向訊號中心"釋出"(publish)一個訊號,其他任務可以向訊號中心"訂閱"(subscribe)這個訊號,從而知道什麼時候自己可以開始執行。這就叫做"釋出/訂閱模式",又稱"觀察者模式"。

element.subscribe("event", taskFun2);
function taskFun1(){
    setTimeout(function () {
        // taskFun1的任務程式碼
        element.publish("event");
    }, 2000);
}
複製程式碼
  • 優點:可以完全掌握事件被訂閱的次數,以及訂閱者的資訊,管理起來特別方便。

4.Promise物件
關於Promises的具體介紹和實現,可以參考用ES6實現一個簡單易懂的Promise

比如平時我們常用的axios外掛就是採用了promise模式:

axios.get('./demo.txt')
  .then(function(response){
    console.log(response);
  })
  .catch(function(err){
    console.log(err);
  });
複製程式碼

而實現的機制就是promise把成功和失敗分別代理到resolved 和 rejected .

var promise = new Promise(function(resolve, reject) {
  // 非同步操作的程式碼
  if (/* 非同步操作成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});
複製程式碼
  • 優點:回撥函式變成了鏈式寫法,程式的流程可以看得很清楚,可以實現許多強大的功能,同時還可以捕獲到catch異常。
  • 缺點:寫法和理解起來都相對費勁

5.Generator與co相結合
與promise不同的是,Generator設計的初衷並不是為了來控制非同步流程的,這種寫法是express和koa框架的作者拿Generator與co相結合的一種寫法,由於generator是一個狀態機,所以需要手動呼叫next 才能執行,node框架的作者開發了co模組,可以自動執行generator,可以理解為一種geek寫法。

function readFile(filename) {
  return new Promise(function (resolve, reject) {
    fs.readFile(filename, 'utf8', function (err, data) {
      err ? reject(err) : resolve(data);
    });
  })
}
function *read() {
  console.log('開始');
  let a = yield readFile('1.txt');
  console.log(a);
  let b = yield readFile('2.txt');
  console.log(b);
  let c = yield readFile('3.txt');
  console.log(c);
  return c;
}
co(read).then(function (data) {
  console.log(data);
});
複製程式碼
  • 優點:可以用同步的方式編寫非同步程式碼
  • 缺點:不夠直觀,沒有語義化

6.await,async
await,async是ES7 引入了的關鍵字,async函式完全可以看作多個非同步操作,包裝成的一個Promise物件,實質上是generator+promise的語法糖


*async function read(){
 //await後面必須跟一個promise,
 let a = await readFile('./1.txt');
 console.log(a);
 let b = await readFile('./2.txt');
 console.log(b);
 let c = await readFile('./3.txt');
 console.log(c);
 return 'ok';
 }*/
複製程式碼
  • 優點:相比於之前的方式有很好的語義,實現也比較簡單,被認為是目前最優的非同步流程控制模式。

  

相關文章