iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 5
0
Modern Web

深入現代前端開發系列 第 5

Day5 [JavaScript 基礎] Event Loop 機制

Event Loop

什麼是 event loop?JavaScript 跑在一個 thread 上,一次只能做一件事,不能像其他程式語言一樣,想開 thread 就開 thread,想怎樣就怎樣。(備註:nodejs 在 12.10.0 推出了 threads module)

這和 Javascript 的歷史以及設計目的有關,當初 JavaScript 是為了在瀏覽器上運作,和使用者互動而設計的腳本語言。

由於 Javascript 需要操作 DOM,並且將 DOM 正確地繪製在瀏覽器上。如果設計成多個 thread,就需要考慮各種問題,像是有兩個 thread 同時修改 DOM 怎麼辦?dead lock 的問題怎麼辦?同時存取同一變數的情形,也會讓情況變得更加複雜。

如果有寫過 iOS 或是 Android 的手機應用開發,可以發現編譯器會要求任何的繪製動作都要在 main thread 執行,否則會有不可預期的結果。

不過,為什麼會有不可預期的結果?或許可以試著從幾個角度思考:

  • 如果兩個 thread 同時存取 DOM 節點,其中一個將節點刪除,另外一個 thread 存取 DOM 的屬性(假設是 getAttribute 好了),只要一不小心就會噴出例外
  • 假設多個 thread 同時監聽同樣的 event,執行順序該怎麼處理

像是這樣的 case 處理起來可以說是相當棘手。JavaScript 可沒那麼多時間慢慢想了,為了避免不必要的複雜性,所以只有一個,這也讓前端開發省下許多功夫去處理

那麼問題就來了,如果一個 API call 需要 3 秒鐘,難道瀏覽器就要停在那等 3 秒嗎?顯然不是,為了解決這樣的問題,衍生出了 event loop 的機制。

所有同步性的工作,瀏覽器會一個個執行,遇到非同步的操作(IO, API call 等等),會先放到一個叫做 task queue 的地方,等到瀏覽器目前沒有其他工作,就會到 task queue 看看有沒有還沒執行的任務,再把它拿出來執行。

整個流程圖大概像這樣:

https://ithelp.ithome.com.tw/upload/images/20190906/20103565ytBAHYgvSV.png

由於這個過程是不間斷的,所以就叫做 event loop。

setTimeout 也會將任務放入 task queue 當中,不斷檢查 timeout 是否符合,符合的話就會將函數拿出來執行。

意識到這件事很重要,我們必須要等到整個 stack 的任務執行完畢後,stack 為空才會將 task queue 的函數拿出來。例如下面的程式碼:

function fib(n) {
    if (n < 1) {
        return 1;
    }
    return fib(n - 1) + fib(n - 2);
}
setTimeout(() => console.log('hello'), 1000);
fib(40);

在瀏覽器執行這一段程式碼,你會發現為什麼過了一秒,console.log 卻還沒出現。

這是因為 fib(40) 造成的遞迴塞滿了整個 stack,直到瀏覽器消化完了之後,才趕緊將 task queue 的 setTimeout 拿出來。

所以即使有 setTimeout 的機制存在,也無法擺脫 JavaScript 本質是一個 thread 的事實,也不是在 setTimeout 中執行效能昂貴的操作就沒事了。

另外我們再來看看,如果在 setTimeout 執行過長的任務會怎樣:

setTimeout(() => {
  fib(40);
}, 0);
setTimeout(() => {
  console.log('hello');
}, 500);

從這裡我們也可以清楚了解 setTimeout(fn, 0) 跟直接執行有什麼區別,setTimeout 會先把任務放入 task queue 當中再回到 stack 執行,若 stack 有其他 task 也會阻塞任務執行。因此,fib(40) 執行後因為讓 stack 持續有任務,導致 500ms 過後 console.log('hello') 還沒出現。

這個機制看起來沒什麼,卻告訴了我們幾件事:

  • 若 stack 裡頭有其他任務正在進行,setTimeout 的時間可能不會被正確觸發。
  • setTimeout 裡頭執行過長的任務也會導致 UI blocking。

Micro Tasks

除了一般 setInterval, setTimeout, ajax 請求之外,還有其他 browser 的 API 會使用 task queue,但執行時機又稍有不同。

  • Promise
  • MutationObserver

這兩者的差異最大的差異在於執行時機,我們來看看下列的程式碼:

Promise.resolve('hello world').then(function(str) {
  console.log(str);
}).then(function() {
  console.log('promise2');
});

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

// hello world
// promise2
// setTimeout

