原文鏈接
對於瀏覽器而言,有多個線程協同合作,如下圖。具體細節可以參考一幀剖析。
對於常說的JS單線程引擎也就是指的 Main Therad
。
注意以上主線程的每一塊未必都會執行,需要看實際情況。
先把 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
標籤XHR
、addEventListener
等事件回調setTimeout
定時器
每個 task
執行在一個輪詢中,有自己的上下文環境互不影響。也就是爲什麼,script
標籤內的代碼崩潰了,不影響接下來的 script
代碼執行。
- 輪詢僞代碼如下(原視頻中使用
pop
,便於JSer
的世界觀改用shift
)
while(true) {
task = taskQueue.shift();
execute(task);
}
- 任務隊列未必維護在一個隊列裏,例如
input event
、setTimeout
的callback
可能維護在不同的隊列中。
代碼如果操作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();
看似無限循環執行 loop
,setTimeout
到時後產生一個 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
調用次數太多 3-4次
,多於用戶能夠看到的,也多於瀏覽器能夠顯示的,大約3/4是浪費的。
很多老的動畫庫,用 setTimeout(animFrame, 1000 / 60)
來優化。
但 setTimeout
並不是爲動畫而生,執行不穩定,會產生飄移或任務過重會推遲渲染管道流。
requestAnimationFrame
正是用來解決這些問題的,使一切整潔有序,每一幀都按時發生。
推薦使用 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
處。
這是因爲在 addEventListener
的 task
中同步代碼修改爲 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
Edge
和 Safari
的 rAF
不符合規範,錯誤的放在渲染管道流之後執行。
微任務 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
。
常見微任務
- MutationObserver —— DOM變化事件的觀察者。
- Promise
- process.nextTick (node 中)
微任務是在一次事件輪詢中取出的 task
執行完畢,即 JavaScript
運行棧(stack)中已經沒有可執行的內容了。
瀏覽器緊接着取出微任務隊列中所有的 microtasks
來執行。
- 如果用微任務創建一個像之前的
loop
會怎樣?
function loop() {
Promise.resolve().then(loop);
}
loop();
你會發現,它跟之前的 while
一樣卡死。
現在我們有了3個不同性質的隊列
- task queue
- rAF queue
- 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
。
task 0
執行完畢後,webapi
監聽事件。- 用戶點擊按鈕,觸發
click
事件,task queue
中入隊task 1
、task 2
。 - 輪詢取出
task 1
執行,Microtask queue
入隊Microtask 1
。
console
輸出Listener 1
。task 1
執行完畢。 - 執行所有的
microtask
(目前只有Microtask 1
),取出執行,console 輸出Microtask 1
。 - 輪詢取出
task 2
執行,Microtask queue
入隊Microtask 2
。
console
輸出Listener 2
。task 2
執行完畢。 - 執行所有的
microtask
,取出Microtask 2
執行,console 輸出Microtask 2
。
答案:Listener 1
-> Microtask 1
-> Listener 2
-> Microtask 2
如果你答對了,那麼恭喜你,超越了 87%
的答題者。
- 如果是代碼觸發呢?
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();
思路一樣分析
task 0
執行到button.click()
等待事件回調執行完畢。- 同步執行
Listener 1
,Microtask queue
入隊Microtask 1
。console
輸出Listener 1
。 - 同步執行
Listener 2
,Microtask queue
入隊Microtask 2
。console
輸出Listener 2
。 click
函數return
,結束task 0
。- 執行所有的
microtask
,取出Microtask 1
執行,console 輸出Microtask 1
。 - 取出
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
- 沒有腳本解析事件(如,解析 HTML 中的 script)
- 沒有用戶交互事件
- 沒有
rAF
callback
- 沒有渲染管道(rendering pipeline)
node 不需要一直輪詢有沒有任務,清空所有隊列就結束。
常見任務隊列 task queue
- XHR requests、disk read or write queue(I/O)
- check queue (setImmediate)
- timer queue (setTimeout)
常見微任務 microtask queue
- process.nextTick
- 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