iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 11 篇

Day 11|沒有防抖debounce,請求像愛如潮水

  • 分享至 

  • xImage
  •  

打五個字,後端收到五個請求

Day 9 那個 useSearch 上線一週後,後端同事傳訊息來:

「搜尋 API 的流量怎麼是上個月的四倍?」

我打開 Network 面板,在搜尋框打了 "report":

r       → 請求出去 → 被取消
re      → 請求出去 → 被取消
rep     → 請求出去 → 被取消
repo    → 請求出去 → 被取消
repor   → 請求出去 → 被取消
report  → 請求出去 → 回來了 ✅

畫面完全正確,競態也處理好了。但每打一個字,就有一個請求出門——前端取消得很乾淨,後端卻一個不漏地全收到了。


一、Angular:一個運算子的事

在 Angular,搜尋框的標準寫法大概長這樣:

this.results$ = this.keyword$.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap(k => this.api.search(k)),
);

debounceTime(300) 的意思是:等使用者停手 300ms,才把最後那個值放行。 期間只要又有新值進來,計時就重來。

所以使用者一口氣打完 "report",只有最後那個 "report" 會通過,前面五個全被吞掉。

distinctUntilChanged() 則是再補一道:如果放行的值跟上一次一樣,就不送。

三個運算子乖乖排成一條管線看了就舒服。這套寫法從來沒想過它底下在做什麼。


二、第一次嘗試:把 debounce 包在函式上

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 都不排。
  • 就算重新 render 了,useSearch 裡的 useEffect 依賴是 [keyword],值沒變,effect 也不會重跑(Day 4)。

distinctUntilChanged 在做的事,React 的 state 跟依賴陣列本來就在做。

這是繼昨天的 combineLatest 之後,第二個不用手刻的運算子。


五、hooks 疊起來,也是一條管線

把整段對照來看:

// 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 就是一個三元運算。

而這條管線還有很多洞:

  • 使用者按 Enter 想立刻搜尋,不想等 300ms?要再加一個「立即送出」的機制。
  • 捲動、拖曳這類事件要的是 throttleTime,不是 debounce?那是另一個 hook。
  • 使用者一直打字不停手,永遠不送?RxJS 可以組合出上限時間,我這邊又得再加參數。

每一個,在 RxJS 都是換一個運算子或多接一節。在我這邊,都是再寫一個 hook。


今天的結論

debounceTime 在 RxJS 裡,是作用在串流上的時間運算子:值一個一個流過來,它決定哪些放行。

React 沒有串流,只有快照。所以時間這件事,得轉換成 React 聽得懂的形式:一個 state、一個 effect、一個清除函式。

state 存「延遲後的值」,effect 設計時器,清除函式負責在新值進來時把舊的計時器收掉。三樣東西湊在一起,就是一個 debounce。。

只是我的 lib/ 資料夾裡,手刻的 hook 已經有兩個了。它們長得很像,而且我隱約覺得,這種東西應該早就有人寫好了,後來就看到同事有引入類似可以處理的套件了嘿嘿。

不過抗拒期還沒結束。明天要處理最後一塊:Angular 裡被我拿來當全域狀態總線的 Subject,在 React 該怎麼辦。


上一篇
Day 10|少了 forkJoin,我的頁面開始排隊
下一篇
Day 12| React 裡,找到了我的 BehaviorSubject
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言