帶你瞭解JavaScript的執行機制—Event Loop

銅板街科技發表於2019-02-02

JS 是單執行緒的。

首先,眾所周知,JS 是單執行緒的,為什麼這種低效的執行方式依舊沒有被淘汰那?這是由它的用途決定的;JS 主要用途是使用者互動和DOM操作,舉例來說假如js同時有兩執行緒,一個執行緒在某個DOM節點上新增內容,另一個執行緒卻刪除了這個節點,這時候瀏覽器就不知所措了,該以哪個執行緒為標準那?(為了提高執行效能,新的 html5 裡新增了web worker,其能在主執行緒內新增子執行緒,但是限制了其無法操作DOM。)

任務佇列(task queue)

由於 JS 是單執行緒,所以任務的執行就需要排隊,一個一個執行,前一個任務結束了,下一個任務才能開始。但是當一個任務是非同步任務時,瀏覽器就需要等待較長時間,才能得到它的返回結果繼續執行,中間等待的時間cpu是空閒。JS 的應對方案是,將該任務暫時擱置,去執行其他任務。當有返回結果時再重新回來執行該任務。

這個暫時擱置,擱置於何處那,答案就是任務佇列

同步任務是指在主執行緒上執行的任務,只有前一個任務執行完畢,下一個任務才能執行。 非同步任務是指不進入主執行緒,而是進入任務佇列(task queue)的任務,只有主執行緒任務執行完畢,任務佇列的任務才會進入主執行緒執行。

執行棧(JS stack)

首先,我們先來了解一下堆(heap)和棧(stack)的概念。棧是用來靜態分配記憶體的而堆是動態分配記憶體的,它們都是存在於計算機記憶體之中。堆是先進後出,棧(堆疊)是先進先出的。js的所有任務都是在js執行棧中執行的。先進入棧的任務後執行,但是大部分時候js執行棧內都只有一個任務。(下文會提及)

巨集任務和微任務(task & Microtask)

上文說道非同步任務不在主執行緒上執行,其實不單單是非同步任務,所有的微任務都不在主執行緒上執行。由此其實我們可以將上文的任務佇列稱之為微任務佇列。巨集任務直接在主執行緒上自行,而微任務需要進入為任務佇列,等待執行。

我們看一下程式碼(example1)

console.log('script start');

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

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

console.log('script end');
複製程式碼

這個輸出結果是什麼那?

順序是:

script start
script end
promise1
promise2
setTimeout
複製程式碼

首先我們視整段程式碼為一個 script 標籤,它作為一個巨集任務,直接進入js執行棧中執行:

輸出==script start==;

遇到setTimeout,而0秒後setTimeout作為一個獨立的巨集任務加入到"巨集任務佇列"中。(注意這裡說的是巨集任務佇列,也就是上文所說的主執行緒);

遇到promise,promise完成後的第一個then作為一個獨立的微任務加入到“微任務佇列”中,第二個then又做為一個微任務加入到微任務的佇列中。

然後輸出==script end==;

現在,我們來理一下:script一整個巨集任務執行完畢了,這時候js執行棧是空的,巨集任務佇列(主執行緒)中有一個setTimeout,而微任務佇列中有兩個promise(then)任務。先執行哪個?回想我們之前說的非同步任務執行策略,就不難推測,下一個進入js執行棧就是第一個promise(then);

輸出 ==promise1==;

然後此時再看巨集任務佇列和微任務佇列。微任務佇列還有一個promise(then),所以將這個微任務壓入js執行棧執行;

輸出==promise2==;

此時,微任務佇列為空,所以再去執行巨集任務佇列中的任務,setTimeout;

輸出==setTimeout==;

總結來說,任務分為巨集任務和微任務,對應巨集任務佇列(主執行緒)和微任務佇列。微任務是在當前正在執行指令碼結束之後立即執行的任務。當一個任務執行結束後,JS 執行棧空出來,這時候會首先去微任務佇列中尋找任務,當微任務佇列不為空時,將一個微任務加入到 JS 執行棧中。噹噹前的微任務佇列為空時,再去執行巨集任務佇列中的任務。

如何區分微任務和巨集任務:

巨集任務(task):是嚴格按照時間順序壓棧和執行的,所以瀏覽器能夠使得 JavaScript 內部任務與 DOM 任務能夠有序的執行。當一個 task 執行結束後,在下一個 task 執行開始前,瀏覽器可以對頁面進行重新渲染。每一個 task 都是需要分配的,例如從使用者的點選操作到一個點選事件,渲染HTML文件,同時還有上面例子中的 setTimeout。

setTimeout 的工作原理相信大家應該都知道,其中的延遲並不是完全精確的,這是因為 setTimeout 它會在延遲時間結束後分配一個新的 task 至 event loop 中,而不是立即執行,所以 setTimeout 的回撥函式會等待前面的 task 都執行結束後再執行。這就是為什麼 'setTimeout' 會輸出在 'script end' 之後,因為 'script end' 是第一個 task 的其中一部分,而 'setTimeout' 則是一個新的 task。

