vatt'ghern jaskier's ballads
本文 1 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

kciter.so的demo只做一件事——按下「block for 2s」,畫面上用JavaScript驅動的方塊立刻停住,輸入框打字沒有任何反應;但旁邊一顆用CSS animation跑的方塊完全沒事,還在移動。

主執行緒的一幀,實際只剩十毫秒左右可以花

完這篇之後你會知道一件事:瀏覽器只有一條main thread在管你寫的每一行JavaScript,也在管style計算、layout、paint這整條渲染管線——你的程式碼跟你的畫面共用同一顆CPU核心的同一段時間。這篇文章會從「為什麼是同一條thread」開始講,算出一幀真正剩多少毫秒可以用,再把常見的解法一個個對上各自解決的那個具體問題:有的解法讓main thread喘口氣、有的把工作收斂成更少次、有的乾脆把工作搬到別的地方跑。

按下這顆按鈕,畫面會發生什麼事

kciter.so的文章裡放了一個demo,兩顆按鈕,文案就寫著「Block for 0.5s」跟「Block for 2s」——先讓main thread忙著什麼都不做地空轉0.5秒或2秒。原文形容按下去之後的畫面:「When you press the button, the JS animation stops and typing into the input field does nothing.」(按下按鈕之後,JS動畫停了,輸入框打字也沒反應。)

有意思的地方在旁邊那顆用CSS animation跑的方塊。原文說最後把畫好的圖層組裝上螢幕的那一步,「is handed off to the compositor thread」——被交給獨立的compositor thread處理,不需要main thread插手。所以main thread被占滿的期間,JS驅動的動畫停了,CSS driven的動畫照跑。

按下按鈕封鎖main thread,觀察兩種動畫的差異 · 兩顆按鈕、一個掉幀計數器

已掉幀:0
compositor thread(transform動畫)
上排方塊由requestAnimationFrame在main thread逐幀重繪;下排圓點用Web Animations API驅動transform,走的是compositor thread,不需要main thread插手。按下阻塞按鈕,比較兩者的差別。

上排方塊由requestAnimationFrame在main thread逐幀重繪;下排圓點用Web Animati…

按下阻塞按鈕後,JavaScript畫的方塊整個凍結,但用transform驅動的圓點完全不受影響、持續移動,因為它跑在compositor thread。

「已掉幀」這個數字的計算方式很直接:只要兩次重繪之間的間隔超過33毫秒(等於漏掉了一幀以上),就計一次。放著不管的時候這個數字應該停在0;按下阻塞按鈕之後,數字會一次跳好幾格,因為main thread恢復之後,要一口氣補上剛剛沒畫的那幾幀進度。這跟原文demo的邏輯是同一件事,只是這裡把「掉了幾幀」這個原本要自己盯畫面數的東西,變成一個明確的數字。

把這個demo放大到真實專案裡看,0.5秒到2秒的阻塞不是誇張的極端值。一段沒切開的陣列排序、一次沒節流的正規表示式驗證、一段同步跑完才回傳的JSON.parse,隨便一個都可能單獨吃掉幾百毫秒的main thread時間。這篇文章沒有列出這些情境各自的benchmark數字,但demo本身的設計已經把「main thread被占滿」這件事,從一句抽象的效能術語,變成你自己按一下就能感覺到的東西——輸入框沒反應,這種感受比任何ms數字都直接。

這個demo好懂,但它背後的問題值得往下拆一層:main thread為什麼會被占滿?占滿多久才算是問題?

這篇文章後來也被貼上Lobsters,標題被引用成「The Browser's Main Thread Is Expensive」,掛在web這個tag下;讀取當下11點、2則留言。留言數量不多,比較像是前端圈子裡「對,就是這樣」的共識,不是需要吵起來的爭議——這也是為什麼這篇文章值得認真拆解一次,而不是當成又一篇效能提醒文章滑過去。

為什麼一個main thread要同時管JS、樣式、排版、繪製

先把main thread講清楚。原文的定義是:「The browser has a number of threads, but almost everything we can touch from code is concentrated on the main thread. Computation, rendering, event handling, network response handling, and your framework's internals are all processed there.」瀏覽器不只一條thread,但開發者能碰到的程式碼幾乎全部集中在main thread上——運算、渲染、事件處理、網路回應處理、你用的框架內部邏輯,全部跑在那裡。

這句話裡「your framework's internals」這幾個字值得多看一眼——不管用的是React、Vue,還是任何一個做virtual DOM diffing的框架,它們的reconciliation、re-render排程,一樣是main thread上的JS,一樣受run-to-completion限制。框架能把「什麼時候該重繪」包裝得更方便,但沒辦法讓main thread變成兩條。跟這句話對照著看:框架常見的效能優化手法——useMemo、shouldComponentUpdate、keyed list——最終處理的都是同一件事,讓main thread少做一點、少跑一次;框架提供的是介面,不是解法本身。這是延伸自上面這段引文的推論,原文本身沒有點名任何框架API。

