具代表性的面試主題

前端面試:JavaScript 事件迴圈如何運作?

前端困難
OfferCC 編輯團隊發佈 更新

題幹

一段點擊處理器同時安排 Promise、queueMicrotask、setTimeout 與大量遞迴微任務。請預測日誌順序,解釋為什麼計時器、使用者輸入與頁面繪製會延遲,並改造計算流程以維持頁面回應。

題幹與適用場景

你在一個資料儀表板中處理使用者點擊。下面的程式先用來考察執行順序:

javascript
console.log("A");

setTimeout(() => console.log("timeout"), 0);

Promise.resolve().then(() => {
  console.log("promise");
  queueMicrotask(() => console.log("nested"));
});

queueMicrotask(() => console.log("microtask"));

console.log("B");

請回答日誌順序,並解釋每一步為什麼發生。接著看效能問題:

javascript
let remaining = 100_000;

function continueInMicrotask() {
  remaining -= 1;
  if (remaining > 0) {
    queueMicrotask(continueInMicrotask);
  }
}

queueMicrotask(continueInMicrotask);
setTimeout(() => console.log("timer can run"), 0);
requestAnimationFrame(() => console.log("frame can render"));

說明為什麼計時器、後續點擊與繪製都會被延後,並將這段批次處理改成能保持回應的實作。這裡討論瀏覽器主執行緒上的 JavaScript;Worker 有自己的事件迴圈,Node.js 的階段模型也不應直接套用到瀏覽器。

這道題適合中高階前端、Web 效能與全端職缺。強回答需要把執行模型、程式推導與使用者體驗連起來:工作最後跑完,不代表頁面在過程中仍可互動。

面試官考察點

第一個訊號是模型準確。初始腳本、點擊回呼與到期的計時器會形成 task。Promise reaction、queueMicrotask() 回呼與 MutationObserver 回呼使用 microtask。一個 task 結束後,事件迴圈執行 microtask checkpoint,持續處理微任務,直到佇列清空。

第二個訊號是候選人不把瀏覽器描述成永遠只有一條「巨集任務佇列」。HTML 標準允許不同 task source 關聯到不同 task queue,瀏覽器可以在可執行佇列間做實作相關的選擇,同時維持同一 task source 內的順序。面試中可以使用「巨集任務」協助溝通,但應說明更準確的標準術語是 task。

第三個訊號是能解釋渲染邊界。microtask checkpoint 結束後,瀏覽器才有機會處理其他 task 或更新渲染;渲染不是每個 task 後必然發生。只要程式持續把新微任務放回佇列,輸入事件、計時器與渲染機會就可能一直等不到。

第四個訊號是工具選擇正確。await Promise.resolve() 只把後續程式放進 microtask,無法讓後續 task 或繪製先執行。真正的主執行緒讓步需要把繼續執行安排到未來的 task,例如支援時使用 scheduler.yield(),或以 setTimeout() 作為相容性回退。無法安全切片的重 CPU 工作更適合 Worker。

回答前需要澄清的問題

  • 目標環境包含哪些瀏覽器? scheduler.yield() 尚未涵蓋所有常用瀏覽器。需要廣泛相容時必須做能力偵測與回退。
  • 工作是可切片的迴圈,還是一次呼叫就可能阻塞很久? 可切片迴圈可以分批讓步;單筆資料的處理本身就很重時,主執行緒分批仍無法避免長阻塞,應考慮 Worker 或更換演算法。
  • 進度更新必須每批繪製,還是只需要最終結果? 需要可見進度時,每批更新狀態後要讓出主執行緒;只需要最終結果時可以減少 DOM 更新,降低版面配置與繪製成本。
  • 工作結果是否必須嚴格依序提交? Worker 可以平行計算,但亂序完成時需要序號、合併規則或順序提交緩衝區。
  • 頁面在背景分頁還要繼續處理嗎? requestAnimationFrame() 在多數背景分頁會暫停,因此不適合作為必須持續執行的通用批次排程器。
  • 回應目標如何驗證? 先約定互動延遲、單批預算、總吞吐量與取消行為,否則「不卡」無法轉成可測試條件。

30 秒回答框架

