聊聊JavaScript異步中的macrotask和microtask


前言

首先來看一個JavaScript的代碼片段:

console.log(1);

setTimeout(() => {
  console.log(2);
  Promise.resolve().then(() => {
    console.log(3)
  });
}, 0);

new Promise((resolve, reject) => {
  console.log(4)
  resolve(5)
}).then((data) => {
  console.log(data);
})

setTimeout(() => {
  console.log(6);
}, 0)

console.log(7);

如果你能知道正確的答案,那么后續的內容可以略過了;如果不能建議看看下面有關js異步的內容,百利無一害,😁😁。

任務隊列

js的一大特點是單線程,即同一個時間只能做一件事,這樣設計主要與其作為瀏覽器腳本語言有關,js主要用途是用戶交互以及操作dom,這決定其是單線程設計,否則會帶來復雜的同步問題。比如一個線程刪除一個節點,而另一個線程要操作該節點,瀏覽器不知以哪個線程為准。

單線程意味着任務需要排隊,如果前一個任務耗時長,那么就會阻塞后續任務的執行。為此js出現了同步和異步任務,二者都需要在主線程執行棧中執行;其中異步任務需要進入任務隊列(task queue)進行排隊,其具體運行機制如下:

  • 同步任務在主線程上執行,形成一個執行棧

  • js會將主線程執行棧中的異步任務置於任務隊列排隊

  • 一旦主線程執行棧同步任務執行完畢處於空閑狀態時,就會將任務隊列中任務入棧開始執行

還是先來看一個js片段:

console.log('script start')
setTimeout(function() {
    console.log('timeout')
}, 0)
console.log('script end')

這段代碼在進入主線程執行時,當執行到setTimeout時會將其放置到異步任務隊列中,即使設置時間為0也不會馬上執行,必須等到主線程執行棧空閑時(執行完console.log('script end')語句后)才會讀取異步隊列的任務執行。

macrotask與microtask

二者任務都會被放置於任務隊列中等待某個時機被主線程入棧執行,其實任務隊列分為宏任務隊列和微任務隊列,其中放置的分別為宏任務和微任務。

  • macrotask(宏任務) 在瀏覽器端,其可以理解為該任務執行完后,在下一個macrotask執行開始前,瀏覽器可以進行頁面渲染。觸發macrotask任務的操作包括:

    • script(整體代碼)

    • setTimeoutsetIntervalsetImmediate

    • I/OUI交互事件

    • postMessageMessageChannel

  • microtask(微任務)可以理解為在macrotask任務執行后,頁面渲染前立即執行的任務。觸發microtask任務的操作包括:

    • Promise.then

    • MutationObserver

    • process.nextTick(Node環境)

下面通過例子來看看二者的不同:

console.log('script start');
setTimeout(function() {
  console.log('timeout');
}, 0);
Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});
console.log('script end');

上面一段代碼輸出結果為:

script start > script end > promise1 > promise2 > timeout

具體的可視化操作演示可以參考Tasks, microtasks, queues and schedules

上面代碼運行到最后一句console后,生成的任務隊列:

macrotasks:【setTimeout回調】

microtasks:【Promise.then回調1, Promise.then回調2】

兩種不同的任務隊列,為啥microtask的任務會先執行呢,這就要說說macrotask與microtask的運行機制[3]如下:

  • 執行一個macrotask(包括整體script代碼),若js執行棧空閑則從任務隊列中取

  • 執行過程中遇到microtask,則將其添加到micro task queue中;同樣遇到macrotask則添加到macro task queue中

  • macrotask執行完畢后,立即按序執行micro task queue中的所有microtask;如果在執行microtask的過程中,又產生了microtask,那么會加入到隊列的末尾,也會在這個周期被調用執行

  • 所有microtask執行完畢后,瀏覽器開始渲染,GUI線程接管渲染

  • 渲染完畢,從macro task queue中取下一個macrotask開始執行

Event loop

在主線程執行棧空閑的情況下,從任務隊列中讀取任務入執行棧執行,這個過程是循環不斷進行的,所以又稱Event loop(事件循環)。

Event loop是一個js實現異步的規范,在不同環境下有不同的實現機制,例如瀏覽器和NodeJS實現機制不同:

  • 瀏覽器的Event loop是按照html標准定義來實現,具體的實現留給各瀏覽器廠商

  • NodeJS中的Event loop是基於libuv實現

下面來說說瀏覽器環境下的Event loop,首先借用一幅圖:

根據HTML Standard - event loop processing model對Event loop規范描述來簡單說明事件循環模型:

  1. 按先進先出原則選擇最新進入Event loop任務隊列的一個macrotask,若沒有則直接進入第6步的microtask

  2. 設置Event loop的當前任務為上面一步選擇的任務

  3. 進棧運行所選的任務

  4. 運行完畢設置Event loop的當前任務為null

  5. 將第一步選擇的任務從任務隊列中刪除

  6. 執行microtask:perform a microtask checkpoint,具體執行步驟參考這里

  7. 更新並進行UI渲染

  8. 返回第一步執行

microtask的應用

根據Event loop機制,macrotask的一個任務執行完后就進行UI渲染,然后進行另一個macrotask任務執行,macrotask任務的應用就不做過多介紹。下面來說說microtask任務的應用場景,我們以vue的異步更新DOM來做說明,先看官網的說明:

Vue異步執行DOM更新,只要觀察到數據變化,Vue將開啟一個隊列,並緩沖在同一事件循環中發生的所有數據變更。

也就是說,Vue綁定的數據發生變化時,頁面視圖不會立即重新更新,需要等到當前任務執行完畢時進行更新。例如下面代碼:

<template>
  <div>
    <div ref="test">{{test}}</div>
    <button @click="handleClick">tet</button>
  </div>
</template>
export default {
    data () {
        return {
            test: 'begin'
        };
    },
    methods () {
        handleClick () {
            this.test = 'end';
            console.log(this.$refs.test.innerText);//打印“begin”
        }
    }
}

上面代碼在執行this.test = 'end'后,頁面視圖綁定數據test發生變化,若按照同步執行代碼,視圖應該能馬上獲取到對應dom的內容,但是並沒有獲取到。這是因為Vue采用異步視圖更新的。具體來說就是Vue在偵聽到數據變化時,異步更新視圖最終是通過nextTick來完成的,而該方法默認采用microtask任務來實現異步任務,具體的可以參考從Vue.js源碼看nextTick機制;這樣在 microtask 中就完成數據更新,task 結束就可以得到最新的 UI 了。上面代碼如下:

handleClick () {
 this.test = 'end';
 this.$nextTick(() => {
  console.log(this.$refs.test.innerText);//打印"end"
 });
}

按照HTML Standard描述,macrotask、microtask和UI 渲染的執行順序:

一個macrotask任務 --> 所有microtask任務 --> UI 渲染

既然nextTick是按照microtask來實現異步的,那么microtask任務應該是在UI渲染前執行的,為什么表現的是microtask在UI 渲染之后執行的呢?可能有人對上面提出過質疑。猜測原因如下,具體原因可以參考這篇文章

JS更新dom是同步完成的,但是UI渲染是異步的。

microtask跨瀏覽器實現

從Vue的nextTick方法的實現以及immediate的實現可以看出,怎么實現Event loop中的microtask實現呢?那就是借助js原生支持的Promise、MutationObserver(瀏覽器)、process.nextTick(nodejs環境)來實現,均不支持時使用setTimeout(fn, 0)來兜底降級實現。下面就來簡單說說microtask的實現思路:

  • 瀏覽器是否原生實現Promise,有則使用Promise類似如下實現,否則走下一步。

    if (typeof Promise !== 'undefined' && isNative(Promise)) {
      const p = Promise.resolve()
      microTimerFunc = () => {
        p.then(handle)
      }
    
  • 瀏覽器環境是否原生支持MutationObserver,支持可以這么實現,否則走下一步。

    function microFun(handle) {
     var observer = new MutationObserver(handle);
     var element = document.createTextNode('');
     observer.observe(element, {
       characterData: true
     });
     return function () {
       element.data = blabla;
     };
    }
    
  • 瀏覽器是否支持onreadystatechange事件,支持則創建一個空的script標簽,一旦插入到document中,其onreadystatechange事件將會異步地觸發,比setTimeout(fn,0)快,否則走下一步

    function microFun(handle) {
      return function () {
        var scriptEl = document.createElement('script');
        scriptEl.onreadystatechange = function () {
          handle();
    
          scriptEl.onreadystatechange = null;
          scriptEl.parentNode.removeChild(scriptEl);
          scriptEl = null;
        };
        document.documentElement.appendChild(scriptEl);
        return handle;
      };
    };
    
  • 使用setTimeout(fn, 0)來兜底實現

下面看一下core-js模塊中Promise中對microtask的模擬實現,具體可以參考源碼:

module.exports = function () {
  var head, last, notify;

  var flush = function () {
    var parent, fn;
    if (isNode && (parent = process.domain)) parent.exit();
    while (head) {
      fn = head.fn;
      head = head.next;
      try {
        fn();
      } catch (e) {
        if (head) notify();
        else last = undefined;
        throw e;
      }
    } last = undefined;
    if (parent) parent.enter();
  };

  // Node.js
  if (isNode) {
    notify = function () {
      process.nextTick(flush);
    };
  // browsers with MutationObserver
  } else if (Observer) {
    var toggle = true;
    var node = document.createTextNode('');
    new Observer(flush).observe(node, { characterData: true }); // eslint-disable-line no-new
    notify = function () {
      node.data = toggle = !toggle;
    };
  // environments with maybe non-completely correct, but existent Promise
  } else if (Promise && Promise.resolve) {
    var promise = Promise.resolve();
    notify = function () {
      promise.then(flush);
    };
  // for other environments - macrotask based on:
  // - setImmediate
  // - MessageChannel
  // - window.postMessag
  // - onreadystatechange
  // - setTimeout
  } else {
    notify = function () {
      // strange IE + webpack dev server bug - use .call(global)
      macrotask.call(global, flush);
    };
  }

  return function (fn) {
    var task = { fn: fn, next: undefined };
    if (last) last.next = task;
    if (!head) {
      head = task;
      notify();
    } last = task;
  };
};

問題答案

對於文章開頭的js代碼,其最終輸出內容為:

1 -> 4 -> 7 -> 5 -> 2 -> 3 -> 6

可以從以下幾個步驟來簡單分析,具體執行步驟如下圖所示:

參考文獻


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM