深刻理解事件循環和異步流程控制

前言

javascript的執行分爲三個部分:運行時,事件循環,js引擎。運行時提供了諸如注入全局API(dom, setTimeout之類)這樣的功能。js引擎負責代碼編譯執行,包括內存管理。以前寫了一篇關於javascript內存管理的文章,具體可見 javascript內存管理以及三種常見的內存泄漏
javascript執行示意圖以下所示:
javascript


事件循環與回調隊列相對應,負責處理咱們的異步邏輯。本篇文章將會從事件循環的誕生背景(解決什麼問題), 處理異步執行問題的思路(怎樣解決的問題)以及javascript語言層面對於異步邏輯編寫的封裝

事件循環(event loop)

爲何咱們須要事件循環

做爲前端工程師,咱們都知道javascript是單線程的。所謂單線程,就是在同一時間咱們只能響應一個操做,這帶來的問題是若是某個操做極爲耗時,好比處理複雜的圖像運算或者等待服務器返回數據的過程,典型的場景以下所示:前端

// This is assuming that you're using jQuery
jQuery.ajax({
    url: 'https://api.example.com/endpoint',
    success: function(response) {
        // This is your callback.
    },
    async: false // And this is a terrible idea
});
複製代碼

這個ajax請求以同步的方式進行調用,在接口返回數據以前javascript線程都會處於被佔用的狀態,會致使當前頁面在success函數執行完成前不能響應用戶的任何操做。若是這個過程持續時間過長,就會直接形成頁面處於假死狀態
java

若是有一種機制能夠實現代碼執行過程當中無阻塞的響應用戶操做,那麼世界將更加美好。事件循環就是爲此而生的,它的做用是監控調用棧和回調隊列,調用棧負責處理javascript執行線程中的任務,遇到像ajax請求或者setTimeout這些異步邏輯時會執行它們,但不會阻塞後續任務的執行,當ajax請求或者定時器完成時其指定的回調會被放進回調隊列中,等到調用棧空間沒有正在執行的函數,事件循環就會從回調隊列中提取回調函數壓入調用棧執行。

事件循環的執行機制

讓咱們來看以下這段代碼:jquery

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 5000);
console.log('Bye');
複製代碼

執行這段代碼,咱們能夠看下調用棧和任務隊列中都發生了什麼git

  1. 初始狀態,調用棧和任務隊列均空白
  2. 添加console.log('Hi')至調用棧
  3. console.log('Hi')被執行
  4. console.log('Hi')被移除出調用棧
  5. 添加setTimeout(function cb1() { ... })至調用棧
  6. setTimeout(function cb1() { ... })被執行,瀏覽器會根據web API建立一個定時器
  7. setTimeout(function cb1() { ... })執行完成並被移除出調用棧
  8. 添加console.log('Bye')到調用棧
  9. 執行console.log('Bye')
  10. console.log('Bye')被移除出調用棧,調用棧再度爲空。
  11. 至少5000ms後,定時器執行完成,此時它會將cb1回調函數加入到回調隊列中
  12. 事件循環檢測到此時調用棧爲空,將cb1取出壓入到調用棧中
  13. cb1被執行,console.log('cb1')被壓入調用棧
  14. console.log('cb1')被執行
  15. console.log('cb1')被移除出調用棧
  16. cb1被移除出調用棧

    整個流程的快速動畫展現以下所示:

    經過上述示例的執行流程示意圖咱們能夠很清楚的知道在代碼執行過程當中事件循環,調用棧,回調任務隊列的合做機制。同時咱們也能夠注意到對於像setTimeout這一類的方法,其回調函數並不會像咱們想象的那樣是在咱們指定的時刻執行,而是在該時刻它會被加入到回調隊列中,等待調用棧沒有在執行中的任務時纔會由事件循環去讀取它將其放到調用棧中執行,若是調用棧一直有任務在執行,那麼該回調函數就會一直被阻塞,即便你傳給setTimeout方法的時間參數爲0ms也是同樣。因而可知,異步任務的執行時機是不可預測的,但是咱們要如何才能使不一樣的異步回調任務按照咱們想要的順序去執行呢,這就須要用到異步流程控制的解決方案了

異步流程控制

隨着javascript語言的發展,針對異步流程控制也有了愈來愈多的解決方案,依照歷史發展的車轍,主要有四種:程序員

  1. 回調函數
    好比咱們但願xx2的請求發生在xx1的請求完成以後,來看下面這段代碼:
// 以jquery中的請求爲例
$.ajax({
  url: 'xx1',
  success: function () {
    console.log('1');
    $.ajax({
      url: 'xx2',
      success: function () {
        console.log('2')
      }
    })
  }
})
複製代碼

