# [React] 記錄處理 input debounce + search queries ###### tags: `React` `前端筆記` ## 需求說明 最近優化公司元件時發現「debounce 輸入 + 更新 search queries」這部分的邏輯有點卡卡的,因此趁著現在記憶猶新時趕緊實作一個小 Demo 記錄。 需求: - 待使用者輸入約 2 秒後無更動才打 API - 當使用者重新整理時還是可以保持上一次輸入的內容及參照輸入訪問 API ## 實作 ### 該怎麼記錄使用者停止輸入兩秒後的值? 為了避免浪費的 API call,必須要達到下列的行為(這個行為被稱作 `debounce`): 1. 真的輸入完畢後 2. 才拿值打 API #### 一般來說可以透過 `useEffect` 完成這個需求 第一次元件載入的流程: ```javascript= export default function App() { const [enteredInput, setEnteredInput] = useState(""); const [debouncedInput, setDebouncedInput] = useState(""); // Step 1: 當 DOM 繪製完畢會叫用這個 effect useEffect(() => { // 想像這裡是執行打 API 的任務 const fetchApiData = () => { console.log(`Fetch api data with query: ${debouncedInput}`); }; fetchApiData(); }, [debouncedInput]); // Step 1-1: 當 DOM 繪製完畢會叫用這個 effect,但是一開始元件載入的時候兩個 state 是相同的,所以就不會執行任務 useEffect(() => { const timer = setTimeout(() => { if (enteredInput !== debouncedInput) { setDebouncedInput(enteredInput); } }, 2000); return () => { clearTimeout(timer); }; }, [enteredInput, debouncedInput]); return ( <div className="App"> <label htmlFor="search">Search:</label> <input id="search" value={enteredInput} onChange={(e) => setEnteredInput(e.target.value)} /> <p>DebouncedInput: {debouncedInput}</p> </div> ); } ``` 當使用者變更 input 後過了兩秒後會執行的流程: ```javascript= import { useState, useEffect } from "react"; import "./styles.css"; export default function App() { const [enteredInput, setEnteredInput] = useState(""); const [debouncedInput, setDebouncedInput] = useState(""); // Step 4: 因 setDebouncedInput 執行後 state 變更,所以元件會重新渲染。因此 effect 有將 debouncedInput 放入 dependency 追蹤,所以當此輪次的 debouncedInput 與上一輪次的 debouncedInput 不同時就會執行這個 effect(打 API) useEffect(() => { // 想像這裡是執行打 API 的任務 const fetchApiData = () => { console.log(`Fetch api data with query: ${debouncedInput}`); }; fetchApiData(); }, [debouncedInput]); // Step 2: 因此 effect 有將 enteredInput 放入 dependency 追蹤,所以個輸入都會觸發這個 effect // Step 5: 因 setDebouncedInput 執行後 state 變更,所以元件會重新渲染。因此 effect 有將 debouncedInput 放入 dependency 追蹤,所以當此輪次的 debouncedInput 與上一輪次的 debouncedInput 不同時就會執行這個 effect。但此時 enteredInput 與 debouncedInput 已經同步了,所以並不會通過判斷。 useEffect(() => { // Step 3: 當使用者真的結束輸入後,通過判斷就會執行 setDebouncedInput const timer = setTimeout(() => { if (enteredInput !== debouncedInput) { setDebouncedInput(enteredInput); } }, 2000); // Step 2-1: 清除延遲的 2 秒,等到使用者沒繼續輸入後才會執行該輪次的 effect return () => { clearTimeout(timer); }; }, [enteredInput, debouncedInput]); return ( <div className="App"> <label htmlFor="search">Search:</label> <input id="search" value={enteredInput} // Step 1: 使用者透過事件更改 enteredInput onChange={(e) => setEnteredInput(e.target.value)} /> <p>DebouncedInput: {debouncedInput}</p> </div> ); } ``` ### 既然已經達成記錄兩秒後的值,該怎麼放入 URL 呢? #### 使用 `useSearchParams` 可以變更 URL 上的 query `React-Router` 有提供一個 hook `useSearchParams` 供開發者使用,可以用來更改 URL 上的 queries: ```javascript= // 使用上就跟一般的 useState 相同,有 [getter, setter] 可以使用 const [searchParams, setSearchParams] = useSearchParams(); // searchParams 的型別是 URLSearchParams 所以可以取用對應的方法 searchParams.get(keyName) searchParams.delete(keyName) ... // 更多方法可以參考 https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams // set 的方式就比較特別 // 1. 把更動完的 searchParams 丟入 setter setSearchParams(searchParams) // 2. 直接丟物件 setSearchParams({ key: val }) // 3. 也接受使用 update function setSearchParams((prev) => { return { ...prev, ...{ name: '123'}} }) ``` :::warning **要注意的事項:** 1. 前陣子處理公司營運人員發現 GA 會重新監測事件的問題,實際檢查程式碼後發現程式中有設定一個兩秒的 timeout,其任務有執行 `setSearchParams`,但是其實與一開始 `searchParams` 的 properties 是相同的,不確定是不是對 GA 來說即便 properties 相同,只要有 `setSearchParams` 就會觸發(這部分可能就要釐清 GA 的運作及閱讀 `setSearchParams` 的原始碼) 2. 當元件使用 `useSearchParams` hook 時,只要 URL queries 有不同就會觸發元件 render ::: ### state 往上拉到 URL queries 以 URL queries 當作 debounced value 比對以及 state 初始化(initialize) 為了重新整理時可以繼續保存使用者的輸入,因此必須要解決: 1. 重新整理時整個 App 重新載入,造成元件重新初始化渲染 所以就將元件初始化定義 state 時先從 URL queries 取得: ```javascript= // ... const [searchParams, setSearchParams] = useSearchParams(); const [enteredInput, setEnteredInput] = useState(searchParams.get('search') ?? ''); ``` 將對應的 URL query 當作原先 `debouncedInput` 值,以 query 當作訪問 API 時需要帶入的資料,==所以實際是以該 query 有變動才要觸發因使用者變更 input 後再次訪問 API 的行為。== 第一次元件載入的流程: ```javascript= export default function App() { // Step 1: 元件內使用 useSearchParams,取得其 getter 及 setter const [searchParams, setSearchParams] = useSearchParams(); // Step 2: state 初始化直接拿 key 取 URL query(因為抓不到會回傳 null,所以再額外灌入初始值 '') const [enteredInput, setEnteredInput] = useState( searchParams.get("search") ?? "" ); // Step 3: 當 DOM 繪製完畢會叫用這個 effect(從 searchParams 取得最後要發送的資料) useEffect(() => { // 想像這裡是執行打 API 的任務 const fetchApiData = () => { const currentUrlQuery = searchParams.get("search") ?? ""; console.log(`Fetch api data with query: ${currentUrlQuery}`); }; fetchApiData(); }, [searchParams]); // Step 3-1: 當 DOM 繪製完畢會叫用這個 effect,但是一開始元件載入的時候 state(透過 URL query 初始化)與 URL query 是相同的,所以就不會執行任務 useEffect(() => { const timer = setTimeout(() => { const currentUrlQuery = searchParams.get("search") ?? ""; if (enteredInput !== currentUrlQuery) { searchParams.set("search", enteredInput); setSearchParams(searchParams); } }, 2000); return () => { clearTimeout(timer); }; }, [enteredInput, searchParams, setSearchParams]); return ( <div className="App"> <label htmlFor="search">Search:</label> <input id="search" value={enteredInput} onChange={(e) => setEnteredInput(e.target.value)} /> </div> ); } ``` 當使用者變更 input 後過了兩秒後會執行的流程: ```javascript= export default function App() { const [searchParams, setSearchParams] = useSearchParams(); const [enteredInput, setEnteredInput] = useState( searchParams.get("search") ?? "" ); // Step 4: 因 setSearchParams 執行後 URL queries 變更,所以元件會重新渲染。因此 effect 有將 searchParams 放入 dependency 追蹤,所以當此輪次的 searchParams 與上一輪次的 debouncedInput 不同時就會執行這個 effect(打 API) useEffect(() => { // 想像這裡是執行打 API 的任務 const fetchApiData = () => { const currentUrlQuery = searchParams.get("search") ?? ""; console.log(`Fetch api data with query: ${currentUrlQuery}`); }; fetchApiData(); }, [searchParams]); // Step 2: 因此 effect 有將 enteredInput 放入 dependency 追蹤,所以個輸入都會觸發這個 effect // Step 5: 因 setSearchParams 執行後 URL queries 變更,所以元件會重新渲染。因此 effect 有將 searchParams 放入 dependency 追蹤,所以當此輪次的 searchParams 與上一輪次的 searchParams 不同時就會執行這個 effect。但此時 enteredInput 與 searchParams.get('search') 已經同步了,所以並不會通過判斷。 useEffect(() => { // Step 3: 當使用者真的結束輸入後,通過判斷就會執行 setSearchParams const timer = setTimeout(() => { const currentUrlQuery = searchParams.get("search") ?? ""; if (enteredInput !== currentUrlQuery) { searchParams.set("search", enteredInput); setSearchParams(searchParams); } }, 2000); // Step 2-1: 清除延遲的 2 秒,等到使用者沒繼續輸入後才會執行該輪次的 effect return () => { clearTimeout(timer); }; }, [enteredInput, searchParams, setSearchParams]); return ( <div className="App"> <label htmlFor="search">Search:</label> <input id="search" value={enteredInput} // Step 1: 使用者透過事件更改 enteredInput onChange={(e) => setEnteredInput(e.target.value)} /> </div> ); } ``` ![](https://hackmd.io/_uploads/SycAZwQT3.gif) *(結束輸入兩秒後變更 URL query,並且以 URL query 當作訪問 API 要傳送的資料。除此之外,當頁面重新整理時,如果 URL query 有 `search` 時,state 初始化會有其值。)* ## 但是當使用者透過瀏覽器的上一頁按鈕返回上一頁時會先正確地訪問 API 但是兩秒後又會以點選上一頁前的資料訪問 API :::warning 因為 codesandbox 沒辦法實作出相同的情境,因此在最後附上的程式範例中改變 URL query 後點選上一頁後不會出現這個問題。 ::: ### 點選上一頁後元件不會重新執行初始化(也就是重新 mounted) 因為點選上一頁後,元件並不會重新初始化。而且當使用者點選上一頁後確實也更改了 URL query(`?search=123` -> `?search=223` -> `?search=123`),所以以目前的程式碼,確實會觸發兩次 API call: ```javascript= export default function App() { const [searchParams, setSearchParams] = useSearchParams(); // Step 1: 點擊上一頁後元件重新 render,但是不會重新初始化,所以這裡還是 223 const [enteredInput, setEnteredInput] = useState( searchParams.get("search") ?? "" ); // Step 2: 因 setSearchParams 執行後 URL queries 變更,所以元件會重新渲染。因此 effect 有將 searchParams 放入 dependency 追蹤,所以當此輪次的 searchParams 與上一輪次的 debouncedInput 不同時就會執行這個 effect(打 API) // Step 2-1: 注意這裡打 API 時帶入的是 URL query 123 // Step 4: 因為改變 URL query,元件又重新 render。因此 effect 有將 searchParams 放入 dependency 追蹤,所以又會執行 API call // Step 4-1: 注意這裡打 API 時帶入的是 URL query 223 useEffect(() => { // 想像這裡是執行打 API 的任務 const fetchApiData = () => { const currentUrlQuery = searchParams.get("search") ?? ""; console.log(`Fetch api data with query: ${currentUrlQuery}`); }; fetchApiData(); }, [searchParams]); // Step 3: 因此 effect 有將 enteredInput, searchParams 放入 dependency 追蹤,所以當其值有所不同值就會觸發整個 effect // Step 3-1: 元件並不會因為使用者點選上一頁就重新初始化,所以判斷會成功(元件 state 確實與 URL search 不同) // Step 3-2: URL query 又被替換成 223 useEffect(() => { const timer = setTimeout(() => { const currentUrlQuery = searchParams.get("search") ?? ""; if (enteredInput !== currentUrlQuery) { if (enteredInput) { searchParams.set("search", enteredInput); } else { searchParams.delete("search"); } setSearchParams(searchParams); } }, 2000); return () => { clearTimeout(timer); }; }, [enteredInput, searchParams, setSearchParams]); return ( // template... )} ``` 實際 effect 的順序可以想成這樣: ```javascript= // 元件第一次初始化(mounted),DOM 繪製完後 1. API call effect 2. debounced 比對 effect // 因 debounced 比對 effect 造成 URL query 不同而導致的 render,DOM 繪製完後 1. API call effect(帶著錯誤的 state) 2. debounded 比對 effect // 點選上一頁後 1. API call effect(拿 URL query) 2. debounced 比對 effect(此時元件 state 與 URL query 不相同,因此會再次觸發 setSearchParams) 3. API call effect 4. debounded 比對 effect ``` ### 額外確保元件 state 在初始化後可以正確地從 URL query 同步 為了要使 state 在初始化(從 URL query 拿)之外還可以正確地與 URL query 同步,所以多使用一個 effect 處理: ```javascript= export default function App() { const [searchParams, setSearchParams] = useSearchParams(); const [enteredInput, setEnteredInput] = useState( searchParams.get("search") ?? "" ); // 多註冊一個 effect 確保 state 可以與 URL query 同步 useEffect(() => { const urlSearch = searchParams.get("search") ?? ""; setEnteredInput(urlSearch); }, [searchParams]); useEffect(() => { const fetchApiData = () => { const currentUrlQuery = searchParams.get("search") ?? ""; console.log(`Fetch api data with query: ${currentUrlQuery}`); }; fetchApiData(); }, [searchParams]); useEffect(() => { const timer = setTimeout(() => { const currentUrlQuery = searchParams.get("search") ?? ""; if (enteredInput !== currentUrlQuery) { if (enteredInput) { searchParams.set("search", enteredInput); } else { searchParams.delete("search"); } setSearchParams(searchParams); } }, 2000); return () => { clearTimeout(timer); }; }, [enteredInput, searchParams, setSearchParams]); return ( ); } ``` 因為目前的邏輯是以 `searchParams` 為優化 API call `effect` 的手段,且當使用者完成輸入兩秒後才會順道更新 `searchParams`,所以只要額外同步 `enteredInput` 的狀態就可以避免使用者點選上一頁造成 state 與 URL query 不同步導致的 bug,實際的 `effects` 執行可以想像成這樣: ```javascript= // 元件第一次初始化(mounted),DOM 繪製完後 1. 確保 URL query 及 state 同步的 effect(但是初始化時確實有先從 URL query 取值,所以即時執行 setter 後經過 Object.is() 比對歷史 state 也不會因為不同而導致元件 render) 2. API call effect 3. debounced 比對 effect // --- 點選上一頁後,同一次 render 時的 scope --- 1. 初始化 debounced 比對 effect 時回傳的 clean up 2. 確保 URL query 及 state 同步的 effect(執行 setter 經國 Object.is() 比對歷史 state,發現不同導致元件 render) 3. API call effect(拿 URL query) 4. debounced 比對 effect(template 及 URL query 不同,所以會觸發 effect,兩秒的計時器開始) // --- 同一次 render 時的 scope --- // --- 同步 URL query 及 state 的 effect 執行後導致元件 render --- 1. 執行上一次 render 時 debouned 比對的 clean up function,刪除兩秒計時器的任務 2. debouned 比對 effect(因此次 render 時兩者同步,所以不會執行需經判斷才執行的任務) // --- 同步 URL query 及 state 的 effect 執行後導致元件 render --- ``` ## 完整程式範例 [[Note] input debounce + search queries](https://codesandbox.io/s/note-input-debounce-search-queries-dhwkd8?file=/src/App.js) ## Recap 1. 每一次 render 都有自己的 scope,而且即使在 effect 中執行 `setter` 更新 state,也是會等到該輪次 effect 都執行完畢才會繼續執行因 `setter` 變更 state 導致元件重新 render 後再次觸發的 effect - effect 的 dependency 是優化效能的手段,比對的方式如同 state,是使用 `Object.is()` 將當前 state 與歷史 state 比對 - 每一個 scope 都有自己的 **everything** 2. effect 如有 clean up,在執行下次的 effect 之前都會先執行上一次 render 時的 clean up(並且因為 scope 的緣故,在該 clean up 中會讀取到上一次 render 時的所有東西) 3. 當元件註冊 `useSearchParams` 時,當 URL query 有更動時就會導致該元件 render 4. effect 複雜的話算 render 的 scope 就很重要!(所以在建立 effect 時要特別注意是否真的需要) ## 參考資料 1. [useSearchParams](https://reactrouter.com/en/main/hooks/use-search-params)