節省符號
Learn Debounce & Throttle

從動圖輕鬆解題:防抖與節流

@ 實作
  • # 中等題

遇見問題痛點

使用者與網頁互動的方式有非常多種,像按鈕、文字框、拖拉內容、畫面滾動……等,監聽過程中會反覆的觸發相似的事件,因此需要特別留意用戶的輸入頻率,過度頻繁的觸發事件將可能會導致:

  • 網頁效能低下
  • 伺服器負擔加大

以實際案例來說,假設畫面上有一個可輸入的文字框,每次輸入就會以文字框內的文字內容送出請求去索取第三方資源:

input.addEventListener('input', (e) => {
fetch(`/api/getOptions?query=${e.target.value}`);
});

了解問題痛點後,讓我們來看看常見的解決方案 —— 防抖與節流。這兩項方法綜合了非同步程式、閉包🔗其餘參數🔗 等進階觀念,除了是實際常見的問題之外,面試時常會出現的題目。

防抖 Debounce

以日常生活為例:像是自動門,當有一群人排隊等待進入門內時,並不會每有個人進入就執行一次「關門」的動作,而是每次有人進門就刷新關門的時間,直到沒人進門時過一段時間就會自動關門,如此一來就節省了大量的「關門與開門的動作」。

實際一點的例子:舉前面輸入框搜尋的例子,當用戶輸入時並不用急著馬上提交請求,而是啟動一個計時器,時間到了再提交最新的請求即可,如果在等待的過程中用戶有輸入的動作就重新計時。

防抖第一步:描述程序流程

撰寫一個 debounce 函式,有兩個參數分別是 等待被呼叫的回呼函式等待時間 (預設 1000 毫秒),並事先創造 timeout 變數用於存儲計時器,當被呼叫時:

  1. 停止目前執行中的計時器
  2. 啟動新的計時器
  3. 當計時器完成計時,執行 回呼函式 。

防抖第二步:創立函式與閉包

創造一個 debounce 函式,每當呼叫時就會回傳一個匿名函式,並使用閉包 (Closure) 的特性讓 timeout 變數可以與每個防抖函式共享。

function debounce(callback, delay = 1000) {
let timeout;
return (...args) => {};
}

防抖第三步:製作計時器

當每次呼叫 debounce 創建全新的計時器之前,如果有計時器就先刪除上次的計時,並指定在指定延遲的時間之後呼叫回呼函式。

function debounce(callback, delay = 250) {
let timeout;
return (...args) => {
if (timerId) {
clearTimeout(timerId);
}
timeout = setTimeout(() => {
callback(...args);
}, delay);
};
}

節流 Throttle

以日常生活為例:超級英雄變身有冷卻時間,變身這個功能在啟用過後一段時間才能再次使用,這期間無論如何嘗試想要變身都無法成功。

實際一點的例子:當網頁滑到底部時希望顯示或加載一些新內容,因此使用節流來限制事件觸發的數量。

節流第一步:描述程序流程

撰寫一個 throttle 函式,兩個參數分別是 等待被呼叫的回呼函式等待時間 (預設 1000 毫秒),並事先:

  • 創造 throttleTimeout 變數存儲節流計時器
  • 創造 callbackArgs 變數存儲回呼函式的參數

當閉包被執行時:

  • 將回呼函式中的參數記錄到 callbackArgs 中,才可以在最後一次輸入時使用
  • 如果節流計時器存在,離開函式
  • 執行回呼函式 (帶入最新的參數)
  • 啟動節流計時器於 throttleTimeout

當節流計時器結束時:

  • 取消 throttleTimeout 的內容,表示已無計時
  • 再次呼叫一次回呼函式(帶入最新的參數)

節流第二步:創立函式與閉包

創造一個 throttle 函式,每當呼叫時就會回傳一個匿名函式,並使用閉包 (Closure) 的特性讓 throttleTimeoutcallbackArgs 變數可以與每個節流函式共享。

function throttle(callback, delay = 1000) {
let throttleTimeout = null;
let callbackArgs = null;
return (...args) => {};
}

節流第三步:當閉包被執行時

callbackArgs = args;

首先儲存最新的回呼參數,這樣才能在計時器結束時檢查是否還有未執行的更新觸發的回呼函式存在,如果有就再執行一次整個回呼函式。

再來當計時器不存在時就直接離開整個函式。

if (throttleTimeout) {
return;
}

緊接著執行一次回呼函式的內容。

callback(...callbackArgs);

因為這項回呼函式執行過了,所以取消閉包外的紀錄,改為 null (無)。

callbackArgs = null;

節流第三步:製作計時器

來到最重要的計時器部分:當計時器結束就取消其記錄,檢查是否還有未執行的回呼參數存在,如果有就再執行一次整個回呼函式

throttleTimeout = setTimeout(() => {
throttleTimeout = null;
if (callbackArgs) {
callback(...callbackArgs);
}
}, delay);

以下是最終成果。

function throttle(callback, delay = 1000) {
// 創造一個閉包使以下變數可被所有節流事件共享
// 變數一:節流計時器
// 變數二:最近一次發生的事件
let throttleTimeout = null;
let callbackArgs = null;
// 回傳一個匿名函式(回呼事件處理),輸入回呼的參數
return (...args) => {
// 每次迭代中存儲當下最新的事件
callbackArgs = args;
// 當節流計時器正在執行:離開函式
if (throttleTimeout) {
return;
}
// 當節流計時器沒有在執行:執行回呼函式並且啟動節流計時器
// 執行回呼函式(使用已記錄的最新事件)
callback(...callbackArgs);
// 清空已記錄的最新事件
callbackArgs = null;
// 創造一個新的節流計時器,紀錄於 throttleTimeout 中,在啟動期間擋下傳入的函式被執行
// 當計時結束,如果紀錄中有最新事件就再重新執行一次
throttleTimeout = setTimeout(() => {
// 當計時結束,清空 throttleTimeout 讓傳入的函式可被執行
throttleTimeout = null;
// 當計時器結束,檢查是否還有未執行的事件,如果有就再執行一次整個回呼函式
if (callbackArgs) {
callback(...callbackArgs);
}
}, delay);
};
}

總結

防抖統整許多動作為一;節流保持動作在一定的頻率內。

實際範例可以參考看看我在六角學院 2022 秋季 JS 直播班所出的題目: 從動圖輕鬆解題:防抖與節流

參考資料