在上述代碼中咱們經過在xx1請求完成的回調函數中發起xx2的請求這種回調嵌套的方式來實現兩個異步任務的執行順序控制。這種回調函數的方式在es6出現以前是應用最爲普遍的實現方案,可是其缺點也很明顯,若是咱們有多個異步任務須要依次執行,那麼就會致使很是深的嵌套層次,形成回調地獄,下降代碼可讀性。es6

  1. Promise
    es6中提供了promise的語法糖對異步流程控制作了更好的封裝處理,它提供了更加優雅的方式管理異步任務的執行,可讓咱們以一種接近於同步的方式來編寫異步代碼。仍是以上述的兩個請求處理做爲示例:
var ajax1 = function () {
  return new Promise(function (resolve, reject) {
    $.ajax({
      url: 'xx1',
      success: function () {
        console.log('1')
        resolve()
      }
    }) 
  })
}
ajax1().then(() => {
  $.ajax({
    url: 'xx1',
    success: function () {
      console.log('2')
    }
  })
})
複製代碼

promise經過then方法的鏈式調用將須要按順序執行的異步任務串起來,在代碼可讀性方面有很大提高。
究其實現原理,Promise是一個構造函數,它有三個狀態,分別是pending, fullfilled,rejected,構造函數接受一個回調做爲參數,在該回調函數中執行異步任務,而後經過resolve或者reject將promise的狀態由pending置爲fullfilled或者rejected。
Promise的原型對象上定義了then方法,該方法的做用是將傳遞給它的函數壓入到resolve或者reject狀態對應的任務數組中,當promise的狀態發生改變時依次執行與狀態相對應的數組中的回調函數,此外,promise在其原型上還提供了catch方法來處理執行過程當中遇到的異常。
Promise函數自己也有兩個屬性race,all。race,all都接受一個promise實例數組做爲參數,二者的區別在於前者只要數組中的某個promise任務率先執行完成就會直接調用回調數組中的函數,後者則須要等待所有promise任務執行完成。
一個mini的promise代碼實現示例以下所示:github

function Promise (fn) {
  this.status = 'pending';
  this.resolveCallbacks = [];
  this.rejectCallbacks = [];
  let _this = this
  function resolve (data) {
    _this.status = 'fullfilled'
    _this.resolveCallbacks.forEach((item) => {
      if (typeof item === 'function') {
        item.call(this, data)
      }
    })
  }
  function reject (error) {
    _this.status = 'rejected'
    _this.rejectCallbacks.forEach((item) => {
      if (typeof item === 'function') {
        item.call(this, error)
      }
    })
  }
  fn.call(this, resolve, reject)
}
Promise.prototype.then = function (resolveCb, rejectCb) {
  this.resolveCallbacks.push(resolveCb)
  this.rejectCallbacks.push(rejectCb)
}
Promise.prototype.catch = function (rejectCb) {
  this.rejectCallbacks.push(rejectCb)
}
Promise.race = function (promiseArrays) {
  let cbs = [], theIndex
  if (promiseArrays.some((item, index) => {
    return theIndex = index && item.status === 'fullfilled'
  })){
    cbs.forEach((item) => {
      item.call(this, promiseArrays[theIndex])
    })
  }
  return {
    then (fn) {
      cbs.push(fn)
      return this
    }
  }
}
Promise.all = function (promiseArrays) {
  let cbs = []
  if (promiseArrays.every((item) => {
    return item.status === 'fullfilled'
  })) {
    cbs.forEach((item) => {
      item.call(this)
    })
  }
  return  {
    then (fn) {
      cbs.push(fn)
      return this
    }
  }
}
複製代碼

以上是我對promise的一個很是簡短的實現,主要是爲了說明promise的封裝運行原理,它對異步任務的管理是如何實現的。web

  1. Generator函數
    generator也是es6中新增的一種語法糖,它是一種特殊的函數,能夠被用來作異步流程管理。依舊以以前的ajax請求做爲示例, 來看看用generator函數如何作到流程控制:
function* ajaxManage () {
  yield $.ajax({
    url: 'xx1',
    success: function () {
      console.log('1')
    }
  })
  yield $.ajax({
    url: 'xx2',
    success: function () {
      console.log('2')
    }
  })
  return 'ending'
}
var manage = ajaxManage()
manage.next()
manage.next()
manage.next()  // return {value: 'ending', done: true}
複製代碼

