你真的懂JS事件循環嗎


原文鏈接

對於瀏覽器而言,有多個線程協同合作,如下圖。具體細節可以參考一幀剖析
在這裏插入圖片描述

對於常說的JS單線程引擎也就是指的 Main Therad

main-thread

注意以上主線程的每一塊未必都會執行,需要看實際情況。
先把 Parse HTML -> Composite 的過程稱爲渲染管道流 Rendering pipeline

瀏覽器內部有一個不停的輪詢機制,檢查任務隊列中是否有任務,有的話就取出交給 JS引擎 去執行。

例如:

const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");

bar();
foo();
baz();

過程:
在這裏插入圖片描述

任務隊列 Tasks Queue

一些常見的 webapi 會產生一個 task 送入到任務隊列中。

  • script 標籤
  • XHRaddEventListener 等事件回調
  • setTimeout 定時器

每個 task 執行在一個輪詢中,有自己的上下文環境互不影響。也就是爲什麼,script 標籤內的代碼崩潰了,不影響接下來的 script 代碼執行。

  • 輪詢僞代碼如下(原視頻中使用pop,便於 JSer 的世界觀改用 shift)
while(true) {
  task = taskQueue.shift();
  execute(task);
}
  • 任務隊列未必維護在一個隊列裏,例如 input eventsetTimeoutcallback 可能維護在不同的隊列中。
    代碼如果操作 DOM,主線程還會執行渲染管道流。僞代碼修改如下:
while(true) {
+  queue = getNextQueue();
-  task = taskQueue.shift();
+  task = queue.shift();
  execute(task);
  
+  if(isRepaintTime()) repaint();
}
  • 舉個例子
button.addEventListener('click', e => {
  while(true);
});

點擊 button 產生一個 task,當執行該任務時,一直佔用主線程卡死,該任務無法退出,導致無法響應用戶交互或渲染動態圖等。

改換執行以下代碼

function loop() {
  setTimeout(loop, 0);
}
loop();

看似無限循環執行 loopsetTimeout 到時後產生一個 task。執行完 loop 即退出主線程。使得用戶交互事件和渲染能夠得以執行。

正因爲如此,setTimeout 和其他 webapi 產生的 task 執行依賴任務隊列中的順序。
即使任務隊列沒有其他任務,也不能做到 0秒 運行,setTimeout 定時器到時間 cb 入任務隊列,在輪詢取出 task 給引擎執行,最少大約 4.7ms

requestAnimationFrame

  • 舉個例子,不停移動一個盒子向前1像素
function callback() {
  moveBoxForwardOnePixel();
  requestAnimationFrame(callback);
}

callback()

換成 setTimeout

function callback() {
  moveBoxForwardOnePixel();
-  requestAnimationFrame(callback);
+  setTimeout(callback, 0);
}

callback()

對比,可以發現 setTimeout 移動明顯比 rAF 移動快很多(3.5倍左右)。
意味着 setTimeout 回調過於頻繁,這並不是一件好事。

渲染管道流不一定發生在每個 setTimeout 產生的 task 之間,也可能發生在多個 setTimeout 回調之後。
由瀏覽器決定何時渲染並且儘可能高效,只有值得更新纔會渲染,如果沒有就不會。

如果瀏覽器運行在後臺,沒有顯示,瀏覽器就不會渲染,因爲沒有意義。大多數情況下頁面會以固定頻率刷新,
保證 60FPS 人眼就感覺很流暢,也就是一幀大約 16ms。頻率高,人眼看不見無意義,低於人眼能發現卡頓。

在主線程很空閒時,setTimeout 回調能每 4ms 左右執行一次,留 2ms 給渲染管道流,setTimeout 一幀內能執行大概 3.5次
3.5ms * 4 + 2ms = 16ms

setTimeout

setTimeout 調用次數太多 3-4次,多於用戶能夠看到的,也多於瀏覽器能夠顯示的,大約3/4是浪費的。
很多老的動畫庫,用 setTimeout(animFrame, 1000 / 60)來優化。
在這裏插入圖片描述

setTimeout 並不是爲動畫而生,執行不穩定,會產生飄移或任務過重會推遲渲染管道流。

broken

