一篇文章讓你搞清楚JavaScript事件循環

前言

異步函數也是有執行順序的。本質上來說,JavaScript是單線程語言,不管是在瀏覽器中還是nodejs環境下。瀏覽器在執行js代碼和渲染DOM節點都是在同一個線程中,執行js代碼就無法渲染DOM,渲染DOM的時候就無法執行js代碼。如果按照這種同步方式執行,頁面的渲染將會出現白屏甚至是報錯,特別是遇到一些耗時比較長的網絡請求或者js代碼,因此在實際開發中一般是通過異步的方式解決。

什麼是異步?js是一步一步執行代碼的,遇到alert這種阻塞代碼時,js將會停止往下執行直到阻塞代碼執行完畢。異步就是將函數放在單獨的異步隊列中,不會產生阻塞,js可以繼續往下執行,等到同步代碼執行完畢後再執行異步隊列中的函數。因此,js會先執行完同步代碼,才會執行異步代碼。異步函數之間,雖然都是異步,但是還是有相對的執行順序。

異步函數的執行主要依靠事件循環來處理,本文重點探討異步的分類(宏任務、微任務)、事件循環以及異步函數的執行順序。

宏任務

宏任務,也可簡單的說成是任務,在下一輪DOM渲染之後執行。常見的宏任務有:

  • setTimeout:設置一個定時器,該定時器會在設置的延遲時間到期後執行一個函數或者指定的代碼塊。值得註意的是,setTimeout不一定會在延遲時間到達後就立即執行函數,而是會判斷執行隊列中是否還有函數沒有處理,如果沒有瞭並且棧為空,setTimeout才會在延遲時間到達後執行函數。

    // setTimeout 延遲執行不等於到期時立即執行
    let now = new Date().getSeconds();
    setTimeout(() => {
        console.log('this is setTimeout 0');
    }, 0);
    setTimeout(() => {
        console.log('this is setTimeout 200');
    }, 200);
    while(true) {
        if (new Date().getSeconds() - now >= 2) {
            console.log('break out while loop');
            break;
        }
    }

    運行結果

  • break out while loop
    this is setTimeout 0
    this is setTimeout 200

    先執行同步代碼,再執行異步。setTimeout(() => {}, 0)表示0毫秒後立即執行函數,但是當前執行隊列中還有未處理完的while循環,因此需要等到while循環執行完畢後,才會根據延遲到期時間執行函數。

  • setInterval:設置定時器,表示在固定的時間間隔內,重復執行某一函數或者特定的代碼塊。註意使用setInterval有最小延遲時間限制以及確保執行時間要小於間隔時間,如果執行時間無法確定,則應采用遞歸調用setTimeout的方式代替。

  • 網絡請求:隻要是指XMLHttpRequest等網絡請求

微任務

微任務,在下一輪DOM渲染之前執行,微任務比宏任務更早執。常見的微任務有:

  • promise:表示一個異步操作最終的結果和返回值,可能會失敗,也可能成功。異步函數在執行時,什麼時候返回結果是不可預料的,Promise把異步操作的返回值和函數關聯起來,保證在異步執行結束後會執行對應的函數,並通過函數返回操作值。這種效果就類似於把異步代碼“同步執行”。
  • queueMicrotask:將函數添加到微任務隊
console.log('start');
// 微任務隊列
Promise.resolve().then(() => {
    console.log('promise then');
});
queueMicrotask(() => {
    console.log('queueMicrotask');
});
console.log('end');

運行結果

start
end
promise then
queueMicrotask

事件循環

因為有異步操作的存在,所以出現瞭事件循環,如果都是同步操作,一行一行執行代碼,事件循環也就失去瞭用武之地。在瞭解事件循環前,還需要補充js的執行過程:

js在執行代碼時,遇到函數就會將其添加到調用棧中,每一幀都會存儲當前函數的參數和局部變量,當一個函數執行完畢,則會從調用棧中彈出,直到棧被清空,那麼程序也就執行完畢。在執行的過程中,需要的引用數據都是從堆中獲取。

在實際開發中,往往是同步代碼和異步代碼都有。在js執行時,還是從第一行代碼開始執行,遇到函數就將其添加到棧中,然後執行同步操作;如果遇到異步函數,則根據其類型,宏任務就添加到宏任務隊列,微任務添加到微任務隊列。直到同步代碼執行完畢,則開始執行異步操作。

異步操作後於同步操作,異步操作內部也是分先後順序的。總的來說:

  • 微任務先於宏任務執行
  • 微任務與微任務之間根據先後順序執行,宏任務與宏任務之間根據延遲時間順序執行
  • 微任務在下一輪DOM渲染前執行,宏任務在下一輪DOM渲染之後執行
  • 每個任務的執行都是一次出棧操作,直到棧被清空

微任務比宏任務先執行

console.log('start');
// 宏任務隊列
setTimeout(() => {
    console.log('setTimeout');
});
// 微任務隊列
Promise.resolve().then(() => {
    console.log('promise then');
});
console.log('end');
// 執行結果
start
end
promise then
setTimeout

微任務在下一輪DOM渲染前執行,宏任務在之後執行

