blog.kwchang.dev
Steven’s Digital Garden

記錄軟體開發、拆解技術原理,透過互動實驗加深理解。

圖例
常綠文章
幼苗筆記
閱讀與運動

debounce:等你停下來,再做事

全程 988 字約 2 分鐘技術・常綠文章

做搜尋框的時候,很常寫出這種程式:使用者每打一個字,就送一次 API 去拿搜尋建議。打「debounce」八個字母,就送了八次 request,但真正有用的只有最後一次。

debounce 就是用來解決這件事的:等使用者停下來,再做事。

先玩玩看

在下面的框框裡打字,或按「自動打字」。上面那條是每次按鍵,下面那條是 debounce 之後真正送出的時間點。

按鍵 0 次送出 0 次省下 0 次送出的內容:(還沒送出)

你會看到幾件事:

  • 每按一次鍵,下面就開始畫一條虛線,代表「開始等」。
  • 等還沒結束又按了一次鍵,那條虛線就被切斷,變成灰色,重新開始等。
  • 只有一條虛線完整撐滿等待時間,才會出現一個圓點,也就是真的送出。

把等待時間拉長再試一次,會發現打字稍微停頓一下也不會送出;拉短的話,打慢一點就會多送幾次。

它在做什麼

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:

  1. clearTimeout(timer):把上一次還沒跑完的計時器取消,就是 demo 裡虛線被切斷的那一刻。
  2. setTimeout(...):重新開始等 wait 毫秒。
  3. 等完都沒人打斷,才呼叫 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。