瀏覽器的 Event Loop 宏任務,微任務,事件冒泡

一. 爲何JavaScript是單線程?


JavaScript語言的一大特色就是單線程,也就是說,同一個時間只能作一件事。那麼,爲何JavaScript不能有多個線程呢?這樣能提升效率啊。javascript

JavaScript的單線程,與它的用途有關。做爲瀏覽器腳本語言,JavaScript的主要用途是與用戶互動,以及操做DOM。這決定了它只能是單線程,不然會帶來很複雜的同步問題。好比,假定JavaScript同時有兩個線程,一個線程在某個DOM節點上添加內容,另外一個線程刪除了這個節點,這時瀏覽器應該以哪一個線程爲準?html

因此,爲了不復雜性,從一誕生,JavaScript就是單線程,這已經成了這門語言的核心特徵,未來也不會改變。java

爲了利用多核CPU的計算能力,HTML5提出Web Worker標準,容許JavaScript腳本建立多個線程,可是子線程徹底受主線程控制,且不得操做DOM。因此,這個新標準並無改變JavaScript單線程的本質。面試

2、瀏覽器js運行機制


簡介

單線程就意味着,全部任務須要排隊,前一個任務結束,纔會執行後一個任務。若是前一個任務耗時很長,後一個任務就不得不一直等着。ajax

若是排隊是由於計算量大,CPU忙不過來,倒也算了,可是不少時候CPU是閒着的,由於IO設備(輸入輸出設備)很慢(好比Ajax操做從網絡讀取數據),不得不等着結果出來,再往下執行。數據庫

JavaScript語言的設計者意識到,這時主線程徹底能夠無論IO設備,掛起處於等待中的任務,先運行排在後面的任務。等到IO設備返回告終果,再回過頭,把掛起的任務繼續執行下去。promise

因而,全部任務能夠分紅兩種,一種是同步任務(synchronous),另外一種是異步任務(asynchronous)。同步任務指的是,在主線程上排隊執行的任務,只有前一個任務執行完畢,才能執行後一個任務;異步任務指的是,不進入主線程、而進入"任務隊列"(task queue)的任務,只有"任務隊列"通知主線程,某個異步任務能夠執行了,該任務纔會進入主線程執行。瀏覽器

具體來講,異步執行的運行機制以下。(同步執行也是如此,由於它能夠被視爲沒有異步任務的異步執行。)bash

(1)全部同步任務都在主線程上執行,造成一個執行棧(execution context stack)。

(2)主線程以外,還存在一個"任務隊列"(task queue)。只要異步任務有了運行結果,就在"任務隊列"之中放置一個事件。

(3)一但"執行棧"中的全部同步任務執行完畢,系統就會讀取"任務隊列",看看裏面有哪些事件。那些對應的異步任務,因而結束等待狀態,進入執行棧,開始執行。

(4)主線程不斷重複上面的第三步。
複製代碼

只要主線程空了,就會去讀取"任務隊列",這就是JavaScript的運行機制。這個過程會不斷重複。網絡

三. 瀏覽器的 Event Loop 事件循環


簡介

主線程從"任務隊列"中讀取事件,這個過程是循環不斷的,因此整個的這種運行機制又稱爲Event Loop(事件循環)。爲了更好地理解Event Loop,請看下圖

事件循環能夠簡單描述爲:

函數入棧,當Stack中執行到異步任務的時候,就將他丟給WebAPIs,接着執行同步任務,直到Stack爲空; 在此期間WebAPIs完成這個事件,把回調函數放入CallbackQueue中等待; 當執行棧爲空時,Event Loop把Callback Queue中的一個任務放入Stack中,回到第1步。

  • Event Loop是由javascript宿主環境(像瀏覽器)來實現的;
  • WebAPIs是由C++實現的瀏覽器建立的線程,處理諸如DOM事件、http請求、定時器等異步事件;
  • JavaScript 的併發模型基於"事件循環";
  • Callback Queue(Event Queue 或者 Message Queue) 任務隊列,存放異步任務的回調函數

接下來看一個異步函數執行的例子:

var start=new Date();
setTimeout(function cb(){
    console.log("時間間隔:",new Date()-start+'ms');
},500);
while(new Date()-start<1000){};
複製代碼
  1. main(Script) 函數入棧,start變量開始初始化
  2. setTimeout入棧,出棧,丟給WebAPIs,開始定時500ms;
  3. while循環入棧,開始阻塞1000ms;
  4. 500ms事後,WebAPIs把cb()放入任務隊列,此時while循環還在棧中,cb()等待;
  5. 又過了500ms,while循環執行完畢從棧中彈出,main()彈出,此時棧爲空,Event Loop,cb()進入棧,log()進棧,輸出'時間間隔:1003ms',出棧,cb()出棧

