Debounce 的定義:「在事件連續觸發時,只在最後一次觸發後過了指定時間才執行。」
搜尋框輸入每個字元都打一次 API——用 debounce 讓使用者停止輸入 300ms 後才打。Log 寫入每次都觸發 flush——debounce 讓它合併成批量寫入。
這個功能在前端很常見,但後端同樣用得到(批量寫入、限制回呼頻率、合併重複事件)。
Debounce 的實作需要三樣東西:
- Closure:記住上一次的 timer
- Timer:延遲執行
- 並行控制:如果有多個 goroutine/thread,要確保 timer 的讀寫是安全的
JavaScript / Node.js
JavaScript 最自然——closure 和 timer 是語言的核心功能:
function debounce(fn, delay) {
let timer = null; // 閉包變數,記住 timer
return function(...args) {
if (timer) {
clearTimeout(timer); // 取消上一個 timer
}
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
};
}
// 使用
const debouncedSave = debounce((data) => {
console.log("saving:", data);
}, 300);
debouncedSave("a"); // 取消,因為 300ms 內又來了
debouncedSave("ab"); // 取消
debouncedSave("abc"); // 300ms 後執行為什麼在 Node.js 不需要鎖:Node.js 是單 thread 的 event loop,JavaScript 不會有兩個 callback 同時執行(concurrent 但不是 parallel),所以 timer 這個閉包變數不存在 race condition。
Python(asyncio 版)
Python 的 asyncio 版本,思路和 JavaScript 類似,但需要用 asyncio.create_task 和 asyncio.CancelledError:
import asyncio
from typing import Callable, Any
def debounce(fn: Callable, delay: float):
task: asyncio.Task | None = None
async def wrapper(*args: Any, **kwargs: Any) -> None:
nonlocal task
if task is not None:
task.cancel() # 取消上一個 task
async def delayed():
await asyncio.sleep(delay)
await fn(*args, **kwargs)
task = asyncio.create_task(delayed())
return wrapper
# 使用
async def save(data: str):
print(f"saving: {data}")
debounced_save = debounce(save, 0.3)
async def main():
await debounced_save("a")
await debounced_save("ab")
await debounced_save("abc") # 只有這個會在 300ms 後執行
await asyncio.sleep(0.5) # 等待執行完成
asyncio.run(main())asyncio 的單 thread 特性:和 JavaScript 一樣,asyncio 是協作式並行,task 的讀寫在同一個 event loop 裡,不需要鎖。
Go
Go 的版本更複雜,因為 goroutine 是真正的並行——多個 goroutine 可能同時呼叫 debounced function,timer 變數需要保護:
import (
"sync"
"time"
)
func Debounce(fn func(), delay time.Duration) func() {
var mu sync.Mutex
var timer *time.Timer
return func() {
mu.Lock()
defer mu.Unlock()
if timer != nil {
timer.Stop()
}
timer = time.AfterFunc(delay, func() {
fn()
mu.Lock()
timer = nil
mu.Unlock()
})
}
}
// 使用
save := func() {
fmt.Println("saving...")
}
debouncedSave := Debounce(save, 300*time.Millisecond)
// 從多個 goroutine 安全地呼叫
go debouncedSave()
go debouncedSave()
debouncedSave() // 只有最後一個在 300ms 後執行需要 mutex 的原因:
Go 的 goroutine 是 M:N scheduling,可以真正在多個 CPU 核心上平行執行。如果不加鎖:
Goroutine A: 讀 timer == nil
Goroutine B: 讀 timer == nil
Goroutine A: 建立新 timer,timer = timerA
Goroutine B: 建立新 timer,timer = timerB(覆蓋了 timerA,但 timerA 不會被 Stop!)
結果:timerA 和 timerB 都會在 300ms 後觸發,fn 執行了兩次。
三個實作的對照
| JavaScript | Python (asyncio) | Go | |
|---|---|---|---|
| Closure | let timer = null | nonlocal task | var timer *time.Timer |
| 取消舊 timer | clearTimeout | task.cancel() | timer.Stop() |
| 建新 timer | setTimeout | asyncio.create_task | time.AfterFunc |
| 需要鎖? | 不需要(單 thread) | 不需要(協作式) | 需要(真正並行) |
這三個實作揭示了 B01 前幾篇的核心差異:
- Closure:三個語言都用到,語法不同但概念相同(B01-09)
- Timer 的取消語意:
clearTimeoutvstask.cancel()vstimer.Stop()——Go 的Stop()不保證 callback 不執行(如果 timer 已經觸發),需要額外處理 - 並行模型決定了你是否需要鎖(B01-12):event loop 天生安全,goroutine 需要顯式鎖
Go 的 Timer 細節(值得注意)
time.AfterFunc 回傳的 *Timer,.Stop() 的語意是「嘗試停止」——如果 timer 已經觸發,.Stop() 返回 false,callback 可能已經在另一個 goroutine 裡跑了。
更嚴謹的 Go 實作需要用 channel 或 atomic 確保「停止和執行只有一個能發生」,但對大多數業務場景,上面的版本已經夠用,race window 極小。
這種細節差異,正是「理解模型」vs「只記語法」的分水嶺——知道 Go timer 的語意,你才知道為什麼官方文件說 Stop() 之後要額外判斷。