一次彻底解决并发锁、闭包陷阱、双重触发三个经典问题
在 React Native 开发中,带下拉刷新和上拉加载的分页列表几乎是每个 App 的标配。但只要你认真做过,就会踩到这几个坑:
- 快速滑动时发出多个重复请求
loadMore拿到过期的页码,导致加载了错误的页FlatList渲染完第一页就立刻触发onEndReached,无故请求第二页
这篇文章记录我封装 usePaginatedList 的完整思路,以及每个设计决策背后的原因。
先看最终接口
typescript
const {
data,
total,
loading, // 初始加载
refreshing, // 下拉刷新中
loadingMore, // 上拉加载更多中
hasMore,
refresh,
loadMore,
} = usePaginatedList({
fetcher: ({ pageNumber, pageSize, type }) =>
api.getList({ pageNumber, pageSize }),
pageSize: 20,
autoLoad: true,
});
fetcher 是唯一必填项,其他都有默认值。Hook 内部管理所有状态,调用方只需要关心"拿到的数据"和"触发动作"。
三种加载状态,分开管理
很多实现只有一个 loading,用来表示所有加载场景。这会带来一个问题:下拉刷新时,列表底部的"加载更多"指示器也会一起亮起;反过来上拉加载时,顶部的下拉刷新转圈也可能错误地显示。
分开管理语义更清晰,也和 FlatList 的 props 直接对应:
scss
const [loading, setLoading] = useState(false); // 首次进入页面
const [refreshing, setRefreshing] = useState(false); // FlatList.refreshing
const [loadingMore, setLoadingMore] = useState(false);// 底部 Loading 组件
在 fetchData 里根据 type 参数决定置哪个 flag:
bash
if (type === 'refresh') {
setRefreshing(true);
} else if (type === 'loadMore') {
setLoadingMore(true);
} else {
setLoading(true);
}
三个状态在 finally 里统一重置,不管请求成功还是失败都能正确恢复:
ini
finally {
setLoading(false);
setRefreshing(false);
setLoadingMore(false);
isFetchingRef.current = false;
lastFetchTimeRef.current = Date.now();
}
并发锁:用 Ref 而不是 State
这是最容易写错的地方。很多人会用 useState 来做"正在请求"的 flag:
javascript
// ❌ 错误写法
const [isFetching, setIsFetching] = useState(false);
const fetchData = async () => {
if (isFetching) return; // 这里读到的是闭包里的旧值!
setIsFetching(true);
// ...
};
问题在于 useState 的更新是异步的,当你在 fetchData 里读 isFetching 时,读到的是本次渲染时的快照值。如果两个请求几乎同时触发,两个闭包里的 isFetching 都是 false,锁就失效了。
正确做法是用 useRef:
ini
// ✅ 正确写法
const isFetchingRef = useRef(false);
const fetchData = async () => {
if (isFetchingRef.current) return; // Ref 是可变对象,永远读最新值
isFetchingRef.current = true;
// ...
finally {
isFetchingRef.current = false;
}
};
useRef 返回的是同一个对象引用,.current 的修改立刻生效,不受闭包影响。这才是真正的同步锁。
闭包陷阱:页码也要用 Ref 追踪
loadMore 的实现里有一个类似的坑------页码。
直觉上你可能会这样写:
scss
// ❌ 容易出 bug 的写法
const loadMore = useCallback(() => {
if (hasMore && !isFetchingRef.current) {
fetchData(pageNum + 1, 'loadMore');
}
}, [hasMore, fetchData, pageNum]);
这里的 pageNum 是 useState 的值,loadMore 会在每次 pageNum 变化时重新生成。但问题是:loadMore 同时还被 FlatList 的 onEndReached 引用着,在某些 layout 变化场景下,新的 loadMore 还没有传递给 FlatList,旧的那个就被调用了,于是 pageNum 还是上一次的值。
同样,用 Ref 来解决:
scss
const pageNumRef = useRef(1);
// 每次请求成功后同步更新
pageNumRef.current = page;
setPageNum(page); // State 用于驱动 UI 重渲染,Ref 用于在闭包中读最新值
// ✅ loadMore 里用 Ref 读页码
const loadMore = useCallback(() => {
if (hasMore && !isFetchingRef.current) {
fetchData(pageNumRef.current + 1, 'loadMore');
}
}, [hasMore, fetchData]);
pageNumRef 不出现在依赖数组里------因为 Ref 的变化不触发重渲染,也不需要触发 loadMore 的重新生成。这样 loadMore 的引用保持稳定,同时始终能读到最新页码。
hasMore 的判断逻辑
hasMore 决定了是否还要继续加载,判断方式值得想一想。
最简单的判断是 data.length < total,但这要求每次都能拿到准确的 total,而部分接口设计不返回 total,或者 total 可能因为数据变化而不准确。
这里采用了一个更鲁棒的策略:用返回条数是否达到 pageSize 来判断。
scss
setHasMore(items.length >= pageSize);
逻辑是:如果这次请求返回了满页(比如 pageSize=20,返回了 20 条),说明后面大概率还有数据;如果返回了不足一页(比如只有 7 条),说明已经到底了。
这个判断在绝大多数场景下都准确,边界情况(恰好是 pageSize 的整数倍)会多发一次请求,但返回空数组后 hasMore 会变为 false,不影响正确性。
为什么要记录 lastFetchTimeRef
ini
lastFetchTimeRef.current = Date.now();
这个 Ref 记录了上次请求完成的时间戳。它本身没有在 Hook 内部使用,而是暴露给调用方用于防抖。
FlatList 有一个特性:当列表高度发生变化(比如第一页数据渲染完成),它会重新计算 onEndReachedThreshold,有时会再次触发 onEndReached。虽然 isFetchingRef 能挡住并发请求,但在请求刚结束、数据刚渲染完的那一瞬间,锁已经释放,layout 变化又来了一次触发,就会发出第二个请求。
调用方可以这样使用:
scss
const { loadMore, lastFetchTimeRef } = usePaginatedList({ ... });
const handleEndReached = useCallback(() => {
// 上次请求结束后 500ms 内不响应
if (Date.now() - lastFetchTimeRef.current < 500) return;
loadMore();
}, [loadMore, lastFetchTimeRef]);
这是一个协作式冷却的设计:Hook 提供时间戳,调用方决定冷却策略,关注点分离。
FetchType 的设计意图
ini
export type FetchType = 'init' | 'refresh' | 'loadMore';
type 被透传给 fetcher,这让调用方可以根据加载类型做差异化处理:
typescript
fetcher: ({ pageNumber, pageSize, type }) => {
// 比如刷新时清除本地缓存,初始化时走缓存优先
if (type === 'refresh') {
cache.clear();
}
return api.getList({ pageNumber, pageSize });
}
Hook 不需要知道这些业务逻辑,只是诚实地把信息传出去。
数据合并策略
ini
setData(prev => (type === 'loadMore' ? [...prev, ...items] : items));
只有 loadMore 时追加数据,init 和 refresh 都是替换。用函数式更新 prev => ... 而不是直接引用 data,同样是为了避免闭包里读到过期的 data。
完整代码
ini
import {useCallback, useEffect, useState, useRef} from 'react';
export type FetchType = 'init' | 'refresh' | 'loadMore';
interface PaginatedResult<T> {
data: T[];
total: number;
}
interface UsePaginatedListOptions<T> {
fetcher: (params: {
pageNumber: number;
pageSize: number;
type: FetchType;
}) => Promise<{
code: number | string;
data?: PaginatedResult<T>;
}>;
pageSize?: number;
autoLoad?: boolean;
}
export function usePaginatedList<T>({
fetcher,
pageSize = 10,
autoLoad = true,
}: UsePaginatedListOptions<T>) {
const [data, setData] = useState<T[]>([]);
const [pageNum, setPageNum] = useState(1);
const pageNumRef = useRef(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [total, setTotal] = useState(0);
const isFetchingRef = useRef(false);
const lastFetchTimeRef = useRef(0);
const fetchData = useCallback(
async (page: number, type: FetchType) => {
if (isFetchingRef.current) return;
isFetchingRef.current = true;
if (type === 'refresh') {
setRefreshing(true);
} else if (type === 'loadMore') {
setLoadingMore(true);
} else {
setLoading(true);
}
try {
const res = await fetcher({pageNumber: page, pageSize, type});
if (res.code === 200 && res.data) {
const items = res.data.data ?? [];
setData(prev => (type === 'loadMore' ? [...prev, ...items] : items));
setTotal(res.data.total ?? 0);
setHasMore(items.length >= pageSize);
pageNumRef.current = page;
setPageNum(page);
}
} catch {
} finally {
setLoading(false);
setRefreshing(false);
setLoadingMore(false);
isFetchingRef.current = false;
lastFetchTimeRef.current = Date.now();
}
},
[fetcher, pageSize],
);
const refresh = useCallback(async () => {
await fetchData(1, 'refresh');
}, [fetchData]);
const loadMore = useCallback(() => {
if (hasMore && !isFetchingRef.current) {
fetchData(pageNumRef.current + 1, 'loadMore');
}
}, [hasMore, fetchData]);
useEffect(() => {
if (autoLoad) {
fetchData(1, 'init');
}
}, [autoLoad, fetchData]);
return {
data,
total,
loading,
refreshing,
loadingMore,
hasMore,
refresh,
loadMore,
lastFetchTimeRef,
};
}
小结
这个 Hook 没有引入任何第三方库,核心是对 React 几个基础机制的正确使用:
| 问题 | 解决方案 |
|---|---|
| 并发请求重复 | useRef 同步锁(而不是 useState) |
| 闭包读到过期页码 | pageNumRef 追踪最新页码 |
FlatList layout 变化触发双请求 |
lastFetchTimeRef 暴露时间戳给调用方防抖 |
数据合并读到过期 data |
函数式 setData(prev => ...) |
| 三种加载状态混用 | loading / refreshing / loadingMore 分开管理 |
最核心的一条原则:需要在闭包中读取、且要求始终是最新值的变量,用 useRef 而不是 useState。 State 驱动渲染,Ref 驱动逻辑。分清楚这两件事,大部分 React 的"玄学 bug"都能迎刃而解。