四. 宏任務(Macrotasks)和微任務(Microtasks)


簡介

JS的異步有一個機制的,就是會分爲宏任務和微任務。宏任務和微任務會放到不一樣的event queue中,先將全部的宏任務放到一個event queue(macro-task),再將微任務放到一個event queue(micro-task)中。執行完宏任務以後,就會先從微任務中取這個回調函數執行。

講的詳細一點的話

最開始, 執行棧爲空, 微任務隊列爲空, 宏任務隊列有一個 script 標籤(內含總體代碼)

將第一個宏任務出隊, 這裏即爲上述的 script 標籤

總體代碼執行過程當中, 若是是同步代碼, 直接執行(函數執行的話會有入棧出棧操做), 若是是異步代碼, 會根據任務類型推入不一樣的任務隊列中(宏任務或微任務)

當執行棧執行完爲空時, 會去處理微任務隊列的任務, 將微任務隊列的任務一個個推入調用棧執行完

微任務執行完後,檢查是否須要從新渲染 UI。

...往返循環直到宏任務和微任務隊列爲空

總結一下上述循環機制的特色:

出隊一個宏任務 -> 調用棧爲空後, 執行一隊微任務 -> 更新界面渲染 -> 回到第一步

宏任務 macro-task(Task)

一個event loop有一個或者多個task隊列。task任務源很是寬泛,好比ajax的onload,click事件,基本上咱們常常綁定的各類事件都是task任務源,還有數據庫操做(IndexedDB ),須要注意的是setTimeout、setInterval、setImmediate也是task任務源。總結來講task任務源:

  • script
  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • requestAnimationFrame
  • UI rendering

微任務 micro-task(Job)

microtask 隊列和task 隊列有些類似,都是先進先出的隊列,由指定的任務源去提供任務,不一樣的是一個 event loop裏只有一個microtask 隊列。另外microtask執行時機和Macrotasks也有所差別

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver

宏任務和微任務的區別

  • 宏隊列能夠有多個,微任務隊列只有一個,因此每建立一個新的settimeout都是一個新的宏任務隊列,執行完一個宏任務隊列後,都會去checkpoint 微任務。
  • 一個事件循環後,微任務隊列執行完了,再執行宏任務隊列
  • 一個事件循環中,在執行完一個宏隊列以後,就會去check 微任務隊列

宏任務和微任務的運行

下圖是一個事件循環的流程

舉個簡單的例子,假設一個script標籤的代碼以下:

Promise.resolve().then(function promise1 () {
       console.log('promise1');
    })
setTimeout(function setTimeout1 (){
    console.log('setTimeout1')
    Promise.resolve().then(function promise2 () {
       console.log('promise2');
    })
}, 0)

setTimeout(function setTimeout2 (){
   console.log('setTimeout2')
}, 0)
複製代碼

運行過程:

script裏的代碼被列爲一個task,放入task隊列。

循環1:

  • 【task隊列:script ;microtask隊列:】

    1. 從task隊列中取出script任務,推入棧中執行。
    2. promise1列爲microtask,setTimeout1列爲task,setTimeout2列爲task。
  • 【task隊列:setTimeout1 setTimeout2;microtask隊列:promise1】

    1. script任務執行完畢,執行microtask checkpoint,取出microtask隊列的promise1執行。

循環2:

  • 【task隊列:setTimeout1 setTimeout2;microtask隊列:】

    1. 從task隊列中取出setTimeout1,推入棧中執行,將promise2列爲microtask。
  • 【task隊列:setTimeout2;microtask隊列:promise2】

    1. 執行microtask checkpoint,取出microtask隊列的promise2執行。
    (循環2中的 setTimeout2爲何不是跟在setTimeout1的後面輸出?
    這裏我以爲應該是setTimeout1和setTimeout2不是在同一個task隊列中,
    是兩個task隊列。在執行完setTimeout1的task隊列後,
    event loop去檢查microtask隊列是否有事件,而且把事推入到主棧。)
    複製代碼

循環3:

  • 【task隊列:setTimeout2;microtask隊列:】

    1. 從task隊列中取出setTimeout2,推入棧中執行。
    2. setTimeout2任務執行完畢,執行microtask checkpoint。
  • 【task隊列:;microtask隊列:】

