搜尋框綁 onInput 直接打 API,使用者打「debounce」這個字,你就打了 8 次 API。
更糟的是後面那段:8 個請求不保證照順序回來。第 3 個字的結果比第 8 個字晚到,畫面就會顯示成搜尋「deb」的結果——使用者明明已經打完了。你的 server 白扛 7 倍流量,還換來一個錯的畫面。
這就是 debounce 存在的原因:在事件連續觸發時,只在最後一次觸發後過了指定時間才執行。使用者停手 300 毫秒才打那一次 API,前面 7 次連送都不送。
這個功能在前端很常見,但後端同樣用得到(批量寫入、限制回呼頻率、合併重複事件)。而它剛好是一個小到能一眼看完、又同時用上三個核心機制的練習題:
- Closure(閉包,函式結束後仍然記得住外層變數的能力):記住上一次的 timer
- Timer:延遲執行
- 並行控制:如果有多個 goroutine / thread 同時呼叫它,要確保 timer 的讀寫不會打架
同一個東西在三個語言寫一次,最有意思的不是語法差異,是第 3 點在 JavaScript 和 Python 裡根本不用寫,到了 Go 就非寫不可。那個「為什麼」就是三個語言並行模型的差別。
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 是協作式並行——「協作式」的意思是,切換時機由程式自己決定(寫了 await 才讓出控制權),不是作業系統隨時可以打斷你。所以兩段程式碼之間不會被硬插隊,task 的讀寫都在同一個 event loop 裡,不需要鎖。
Go
Go 的版本更複雜,因為 goroutine(Go 的輕量執行單位,開一個的成本遠低於 OS thread,所以可以開幾萬個)是真正的平行——它不是輪流,是同一瞬間真的有多個在跑。多個 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(M 個 goroutine 被排到 N 個 OS thread 上跑,由 Go 自己的排程器分配),可以真正在多個 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(goroutine 之間傳遞訊息的管道)或 atomic(由 CPU 保證「讀了就改、中間不會被插隊」的操作)確保「停止和執行只有一個能發生」。但對大多數業務場景,上面的版本已經夠用——race window(兩者可能撞在一起的那個時間縫隙)極小。
這種細節差異,正是「理解模型」vs「只記語法」的分水嶺——知道 Go timer 的語意,你才知道為什麼官方文件說 Stop() 之後要額外判斷。
