Day 9 那個 useSearch 上線一週後,後端同事傳訊息來:
「搜尋 API 的流量怎麼是上個月的四倍?」
我打開 Network 面板,在搜尋框打了 "report":
r → 請求出去 → 被取消
re → 請求出去 → 被取消
rep → 請求出去 → 被取消
repo → 請求出去 → 被取消
repor → 請求出去 → 被取消
report → 請求出去 → 回來了 ✅
畫面完全正確,競態也處理好了。但每打一個字,就有一個請求出門——前端取消得很乾淨,後端卻一個不漏地全收到了。
在 Angular,搜尋框的標準寫法大概長這樣:
this.results$ = this.keyword$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(k => this.api.search(k)),
);
debounceTime(300) 的意思是:等使用者停手 300ms,才把最後那個值放行。 期間只要又有新值進來,計時就重來。
所以使用者一口氣打完 "report",只有最後那個 "report" 會通過,前面五個全被吞掉。
distinctUntilChanged() 則是再補一道:如果放行的值跟上一次一樣,就不送。
三個運算子乖乖排成一條管線看了就舒服。這套寫法從來沒想過它底下在做什麼。
React 沒有 debounceTime,但 debounce 本身是個老技巧,lodash 裡就有一個。我照直覺寫:
import debounce from 'lodash/debounce';
function SearchBox() {
const [keyword, setKeyword] = useState('');
const debouncedSearch = debounce((q: string) => {
api.search(q).then(setResults);
}, 300);
return (
<input
value={keyword}
onChange={e => {
setKeyword(e.target.value);
debouncedSearch(e.target.value);
}}
/>
);
}
測了一下:打 "report",還是六個請求,只是每個都晚了 300ms 才出去。
debounce 完全沒生效。
盯著看了很久才想通,又是 Day 4 那件事:元件函式每次 render 都會整個重跑。
打 r → setKeyword → re-render → 建立新的 debounce 函式 A → A 開始計時
打 e → setKeyword → re-render → 建立新的 debounce 函式 B → B 開始計時
...
debounce 靠的是「同一個函式記得自己上一次的計時器」,才能在新呼叫進來時把舊的取消。但這裡每次 render 都換了一個全新的 debounce 函式,每個都只被呼叫過一次,每個都有自己的計時器——沒有任何一個人負責取消前一個。
要修,讓那個 debounce 函式活過每一次 render:
const debouncedSearch = useMemo(
() => debounce((q: string) => api.search(q).then(setResults), 300),
[]
);
useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]);
useMemo 固定住參考(Day 4),useEffect 的清除函式負責元件卸載時把計時器收掉——不然使用者打完字立刻切頁,300ms後那個請求還是會出去。
看著這幾行,覺得哪裡不對:我在跟 React 的 render 打架,只為了保住一個計時器。
後來我看到另一種寫法,想法完全反過來:
不要延遲「呼叫」,而是延遲「值」。
function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
十行,沒有 lodash,沒有 useMemo。
它的原理全靠 Day 4 講過的清除函式:每次 effect 要重跑之前,上一輪的清除函式會先執行。
打 r → value = 'r' → 設計時器 T1(300ms 後設成 'r')
打 e → value = 're' → 先清掉 T1 → 設計時器 T2
打 p → value = 'rep' → 先清掉 T2 → 設計時器 T3
...
打 t → value = 'report'→ 先清掉 T5 → 設計時器 T6
停手 300ms → T6 觸發 → debounced 變成 'report'
使用者每打一個字,上一個計時器就被清掉。只有最後那個計時器活得夠久,能真的把值送出去。
Day 9 用清除函式取消請求,今天用它取消計時器。 同一個機制,換個東西收拾而已。
用起來是這樣:
function SearchBox() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebouncedValue(keyword, 300);
const { results } = useSearch(debouncedKeyword);
return <input value={keyword} onChange={e => setKeyword(e.target.value)} />;
}
function useSearch(keyword: string) {
// ...
useEffect(() => {
api.search(keyword); // 發請求
}, [keyword]); // 👈 卡住請求的就是這一行
}
注意這裡有兩個版本的關鍵字:
keyword:即時的,給輸入框用,每打一個字就變debouncedKeyword:延遲的,給搜尋用,停手 300ms 才變輸入框需要即時反應,不然使用者會覺得打字卡卡的;搜尋需要穩定,不然後端會被打爆。同一份資料,兩種節奏,各自一個 state。
distinctUntilChanged 是免費的寫完 useDebouncedValue,我原本準備再刻一個 useDistinct。
然後我試了一下:打 "rep",刪掉 "p",再打回 "p"。停手之後,debouncedKeyword 從 'rep' 設成 'rep'——
沒有新的請求。
原因有兩層,都是前幾天講過的東西:
setDebounced('rep') 時,React 用 Object.is 比較新舊值(Day 5)。一樣,就直接跳過,連 render 都不排。useSearch 裡的 useEffect 依賴是 [keyword],值沒變,effect 也不會重跑(Day 4)。distinctUntilChanged 在做的事,React 的 state 跟依賴陣列本來就在做。
這是繼昨天的 combineLatest 之後,第二個不用手刻的運算子。
把整段對照來看:
// Angular
this.results$ = this.keyword$.pipe(
debounceTime(300),
distinctUntilChanged(),
filter(k => k.length >= 2),
switchMap(k => this.api.search(k)),
);
// React
const debouncedKeyword = useDebouncedValue(keyword, 300);
const query = debouncedKeyword.length >= 2 ? debouncedKeyword : '';
const { results } = useSearch(query);
其實挺像的。hook 一個接一個,前一個的輸出是下一個的輸入——hooks 疊起來,也是一條管線。
差別在於管線上的每一節是誰做的。
左邊四個運算子,全部是 RxJS 給的,經過十年、無數專案的打磨。右邊,useDebouncedValue 是我寫的,useSearch 是我寫的,distinctUntilChanged 是 React 順手給的,filter 就是一個三元運算。
而這條管線還有很多洞:
throttleTime,不是 debounce?那是另一個 hook。每一個,在 RxJS 都是換一個運算子或多接一節。在我這邊,都是再寫一個 hook。
debounceTime 在 RxJS 裡,是作用在串流上的時間運算子:值一個一個流過來,它決定哪些放行。
React 沒有串流,只有快照。所以時間這件事,得轉換成 React 聽得懂的形式:一個 state、一個 effect、一個清除函式。
state 存「延遲後的值」,effect 設計時器,清除函式負責在新值進來時把舊的計時器收掉。三樣東西湊在一起,就是一個 debounce。。
只是我的 lib/ 資料夾裡,手刻的 hook 已經有兩個了。它們長得很像,而且我隱約覺得,這種東西應該早就有人寫好了,後來就看到同事有引入類似可以處理的套件了嘿嘿。
不過抗拒期還沒結束。明天要處理最後一塊:Angular 裡被我拿來當全域狀態總線的 Subject,在 React 該怎麼辦。