「瀏覽器事件迴圈每次選擇一個可執行的 task,task 結束後做 microtask checkpoint,並把微任務一直清空;之後瀏覽器才可能處理渲染或下一個 task。第一段程式先同步輸出 A、B,接著依入列順序輸出 promise、microtask,promise 中新增的 nested 排在既有微任務後面,最後才是 timeout。遞迴微任務會讓 checkpoint 長時間無法結束,所以計時器、輸入與繪製都被餓死。await Promise.resolve() 仍是微任務,不算真正讓出主執行緒。我會把可切片工作分批,並在批次之間使用能力偵測後的 scheduler.yield(),不支援時回退到 setTimeout();單筆計算仍然很重時移到 Worker,再用效能面板與真實輸入驗證互動延遲。」

分步驟深入解答

第一步:從入列時刻推導日誌順序

整段腳本本身正在一個 task 中執行。同步陳述式不會等待事件迴圈,因此先輸出:

text
A
B

setTimeout(..., 0) 表示計時條件滿足後,其回呼可以成為未來的 task;0 不表示立刻中斷目前腳本。Promise.resolve().then(...) 先把 Promise reaction 放入 microtask 佇列,後面的 queueMicrotask(...) 再放入第二個微任務。

目前腳本結束後開始 microtask checkpoint。第一個微任務輸出 promise,並把 nested 加到微任務佇列尾端。此時原本排在後面的明確微任務仍先執行,所以接著輸出 microtask,然後才輸出 nested。微任務佇列清空後,計時器 task 才有機會執行:

text
A
B
promise
microtask
nested
timeout

推導時應記錄「何時入列」與「進入哪個排程類別」,不要只背誦「微任務優先」。如果兩個回呼根本還沒入列,只憑 API 名稱不能決定先後。

第二步:建立 task、microtask 與渲染的邊界

可重複使用的簡化流程是:

  1. 瀏覽器從某個可執行的 task queue 選擇一個 task。
  2. 執行該 task,直到 JavaScript 呼叫堆疊清空。
  3. 執行 microtask checkpoint;期間新增的微任務也繼續在本次 checkpoint 中處理。
  4. 瀏覽器依照是否存在渲染機會、頁面可見性與實作策略,決定是否更新渲染。
  5. 進入後續迴圈,處理其他 task。

這解釋了兩個常見現象。第一,在一個點擊回呼裡修改 DOM 後立刻做大量同步計算,使用者通常看不到中間狀態,因為瀏覽器還沒取得繪製機會。第二,微任務雖然適合在其他事件與計時器前完成短小的一致性工作,卻不適合承載無限或大規模遞迴計算。

第三步:解釋微任務飢餓

第二段程式的第一個微任務每次執行都會再加入一個微任務。事件迴圈的 checkpoint 要等佇列清空才能結束,因此 100,000 次呼叫都處理完之前,不會進入計時器 task,也沒有正常機會處理下一次點擊。

如果遞迴沒有終止條件,微任務佇列在模型上永遠無法清空。瀏覽器可能最後顯示無回應、終止頁面或受到實作保護,但不能依賴這些行為恢復可用性。requestAnimationFrame() 也不會搶占正在執行的 JavaScript;它只是請求瀏覽器在下一次繪製前呼叫回呼。主執行緒沒有到達對應渲染步驟時,回呼只能等待。

下面的「讓步」仍然錯誤:

javascript
async function processAll(records) {
  for (const record of records) {
    normalize(record);
    await Promise.resolve();
  }
}

每次 await 的 continuation 仍透過 Promise microtask 恢復。呼叫堆疊短暫清空了,但 checkpoint 會繼續消耗這些微任務,後續輸入與計時器仍無法進來。

第四步:把可切片工作拆到多個 task

先給每批工作一個可測量的時間預算,再在批次之間真正讓出主執行緒:

javascript
function yieldToMain() {
  return globalThis.scheduler?.yield
    ? globalThis.scheduler.yield()
    : new Promise((resolve) => setTimeout(resolve, 0));
}

async function processRecords(records, budgetMs = 5) {
  let index = 0;

  while (index < records.length) {
    const deadline = performance.now() + budgetMs;

    while (index < records.length && performance.now() < deadline) {
      normalize(records[index]);
      index += 1;
    }

    updateProgress(index / records.length);

    if (index < records.length) {
      await yieldToMain();
    }
  }
}

