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