JavaScript本身的設計是single-threaded event loop model。web.dev把這個模型講得更精確:「JavaScript works this way because it uses the run-to-completion model of task execution. This means that each task will run until it finishes, regardless of how long it may block the main thread.」也就是說,一個task一旦開始跑,就會跑到結束為止,中間main thread被占用多久都不會被打斷。kciter.so那句話講的是同一件事的後果:「while that task is running, nothing else can happen.」

「nothing else」包含哪些東西?點擊事件的handler、輸入框的按鍵回應、scroll觸發的layout重算、下一幀的style計算與paint,通通排在同一個queue裡,等前面那個task讓出main thread才輪得到自己。這也是為什麼demo裡「JS動畫停了」跟「輸入框沒反應」會同時發生——它們不是兩個獨立故障,是同一個原因表現在兩個地方。

把這句話落到實際操作上:使用者點一下按鈕,這個click事件不會插隊,它得先排進task queue,等前面那個task(不管是你自己寫的、還是框架內部在做的)讓出main thread才輪得到它被處理。scroll觸發的layout重算、輸入框每敲一下鍵盤要跑的handler,全部走同一條隊伍。這也是為什麼「main thread被占滿」這句話聽起來抽象,實際感受起來卻很具體——你點了沒反應、你打字沒出現,都是同一條隊伍卡住的結果。

一幀真正剩多少毫秒可以花

螢幕多久畫一次,決定了main thread必須多快把工作做完。60Hz的螢幕理論上一秒畫60次,換算下來一幀的frame budget是原文說的「about 16.6 milliseconds per frame」。但這16.6ms不是全部留給開發者的程式碼——瀏覽器自己要處理輸入、排程、部分渲染管線,扣掉這些之後原文給的實際可用數字是「around 10 milliseconds」。那扣掉的6.6ms去哪了?原文沒有逐項列出,只給了這個結論數字——保守地說,只要記得16.6ms不是全部拿來寫程式碼的空間就夠了。

這個budget不是固定的。原文接著補一句:「and on a 120Hz device the budget itself is cut in half.」螢幕更新率越高,留給main thread的餘裕越窄。16.6ms砍半,換算下來120Hz裝置的理論frame budget大概只剩8.3ms——原文沒有再往下拆到「可用」的部分,但可以合理推測會比8.3ms更緊。

那多久算「太久」?原文給了一個業界慣用的門檻:「A task that runs this long and holds the main thread is called a long task, and anything over 50 milliseconds is generally considered a problem.」web.dev對同一個門檻的定義幾乎一模一樣:「Any task that takes longer than 50 milliseconds is a long task.」web.dev給的是定義句,kciter.so則用「generally considered」轉述同一條業界共識——同一個數字、同一條源流,不是兩次獨立量測,但仍然足以當稽核基準。

指標 數值 原文用語
60Hz的理論frame budget16.6 ms「about 16.6 milliseconds per frame」
扣掉瀏覽器開銷後的可用budget10 ms「around 10 milliseconds」
120Hz裝置的理論frame budget≈ 8.3 ms「the budget itself is cut in half」(16.6ms砍半,換算)
long task門檻50 ms「anything over 50 milliseconds」
四個數字放在一起看:10ms是「舒服」的上限,16.6ms是理論極限,50ms是業界(web.dev定義)認定為long task的門檻。中間那段——超過10ms但還沒到50ms——是最容易被忽略的灰色地帶:使用者已經感覺得到卡頓,卻還沒跨過50ms這條long task的線。

這也解釋了為什麼「有點卡」的抱怨常常比「掉了幾幀」更早出現——落在10ms到50ms之間的task,還沒跨過50ms這條long task的線,卻已經吃掉了「可自由運用」的10ms budget,使用者的手感先感覺到了。合理的推測是,這段灰色地帶正是很多team一開始覺得「應該還好」,卻在使用者實際回報卡頓之後才回頭去查的落差。

要知道自己的task有沒有超過這條線,最直接的方法就是照著demo本身的邏輯自己動手量——挑一段懷疑很重的操作,量它實際跑多久,而不是用感覺猜。這篇文章沒有推薦特定的量測工具,但「先量再說」本身就是這篇文章示範給讀者的第一個習慣:demo的兩顆按鈕,本質上就是把「這段work跑多久」變成一個你可以親自按出來的數字。

把工作切開跟收斂的四種手法

知道budget多窄之後,剩下的問題是怎麼在budget裡把事情做完。文章把「善用main thread本身」拆成四種手法,前兩種是切開跟收斂,後兩種是排序跟延後。