這裡的 5 毫秒只是本題的初始假設,不是跨裝置標準。應依目標裝置、單筆成本、互動延遲與吞吐量測量後調整。scheduler.yield() 會把 continuation 安排成後續的優先 task,並允許瀏覽器先處理必要工作;由於相容性有限,範例做了能力偵測。setTimeout() 回退更通用,但計時器會受到巢狀節流、背景策略與其他 task 競爭影響。

這段實作還有一個邊界:時間預算只能在兩次 normalize() 之間檢查。如果單次呼叫就阻塞 80 毫秒,設定 5 毫秒預算也無效。此時要拆分 normalize()、更換演算法,或把計算移到 Worker。

第五步:依工作類型選擇排程工具

需求選擇主要代價或邊界
在目前 task 結束後、其他事件前做短小清理或一致性通知queueMicrotask()遞迴或重計算會餓死其他工作
將主執行緒長工作分成多批並保留 continuation 優先順序scheduler.yield()需要能力偵測,相容性尚不完整
廣泛相容地把繼續執行放到未來 tasksetTimeout()有計時器延遲與排程不確定性
在下一次繪製前更新動畫狀態requestAnimationFrame()回呼內的重計算仍會阻塞繪製,背景頁面常暫停
執行無法安全切片的 CPU 密集計算Web Worker有訊息傳遞、複製或共享記憶體協定成本

requestAnimationFrame() 的職責是對齊視覺更新,不是通用的背景工作佇列。可以在回呼中提交輕量 DOM 變更,但把大量計算塞進回呼只會錯過目前影格。Worker 則把 CPU 工作移出頁面主執行緒;它不會自動解決取消、進度、結果順序與資料傳輸成本,需要明確協定。

第六步:驗證回應性而非只驗證最終結果

驗證至少涵蓋四層:

  1. 順序測試: 在最小範例中記錄同步、Promise、queueMicrotask() 與計時器日誌,確認推導與瀏覽器行為一致。
  2. 時間軸檢查: 使用瀏覽器效能面板錄製點擊、批次處理與進度繪製,檢查長 task、連續微任務、影格間隔與輸入回呼何時得到執行。
  3. 壓力與取消: 使用低效能裝置或 CPU 降速,增加資料筆數,在處理中連續點擊、捲動並觸發取消,確認佇列不會無限成長。
  4. 邊界環境: 在不支援 scheduler.yield() 的瀏覽器驗證回退;切換背景分頁,確認業務沒有錯誤依賴 requestAnimationFrame() 持續執行。

驗收需要同時看總完成時間與互動延遲。分批會增加排程成本,可能讓總耗時略增;它的價值是替輸入、繪製與其他必要 task 留出執行窗口。若吞吐量下降不可接受,再使用 Worker 或演算法最佳化,而不是重新用微任務把主執行緒占滿。

高品質示範回答

「我會先按入列時刻推導。目前腳本是一個 task,所以同步輸出 A、B。計時器只安排未來 task。Promise 的 then 先進入微任務佇列,明確的 queueMicrotask 後進入,因此 checkpoint 先輸出 promise。promise 回呼裡新增的 nested 會加入佇列尾端,排在已經等待的 microtask 後面,所以最終順序是 A、B、promise、microtask、nested、timeout。

瀏覽器在一個 task 結束後會執行 microtask checkpoint,而且要持續處理到微任務佇列清空。第二段程式不斷補充微任務,導致 checkpoint 長時間無法結束。計時器與點擊屬於後續 task,繪製也要等主執行緒進入渲染機會,因此它們都會延遲。requestAnimationFrame 不會搶占目前 JavaScript,await Promise.resolve 也只恢復到另一個微任務,所以兩者都不能修復這種飢餓。

如果每筆資料可以快速處理,我會依時間預算分批,在批次之間使用 scheduler.yield;由於它不是所有瀏覽器都可用,我會做能力偵測並以 setTimeout 回退。預算從例如 5 毫秒開始只是實驗值,要在目標裝置上依輸入延遲與吞吐量調整。如果單筆計算本身就很長,我會拆開它或放進 Worker。

