React自定義Hook之如何優(yōu)雅管理復雜列表的篩選狀態(tài)
前言
在 React/React Native 開發(fā)中,帶有復雜篩選條件的列表頁是一個極其常見的場景??此坪唵蔚?ldquo;改變條件 -> 重新請求數據”流程,在 React 異步更新機制的加持下,往往會衍生出諸多邊緣問題,例如請求參數滯后、重置信號丟失等。
今天我們通過實現一個專門用于管理列表篩選狀態(tài)的 Hook —— useFilters,來聊聊如何優(yōu)雅地解決這些痛點,并在實踐中規(guī)避 TypeScript 和 React State 機制的隱藏陷阱。
痛點分析:為什么單純的 useState 不夠用?
通常,我們習慣用 useState 來保存查詢條件:
const [filters, setFilters] = useState({ keyword: '', type: 1 });
const updateFilter = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
refresh(); // ? 這里拿到的依舊是舊 filters 參數
};
React 的 setState 是異步的,如果在更新狀態(tài)后立即觸發(fā)數據刷新,網絡請求內部讀到的依然是未更新的舊狀態(tài)。為了解決這個問題,許多開發(fā)者會采用 useEffect 監(jiān)聽狀態(tài)變化來觸發(fā)請求。但在復雜業(yè)務邏輯中(包含防抖、多種觸發(fā)源),過度依賴 useEffect 會讓數據流變得難以追蹤。
破局思路:State 負責 UI 渲染,Ref 負責邏輯讀取。
核心 API 設計:State 與 Ref 的雙重奏
為了兼顧“UI 響應式”和“隨時獲取最新參數”,我們在 useFilters 中引入了 useRef 來同步追蹤最新的篩選狀態(tài)。
import { useCallback, useRef, useState } from 'react';
export function useFilters<T extends object>(initial: T) {
const [filters, setFiltersState] = useState<T>(initial);
const filtersRef = useRef<T>(initial);
const updateFilter = useCallback(<K extends keyof T>(key: K, value: T[K]) => {
// 1. 立即更新同步的 Ref(供接口請求使用)
filtersRef.current = { ...filtersRef.current, [key]: value };
// 2. 觸發(fā)異步的 State 更新(供 UI 重新渲染)
setFiltersState(filtersRef.current);
}, []);
// ...
return { filters, filtersRef, updateFilter };
}
通過這一層封裝:
filters:響應式狀態(tài)對象。用于驅動 UI 渲染(如輸入框回顯、Picker 選中態(tài))。filtersRef:同步引用對象。閉包或外部函數(如fetcher)隨時可通過filtersRef.current獲取無延遲的最新參數,從此告別參數滯后。
深入細節(jié):TypeScript 聯(lián)合類型的類型收窄陷阱
在使用習慣上,我們希望 setFilters 能像原生的 setState 一樣,既支持直接傳入新對象,也支持傳入基于舊狀態(tài)計算新狀態(tài)的 updater 函數:
// 支持形態(tài) A
setFilters({ keyword: '張三' });
// 支持形態(tài) B
setFilters(prev => ({ ...prev, keyword: '張三' }));
這里入參的類型是聯(lián)合類型 T | ((prev: T) => T)。在實現時,我們需要區(qū)分它:
const setFilters = useCallback((next: T | ((prev: T) => T)) => {
if (typeof next === 'function') {
// ?? 注意這里的類型斷言
filtersRef.current = (next as (prev: T) => T)(filtersRef.current);
} else {
filtersRef.current = next;
}
setFiltersState(filtersRef.current);
}, []);
為什么在 typeof next === 'function' 分支里還要寫 as (prev: T) => T?
理論上,TypeScript 應當能自動把 next 的類型收窄為函數。但實際上并不行。這是 TS 的一個已知限制:當聯(lián)合類型中同時包含「對象類型(T)」與「函數類型」時,因為函數本質上也是對象,TS 的類型收窄會趨于保守,導致 next 在分支內部依然被視為聯(lián)合類型,無法直接調用。
有些開發(fā)者可能會直接寫 (next as any)(...) 強行通過編譯。但我們極力反對這么做——as any 會抹殺掉所有的類型檢查。如果未來函數簽名調整為 (prev: T) => Partial<T>,any 將無法拋出錯誤,造成運行時隱患。
經驗總結:精確斷言 as (prev: T) => T 與 as any 在運行時完全等價,但保留了嚴密的類型校驗。處理聯(lián)合類型里的函數分支時,請始終使用精確斷言。
進階場景:重置操作與“信號丟失”之謎
重置條件是一個非常特殊的動作:當我們點擊“重置”按鈕時,不僅要清空 UI 上的篩選框,還期望列表自動使用初始條件刷新一次。
由于 updateFilter 不應與具體的請求邏輯(如 refresh)產生耦合,我們采用了一種**“埋點信號”**的機制:
const resetSignalRef = useRef(false);
const resetFilters = useCallback(() => {
const fresh = { ...initialRef.current };
filtersRef.current = fresh;
setFiltersState(fresh);
resetSignalRef.current = true; // 埋下信號
}, []);
const consumeResetSignal = useCallback(() => {
if (resetSignalRef.current) {
resetSignalRef.current = false; // 消費信號
return true;
}
return false;
}, []);
在組件端,我們通過 useEffect 響應這個信號:
useEffect(() => {
if (consumeResetSignal()) {
refresh();
}
}, [filters, refresh, consumeResetSignal]); // 依賴 filters 變化觸發(fā)
隱藏的 Bug:React 的狀態(tài)復用優(yōu)化
上述代碼曾經遭遇過一個隱蔽的 Bug:當用戶進入頁面后,沒有修改任何篩選條件,直接點擊“重置”按鈕,列表毫無反應。而且,在此之后的第一次正常搜索,會意外觸發(fā)兩次請求。
問題出在哪里?出在 React 的內置優(yōu)化上。
如果我們在 resetFilters 里直接賦值原引用:
setFiltersState(initialRef.current);
當前狀態(tài) filters 已經等于初始狀態(tài)時,Object.is(newState, currentState) 成立。React 判定狀態(tài)無實質改變,直接跳過了本次重新渲染(Bailout),關聯(lián)的 useEffect 也不會執(zhí)行。
這就導致了災難性的連鎖反應:
resetSignalRef.current = true被悄悄埋下。- Effect 沒執(zhí)行,信號沒人消費,滯留在了內存里。
- 隨后用戶正常輸入搜索,
filters改變,Effect 執(zhí)行,讀到了這個滯留的信號,造成意外的refresh動作。
破局之道:0 成本的淺拷貝
解決辦法異常優(yōu)雅,只需保證每次重置都產生一個新引用:
// 淺拷貝產生新引用,打破 Object.is 判斷
const fresh = { ...initialRef.current };
setFiltersState(fresh);
為什么不用深拷貝(Deep Clone)?
- React 只看外層引用:觸發(fā)重繪只需最外層對象的內存地址改變。
- 保護下游性能優(yōu)化:淺拷貝復用了內層引用(如數組字段)。依賴內層字段的被
React.memo包裹的子組件,不會發(fā)生無意義的重新計算,完美契合了 React 不可變(Immutable) 的設計哲學。
完整實戰(zhàn)演練
最后,來看看 useFilters 與分頁 Hook usePaginatedList 的絲滑配合:
import { useCallback, useEffect } from 'react';
import { useFilters, usePaginatedList } from '@/hooks';
const INITIAL = { keyword: '', type: 1 };
const MyList = () => {
const { filters, filtersRef, updateFilter, resetFilters, consumeResetSignal } = useFilters(INITIAL);
// 1. fetcher 內部統(tǒng)一通過 filtersRef 讀取參數
const fetcher = useCallback((params) => {
return queryApi({
...params,
...filtersRef.current,
});
}, []);
const { refresh, data } = usePaginatedList({ fetcher });
// 2. 統(tǒng)一響應重置信號
useEffect(() => {
if (consumeResetSignal()) {
refresh();
}
}, [filters, refresh, consumeResetSignal]);
return (
<View>
<SearchBar
value={filters.keyword}
onChange={(v) => updateFilter('keyword', v)}
onSubmit={refresh}
onReset={resetFilters}
/>
{/* 列表渲染邏輯... */}
</View>
);
};
總結
一個看似簡單的 useFilters,內部卻大有乾坤:
- State 渲染,Ref 獲取最新值,打破了 React 異步更新帶來的延遲限制。
- 面對聯(lián)合類型斷言,摒棄
as any,堅持精確的函數簽名斷言。 - 警惕 React
setState的**“引用相等跳過更新”機制**,利用一行淺拷貝巧妙消除跨渲染周期通信的隱藏 Bug。
良好的前端架構不僅僅在于使用高級的框架,更在于對框架底層的邊界情況有著深入且清晰的掌控。希望這個小小的自定義 Hook 實戰(zhàn)能給你帶來啟發(fā)!
到此這篇關于React自定義Hook之如何優(yōu)雅管理復雜列表的篩選狀態(tài)的文章就介紹到這了,更多相關React自定義Hook篩選狀態(tài)內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
react-router4 配合webpack require.ensure 實現異步加載的示例
本篇文章主要介紹了react-router4 配合webpack require.ensure 實現異步加載的示例,非常具有實用價值,需要的朋友可以參考下2018-01-01