requestAnimationFrame 正是用來解決這些問題的,使一切整潔有序,每一幀都按時發生。

happy

推薦使用 requestAnimationFrame 包裹動畫工作提高性能。它解決這個 setTimeout 不確定性與性能浪費的問題,由瀏覽器來保證在渲染管道流之前執行。

  • 一個困惑的問題:以下代碼能實現先從 0px 移動到 1000px 處,再到 500px 處嗎?
button.addEventListener('click', () => {
  box.style.transform = 'translateX(1000px)';
  box.style.transition = 'transform 1s ease-in-out';
  box.style.transform = 'translateX(500px)';
});

結果:從 0px 移動到 500px 處。由於回調任務的代碼塊是同步執行的,瀏覽器不在乎中間態。

  • 修改如下
button.addEventListener('click', () => {
  box.style.transform = 'translateX(1000px)';
  box.style.transition = 'transform 1s ease-in-out';
-  box.style.transform = 'translateX(500px)';

+  requestAnimationFrame(() => {
+    box.style.transform = 'translateX(500px)';
+  });
});

結果:依然從 0px 移動到 500px 處。

這是因爲在 addEventListenertask 中同步代碼修改爲 1000px
在渲染管道流中的計算樣式執行之前,需要執行 rAF,最終的樣式爲 500px

  • 正確修改,在下一幀的渲染管道流執行之前修改 500px
button.addEventListener('click', () => {
  box.style.transform = 'translateX(1000px)';
  box.style.transition = 'transform 1s ease-in-out';

  requestAnimationFrame(() => {
-    box.style.transform = 'translateX(500px)';
+    requestAnimationFrame(() => {
+        box.style.transform = 'translateX(500px)';
+    });
  });
});
  • 不好的方式,但也能達到效果
button.addEventListener('click', () => {
  box.style.transform = 'translateX(1000px)';
  box.style.transition = 'transform 1s ease-in-out';
+  getComputedStyle(box).transform;
  box.style.transform = 'translateX(500px)';
});

getComputedStyle 會導致強制重排,渲染管道流提前執行,多餘操作損耗性能。

  • bad news

EdgeSafarirAF 不符合規範,錯誤的放在渲染管道流之後執行。

微任務 Microtasks

DOMNodeInserted 初衷被設計用來監聽 DOM 的改變。

  • 例如以下代碼,會觸發多少次 DOMNodeInserted
document.body.addEventListener('DOMNodeInserted', () => {
  console.log('Stuff added to <body>!');
});

for(let i = 0; i < 100; i++) {
  const span = document.createElement('span');
  document.body.appendChild(span);
  span.textContent = 'hello';
}

理想 for 循環完畢後,DOMNodeInserted 回調執行一次。
結果:執行了 200 次。添加 span 觸發 100 次,設置 textContent 觸發 100
這就讓使用 DOMNodeInserted 會產生極差的性能負擔。
爲了解決此等問題,創建了一個新的任務隊列叫做微任務 Microtasks

常見微任務

  1. MutationObserver —— DOM變化事件的觀察者。
  2. Promise
  3. process.nextTick (node 中)

微任務是在一次事件輪詢中取出的 task 執行完畢,即 JavaScript 運行棧(stack)中已經沒有可執行的內容了。
瀏覽器緊接着取出微任務隊列中所有的 microtasks 來執行。

  • 如果用微任務創建一個像之前的 loop 會怎樣?
function loop() {
  Promise.resolve().then(loop);
}

loop();

你會發現,它跟之前的 while 一樣卡死。

現在我們有了3個不同性質的隊列

  1. task queue
  2. rAF queue
  3. microtask queue
  • task queue 前面已知,事件輪詢中取出一個 task 執行,如果產生new task 入隊列。task 執行完畢等待下一次輪詢取出next task
  • microtask queue task 執行完畢後,執行隊列中所有 microtask,如果產生new microtask,入隊列,等待執行,直到隊列清空。
while(true) {
  queue = getNextQueue();
  task = queue.shift();
  execute(task);
  
+  while(microtaskQueue.hasTasks()) {
+    doMicrotask();
+  }
	  
  if(isRepaintTime()) repaint();
}
  • rAF queue 每一幀渲染管道流開始之前一次性執行完所有隊列中的 rAF callback,如果產生new rAF 等待下一幀執行。
