深入理解事件迴圈和非同步流程控制

墨箏發表於2017-12-13

前言

javascript的執行分為三個部分:執行時,事件迴圈,js引擎。執行時提供了諸如注入全域性API(dom, setTimeout之類)這樣的功能。js引擎負責程式碼編譯執行,包括記憶體管理。之前寫了一篇關於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函式執行完成前不能響應使用者的任何操作。如果這個過程持續時間過長,就會直接造成頁面處於假死狀態

深入理解事件迴圈和非同步流程控制
如果有一種機制可以實現程式碼執行過程中無阻塞的響應使用者操作,那麼世界將更加美好。事件迴圈就是為此而生的,它的作用是監控呼叫棧和回撥佇列,呼叫棧負責處理javascript執行執行緒中的任務,遇到像ajax請求或者setTimeout這些非同步邏輯時會執行它們,但不會阻塞後續任務的執行,當ajax請求或者定時器完成時其指定的回撥會被放進回撥佇列中,等到呼叫棧空間沒有正在執行的函式,事件迴圈就會從回撥佇列中提取回撥函式壓入呼叫棧執行。

事件迴圈的執行機制

讓我們來看如下這段程式碼:

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

執行這段程式碼,我們可以看下呼叫棧和任務佇列中都發生了什麼

  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出現之前是應用最為廣泛的實現方案,但是其缺點也很明顯,如果我們有多個非同步任務需要依次執行,那麼就會導致非常深的巢狀層次,造成回撥地獄,降低程式碼可讀性。

  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程式碼實現示例如下所示:

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的封裝執行原理,它對非同步任務的管理是如何實現的。

  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 模組,有興趣的同學可以多瞭解下。

  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…

相關文章