debounce:等你停下來,再做事
做搜尋框的時候,很常寫出這種程式:使用者每打一個字,就送一次 API 去拿搜尋建議。打「debounce」八個字母,就送了八次 request,但真正有用的只有最後一次。
debounce 就是用來解決這件事的:等使用者停下來,再做事。
先玩玩看
在下面的框框裡打字,或按「自動打字」。上面那條是每次按鍵,下面那條是 debounce 之後真正送出的時間點。
你會看到幾件事:
- 每按一次鍵,下面就開始畫一條虛線,代表「開始等」。
- 等還沒結束又按了一次鍵,那條虛線就被切斷,變成灰色,重新開始等。
- 只有一條虛線完整撐滿等待時間,才會出現一個圓點,也就是真的送出。
把等待時間拉長再試一次,會發現打字稍微停頓一下也不會送出;拉短的話,打慢一點就會多送幾次。
它在做什麼
debounce 的規則只有一條:每次被呼叫,就把計時器重新歸零。 計時器能順利跑完,才真的執行。
所以連續一直呼叫的時候,計時器一直被重設,什麼事都不會發生。等到呼叫停下來、安靜了一段時間,才執行最後那一次。
程式碼只有幾行
上面的 demo 用的就是這段:
export function debounce<A extends unknown[]>(fn: (...args: A) => void, wait: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
return (...args: A) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
重點在 timer 這個變數。它被包在 closure 裡,每次呼叫回傳的函式都會用到同一個 timer:
clearTimeout(timer):把上一次還沒跑完的計時器取消,就是 demo 裡虛線被切斷的那一刻。setTimeout(...):重新開始等wait毫秒。- 等完都沒人打斷,才呼叫
fn,而且帶的是最後一次的參數。
用起來像這樣:
const search = debounce((keyword: string) => {
fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
}, 300);
input.addEventListener('input', (e) => search(e.target.value));
什麼時候適合用
適合「只在乎最後結果」的情況:
- 搜尋框的即時建議
- 視窗 resize 完才重新計算版面
- 編輯器的自動儲存
如果需要的是「持續觸發,但最多每 N 毫秒一次」,例如捲動時更新進度條,那是 throttle 的工作。debounce 會一直等到停下來,捲動中途完全不會執行。
React 裡的一個坑
在 React component 裡直接這樣寫,debounce 會完全沒效果:
function SearchBox() {
const search = debounce(fetchSuggestions, 300); // 每次 render 都產生新的
return <input onChange={(e) => search(e.target.value)} />;
}
原因是每次 render 都會建立一個新的 debounce,裡面的 timer 也是新的,上一次的計時器根本不會被取消。要讓它跨 render 保持同一個,可以用 useMemo:
const search = useMemo(() => debounce(fetchSuggestions, 300), []);
上面的 demo 就是這樣寫的,只有等待時間改變時才換一個新的 debounce。