這四種手法的共同前提是:工作還留在main thread上,你沒有多長出一顆CPU核心,只是換了一種方式安排它們的順序跟時機。跟下一節「搬出main thread」的做法比起來,這四種更輕量、不需要引入新的執行環境,但也換不來真正的並行——main thread該做的事,一件都沒少。

第一種是yielding,字面意思是「讓出」。web.dev把這個動作定義成:「briefly interrupting your work to give the browser opportunities to run more important tasks.」短暫中斷自己手上的工作,把時間片讓給更重要的task先跑。三種常見的做法各自恢復執行的時間點不一樣:

滑鼠移到方法名稱看原文對照 · 3種讓出時間片的方式

  • setTimeout:把接下來要做的事推進下一個task,讓main thread先處理其它排隊中的工作。 原文:「pushes the continuation into the next task」
  • requestAnimationFrame:恢復執行的時間點卡在下一次畫面繪製之前,適合跟視覺更新綁在一起的工作。 原文:「resumes just before a frame is drawn」
  • scheduler.yield():恢復執行時排在其它已排隊的task之前,比單純丟進下一個task更積極。 原文:「resumes ahead of other queued tasks」(web.dev稱為「prioritized continuation」)

web.dev提醒一個容易忽略的地方——即使把delay設成0,setTimeout也不是「馬上」執行:「This postpones execution of the callback into a separate task, even if you specify a timeout of 0.」它照樣被推進下一個task,跟其它排隊中的task一起競爭。scheduler.yield()多做的事情,web.dev寫的原話是:「if you yield in the middle of a task, the continuation of the current task will run before any other similar tasks are started.」讓出去之後,這個task剩下的部分會排在其它同類task前面先恢復,不是單純排到隊伍最後面。順帶一提,web.dev也明講isInputPending()已經「no longer recommend using this API」,連yield手段本身都在汰換。這個細節提醒一件事:要用哪個API去讓出時間片,不是一次選定就不用再管的清單,瀏覽器廠商的建議做法會隨時間調整;跟「main thread只有一條」這個底層限制比起來,「用哪個API」反而是相對表面、會變動的部分。

第二種是batching,處理的是另一類問題——不是單一task太長,是同一件事被觸發太多次。原文的說法是scroll、resize、input這類事件「can fire dozens or hundreds of times in a short span」,如果每次觸發都真的跑一次handler,加起來也會吃光main thread的時間。debounce的做法是「running once after things quiet down」(安靜下來之後才跑一次),throttle是「running at most once per interval」(每個固定間隔最多跑一次)。這兩個技巧不是互相替代——debounce適合「等使用者停下來再做」這種場景,例如打字即時建議;throttle適合「畫面要跟著即時更新,但不用每個像素都算一次」這種場景,例如scroll觸發的視差效果。挑哪一個,取決於你要的是「最後一次的結果」還是「規律的更新頻率」。

拖曳滑桿看事件頻率如何影響呼叫次數 · 5~200次/秒

50 次/秒
raw(不處理)
50 次/秒
debounce(安靜期)
停止後才跑1次
throttle(固定間隔)
10 次/秒
throttle的間隔固定在100ms,所以呼叫次數會卡在10次/秒,不管N拖到多高;debounce則完全不看N,持續觸發時是0次,只有停下來才補1次。這正是原文說的「running at most once per interval」跟「running once after things quiet down」的差別。

下面這段是yield最常見的寫法對照,把一段可以切開的工作,用setTimeout跟scheduler.yield()各寫一次:

// setTimeout:把剩下的工作丟進下一個task
function processWithSetTimeout(items, i) {
  const end = performance.now() + 5; // 每個chunk最多跑5ms
  while (i < items.length && performance.now() < end) {
    handle(items[i++]);
  }
  if (i < items.length) {
    setTimeout(() => processWithSetTimeout(items, i), 0);
  }
}

// scheduler.yield():讓出之後,剩下的部分排在其它同類task之前恢復
async function processWithYield(items, i) {
  const end = performance.now() + 5;
  while (i < items.length) {
    if (performance.now() >= end) {
      await scheduler.yield();
      return processWithYield(items, i);
    }
    handle(items[i++]);
  }
}

兩段程式碼做的事情一樣,每處理完一塊資料就讓出main thread一次,差別只在「讓出之後排在哪」。這正是budget有限時,開發者唯一能主動控制的變數:不是把工作變快,是決定工作什麼時候暫停、暫停之後排在誰前面。