while(true) {
  queue = getNextQueue();
  task = queue.shift();
  execute(task);
  
  while(microtaskQueue.hasTasks()) {
      doMicrotask();
  }
  
-  if(isRepaintTime()) repaint();
+  if(isRepaintTime()) {
+    animationTasks = animationQueue.copyTasks();
+    for(task in animationTasks) {
+      doAnimationTask(task);
+    }
+    
+    repaint();
+  }
}
  • 思考,檢驗一下自己是否理解了
button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('Microtask 1'));
  console.log('Listener 1');
});

button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('Microtask 2'));
  console.log('Listener 2');
});

點擊按鈕會是怎麼樣的順序呢?

來分析一下,以上代碼塊爲一個 task 0

  1. task 0 執行完畢後,webapi 監聽事件。
  2. 用戶點擊按鈕,觸發 click 事件,task queue 中入隊 task 1task 2
  3. 輪詢取出 task 1 執行,Microtask queue 入隊 Microtask 1
    console 輸出 Listener 1task 1 執行完畢。
  4. 執行所有的 microtask(目前只有 Microtask 1),取出執行,console 輸出 Microtask 1
  5. 輪詢取出 task 2 執行,Microtask queue 入隊 Microtask 2
    console 輸出 Listener 2task 2 執行完畢。
  6. 執行所有的 microtask,取出 Microtask 2 執行,console 輸出 Microtask 2

答案:Listener 1 -> Microtask 1 -> Listener 2 -> Microtask 2

如果你答對了,那麼恭喜你,超越了 87% 的答題者。

answer

  • 如果是代碼觸發呢?
button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('Microtask 1'));
  console.log('Listener 1');
});

button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('Microtask 2'));
  console.log('Listener 2');
});

+ button.click();

思路一樣分析

  1. task 0 執行到 button.click() 等待事件回調執行完畢。
  2. 同步執行 Listener 1Microtask queue 入隊 Microtask 1console 輸出 Listener 1
  3. 同步執行 Listener 2Microtask queue 入隊 Microtask 2console 輸出 Listener 2
  4. click 函數 return,結束 task 0
  5. 執行所有的 microtask,取出 Microtask 1 執行,console 輸出 Microtask 1
  6. 取出 Microtask 2 執行,console 輸出 Microtask 2

答案:Listener 1 -> Listener 2 -> Microtask 1 -> Microtask 2

在做自動化測試時,需要小心,有時會產生和用戶交互不一樣的結果。

  • 最後來點難度的的題

以下代碼,用戶點擊,會阻止a鏈接跳轉嗎?

const nextClick = new Promise(resolve => {
  link.addEventListener('click', resolve, { once: true });
});
nextClick.then(event => {
  event.preventDefault();
  // handle event
});

如果是代碼點擊呢?

link.click();

暫不揭曉答案,歡迎評論區討論。

node

  1. 沒有腳本解析事件(如,解析 HTML 中的 script)
  2. 沒有用戶交互事件
  3. 沒有 rAF callback
  4. 沒有渲染管道(rendering pipeline)

node 不需要一直輪詢有沒有任務,清空所有隊列就結束。

常見任務隊列 task queue

  1. XHR requests、disk read or write queue(I/O)
  2. check queue (setImmediate)
  3. timer queue (setTimeout)

常見微任務 microtask queue

  1. process.nextTick
  2. Promise

process.nextTick 執行優先級高於 Promise

while(tasksAreWaiting()) {
  queue = getNextQueue();
  
  while(queue.hasTasks()) {
    task = queue.shift();
    execute(task);
    
    while(nextTickQueue.hasTasks()) {
      doNextTickTask();
    }
    
    while(promiseQueue.hasTasks()) {
      doPromiseTask();
    }
  }
}

web worker

  • 沒有 script tag
  • 沒有用戶交互
  • 不能操作 DOM

類似 node

參考

  1. Further Adventures of the Event Loop - Erin Zimmer@JSConf EU 2018
  2. In The Loop - Jake Archibald@JSconf 2018
  3. 動圖-事件循環
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章