題目與適用情境
替瀏覽器端程式實作 debounce(fn, wait, options) 與 throttle(fn, wait, options)。回傳函式要保留最後一次呼叫的參數和 this,回傳最近一次真正執行的結果,並提供 cancel() 與 flush()。選項包括 leading、trailing,debounce 另支援 maxWait。
預設 debounce 只在 trailing 邊緣執行。leading 表示一輪連續呼叫開始時立即執行。如果 leading 與 trailing 同時開啟,一次孤立呼叫只在 leading 邊緣執行一次;等待期間出現第二次呼叫,才會用最新參數補一次 trailing 執行。maxWait 防止連續事件永遠延後工作。throttle 則保證每個 wait 長度的區間內最多真正執行一次,也支援 leading 與 trailing。
這題歸為 frontend,因為契約來自輸入、捲動、視窗調整、繪製與元件生命週期。工具也能在 Node.js 執行,但核心能力仍是把 UI 時間語意轉成小型狀態機。實作使用 JavaScript,額外空間為 O(1)。
面試官評估重點
第一個訊號是先定義契約再寫計時器。「debounce 等待、throttle 限頻」沒有交代 leading 行為、單一 leading 呼叫是否還要 trailing、哪一次參數生效,以及清理的意義。好的回答會先寫呼叫時間軸,再明確選擇。
第二個訊號是狀態推理。加入 maxWait、時鐘回撥、回傳值和 flush() 後,只有一個計時器變數並不足夠。實作需要最後呼叫時間、最後真正執行時間、待處理參數與接收者、計時器代號和最近結果;每一項都要對應一條契約。
第三個訊號是瀏覽器判斷。計時器只保證最早執行時間,不保證精確截止點;長任務和背景分頁限速會讓回呼變晚。用 requestAnimationFrame() 處理捲動可以對齊繪製,但它本身不一定降低事件頻率。有時 scrollend、IntersectionObserver 或框架清理 hook 能直接取代通用計時工具。
最後要看測試,而非用肉眼判斷。依賴真實等待的測試既慢又容易抖動。好的回答會替換時間與計時器、推進假時鐘,並對邊界、取消、flush 和連續輸入的精確呼叫序列下斷言。
回答前要釐清的問題
- 預設選項是什麼? 本文使用 debounce
{ leading: false, trailing: true },throttle{ leading: true, trailing: true }。預設值不同,孤立呼叫與測試結果也不同。 - 兩個邊緣都開啟時怎麼處理? 一次孤立呼叫只執行 leading;等待期間又有呼叫時才執行 trailing,避免單次操作被處理兩次。
- 延遲執行使用哪次參數與接收者? 使用最後一筆待處理呼叫的參數與
this。保留第一筆輸入會讓搜尋建議或尺寸計算讀到舊值。 - 輸入會不會一直持續? 若會,純 trailing debounce 可能永遠不執行;
maxWait從上一次真正執行或目前 burst 開始計算最大延後時間。 cancel()與flush()的契約是什麼? cancel 移除待處理工作並重設一輪呼叫狀態;flush 立即執行符合條件的 trailing 呼叫並回傳結果,後續不能重複執行。- 是否要求精確毫秒? 瀏覽器計時器無法保證精確排程。契約約束最早可執行時間與順序,測試用假時鐘排除排程雜訊。
30 秒回答架構
「我會先用時間軸定義 leading 和 trailing。debounce 把連續呼叫歸成一組,通常在最後一次呼叫後安靜 wait 毫秒才執行;throttle 則要在連續輸入期間週期性推進。我會保存最新參數與接收者、最後呼叫時間、最後真正執行時間、一個計時器和最近結果。
每次呼叫先判斷現在能否執行,否則安排剩餘等待。計時器觸發時重新判斷,因為後來的呼叫可能移動 trailing 截止點。maxWait 防止一直飢餓;把 maxWait 固定成 wait,就能重用同一個狀態機實作 throttle。cancel() 清理待處理狀態,flush() 最多立即補一次 trailing。測試用假時鐘,重點涵蓋邊界時刻、leading 加 trailing、連續輸入、取消、flush 與元件卸載。」
分步深入解答
第一步:把文字轉成時間軸
假設 wait = 100 毫秒,呼叫為 A@0、B@40、C@90、D@220。純 trailing debounce 會得到 C@190 與 D@320:每次呼叫都會移動安靜期截止點,最新參數勝出。leading 與 trailing 同時開啟時得到 A@0、C@190、D@220;D 是孤立呼叫,所以 320 毫秒不會再重複一次。
leading 與 trailing 同時開啟的 throttle,在每個 100 毫秒視窗內最多執行一次,同時保留視窗結束時最新的待處理值。第一輪立即執行 A,再於 trailing 邊界執行最新的 C。真實瀏覽器的執行時間可能晚於概念邊界,但不會早於它。
這條時間軸直接顯示狀態轉換:閒置時的呼叫開啟 burst;後續呼叫替換待處理資料;計時器到期後執行或重新安排;真正執行會清除待處理資料,但保留回傳結果。先寫這些轉換,可以避免大多數邊界與重複 trailing 錯誤。
第二步:實作一個明確的狀態機
以下實作遵守上述契約。shouldInvoke() 處理首次呼叫、安靜期邊界、時鐘回撥與 maxWait;remainingWait() 從 trailing 截止點與最大等待截止點中選擇較早者。
function debounce(fn, wait, options = {}) {
wait = Math.max(0, Number(wait) || 0);
const leading = options.leading === true;
const trailing = options.trailing !== false;
const hasMaxWait = Number.isFinite(options.maxWait);
const maxWait = hasMaxWait
? Math.max(wait, options.maxWait)
: 0;
let timerId;
let lastArgs;
let lastThis;
let lastCallTime;
let lastInvokeTime = 0;
let result;
function invoke(time) {
const args = lastArgs;
const receiver = lastThis;
lastArgs = undefined;
lastThis = undefined;
lastInvokeTime = time;
result = fn.apply(receiver, args);
return result;
}
function shouldInvoke(time) {
const sinceCall = time - lastCallTime;
const sinceInvoke = time - lastInvokeTime;
return lastCallTime === undefined
|| sinceCall >= wait
|| sinceCall < 0
|| (hasMaxWait && sinceInvoke >= maxWait);
}
function remainingWait(time) {
const sinceCall = time - lastCallTime;
const trailingWait = wait - sinceCall;
if (!hasMaxWait) return trailingWait;
const sinceInvoke = time - lastInvokeTime;
return Math.min(trailingWait, maxWait - sinceInvoke);
}
function trailingEdge(time) {
timerId = undefined;
if (trailing && lastArgs) return invoke(time);
lastArgs = undefined;
lastThis = undefined;
return result;
}
function timerExpired() {
const time = Date.now();
if (shouldInvoke(time)) return trailingEdge(time);
timerId = setTimeout(timerExpired, remainingWait(time));
}
function leadingEdge(time) {
lastInvokeTime = time;
timerId = setTimeout(timerExpired, wait);
return leading ? invoke(time) : result;
}
function cancel() {
if (timerId !== undefined) clearTimeout(timerId);
timerId = undefined;
lastArgs = undefined;
lastThis = undefined;
lastCallTime = undefined;
lastInvokeTime = 0;
}
function flush() {
if (timerId === undefined) return result;
clearTimeout(timerId);
return trailingEdge(Date.now());
}
function debounced(...args) {
const time = Date.now();
const invokeNow = shouldInvoke(time);
lastArgs = args;
lastThis = this;
lastCallTime = time;
if (invokeNow) {
if (timerId === undefined) return leadingEdge(time);
if (hasMaxWait) {
clearTimeout(timerId);
timerId = setTimeout(timerExpired, wait);
return invoke(time);
}
}
if (timerId === undefined) {
timerId = setTimeout(timerExpired, wait);
}
return result;
}
debounced.cancel = cancel;
debounced.flush = flush;
return debounced;
}
function throttle(fn, wait, options = {}) {
return debounce(fn, wait, {
leading: options.leading !== false,
trailing: options.trailing !== false,
maxWait: wait,
});
}狀態數量為 O(1),每次包裝函式呼叫執行 O(1) 工作;被包裝回呼本身的成本不計入工具複雜度。正式專案可以匯入成熟實作;面試價值在於能解釋與驗證契約,不是證明專案必須自行開發。
第三步:解釋計時器為何要重新判斷
假設首次呼叫安排了 100 毫秒計時器,第二次呼叫在 90 毫秒抵達。如果原計時器在 100 毫秒無條件執行,安靜期只有 10 毫秒。timerExpired() 會重新計算 sinceCall,發現還沒有安靜 100 毫秒,再安排剩餘 90 毫秒。
每個事件都清除並建立新計時器,是純 trailing debounce 的合理簡化實作。這裡的重新判斷狀態機值得保留,因為它還要統一支援 leading、maxWait、回傳值與 throttle。如果題目只要求基礎 debounce,應先寫較小的實作,再說明擴充需要哪些狀態。
第四步:用 maxWait 防止飢餓
搜尋建議通常可以等待輸入暫停,遙測緩衝或自動儲存不能在輸入持續時無限等待。設定 wait = 300 毫秒、maxWait = 1000 毫秒,即使每 100 毫秒都有呼叫,也會在每個約 1000 毫秒最大邊界至少執行一次;執行環境排程仍可能讓它稍晚。
maxWait 也連結了 debounce 與 throttle。令它等於 wait,連續呼叫就無法把真正執行延後超過一個區間。重用同一狀態機可以避免兩套計時邏輯在邊緣行為上偏離。這是實作選擇,不是 throttle 唯一合法的定義;最後仍以題目契約與測試為準。
第五步:處理生命週期與副作用
延遲工作可能比發起它的 UI 活得更久。元件清理時要呼叫 cancel(),防止舊回呼在卸載後更新狀態、讀取舊 props,或在換頁後發出請求。如果產品要求離開前提交尚未儲存的文字,要明確呼叫 flush() 再清理;不能讓每次卸載都暗中送出資料。
替非同步搜尋做 debounce 只限制請求建立,不能保證回應順序。請求一旦送出,較慢的舊回應仍可能覆蓋新結果。還要搭配 AbortController、請求世代或「只接受最新回應」檢查。限頻與避免陳舊回應解決的是兩種不同故障。
第六步:依真正工作選擇瀏覽器原語
只有穩定後的值有意義時用 debounce,例如輸入暫停後驗證。中間進度也有意義時用 throttle,例如週期性取樣指標或捲動狀態。視覺寫入需要對齊繪製時用 requestAnimationFrame(),但不能聲稱它會自動降低捲動事件頻率。需求是門檻可見性時用 IntersectionObserver,明確需要捲動結束事件時考慮 scrollend。
選擇規則來自行為:丟棄中間狀態、保留週期進度、對齊繪製,或觀察瀏覽器定義的門檻。依習慣選擇可能產生無效工作,也可能隱藏使用者可見更新。
第七步:用虛擬時間驗證
替換 Date.now、setTimeout 與 clearTimeout,或啟用測試框架的假計時器。記錄參數與虛擬時間,推進到截止點前一刻和截止點本身。不要斷言真實的 100 毫秒計時器一定在第 100 毫秒精確執行。
最小測試矩陣包括:純 trailing burst、純 leading burst、單次與多次呼叫下的 leading 加 trailing、連續輸入下的 maxWait、恰好位於 wait 邊界的呼叫、最新參數與接收者、回傳值重用、截止前 cancel、截止前 flush、重複 flush、零等待,以及回呼內再次呼叫包裝函式。UI 整合還要涵蓋元件清理與亂序網路回應。
高品質示範回答
「我會先定義時間軸再寫程式。100 毫秒的 trailing debounce 收到 0、40、90 毫秒三次呼叫,會在 190 毫秒之後第一個可執行時刻,用最後參數執行一次。leading 加 trailing 會在 0 和 190 毫秒執行,但一次孤立的 leading 呼叫不會在 trailing 再重複。
我保留一個計時器、待處理參數與接收者、最後呼叫時間、最後真正執行時間和最近結果。計時器到期不能直接假設自己仍可執行,因為新呼叫可能移動安靜期截止點。maxWait 增加第二條截止線,讓連續輸入無法讓回呼一直飢餓;throttle 重用同一狀態機,把 maxWait 設為 wait。
實作會保留 this,回傳最近結果,cancel() 清除待處理狀態,flush() 最多補一次 trailing。元件清理時取消。搜尋情境還要另外取消請求或檢查請求世代,因為 debounce 無法阻止舊回應晚到。我用假時鐘與精確呼叫序列驗證這些語意,避免依賴可能延遲的瀏覽器計時器。」
常見錯誤
- 未定義邊緣行為就寫程式 → 不同合理實作會通過不同測試 → 先寫預設值與帶時間戳的呼叫序列。
- 保留第一次參數 → 延遲回呼處理舊輸入 → 每次呼叫都替換待處理參數與接收者。
- leading 後一定再 trailing → 一次點擊觸發兩次動作 → 只有區間內出現新的待處理呼叫才補 trailing。
- 持續重設卻沒有
maxWait→ 連續事件會讓自動儲存或批次工作永遠不執行 → 需要週期進度時加入最大截止線。 - 把計時器延遲當成精確時間 → 長任務與執行環境限速會破壞斷言 → 把它視為最早執行資格,並使用虛擬時間測試。
- 把
requestAnimationFrame()當通用 throttle → 它可能與捲動事件以相同頻率執行 → 把它用於繪製對齊,需要降頻時另外計算時間間隔。 - 忘記生命週期清理 → 換頁或卸載後仍執行延遲工作 → 清理時 cancel,只有產品契約要求時才明確 flush。
- 認為 debounce 能消除陳舊搜尋結果 → 已送出的請求仍可能亂序完成 → 搭配請求取消或最新請求檢查。
- 基礎題也直接寫完整狀態機 → 不必要程式碼擴大錯誤面 → 先實作最小契約,再說明擴充。
追問與應對
追問一:呼叫剛好發生在 wait 邊界會怎樣?
要先規定任務順序。在確定性測試中,如果舊計時器任務在相同虛擬時間先執行,舊 burst 可以完成 trailing,新呼叫再開啟下一輪;如果新呼叫先處理,它會在計時器重新判斷前更新待處理狀態。瀏覽器任務佇列不會讓同一時刻的外部事件自動具有原子性。測試要安排明確順序,實作則要在該順序下維持一致。
追問二:為什麼不用 setInterval 實作 throttle?
若沒有額外狀態,interval 在沒有待處理工作時仍會持續喚醒。leading、最後一筆 trailing、取消和閒置後重啟也更難推理。由真實呼叫啟動一次性計時器,可以明確控制下一條邊界。setInterval 在契約清楚時也能實作,但加入全部語意後並不會更簡單。
追問三:trailing 關閉時,flush 是否應該強制執行?
沒有符合條件的 trailing 工作,所以 flush() 只回傳最近一次真正執行的結果,不呼叫 fn。它與一般計時器到期共用同一條 trailing 判斷。如果產品需要「無視選項強制執行」,那是另一套 API,應使用不同名稱與測試。
追問四:如何測試系統時鐘回撥?
注入時鐘,把目前值移到小於 lastCallTime。sinceCall < 0 分支會把該狀態視為可執行,避免安排巨大或負數剩餘時間。能使用單調時鐘時應優先使用;防禦分支則避免依賴執行環境時鐘的工具永久卡住。
追問五:正式專案何時應直接使用 Lodash?
當其公開契約、打包方式與專案依賴政策吻合時,優先使用持續維護的實作。自行開發的工具要承擔相容性測試、審查與長期維護。面試中實作它是為了證明推理能力,不代表正式環境重複實作成熟依賴就是最佳選擇。