這也是為什麼原文把splitting、batching、prioritization、deferring分成四種,而不是含糊地說「切小塊就好」——它們解決的是四種不同形狀的問題:task太長,切成小塊(yielding);task太多次,收斂成更少次(batching);task同時出現,決定順序(prioritization);task不急,往後挪(deferring)。四個問題對四種答案,比套用同一招更有效。

剩下兩種手法,prioritization跟deferring,文章沒有給具體API,給的是兩個問題:「Among several tasks, which goes first?」(面對多個task,決定誰先跑)跟「How do you postpone work that doesn't need to happen now?」(怎麼把不急的工作往後挪)。這兩個問題比前面兩種技巧更貼近設計決策——沒有一個函式庫幫你決定「這個task比較重要」,那是工程判斷。

實務上這兩個問題長什麼樣子?比方一個聊天介面同時要做兩件事——把剛送出的訊息畫到畫面上,跟把整個對話紀錄的搜尋索引重建一次。前者使用者在等,後者使用者感覺不到差別;prioritization要回答的就是「這兩個task,哪個先跑」。deferring則是更進一步:搜尋索引重建這件事,能不能乾脆等使用者閒下來、甚至等瀏覽器真的空閒時再做,而不是每次送出訊息都重跑一次。

什麼時候該把工作搬出main thread

前面四種手法有個共同前提:工作還是留在main thread上,只是切得更小、收斂得更少、排序排得更好。但有一種更乾脆的做法,原文的說法是「The other is to send the work outside the main thread altogether.」把整段工作丟到main thread以外。文章對應的那一節標題是「Sending Work to a Worker」,也就是把運算搬進Web Worker,讓main thread完全不用等它。文章形容需要送進worker的工作是「Heavy work that can't be split like this」——沒辦法像前面yield手法那樣切成小塊分批做的重工作,才是worker真正要解決的對象。如果一段運算可以每幾毫秒暫停一次、用scheduler.yield()喘口氣,通常代表它留在main thread上就夠了;合理的推測是,只有真的沒辦法切、又得一次跑完的運算,才值得付出把資料搬進搬出worker的代價,換一顆獨立的thread——這篇文章沒有進一步展開worker資料傳遞的細節。

文章的目錄裡其實還有第七種分類,標題是「Eliminating the Work Itself」——從字面上看,方向是「乾脆不做這件事」,比前面任何一種技巧都更激進,但這一節本文不展開,只先點出它的存在,不硬湊細節。

compositor thread其實已經示範了「搬出main thread」能帶來的效果——它不是Web Worker,但同一個原理:只要工作不需要main thread插手,main thread被占滿就不影響它。這也是為什麼demo裡CSS動畫沒有停。下面這個widget把main thread跟compositor thread放在同一條時間軸上,拖曳看阻塞時長怎麼影響兩條lane:

拖曳頂端圓點設定阻塞時長 · 0~66ms、4個frame位置

10ms 16.6ms 50ms MAIN COMPOSITOR frame1frame2frame3frame4 目前設定:阻塞 24.0 ms
拖曳上方圓點看不同阻塞時長的效果。
紅色叉叉代表main thread那個位置的frame被阻塞吃掉;compositor那排永遠是實心圓點——不管main thread被鎖多久,最後合成上螢幕的那一步都不受影響。

兩條lane放在一起看,能回答一個實際的問題:這段task值得搬去worker嗎?如果工作本身就是純運算——parse一份大JSON、跑一個排序、算一個hash——搬到worker幾乎沒有代價,main thread連等都不用等。如果工作牽涉DOM讀寫,worker沒辦法碰DOM,那就只剩前面四種「留在main thread、但切得更聰明」的手法可以用。

如果要在四種手法裡先選一個下手,yielding大概是報酬率最高的起點——它不需要重新設計資料流,也不用處理worker跟main thread之間搬資料的複雜度,單純把一段太長的task切開。至於要用setTimeout還是scheduler.yield(),看瀏覽器支援度:能用scheduler.yield()就優先用,因為它的prioritized continuation讓恢復的時機更可控;退回setTimeout也不是錯,只是要接受多一點不確定性。

把整篇文章走一遍:main thread只有一條,一個task開始跑就得跑到結束;60Hz螢幕留給開發者的窗口是16.6ms,實際能用的更接近10ms,超過50ms業界就叫它long task。四種留在main thread上的手法——yield、batching、prioritization、deferring——處理的是「怎麼安排」;worker跟compositor thread處理的是「要不要乾脆搬走」。demo裡那顆停住的方塊跟那顆繼續動的圓點,就是這兩種選擇並排放在一起的樣子。

Take-away:主執行緒只有一條,這條thread同時管你的程式碼跟畫面;一幀真正能用的時間不到16.6ms,實際更接近10ms。你能做的從來不是讓工作變快,而是決定它什麼時候讓出、讓給誰、要不要乾脆搬到別的地方跑。