微任務(Microtask):通常來說就是需要在當前 task 執行結束後立即執行的任務,例如需要對一系列的任務做出迴應,或者是需要非同步的執行任務而又不需要分配一個新的 task,這樣便可以減小一點效能的開銷。microtask 任務佇列是一個與 task 任務佇列相互獨立的佇列,microtask 任務將會在每一個 task 任務執行結束之後執行。每一個 task 中產生的 microtask 都將會新增到 microtask 佇列中,microtask 中產生的 microtask 將會新增至當前佇列的尾部,並且 microtask 會按序的處理完佇列中的所有任務。microtask 型別的任務目前包括了 MutationObserver 以及 Promise 的回撥函式。

每當一個 Promise 被決議(或是被拒絕),便會將其回撥函式新增至 microtask 任務佇列中作為一個新的 microtask 。這也保證了 Promise 可以非同步的執行。所以當我們呼叫 .then(resolve, reject) 的時候,會立即生成一個新的 microtask 新增至佇列中,這就是為什麼上面的 'promise1' 和 'promise2' 會輸出在 'script end' 之後,因為 microtask 任務佇列中的任務必須等待當前 task 執行結束後再執行,而 'promise1' 和 'promise2' 輸出在 'setTimeout' 之前,這是因為 'setTimeout' 是一個新的 task,而 microtask 執行在當前 task 結束之後,下一個 task 開始之前。


進階版,帶你深入task & Microtask(example2):

<body>
    <div class="outer">
      <div class="inner"></div>
    </div>
</body>
<script>
    var outer = document.querySelector('.outer');
    var inner = document.querySelector('.inner');

    new MutationObserver(function() {
      console.log('mutate');
    }).observe(outer, {
      attributes: true
    });

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

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

      outer.setAttribute('data-random', Math.random());
    }

    inner.addEventListener('click', onClick);
    outer.addEventListener('click', onClick);
</script>
複製程式碼

當我們點選inner這個div的時候會輸出什麼那?

順序是:

click
promise
mutate
click
promise
mutate
timeout
timeout
複製程式碼

為何是如此那?

這裡要說明的是一個click操作作為一個巨集任務,當這個inner的click對應的監聽函式執行完後,即視為一個任務的完成,此時執行微任務佇列中的promise(then)和 mutationObserver的回撥。這兩個任務執行完成後微任務佇列為空,然後再執行冒泡造成的outter的click。當outter的click任務和微任務都執行完後,才會再去找巨集任務佇列(主執行緒)中剩下的兩個setTimeout的任務。並將其一個一個的壓入執行棧。


超級進階版(example3):

當我們在上面的js程式碼中加入下面這行程式碼時,會有什麼不同嗎?

inner.click()
複製程式碼

答案是:

click
click
promise
mutate
promise
timeout
timeout
複製程式碼

為何會有如此大的不同那?下面我們來仔細分析:

上一個例子中兩個微任務在兩個click之間執行,而這個例子中,卻是在兩個click之後執行的;

首先inner.click()觸發的事件作為一個任務壓入執行棧,由此產生的inner的監聽函式函式又做為一個任務壓入執行棧,當這個回撥函式產生的任務執行完畢後,輸出了 click,且微任務佇列裡面增加promise和mutate,那按上面的說法不是應該執行promise和mutate嗎?然而並不是,因為此時 JS 執行棧內的inner.click()還沒有執行結束,所以繼續inner.click()的事件觸發outter的監聽函式,由此再輸出click,該回撥結束後,inner.click()這個任務才算是結束,此時才會去執行微任務佇列中的任務。

簡單來說,在這個例子中,由於我們呼叫 inner.click() ,使得事件監聽器的回撥函式和當前執行的指令碼同步執行而不是非同步,所以當前指令碼的執行棧會一直壓在 JS 執行棧 當中。所以在這個例子中的微任務不會在每一個 click 事件之後執行,而是在兩個 click 事件執行完成之後執行。

Event Loop

JS 執行棧不斷的從主執行緒中和微任務佇列讀取任務並執行,這個過程是迴圈不斷的,所以整個的這種執行機制又稱為Event Loop(事件迴圈)

:本文所有執行結果皆給予chrome瀏覽器,其他瀏覽器或有出入

參考文章jakearchibald.com/2015/tasks-…

作者簡介

琦玉,銅板街前端開發工程師,2018年1月加入團隊,目前主要負責大資料團隊前端專案開發。

帶你瞭解JavaScript的執行機制—Event Loop

更多精彩內容,請掃碼關注 “銅板街科技” 微信公眾號。

相關文章