在上述示例中咱們定義了ajaxManage這個generator函數,可是當咱們調用該函數時他並無真正的執行其內部邏輯,而是會返回一個迭代器對象,generator函數的執行與普通函數不一樣,只有調用迭代器對象的next方法時纔會去真正執行咱們在函數體內編寫的業務邏輯,且next方法的調用只會執行單個經過yield或return關鍵字所定義的狀態,該方法的返回值是一個含有value以及done這兩個屬性的對象,value屬性值爲當前狀態值,done屬性值爲false表示當前不是最終狀態。
咱們能夠經過將異步任務定義爲多個狀態的方式,用generator函數的迭代器機制去管理這些異步任務的執行。這種方式雖然也是一種異步流程控制的解決方案,可是其缺陷在於咱們須要手動管理generator函數的迭代器執行,若是咱們須要控制的異步任務數量衆多,那麼咱們就須要屢次調用next方法,這顯然也是一種不太好的開發體驗。
爲了解決這個問題,也有不少開發者寫過一些generator函數的自動執行器,其中比較廣爲人知的就是著名程序員TJ Holowaychuk開發的co 模塊,有興趣的同窗能夠多瞭解下。ajax

  1. async/await
    async/await是es8中引入的一種處理異步流程控制的方案,它是generator函數的語法糖,可使異步操做更加簡潔方便,仍是用以前的示例來演示下async/await這種方式是如何使用的:
async function ajaxManage () {
  await $.ajax({
    url: 'xx1',
    success: function () {
      console.log('1')
    }
  })
  await $.ajax({
    url: 'xx2',
    success: function () {
      console.log('2')
    }
  })
}
ajaxManage()
複製代碼

經過代碼示例能夠看出,async/await在寫法上與generator函數是極爲相近的,僅僅只是將*號替換爲async,將yield替換爲await,可是async/await相比generator,它自帶執行器,像普通函數那樣調用便可。另外一方面它更加語義化,可讀性更高,它也已經獲得大多數主流瀏覽器的支持。

async/await相比promise能夠在不少方面優化咱們的代碼,好比:

  1. 代碼更精簡清晰,好比多個異步任務執行時,使用promise須要寫不少的then調用,且每一個then方法中都要用一個function包裹異步任務。而async/await就不會有這個煩惱。此外,在異常處理,異步條件判斷方面,async/await均可以節省不少代碼。
// `rp` is a request-promise function.
rp(‘https://api.example.com/endpoint1').then(function(data) {
 // …
});
// 使用await模式
var response = await rp(‘https://api.example.com/endpoint1'); 

// 錯誤處理
// promise的寫法
function loadData() {
    try { // Catches synchronous errors.
        getJSON().then(function(response) {
            var parsed = JSON.parse(response);
            console.log(parsed);
        }).catch(function(e) { // Catches asynchronous errors
            console.log(e); 
        });
    } catch(e) {
        console.log(e);
    }
}
// async/await處理
async function loadData() {
    try {
        var data = JSON.parse(await getJSON());
        console.log(data);
    } catch(e) {
        console.log(e);
    }
}
// 異步條件判斷
// promise處理
function loadData() {
  return getJSON()
    .then(function(response) {
      if (response.needsAnotherRequest) {
        return makeAnotherRequest(response)
          .then(function(anotherResponse) {
            console.log(anotherResponse)
            return anotherResponse
          })
      } else {
        console.log(response)
        return response
      }
    })
}
// async/await改造
async function loadData() {
  var response = await getJSON();
  if (response.needsAnotherRequest) {
    var anotherResponse = await makeAnotherRequest(response);
    console.log(anotherResponse)
    return anotherResponse
  } else {
    console.log(response);
    return response;    
  }
}
複製代碼
  1. 報錯定位更加準確
// promise
function loadData() {
  return callAPromise()
    .then(callback1)
    .then(callback2)
    .then(callback3)
    .then(() => {
      throw new Error("boom");
    })
}
loadData()
  .catch(function(e) {
    console.log(err);
// Error: boom at callAPromise.then.then.then.then (index.js:8:13)
});

// async/await
async function loadData() {
  await callAPromise1()
  await callAPromise2()
  await callAPromise3()
  await callAPromise4()
  await callAPromise5()
  throw new Error("boom");
}
loadData()
  .catch(function(e) {
    console.log(err);
    // output
    // Error: boom at loadData (index.js:7:9)
});
複製代碼
  1. debug調試問題
    若是你在promise中使用過斷點調試你就會知道這是件多麼痛苦的事,當你在then方法中設置了一個斷點,而後debug執行時此時若是你想使用step over跳過這段代碼,你會發如今promise中沒法作到這點, 由於debugger 只能跳過同步代碼。而在async/await中就不會有這個問題,await的調用能夠像同步邏輯那樣被跳過。

結語

事件循環是宿主環境處理javascript單線程帶來的執行阻塞問題的解決方案,所謂異步,就是當事件發生時將指定的回調加入到任務隊列中,等待調用棧空閒時由事件循環將其取出壓入到調用棧中執行,從而達到不阻塞主線程的目的。由於異步回調的執行時機是不可預測的,因此咱們須要一種解決方案能夠幫助咱們實現異步執行流程控制,本篇文章也針對這一問題分析了當前處理異步流程控制的幾種方案的優缺點和實現原理。但願能對你們有所幫助。

參考文章

blog.sessionstack.com/how-javascr…

相關文章
相關標籤/搜索