從這裡可以發現,Promise callback 的執行會比 setTimeout 還早。在每次的 event loop 當中,如果 micro task queue 裡頭有函數,會在下一個頁面渲染之前執行 micro task queue (promise, MutationObserver)裏頭的函數,再執行 task queue (setTimeout, setInterval)的函數,再渲染頁面。

簡單來說就是在頁面渲染之後執行的任務,只是為什麼要有這樣的機制呢?

他們會優先於渲染頁面之前開始跑,原因在於這些操作有可能會有 DOM 的操作,在同一個 event loop 執行後可以確保頁面渲染只有一次。

後記

雖然整個 event loop 的過程比上述來得更複雜一些,但這裡掌握幾件事就可以了。

  1. 盡量不要在 callback 當中執行負擔過重的函數,避免佔據 call stack
  2. 理解 microtask 與一般 task queue 執行順序不同。

如果對詳細的運作過程有興趣,可以參考 tasks-microtaks-queues,裡頭提供了很詳盡的視覺化,可以讓你了解背後是怎麼運作的。


上一篇
Day4 [JavaScript 基礎] 我知道 `==` 與 `===` 不同,但為什麼? 淺談相等性
下一篇
Day6 [JavaScript 基礎] 垃圾回收機制
系列文
深入現代前端開發32

2 則留言

0
hannahpun
iT邦新手 5 級 ‧ 2019-09-07 00:30:02

想請教一下 我理解的 stack 是先進後出(像疊盤子一樣,後面才疊在上面的,需要時會先被拿起來)
這邊也是這樣嗎
但我測試已下程式碼
setTimeout(() => console.log('1'), 1000); setTimeout(() => console.log('2'), 1000);
會是 1, 2
不是應該是 2, 1 ??

huli iT邦新手 5 級‧ 2019-09-07 01:23:03 檢舉

stack 先進後出是沒錯的,call stack 就是這樣。而這邊提到的 event loop 的任務是個 queue,不是 stack。

所以你貼的程式碼執行順序是這樣的:

  1. setTimeout(() => console.log('1'), 1000); 放入 call stack
  2. 執行 setTimeout(() => console.log('1'), 1000);,設定定時器,會在一秒過後把這個函式:() => console.log('1') 放入 callback queue
  3. setTimeout(() => console.log('1'), 1000); 移出 call stack
  4. setTimeout(() => console.log('2'), 1000); 放入 call stack
  5. 執行 setTimeout(() => console.log('2'), 1000);,設定定時器,會在一秒過後把這個函式:() => console.log('2') 放入 callback queue
  6. setTimeout(() => console.log('2'), 1000); 移出 call stack
  7. 目前 callback queue 有兩個東西,依序是 () => console.log('1')() => console.log('2')
  8. call stack 是空的,把 callback queue 裡的 () => console.log('1') 放進去 call stack
  9. 執行 () => console.log('1')
  10. 執行 console.log('1')
  11. 印出 1
  12. console.log('1') 移出 call stack
  13. () => console.log('1') 移出 call stack
  14. call stack 是空的,把 callback queue 裡的 () => console.log('2') 放進去 call stack
  15. 以下略,跟上面流程差不多

可以透過這個經典影片來學習 event loop:
https://www.youtube.com/watch?v=8aGhZQkoFbQ

原來是我誤會了 原來 settimout 是放進去 queue 之後才執行。謝謝詳細解說。

0
arthur104
iT邦新手 5 級 ‧ 2019-09-10 13:05:42

如果我沒有理解錯的話, Event Loop 的順序是

  1. 從 macro task queue (相當於本文的 task queue) 拿 一個 task 出來並執行
  2. 執行 micro task queue 的 所有 task
  3. 頁面渲染

所以這句應該是 "下一次渲染之前執行 micro task queue 的所有任務。" task queue 的任務會等到渲染結束才會再拿一個出來執行

下一個頁面渲染之前執行 micro task queue (promise, MutationObserver)裏頭的函數,再執行 task queue (setTimeout, setInterval)的函數,再渲染頁面。

Promise.resolve('hello world').then(function(str) {
  console.log(str);
}).then(function() {
  console.log('promise2');
});

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

上面這段 code 之所以會誤以為是 micro -> macro -> render 是因為
整段 code 是一個 scripts, 屬於 macro task, 所以

  1. 從 macro task queue 拿出上面這段 script 並執行
    1.1 Promise 產生一個 micro task
    1.2 setTimeout 產生一個 macro task
  2. 執行所有 micro task
    2.1 執行 console.log(str), 又產生一個 micro task
    2.2 執行 console.log('promise2')
  3. micro task 執行完畢進行頁面渲染
  4. 從 macro task queue 拿出 setTimeout 的 callback 並執行

我要留言

立即登入留言