JS 的異步機制一探

JavaScript 異步原理

對於 FEer 來說,JavaScript 是單線程,同一時間只能執行一個任務,這種模式的好處是實現起來比較簡單,執行環境相對單純;壞處是隻要有一個任務耗時很長,後面的任務都必須排隊等着,會拖延整個程序的執行。常見的瀏覽器無響應(假死),往往就是因爲某一段 JavaScript 代碼長時間運行(比如死循環),導致整個頁面卡在這個地方,其他任務無法執行。對於計算類型的任務,需要使用到 CPU,就只能等待任務執行完畢;但是對於很多時候 CPU 都是閒着的,比如在執行 IO 操作(輸入輸出),ajax 請求,文件讀寫等,這些操作 CPU 完全可以不管 IO 操作,可以繼續往下執行其他任務。異步機制就是爲了解決這個問題,這種機制在 JavaScript 內部採用的事件循環機制(Event Loop)。

JavaScript 是單線程,同一時間只能執行一個任務。

當然,在瀏覽器上還是有可以開啓多個線程的解決方案 Web Worker,但是它只能執行計算類的操作,無法操作 DOM。

事件循環

一個事件循環,有一個 Event 的隊列(所有發生的 event 都存儲在這裏——下圖中稱爲任務隊列Task Queue)。還有一個Event Loop,它不斷地將這些 event 從隊列中取出,並調用事件中的回調(call stack 會執行所有的回調)。API 是用於處理異步函數的 API,比如說處理等待來自客戶端或 server 的響應,讀取本地文件,定時器 settimeout 等。

在此流程中,所有 function call 首先進入 call stack,然後通過 API 執行異步任務。當異步任務完成後,callback 進入任務隊列,然後再次進入 call stack。當任務執行完之後,event loop 會再次去 task queue 重複上面的流程。

任務類型

上面提到了任務隊列,在瀏覽器中,主要分成兩種任務:宏任務、微任務。

它們都是通過調用瀏覽器提供的 API 產生。

以下把瀏覽器和 Nodejs 中能夠生成異步任務的 api 都列出來。

宏任務(macrotask)

微任務(microtask)

事件循環流程圖

一個事件循環完整執行過程,可以參考《帶你瞭解事件循環機制 (Event Loop)》[1]。

JavaScript 異步編程

瀏覽器中 JavaScript 異步編程的發展可以分爲四個階段

  1. 回調函數

  2. Promise

  3. Generator

  4. async/await

回調函數

回調函數非常簡單容易理解和實現,缺點不利於代碼的維護和閱讀,各個部分之間高度耦合,還會造成回調地獄。

以實現紅綠燈爲例

function red() {
    console.log('red')
}

function green() {
    console.log('green')
}

function yellow() {
    console.log('yellow')
}

const light = (timer, light, callback) ={
    setTimout(() ={
        switch(light) {
            case 'red': red(); break;
            case 'green': green(); break;
            case 'yellow': yellow(); break;
        }
        callback()
    }, timer)
}

const work = () ={
    task(3000, 'red'() ={
        task(1000, 'green'() ={
            task(2000, 'yellow', work)
        })
    })
}
work()

Promise

Promise 是爲了解決回調地獄才被提出來的,它允許將傳統的嵌套回調函數寫法轉化爲鏈式調用。

const promiseLight = (timer, light) ={
  return new Promise((resolve, reject) ={
    setTimeout(() ={
      switch (light) {
        case 'red': red(); break;
        case 'green': green(); break;
        case 'yellow': yellow(); break;
      }
      resolve()
    }, timer)
  })
}

const work = () ={
  promiseLight(3000, 'red')
    .then(() => promiseLight(1000, 'green'))
    .then(() => promiseLight(2000, 'yellow'))
    .then(work)
}

Generator

Generator 函數可以暫停執行和恢復執行,同時它還具備兩個特性:函數體內的數據轉換和錯誤處理機制。相信很多同學在實際工作中,很少用到 generator,但是瞭解他可以讓我們實現很多有趣的功能。詳細介紹可以參考《什麼是 JavaScript generator 以及如何使用它們》[2] 和《Generator 函數的含義與用法》[3] 兩篇文章。

const generator = function *() {
  yield promiseLight(3000, 'red')
  yield promiseLight(1000, 'green')
  yield promiseLight(2000, 'yellow')
  yield generator()
}

const generatorObj = generator()
generatorObj.next()
generatorObj.next()
generatorObj.next()

async/await

這種語法能夠讓我們以寫同步代碼的習慣來編程異步代碼。Generator 實際就是 asyc 函數的語法糖。

想更深入學些 async/await 用法,可以參考《async 函數的含義和用法》[4]

const asyncTask = async () ={
  await promiseLight(3000, 'red')
  await promiseLight(1000, 'green')
  await promiseLight(2000, 'yellow')
}
asyncTask()

瀏覽器與 Nodejs 中的異同

Node11.0.0(不包括 Nodejs 11) 以前的版本,Node 和瀏覽器的異步流程存在一些細節上的差異。

Nodejs 11.0.0.0 以前的版本一次事件循環:

執行完一個主隊列中的所有任務後,再執行微任務隊列中的任務

Node 的任務隊列總共 6 個:包括 4 個主隊列(main queue)和兩個中間隊列(intermediate queue)

具體介紹可以參看《[翻譯]Node 事件循環系列——2、Timer 、Immediate 和 nextTick》[5] 以及《The Node.js Event Loop, Timers, and process.nextTick()》[6]。

Nodejs 11.0.0 以後的版本一次事件循環和瀏覽器一樣:

執行完主隊列中的一個任務後,立即執行微任務隊列中所有任務,然後再執行主任務隊列中下一個任務

舉一個小例子

setTimeout(() ={
  console.log("計時任務1")
  new Promise((resolve, reject) ={
    resolve();
  }).then(() ={
    console.log("微任務1")
  })
}, 1000);

setTimeout(() ={
  console.log("計時任務2")
  new Promise((resolve, reject) ={
    resolve();
  }).then(() ={
    console.log("微任務2")
  })
}, 1000);

在 Nodejs11 之前版本運行結果

在 Nodejs11 之後版本運行結果

異步編程的 BadCase

在實際開發過程中,無論是進行前端需求開發,還是 Nodejs 功能開發,都使用async/await語法,它給開發帶來了巨大的便利,但是,如果對 JS 異步機制不夠熟悉,就會導致使用錯誤,最終引發功能 bug,有時候還極其難以定位。

下面通過實際例子來進行講解。

異步函數串行執行

有時候需要在同一個函數中調用多個異步函數,但是被調用的異步函數之間並沒有前後依賴關係,本來可以並行執行,比如多個異步接口請求;使用 async/await 寫法就很容易寫成串行執行。如下例子

function sleep(time) {
  return new Promise((resolve) => setTimeout(resolve, time));
}

async function main() {
  const start = console.time('async');
  await sleep(1000);
  await sleep(2000);
  const end = console.timeEnd('async');
}
// 以上輸出3s

解決方法

對於在同一個執行棧中執行的異步函數,如果它們之間沒有依賴關係,可以使用 Promise.all() 進行並行執行;或者不帶 await 先執行函數,再 await 異步函數返回的 promise。

function sleep(time) {
  return new Promise((resolve) => setTimeout(resolve, time));
}

// 方式一
async function main() {
  const start = console.time('async');
  await Promise.all([sleep(1000), sleep(2000)]);
  const end = console.timeEnd('async');
}

// 方式二
async function main() {
  const start = console.time('async');
  const promise1 = sleep(1000);
  const promise2 = sleep(2000);
  const s1 = await promise1;
  const s2 = await promise2;
  const end = console.timeEnd('async');
}
//以上輸出2s

無法捕獲錯誤

使用 Promise 用法,只能通過. catch 的方式捕獲在 promise 內發生的異常,try/catch 無法捕獲;而 async/await 語法則需要使用 try/catch 進行捕獲。

有些情況下,即使使用了 try/catch 將 async 函數體包起來,但還是會無法捕獲錯誤。

async function err() {
  throw "error"
}

async function main() {
  try {
    return err();
  } catch (err) {
    console.log(err);
  }
}

main();

爲了方便,直接將 async 函數返回,這種情況,err 函數發生異常,則異常無法被捕獲。

應該儘可能避免直接在 async 函數中直接返回沒有 await 的異步函數;以上可以通過兩種方式解決。

  1. 在 async 函數體內使用 await 等待所有異步函數執行
async function main() {
  try {
    const res =  await err();
    return res;
  } catch (err) {
    console.log(err);
  }
}
  1. 在 main 函數體外使用 catch 捕獲異常
async function main() {
  try {
    return err();
  } catch (err) {
    console.log(err);
  }
}

main().catch(err ={
    console.log(err);
})

此外,可以使用await-to-js庫進行捕獲,其用法類似 Go 語言的錯誤處理。

async function main() {
  try {
    const [err, res] = await to(err());
    return err();
  } catch (err) {
    console.log(err);
  }
}

這個庫的源碼也非常簡單,感興趣的參看 scopsy/await-to-js[7]。

同步思維編寫異步代碼

在使用 async/await 編寫代碼時,可能比較容易被它 "騙了",因爲 async/await 聲稱可以以同步的寫法來編寫異步代碼。在實現一些比較複雜的功能時,會很容易忽略異步場景的問題。

例如,前端頁面需要實現一個任務功能,點擊任務按鈕(假設有 2 個任務按鈕),會先去請求接口獲取數據,然後修改頁面顏色。

按鈕 A,修改頁面爲紅色;按鈕 B,修改頁面爲藍色;

預期的效果是,頁面顏色應該是最後一次點擊任務按鈕所對應的顏色。

async function taskA() {
  return new Promise((resolve) ={
    setTimeout(() ={
      changePageColor('red')
      resolve()
    }, 500);
  })
}

async function taskB() {
  return new Promise((resolve) ={
    setTimeout(() ={
      changePageColor('blue')
      resolve()
    }, 1000);
  })
}

function changePageColor(color) {
  console.log(color);
}

async function executeTask(task) {
  await task()
}

//第一次點擊任務按鈕B
executeTask(taskB);
//第二次點擊任務按鈕A
executeTask(taskA);

以上代碼,模擬先點擊按鈕 B,再點擊按鈕 A,按鈕 A 請求先於按鈕 B 返回,如果按照同步思維進行實現,可能的實現代碼如上。最終的結果是,頁面先變成紅色,然後變成藍色;而預期頁面的最終顏色應該是紅色。

以上問題需要考慮到異步操作完成時間的不可預知性,需要考慮不同異步操作對同一個數據所產生的影響。可以使用鎖的思路解決以上問題。在執行改變頁面顏色之前,先判斷當前鎖的類型是否和任務對應鎖的類型相等,如果相當,才執行改變顏色,否則,不執行。

let workingLock = false;
async function taskA() {
  return new Promise((resolve) ={
    workingLock = 'red';
    setTimeout(() ={
      if (workingLock === 'red') {
        changePageColor('red')
      }
      resolve()
    }, 500);
  })
}

async function taskB() {
  return new Promise((resolve) ={
    workingLock = 'blue'
    setTimeout(() ={
      if (workingLock === 'blue') {
        changePageColor('blue')
      }
      resolve()
    }, 1000);
  })
}

function changePageColor(color) {
  console.log(color);
}

async function executeTask(task) {
  await task();
}

executeTask(taskB);
executeTask(taskA);

參考資料

[1]

《帶你瞭解事件循環機制 (Event Loop)》: https://blog.csdn.net/weixin_52092151/article/details/119788483

[2]

《什麼是 JavaScript generator 以及如何使用它們》: https://zhuanlan.zhihu.com/p/45599048

[3]

《Generator 函數的含義與用法》: https://www.ruanyifeng.com/blog/2015/04/generator.html

[4]

《async 函數的含義和用法》: https://www.ruanyifeng.com/blog/2015/05/async.html

[5]

《[翻譯]Node 事件循環系列——2、Timer 、Immediate 和 nextTick》: https://zhuanlan.zhihu.com/p/87579819

[6]

《The Node.js Event Loop, Timers, and process.nextTick()》: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

[7]

scopsy/await-to-js: https://github.com/scopsy/await-to-js/blob/master/src/await-to-js.ts

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/jfXQgAR6Su8yvwgc24g-Lw