如何封装一个生产级的 React Native 分页列表 Hook

一次彻底解决并发锁、闭包陷阱、双重触发三个经典问题


在 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]);

这里的 pageNumuseState 的值,loadMore 会在每次 pageNum 变化时重新生成。但问题是:loadMore 同时还被 FlatListonEndReached 引用着,在某些 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 时追加数据,initrefresh 都是替换。用函数式更新 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"都能迎刃而解。

相关推荐
小帅不太帅1 小时前
我做了两个工具,一个 7MB 的壳,一个会记住的壳
前端·app·产品
不瘦80斤不改名1 小时前
HTML基础(一)
开发语言·前端·html
UXbot2 小时前
AI画原型工具如何帮非设计师快速生成UI界面
前端·vue.js·ui·kotlin·swift·原型模式·web app
前端若水2 小时前
原生嵌套(Nesting):以后还写 SCSS 吗?
前端·css·scss
兄弟加油,别颓废了。2 小时前
系统全功能详细操作手册,从启动到测试
前端·chrome
ZC跨境爬虫2 小时前
跟着 MDN 学 HTML day_32:(AbstractRange 抽象接口与 DOM 范围操作)
前端·javascript·ui·html·音视频
十子木2 小时前
设置把所有终端移动到最前端的快捷键
前端
陈老老老板2 小时前
Bright Data Web Scraping 实战:用 MCP + Dify 构建 eBay 商品详情采集 AI 工作流(2026)
前端·人工智能
一渊之隔2 小时前
uniapp蓝牙搜索连接展示蓝牙设备包含信号显示
前端·网络·uni-app·bluetooth