注:有些文章說的一個事件循環的開始是先執行微任務再執行宏任務,有有些說的是先執行宏任務再執行微任務,我我的以爲這兩種只是見解的角度不一致

  • 若是把script載入到主堆棧這一過程當作是執行了宏任務,那麼就是宏任務先開始。
  • 若是不把這個script的運行當作是宏任務,只看異步函數中的宏任務(setTimeout)那麼就是微任務先開始。

宏任務與微任務示例

EXP1 在主線程上添加宏任務與微任務

console.log('-------start--------');

setTimeout(() => {
  console.log('setTimeout');  // 將回調代碼放入另外一個宏任務隊列
}, 0);

new Promise((resolve, reject) => {
  for (let i = 0; i < 5; i++) {
    console.log(i);
  }
  resolve()
}).then(()=>{
  console.log('Promise'); // 將回調代碼放入微任務隊列
})

console.log('-------end--------');
複製代碼

運行結果:

-------start--------
0
1
2
3
4
-------end--------
Promise
setTimeout
複製代碼

由EXP1,咱們能夠看出,當JS執行完主線程上的代碼,會去檢查在主線程上建立的微任務隊列,執行完微任務隊列以後纔會執行宏任務隊列上的代碼

運行順序:

主線程 => 主線程上建立的微任務 => 主線程上建立的宏任務

script裏的代碼被列爲一個task,放入task隊列。

循環1:

  • 【task隊列:script ;microtask隊列:】

    1. 從task隊列中取出script任務,推入棧中執行。
    2. promise列爲microtask,setTimeout列爲task。
  • 【task隊列:setTimeout ;microtask隊列:promise】

    1. script任務執行完畢,執行microtask checkpoint,取出microtask隊列的promise執行。

循環2:

  • 【task隊列:setTimeout ;microtask隊列:】

    1. 從task隊列中取出setTimeout,推入棧中執行
    2. setTimeout任務執行完畢,執行microtask checkpoint。
  • 【task隊列:;microtask隊列:】

EXP2 在微任務中建立微任務

setTimeout(_ => console.log('setTimeout4'))

new Promise(resolve => {
  resolve()
  console.log('Promise1')
}).then(_ => {
  console.log('Promise3')
  Promise.resolve().then(_ => {
    console.log('before timeout')
  }).then(_ => {
    Promise.resolve().then(_ => {
      console.log('also before timeout')
    })
  })
})

console.log(2)
複製代碼

運行結果:

Promise1
2
Promise3
before timeout
also before timeout
setTimeout4
複製代碼

由EXP2,咱們能夠看出,在微任務隊列執行時建立的微任務,仍是會排在主線程上建立出的宏任務以前執行(由於微任務只有一條,自增鏈不斷的話 會一直往下執行微任務,不會被中斷)

運行順序:

主線程 => 主線程上建立的微任務1 => 微任務1上建立的微任務2 => 主線程上建立的宏任務

script裏的代碼被列爲一個task,放入task隊列。

循環1:

  • 【task隊列:script ;microtask隊列:】

    1. 從task隊列中取出script任務,推入棧中執行。
    2. promise3 列爲microtask,setTimeout4 列爲task。
  • 【task隊列:setTimeout4;microtask隊列:promise3】

    1. script任務執行完畢,執行microtask checkpoint,取出microtask隊列的promise3執行。
    2. 將before timeout 列爲 microtask。
  • 【task隊列:setTimeout4;microtask隊列:before timeout】

    1. before timeout 執行
    2. 將also before timeout 列爲microtask
  • 【task隊列:setTimeout4;microtask隊列:also before timeout】

    1. also before timeout 執行

循環2:

  • 【task隊列:setTimeout4 ;microtask隊列:before timeout】

    1. 從task隊列中取出setTimeout4,推入棧中執行
    2. setTimeout4任務執行完畢,執行microtask checkpoint。
  • 【task隊列:;microtask隊列:】

EXP3: 宏任務中建立微任務

// 宏任務隊列 1
setTimeout(() => {
  // 宏任務隊列 1.1
  console.log('timer_1');
  setTimeout(() => {
    // 宏任務隊列 3
    console.log('timer_3')
  }, 0)
  new Promise(resolve => {
    resolve()
    console.log('new promise')
  }).then(() => {
    // 微任務隊列 1
    console.log('promise then')
  })
}, 0)

setTimeout(() => {
  // 宏任務隊列 2.2
  console.log('timer_2')
}, 0)