let div = document.createElement('div');
div.innerHTML = 'hello world';
document.body.appendChild(div);
let divList = document.getElementByTagName('div');
console.log('同步任務 length ---', list.length);
console.log('start');
setTimeout(() => {
    console.log('setTimeout length ---', list.length);
    alert('宏任務 setTimeout 阻塞'); // 使用alert阻塞js執行
});
Promise.resolve().then(() => {
    console.log('promise then length ---', list.length);
    alert('微任務 promise then 阻塞);
});
console.log('end');

事件循環

event loop會持續監聽是否有異步操作,如果有則添加到對應的隊列中,等待執行。例如在宏任務中添加微任務,或者在微任務中添加宏任務,當前任務執行完後,可能還會有新的任務添加到事件循環中。

宏任務與微任務

  • 微任務中創建宏任務

        new Promise((resolve) => {
          console.log('promise 1');
          setTimeout(() => {
            console.log('setTimeout 1');
          }, 500);
          resolve();
        }).then(() => {
          console.log('promise then');
          setTimeout(() => {
            console.log('setTimeout 2');
          }, 0);
        });
        new Promise((resolve) => {
          console.log('promise 2');
          resolve();
        })

    運行結果

  • promise 1
    promise 2
    promise then
    setTimeout 2
    setTimeout 1

    解析

    js執行代碼,遇到兩個Promise,則分別添加到微任務隊列,同步代碼執行完畢。

    在微任務隊列中根據先進先出,第一個Promise先執行,遇到setTimeout,則添加到宏任務隊列,resolve()返回執行結果並執行then,事件循環將其繼續添加到微任務隊列;第一個Promise執行完畢,執行第二個Promise。

    繼續執行微任務隊列,直到清空隊列。遇到setTimeout,並將其添加到宏任務隊列

    宏任務隊列現在有兩個任務待執行,由於第二個setTimeout的延遲事件更小,則優先執行第二個;如果相等,則按照順序執行。

    繼續執行宏任務隊列,直到清空隊列。

  • 宏任務中創建微任務

        setTimeout(() => {
          console.log('setTimeout 1');
          new Promise((resolve) => {
            console.log('promise 1');
            resolve();
          }).then(() => {
            console.log('promise then');
          })
        }, 500);
        setTimeout(() => {
          console.log('setTimeout 2');
          new Promise((resolve) => {
            console.log('promise 2');
            resolve();
          })
        }, 0);

    運行結果

  • setTimeout 2
    promise 2
    setTimeout 1
    promise 1
    promise then

    解析

    js執行代碼,遇到兩個setTimeout,將其添加到宏任務隊列,同步代碼執行完畢

    先檢查微任務隊列中是否有待處理的,剛開始肯定沒有,因此直接執行宏任務隊列中的任務。第二個為零延遲,需要優先執行。遇到Promise,將其添加到微任務隊列。第一個宏任務執行完畢

    在執行第二個宏任務時,微任務隊列中已經存在待處理的,因此需要先執行微任務。

    微任務執行完畢,並且延遲時間到期,第一個setTimeout開始執行。遇到Promise,將其添加到微任務隊列中

    執行微任務隊列中的Promise,執行完畢後遇到then,則將其繼續添加到微任務隊列

    直到所有微任務執行完畢

  • 宏任務中創建宏任務

        setTimeout(() => {
          console.log('setTimeout 1');
          setTimeout(() => {
            console.log('setTimeout 2');
          }, 500);
          setTimeout(() => {
            console.log('setTimeout 3');
          }, 500);
          setTimeout(() => {
            console.log('setTimeout 4');
          }, 100);
        }, 0);

    運行結果

  • setTimeout 1
    setTimeout 4
    setTimeout 2
    setTimeout 3

    解析

    宏任務中創建宏任務,執行順序一般來說是按照先後順序的。對於setTImeout來說,延遲時間相同,則按照先後順序執行;延遲時間不同,則按照延遲時間的大小先後順序執行

  • 微任務中創建微任務

        new Promise((resolve) => {
          console.log('promise 1');
          new Promise((resolve) => {
            console.log('promise 2');
            resolve();
          });
          new Promise((resolve) => {
            console.log('promise 3');
            resolve();
          })
          resolve();
        })

    運行結果

  • promise 1
    promise 2
    promise 3

    解析

    微任務中創建微任務,執行順序一般來說是按照先後順序執行的。

總結

  • 同步代碼直接執行,異步代碼添加到宏任務隊列或者微任務隊列
  • 微任務在下一輪DOM渲染前執行,宏任務在下一輪DOM渲染之後執行
  • 事件循環持續監聽
  • 如果存在異步操作,需要將關聯代碼放在異步函數中執行;或者將異步函數轉為同步操作
  • 如果代碼層次比較復雜,同步、異步代碼混雜,一定要理清代碼的執行順序。避免因為異步,導致代碼出現難以察覺的bug

參考資料

  • ajax請求是宏任務還是微任務一篇搞定(Js異步、事件循環與消息隊列、微任務與宏任務)
  • 深入:微任務與Javascript運行時環境 – Web API 接口參考 | MDN (mozilla.org)
  • 在 JavaScript 中通過 queueMicrotask() 使用微任務 – Web API 接口參考 | MDN (mozilla.org)
  • 並發模型與事件循環 – JavaScript | MDN (mozilla.org)

到此這篇關於JavaScript事件循環的文章就介紹到這瞭,更多相關JS事件循環內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: