我瞭解到的JavaScript非同步程式設計

eraser發表於2017-08-25

一、 一道面試題

前段時間面試,考察比較多的是js非同步程式設計方面的相關知識點,如今,正好輪到自己分享技術,所以想把js非同步程式設計學習下,做個總結。
下面這個demo 概括了大多數面試過程中遇到的問題:

for(var i = 0; i < 3; i++) {
   setTimeout(function() {
       console.log('timeout' + i);
   })
}

new Promise(function(resolve) {
    console.log('promise1');
    for(var i = 0; i < 1000; i++) {
        i == 99 && resolve();
    }
    console.log('promise2');
}).then(function() {
    console.log('then1');
})

console.log('global1');複製程式碼

通過驗證可以得知這個demo的結果為:

clipboard.png
clipboard.png

可是為什麼會是這樣的結果,我們可能需要先了解下下面兩個知識點

二、 二個前提知識點

2.1 瀏覽器核心的多執行緒

clipboard.png
clipboard.png

瀏覽器的核心是多執行緒的,他們在核心的控制下互相配合以保持同步,一個瀏覽器至少實現三個常駐的執行緒:javascript引擎執行緒,GUI渲染執行緒,瀏覽器事件觸發執行緒。

1)js引擎,基於事件驅動單執行緒執行的,js引擎一直等待著任務佇列中任務的到來,然後加以處理,瀏覽器無論什麼時候都只有一個JS執行緒在執行JS程式。
2)GUI執行緒,當介面需要重繪或由於某種操作引發迴流時,該執行緒就會執行。它和JS引擎是互斥的。
3)瀏覽器事件觸發執行緒,當一個事件被觸發時,該執行緒會把事件新增到待處理佇列的隊尾,等待js引擎的處理,這些事件可來自JavaScript引擎當前執行的程式碼塊如,setTimeOut, 也可以來自瀏覽器核心的其他執行緒如滑鼠點選,AJAX非同步請求等,但由於JS的單執行緒關係,所有這些事件都得排隊等待JS引擎處理。

2.2 事件迴圈機制

clipboard.png
clipboard.png

1)任務佇列又分為macro-task(巨集任務)與micro-task(微任務),
在最新標準中,它們被分別稱為task與jobs。

2)macro-task大概包括:script(整體程式碼), setTimeout, setInterval, setImmediate, I/O, UI rendering。

3)micro-task【先執行】大概包括: process.nextTick, Promise, Object.observe(已廢棄), MutationObserver(html5新特性)

setTimeout/Promise等我們稱之為任務源。而進入任務佇列的是他們指定的具體執行任務。

事件迴圈的順序,決定了JavaScript程式碼的執行順序。它從script(整體程式碼)開始第一次迴圈。之後全域性上下文進入函式呼叫棧。直到呼叫棧清空(只剩全域性),然後執行所有的micro-task。當所有可執行的micro-task執行完畢之後。迴圈再次從macro-task開始,找到其中一個任務佇列執行完畢,然後再執行所有的macro-task,這樣一直迴圈下去。

通過這個事件迴圈的順序,我們就知道,為什麼上面提到的面試題為什麼是這樣的輸出結果了。
接下來我們看下三類非同步程式設計的實現。

三、三類非同步程式設計實現

3.1 回撥函式

demo1:

// 一個簡單的封裝
function want() {
    console.log('這是你想要執行的程式碼');
}

function fn(want) {
    console.log('這裡表示執行了一大堆各種程式碼');

    // 其他程式碼執行完畢,最後執行回撥函式
    want && want();
}

fn(want);複製程式碼

demo2:

//callback hell

doSomethingAsync1(function(){
    doSomethingAsync2(function(){
        doSomethingAsync3(function(){
            doSomethingAsync4(function(){
                doSomethingAsync5(function(){
                    // code...
                });
            });
        });
    });
});複製程式碼

可以發現一個問題,在回撥函式巢狀層數不深的情況下,程式碼還算容易理解和維護,一旦巢狀層數加深,就會出現“回撥金字塔”的問題,就像demo2那樣,如果這裡面的每個回撥函式中又包含了很多業務邏輯的話,整個程式碼塊就會變得非常複雜。從邏輯正確性的角度來說,上面這幾種回撥函式的寫法沒有任何問題,但是隨著業務邏輯的增加和趨於複雜,這種寫法的缺點馬上就會暴露出來,想要維護它們實在是太痛苦了,這就是“回撥地獄(callback hell)”。

回撥函式還有一個問題就是我們在回撥函式之外無法捕獲到回撥函式中的異常,一般我們用try catch來捕捉異常,我們嘗試下捕捉回撥中的異常

clipboard.png
clipboard.png

可以看到,不能捕捉到callback中的異常。

3.2 事件監聽(事件釋出/訂閱)

事件監聽是一種非常常見的非同步程式設計模式,它是一種典型的邏輯分離方式,對程式碼解耦很有用處。通常情況下,我們需要考慮哪些部分是不變的,哪些是容易變化的,把不變的部分封裝在元件內部,供外部呼叫,需要自定義的部分暴露在外部處理。從某種意義上說,事件的設計就是元件的介面設計。
1)jQuery事件監聽

    $('#btn').on('myEvent', function(e) {
        console.log('There is my Event');
    });
    $('#btn').trigger('myEvent');複製程式碼

2)釋出/訂閱模式

    var PubSub = function(){
        this.handlers = {}; 
    };
    PubSub.prototype.subscribe = function(eventType, handler) {
        if (!(eventType in this.handlers)) {
            this.handlers[eventType] = [];
        }
        this.handlers[eventType].push(handler); //新增事件監聽器
        return this;//返回上下文環境以實現鏈式呼叫
    };
    PubSub.prototype.publish = function(eventType) {
        var _args = Array.prototype.slice.call(arguments, 1);
        for (var i = 0, _handlers = this.handlers[eventType]; i < _handlers.length; i++) {
            _handlers[i].apply(this, _args);//遍歷事件監聽器
        }
        return this;
    };
    var event = new PubSub;//構造PubSub例項
    event.subscribe('list', function(msg) {
        console.log(msg);
    });
    event.publish('list', {data: ['one,', 'two']});
    //Object {data: Array[2]}複製程式碼

這種模式實現的非同步程式設計,本質上還是通過回撥函式實現的,所以3.1中提到的回撥巢狀和無法捕捉異常的問題還是存在的,接下來我們看ES6提供的Promise物件,是否解決這兩個問題。

3.3 Promise物件

ES 6中原生提供了Promise物件,Promise物件代表了某個未來才會知道結果的事件(一般是一個非同步操作),並且這個事件對外提供了統一的API,可供進一步處理。
使用Promise物件可以用同步操作的流程寫法來表達非同步操作,避免了層層巢狀的非同步回撥,程式碼也更加清晰易懂,方便維護,也可以捕捉異常。

一個簡單例子:

function fn(num) {
  return new Promise(function(resolve, reject) {
    if (typeof num == 'number') {
      resolve();
    } else {
      reject();
    }
  })
  .then(function() {
    console.log('引數是一個number值');
  })
  .then(null, function() {
    console.log('引數不是一個number值');
  })
}
fn('haha');
fn(1234);複製程式碼

為什麼Promise 可以這樣實現非同步程式設計,在這我們簡單分析下Promise實現過程:
1)極簡Promise雛形

// 極簡promise雛形
function Promise(fn) {
  var value = null,
    callbacks = [];  //callbacks為陣列,因為可能同時有很多個回撥

  this.then = function (onFulfilled) {
    callbacks.push(onFulfilled);
  };

  function resolve(value) {
    callbacks.forEach(function (callback) {
      callback(value);
    });
  }

  fn(resolve);
}複製程式碼
  • 如果promise內部的函式是同步函式,我們要加入一些處理,保證在resolve執行之前,then方法已經註冊完所有的回撥;
  • 通過setTimeout機制,將resolve中執行回撥的邏輯放置到JS任務佇列末尾,以保證在resolve執行時,then方法的回撥函式已經註冊完成.

2)加入延時處理

// 極簡promise雛形,加入延時處理
function Promise(fn) {
  var value = null,
    callbacks = [];  //callbacks為陣列,因為可能同時有很多個回撥

  this.then = function (onFulfilled) {
    callbacks.push(onFulfilled);
  };

  function resolve(value) {
    setTimeout(function() {
        callbacks.forEach(function (callback) {
            callback(value);
        });
    }, 0)
  }

  fn(resolve);
}複製程式碼
  • 如果Promise非同步操作已經成功,這時,在非同步操作成功之前註冊的回撥都會執行,但是在Promise非同步操作成功這之後呼叫的then註冊的回撥就再也不會執行了,這顯然不是我們想要的

3)加入狀態判斷

// 極簡promise雛形,加狀態判斷
function Promise(fn) {
  var state = 'pending',
      value = null,
      callbacks = [];

  this.then = function (onFulfilled) {
      if (state === 'pending') {
          callbacks.push(onFulfilled);
          return this;
      }
      onFulfilled(value);
      return this;
  };

  function resolve(newValue) {
      value = newValue;
      state = 'fulfilled';
      setTimeout(function () {
          callbacks.forEach(function (callback) {
              callback(value);
          });
      }, 0);
  }

  fn(resolve);
}複製程式碼

4)鏈式promise

// 極簡promise雛形,鏈式promise
function Promise(fn) {
  var state = 'pending',
      value = null,
      callbacks = [];

  this.then = function (onFulfilled) {
      return new Promise(function (resolve) {
          handle({
              onFulfilled: onFulfilled || null,
              resolve: resolve
          });
      });
  };

  function handle(callback) {
      if (state === 'pending') {
          callbacks.push(callback);
          return;
      }
      //如果then中沒有傳遞任何東西
      if(!callback.onResolved) {
          callback.resolve(value);
          return;
      }

      var ret = callback.onFulfilled(value);
      callback.resolve(ret);
  }

  function resolve(newValue) {
      if (newValue && (typeof newValue === 'object' || typeof newValue === 'function')) {
          var then = newValue.then;
          if (typeof then === 'function') {
              then.call(newValue, resolve);
              return;
          }
      }
      state = 'fulfilled';
      value = newValue;
      setTimeout(function () {
          callbacks.forEach(function (callback) {
              handle(callback);
          });
      }, 0);
  }

  fn(resolve);
}複製程式碼