最後我會錄製效能時間軸,確認微任務沒有形成連續瀑布、進度能實際繪製、點擊與捲動在處理中仍能執行,並測試取消、背景分頁與相容性回退。這個方案允許總耗時增加少量排程成本,但換來可測量的頁面回應性。」

常見錯誤

  • 只背「同步、微任務、巨集任務」 → 無法解釋巢狀微任務為何排在既有微任務之後,也忽略多個 task source → 逐行標記入列時刻、類別與佇列現況。
  • 把 setTimeout(..., 0) 說成立即執行 → 計時結束只讓回呼有資格成為未來 task,不能搶占目前 task 與 microtask checkpoint → 描述最早可執行條件,不承諾精確時間。
  • 認為每個微任務後都會繪製 → checkpoint 會連續清空微任務,渲染機會在其後且可能被瀏覽器跳過 → 把繪製放在完整 checkpoint 之後討論。
  • 用 await Promise.resolve() 分批 → Promise continuation 仍是微任務,無法讓後續輸入與計時器先執行 → 讓 continuation 進入未來 task。
  • 用無限 queueMicrotask() 維持「高優先順序」 → 它會讓其他 task 與渲染飢餓 → 微任務只承載短小、有限的一致性工作。
  • 把重計算移入 requestAnimationFrame() → 回呼仍在主執行緒且發生於繪製前,重計算會直接延後該影格 → rAF 只做輕量視覺更新,重 CPU 工作切片或移到 Worker。
  • 無條件呼叫 scheduler.yield() → 部分常用瀏覽器不支援 → 先能力偵測,並驗證 setTimeout() 回退。
  • 只測總耗時 → 總耗時正常也可能存在長時間輸入無回應 → 同時觀察互動延遲、長 task、影格與佇列成長。
  • 固定一個批次大小套用所有裝置 → 單筆成本、更新率與裝置效能會變動 → 使用時間預算作為起點,並在目標環境量測調整。

追問及應對

追問一:兩個 setTimeout(..., 0) 與一次點擊,誰先執行?

不能只憑 API 名稱給出跨來源的絕對順序。瀏覽器可以維護多個 task queue,並在可執行佇列間選擇;同一 task source 內應維持順序。應說明計時器何時到期、點擊何時發生、頁面是否被其他 task 占用,再對具體實作觀測。面試中的確定性程式題通常會控制這些條件。

追問二:為什麼 await scheduler.yield() 回傳 Promise,卻能讓出主執行緒?

關鍵不在回傳值型別,而在 API 如何安排 Promise 的完成。一般已完成 Promise 的 continuation 進入目前 checkpoint 的 microtask;scheduler.yield() 會安排後續的優先 task 來完成 continuation。在它恢復之前,瀏覽器有機會處理等待中的輸入。仍需能力偵測,並避免把每個微小操作都切成獨立 task。

追問三:把 100,000 筆資料放進 Worker 後還會卡嗎?

主執行緒不再承擔計算,但仍可能因訊息過密、巨大資料複製、頻繁 DOM 更新或主執行緒合併結果而卡頓。應批次傳送進度,控制訊息頻率,評估 transferable object 或共享記憶體的適用性,並讓主執行緒每次只提交必要的視覺變更。

追問四:什麼時候應該使用 queueMicrotask()?

適合在目前同步邏輯結束後、其他事件前執行短小且有限的工作,例如統一同步命中與 Promise 命中兩條路徑的回呼順序,或在函式庫內部批次提交一次狀態通知。它不適合作為長任務排程器。回答時應同時給出終止條件與工作量上限。

追問五:如何支援取消正在進行的分批任務?

在每批開始與讓步恢復後檢查 AbortSignal,停止繼續取得資料,並確保舊任務不能覆蓋新請求的進度或結果。Worker 方案要定義取消訊息與遲到結果的丟棄規則。取消檢查太少會回應慢,放在每個極小操作中又會增加成本,需要依批次量測。

追問六:requestAnimationFrame() 能保證呼叫後立刻繪製一次嗎?

不能。它請求在下一次繪製前呼叫回呼,瀏覽器仍可依頁面可見性與渲染策略安排或跳過更新;多數瀏覽器在背景分頁會暫停回呼。即使回呼執行,回呼中的長計算也會繼續延後真正的繪製。

公開來源

同類題目