console.log('========== Sync queue ==========')

複製代碼

運行結果:

========== Sync queue ==========
timer_1
new promise
promise then
timer_2
timer_3
複製代碼

運行順序:

主線程(宏任務隊列 1)=> 宏任務隊列 1.1 => 微任務隊列 1 => 宏任務隊列 3=>宏任務隊列2.2

循環1:

  • 【task隊列:script ;microtask隊列:】

    1. 從task隊列中取出script任務,推入棧中執行。
    2. timer_1 列爲task,timer_2 列爲task。
  • 【task隊列:timer_1,timer_2;microtask隊列:】

    1. script任務執行完畢,執行microtask checkpoint,無microtask隊列可執行。

循環2

  • 【task隊列::timer_1,timer_2;microtask隊列:】

    1. 從task隊列中取出 timer_1 推入棧中執行。
    2. 將 timer_3 列爲task,promise then 列爲microtask
  • 【task隊列:timer_2,timer_3;microtask隊列:promise then】

    1. 執行microtask checkpoint,取出microtask隊列的promise then執行

循環3

  • 【task隊列:timer_2,timer_3;microtask隊列:】

    1. 從task隊列中取出timer_2,推入棧中執行
  • 【task隊列:timer_3;microtask隊列:】

    1. 執行microtask checkpoint,無microtask隊列可執行

循環4

  • 【task隊列:timer_3;microtask隊列:】

    1. 從task隊列中取出timer_3,推入棧中執行
  • 【task隊列:;microtask隊列:】

    1. 執行microtask checkpoint,無microtask隊列可執行

EXP4:微任務隊列中建立的宏任務

// 宏任務1
new Promise((resolve) => {
  console.log('new Promise(macro task 1)');
  resolve();
}).then(() => {
  // 微任務1
  console.log('micro task 1');
  setTimeout(() => {
    // 宏任務3
    console.log('macro task 3');
  }, 0)
})

setTimeout(() => {
  // 宏任務2
  console.log('macro task 2');
}, 0)

console.log('========== Sync queue(macro task 1) ==========');
複製代碼

運行結果:

========== Sync queue(macro task 1) ==========
new Promise(macro task 1)
micro task 1
macro task 2
macro task 3
複製代碼

異步宏任務隊列只有一個,當在微任務中建立一個宏任務以後,他會被追加到異步宏任務隊列上(跟主線程建立的異步宏任務隊列是同一個隊列)

運行順序:

主線程 => 主線程上建立的微任務 => 主線程上建立的宏任務 => 微任務中建立的宏任務

循環1:

  • 【task隊列:script ;microtask隊列:】

    1. 從task隊列中取出script任務,推入棧中執行。
    2. macro task 2 列爲task,micro task 1 列爲microtask。
  • 【task隊列:macro task 2;microtask隊列:micro task 1】

    1. script任務執行完畢,執行microtask checkpoint,microtask隊列中取出micro task 1 執行。
    2. 執行micro task 1 的時候,把macro task 3列爲task

循環2

  • 【task隊列:macro task 2,macro task 3;microtask隊列:】
    1. 從task隊列中取出 micro task2,推入棧中執行。
    2. 執行microtask checkpoint,無microtask隊列可執行

循環2

  • 【task隊列:macro task 3;microtask隊列:】

    1. 從task隊列中取出 micro task3,推入棧中執行。
    2. 執行microtask checkpoint,無microtask隊列可執行
  • 【task隊列:;microtask隊列:】

小總結

  • 微任務隊列優先於宏任務隊列執行,
  • 微任務隊列上建立的宏任務會被後添加到當前宏任務隊列的尾端,微任務隊列中建立的微任務會被添加到微任務隊列的尾端。
  • 只要微任務隊列中還有任務,宏任務隊列就只會等待微任務隊列執行完畢後再執行。

五. 當 Event Loop 趕上事件冒泡

手動觸發

代碼

<div class="outer">
  <div class="inner"></div>
</div>
複製代碼
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

function onClick() {
  console.log('click');

  setTimeout(function() {
    console.log('timeout');
  }, 0);

  Promise.resolve().then(function() {
    console.log('promise');
  });

}

inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
複製代碼

點擊 inner,最終打印結果爲:

click
promise
click
promise
timeout
timeout
複製代碼

分析

爲何打印結果是這樣的呢?咱們來分析一下: (0)將 script 標籤內的代碼(宏任務)放入執行棧執行,執行完後,宏任務微任務隊列皆空。