四、四個擴充套件點

4.1 Promise常用的應用場景:ajax

利用Promise的知識,對ajax進行一個簡單的封裝。看看會是什麼樣子:

//demo3 promise封裝ajax
var url = 'https://hq.tigerbrokers.com/fundamental/finance_calendar/getType/2017-02-26/2017-06-10';
function getJSON(url) {
  return new Promise(function(resolve, reject) {
    var XHR = new XMLHttpRequest();
    XHR.open('GET', url, true);
    XHR.send();

    XHR.onreadystatechange = function() {
        if (XHR.readyState == 4) {
            if (XHR.status == 200) {
                try {
                    var response = JSON.parse(XHR.responseText);
                    resolve(response);
                } catch (e) {
                    reject(e);
                }
            } else {
                reject(new Error(XHR.statusText));
            }
        }
    }
  })
}
getJSON(url).then(resp => console.log(resp));複製程式碼

除了序列執行若干非同步任務外,Promise還可以並行執行非同步任務。

當有一個ajax請求,它的引數需要另外2個甚至更多請求都有返回結果之後才能確定,那麼這個時候,就需要用到Promise.all來幫助我們應對這個場景。

4.2 Promise.all

Promise.all接收一個Promise物件組成的陣列作為引數,當這個陣列所有的Promise物件狀態都變成resolved或者rejected的時候,它才會去呼叫then方法。

// demo4 promise.all
var url = 'https://hq.tigerbrokers.com/fundamental/finance_calendar/getType/2017-02-26/2017-06-10';
var url1 = 'https://hq.tigerbrokers.com/fundamental/finance_calendar/getType/2017-03-26/2017-06-10';

function renderAll() {
  return Promise.all([getJSON(url), getJSON(url1)]);
}

renderAll().then(function(value) {
  console.log(value); //將得到一個陣列,裡面是兩個介面返回的值
})複製程式碼

結果:

clipboard.png
clipboard.png

有些時候,多個非同步任務是為了容錯。比如,同時向兩個URL讀取使用者的個人資訊,只需要獲得先返回的結果即可。這種情況下,用Promise.race()實現。

4.3 Promise.race

與Promise.all相似的是,Promise.race都是以一個Promise物件組成的陣列作為引數,不同的是,只要當陣列中的其中一個Promsie狀態變成resolved或者rejected時,就可以呼叫.then方法了

// demo5 promise.race
function renderRace() {
  return Promise.race([getJSON(url), getJSON(url1)]);
}

renderRace().then(function(value) {
  console.log(value);
})複製程式碼

這裡then()傳的value值將是介面返回比較快的介面資料,另外一個介面仍在繼續執行,但執行結果將被丟棄。

結果:

clipboard.png
clipboard.png

4.4 Generator 函式

Generator函式是協程在ES 6中的實現,最大特點就是可以交出函式的執行權(暫停執行)。
注意:在node中需要開啟--harmony選項來啟用Generator函式。
整個Generator函式就是一個封裝的非同步任務,或者說是非同步任務的容器。非同步操作需要暫停的地方,都用yield語句註明。

看個簡單的例子:

function* gen(x){
    var y = yield x + 2;
    return y;
}

var g = gen(1);
var r1 = g.next(); // { value: 3, done: false }
console.log(r1);
var r2 = g.next() // { value: undefined, done: true }
console.log(r2);複製程式碼

需要注意的是Generator函式的函式名前面有一個"*"。
上述程式碼中,呼叫Generator函式,會返回一個內部指標(即遍歷器)g,這是Generator函式和一般函式不同的地方,呼叫它不會返回結果,而是一個指標物件。呼叫指標g的next方法,會移動內部指標,指向第一個遇到的yield語句,上例就是執行到x+2為止。
換言之,next方法的作用是分階段執行Generator函式。每次呼叫next方法,會返回一個物件,表示當前階段的資訊(value屬性和done屬性)。value屬性是yield語句後面表示式的值,表示當前階段的值;done屬性是一個布林值,表示Generator函式是否執行完畢,即是否還有下一個階段。

對Generator函式,只有一個感性認知,沒有實踐過,所以就先介紹到這了,後面還有ES7新的知識點async await,看了下網上的資料,理解得還不夠,希望後面自己接觸得更多再來這裡補上,未完待續...

參考資料:
1) www.jianshu.com/p/12b9f73c5…
2) www.jianshu.com/p/fe5f17327…
3) mengera88.github.io/2017/05/18/…
4) www.cnblogs.com/nullcc/p/58…

相關文章