React自定义Hook之如何优雅管理复杂列表的筛选状态
作者:皮蛋小精灵
前言
在 React/React Native 开发中,带有复杂筛选条件的列表页是一个极其常见的场景。看似简单的“改变条件 -> 重新请求数据”流程,在 React 异步更新机制的加持下,往往会衍生出诸多边缘问题,例如请求参数滞后、重置信号丢失等。
今天我们通过实现一个专门用于管理列表筛选状态的 Hook —— useFilters,来聊聊如何优雅地解决这些痛点,并在实践中规避 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 是异步的,如果在更新状态后立即触发数据刷新,网络请求内部读到的依然是未更新的旧状态。为了解决这个问题,许多开发者会采用 useEffect 监听状态变化来触发请求。但在复杂业务逻辑中(包含防抖、多种触发源),过度依赖 useEffect 会让数据流变得难以追踪。
破局思路:State 负责 UI 渲染,Ref 负责逻辑读取。
核心 API 设计:State 与 Ref 的双重奏
为了兼顾“UI 响应式”和“随时获取最新参数”,我们在 useFilters 中引入了 useRef 来同步追踪最新的筛选状态。
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. 触发异步的 State 更新(供 UI 重新渲染)
setFiltersState(filtersRef.current);
}, []);
// ...
return { filters, filtersRef, updateFilter };
}
通过这一层封装:
filters:响应式状态对象。用于驱动 UI 渲染(如输入框回显、Picker 选中态)。filtersRef:同步引用对象。闭包或外部函数(如fetcher)随时可通过filtersRef.current获取无延迟的最新参数,从此告别参数滞后。
深入细节:TypeScript 联合类型的类型收窄陷阱
在使用习惯上,我们希望 setFilters 能像原生的 setState 一样,既支持直接传入新对象,也支持传入基于旧状态计算新状态的 updater 函数:
// 支持形态 A
setFilters({ keyword: '张三' });
// 支持形态 B
setFilters(prev => ({ ...prev, keyword: '张三' }));
这里入参的类型是联合类型 T | ((prev: T) => T)。在实现时,我们需要区分它:
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 的一个已知限制:当联合类型中同时包含「对象类型(T)」与「函数类型」时,因为函数本质上也是对象,TS 的类型收窄会趋于保守,导致 next 在分支内部依然被视为联合类型,无法直接调用。
有些开发者可能会直接写 (next as any)(...) 强行通过编译。但我们极力反对这么做——as any 会抹杀掉所有的类型检查。如果未来函数签名调整为 (prev: T) => Partial<T>,any 将无法抛出错误,造成运行时隐患。
经验总结:精确断言 as (prev: T) => T 与 as any 在运行时完全等价,但保留了严密的类型校验。处理联合类型里的函数分支时,请始终使用精确断言。
进阶场景:重置操作与“信号丢失”之谜
重置条件是一个非常特殊的动作:当我们点击“重置”按钮时,不仅要清空 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 变化触发
隐藏的 Bug:React 的状态复用优化
上述代码曾经遭遇过一个隐蔽的 Bug:当用户进入页面后,没有修改任何筛选条件,直接点击“重置”按钮,列表毫无反应。而且,在此之后的第一次正常搜索,会意外触发两次请求。
问题出在哪里?出在 React 的内置优化上。
如果我们在 resetFilters 里直接赋值原引用:
setFiltersState(initialRef.current);
当前状态 filters 已经等于初始状态时,Object.is(newState, currentState) 成立。React 判定状态无实质改变,直接跳过了本次重新渲染(Bailout),关联的 useEffect 也不会执行。
这就导致了灾难性的连锁反应:
resetSignalRef.current = true被悄悄埋下。- Effect 没执行,信号没人消费,滞留在了内存里。
- 随后用户正常输入搜索,
filters改变,Effect 执行,读到了这个滞留的信号,造成意外的refresh动作。
破局之道:0 成本的浅拷贝
解决办法异常优雅,只需保证每次重置都产生一个新引用:
// 浅拷贝产生新引用,打破 Object.is 判断
const fresh = { ...initialRef.current };
setFiltersState(fresh);
为什么不用深拷贝(Deep Clone)?
- React 只看外层引用:触发重绘只需最外层对象的内存地址改变。
- 保护下游性能优化:浅拷贝复用了内层引用(如数组字段)。依赖内层字段的被
React.memo包裹的子组件,不会发生无意义的重新计算,完美契合了 React 不可变(Immutable) 的设计哲学。
完整实战演练
最后,来看看 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 内部统一通过 filtersRef 读取参数
const fetcher = useCallback((params) => {
return queryApi({
...params,
...filtersRef.current,
});
}, []);
const { refresh, data } = usePaginatedList({ fetcher });
// 2. 统一响应重置信号
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 异步更新带来的延迟限制。
- 面对联合类型断言,摒弃
as any,坚持精确的函数签名断言。 - 警惕 React
setState的**“引用相等跳过更新”机制**,利用一行浅拷贝巧妙消除跨渲染周期通信的隐藏 Bug。
良好的前端架构不仅仅在于使用高级的框架,更在于对框架底层的边界情况有着深入且清晰的掌控。希望这个小小的自定义 Hook 实战能给你带来启发!
到此这篇关于React自定义Hook之如何优雅管理复杂列表的筛选状态的文章就介绍到这了,更多相关React自定义Hook筛选状态内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