(1)點擊 inner,onClick 函數入執行棧執行,打印 "click"。執行完後執行棧爲空,由於事件冒泡的緣故,事件觸發線程會將向上派發事件的任務放入宏任務隊列。

(2)遇到 setTimeout,在最小延遲時間後,將回調放入宏任務隊列。遇到 promise,將 then 的任務放進微任務隊列

(3)此時,執行棧再次爲空。開始清空微任務,打印 "promise"

(4)此時,執行棧再次爲空。從宏任務隊列拿出一個任務執行,即前面提到的派發事件的任務,也就是冒泡。

(5)事件冒泡到 outer,執行回調,重複上述 "click"、"promise" 的打印過程。

(6)從宏任務隊列取任務執行,這時咱們的宏任務隊列已經累計了兩個 setTimeout 的回調了,因此他們會在兩個 Event Loop 週期裏前後獲得執行。

能夠當作是:

function onClick() {
  //模擬outer click事件
  setTimeout(function(){onClick1()},0)
  console.log('click');

  setTimeout(function() {
    console.log('timeout');
  }, 0);

  Promise.resolve().then(function() {
    console.log('promise');
  });
}
function onClick1() {
  console.log('click1');

  setTimeout(function() {
    console.log('timeout1');
  }, 0);

  Promise.resolve().then(function() {
    console.log('promise1');
  });
}
//模擬inner click事件
onClick()
複製代碼

代碼觸發

代碼

inner.click()
複製代碼

打印結果爲:

click
click
promise
promise
timeout
timeout
複製代碼

分析

依舊分析一下:

(0)將 script(宏任務)放入執行棧執行,執行到 inner.click() 的時候,執行 onClick 函數,打印 "click"

(1)當執行完 onClick 後,此時的 script(宏任務)還沒返回,執行棧不爲空,不會去清空微任務,而是會將事件往上冒泡派發

...(關鍵步驟分析完後,續步驟就不分析了)

能夠當作是:

function onClick() {
  console.log('click');

  setTimeout(function() {
    console.log('timeout');
  }, 0);

  Promise.resolve().then(function() {
    console.log('promise');
  });
}
onClick();
onClick();
複製代碼

總結

在通常狀況下,微任務的優先級是更高的,是會優先於事件冒泡的,但若是手動 .click() 會使得在 script代碼塊 還沒彈出執行棧的時候,觸發事件派發。

Event Loop總結

瀏覽器進行事件循環工做方式

  1. 選擇當前要執行的任務隊列,選擇任務隊列中最早進入的任務,若是任務隊列爲空即null,則執行跳轉到微任務(MicroTask)的執行步驟。

  2. 將事件循環中的任務設置爲已選擇任務。

  3. 執行任務。

  4. 將事件循環中當前運行任務設置爲null。

  5. 將已經運行完成的任務從任務隊列中刪除。

  6. microtasks步驟:進入microtask檢查點。

  7. 更新界面渲染。

  8. 返回第一步

【執行進入microtask檢查點時,瀏覽器會執行如下步驟:】

  • 設置microtask檢查點標誌爲true。

  • 當事件循環microtask執行不爲空時:選擇一個最早進入的microtask隊列的microtask,將事件循環的microtask設置爲已選擇的microtask,運行microtask,將已經執行完成的microtask爲null,移出microtask中的microtask。

  • 清理IndexDB事務

  • 設置進入microtask檢查點的標誌爲false。

重點

總結以上規則爲一條通俗好理解的:

  1. 順序執行先執行同步方法,碰到MacroTask直接執行,而且把回調函數放入MacroTask執行隊列中(下次事件循環執行);碰到microtask直接執行。把回調函數放入microtask執行隊列中(本次事件循環執行)
  2. 當同步任務執行完畢後,去執行微任務microtask。(microtask隊列清空)
  3. 由此進入下一輪事件循環:執行宏任務 MacroTask (setTimeout,setInterval,callback)

[總結]全部的異步都是爲了按照必定的規則轉換爲同步方式執行。


以上是本人蔘考如下資料後的理解,若是有錯誤的地方,請各位大牛幫忙糾正,謝謝。

JavaScript 運行機制詳解:再談Event Loop

瀏覽器事件循環機制(event loop)

瀏覽器事件循環 javaScript事件循環 EventLoop

總結事件輪詢機制,以及宏任務隊列與微任務隊列

當 Event Loop 趕上事件冒泡

一次弄懂Event Loop(完全解決此類面試問題)

相關文章
相關標籤/搜索