探究 useInfiniteQuery() 在分页查询场景下的应用

本文是 React Query 系列第五篇,你可以通过以下链接了解过去的内容。

  1. React Query 是做什么的?
  2. 一个数据获取竟然被 React Query 玩出这么多花样来!
  3. React Query 的 useQuery 竟也内置了分页查询支持!
  4. 如何使用 React Query 做下拉数据自动刷新?

上一篇文章中,我们介绍了 useInfiniteQuery() 在无限查询上的应用,并最终实现了一个下拉数据自动刷新的功能。

本文将继续探索 useInfiniteQuery() 在无限查询场景上的应用------分页查询------不仅包含分页请求逻辑,还有关键字搜索逻辑------来加深对它的理解。

最终要实现的效果

网页中通常会涉及到信息检索,当检索的信息非常多时,就要支持下拉数据时进行远程加载。

类似下面这样:

这也是本文要实现的最终效果,现在开始吧。

创建项目

我们使用 Vite 来快速创建一个 React 项目。

bash 复制代码
npm create vite@latest react-query-demos -- --template react
cd react-query-demos

安装 React Query 依赖

安装 react-query 依赖,启动项目。

bash 复制代码
npm install react-query
npm install
npm run dev

接下来删除 index.css 中的内容,再修改 App.jsx,注入 React Query 上下文依赖。

jsx 复制代码
import { QueryClient, QueryClientProvider, useQuery } from 'react-query'

const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

function Example() {
  return <div>{/* ... */}</div>
}

<Example /> 就是我们后续要实现的一个组件。

不过我们还要准备一个模拟请求服务,结果是为了能够通过 http://localhost:3000/api/users?page=1&size=15&keyword=ab 地址查询分页数据。

创建模拟请求服务

这部分 2 块。先制作模拟数据,再启动模拟服务。

制作模拟数据

模拟数据使用的是 faker.js。项目中安装依赖 @faker-js/faker。

bash 复制代码
npm install @faker-js/faker --save-dev

接着,项目中创建 tool/faker.js 文件。内容如下:

js 复制代码
// tool/faker.js
import { faker } from '@faker-js/faker'
import fs from 'node:fs'

const data = [];

for (let i = 0; i < 2000; i++) {
  data.push({
    id: faker.string.uuid(),
    name: faker.person.fullName(),
    email: faker.internet.email(),
  })
}

fs.writeFileSync('users.json', JSON.stringify(data, null, 2), 'utf-8');

我们模拟了 2000 条数据。执行:

bash 复制代码
node ./tool/faker.js

最后,在在项目根目录下会生成一个文件 users.json

创建模拟服务

有了模拟数据,现在启动一个模拟服务。我们会基于 users.json 文件中的数据对外提供一个支持分页、也支持关键字查询的服务。

在项目中创建 server/app.js 文件。内容如下:

js 复制代码
// server/app.js
import express from 'express';
import fs from 'node:fs';
const app = express();

// 读取 data.json 文件
const data = JSON.parse(fs.readFileSync('users.json', 'utf-8'));

// 定义分页路由
app.get('/api/users', (req, res) => {
  const currentPage = parseInt(req.query.page) || 1; // 获取请求的页码,默认值为 1
  const pageSize = parseInt(req.query.size) || 10; // 获取请求的页面条数,默认值为 1
  const startIndex = (currentPage - 1) * pageSize;
  const endIndex = currentPage * pageSize;

  // 获取查询参数
  const keyword = req.query.keyword; // 获取 keyword 查询参数

  // 过滤数据
  let filteredData = data;
  if (keyword) {
    filteredData = data.filter(item => item.name.toLowerCase().includes(keyword.toLowerCase()));
  }

  // 获取当前页数据
  const currentPageData = filteredData.slice(startIndex, endIndex);

  res.header('Access-Control-Allow-Origin', '*');

  // 返回分页数据
  res.json({
    list: currentPageData,
    total: filteredData.length,
  });
});

// 启动服务器
app.listen(3000, () => {
  console.log('Server is listening on port 3000');
});

执行一下,启动服务:

bash 复制代码
node --watch .\server\app.js

最终,会在本地 3000 端口启动了一个服务,并提供了 /api/users 路由查询用户数据。

浏览器访问:http://localhost:3000/api/users?keyword=ab 。效果如下:

无限查询

我们先使用 useInfiniteQuery() 实现一个简单的无限查询。

先基于 /api/users 封装一个 getUsers() 方法。

jsx 复制代码
const getUsers = async (pageParam) => {
  return fetch(`http://localhost:3000/api/users?page=${pageParam.page}&size=${pageParam.size}&keyword=${pageParam.keyword ?? ''}`).then(res => {
    return res.json().then(({ list, total }) => {
      return {
        total,
        data: list,
        hasMore: pageParam.page * pageParam.size < total
      }
    })
  })
}

然后,填充 <Example /> 组件里的内容。

jsx 复制代码
function Example() {
  const {
    isLoading,
    isFetchingNextPage,
    hasNextPage,
    isError,
    error,
    data,
    fetchNextPage
  } = useInfiniteQuery(
    ['users'],
    ({ pageParam = { page: 1, size: 10 } }) => getUsers(pageParam),
    {
      getNextPageParam: (lastPage, allPages) => {
        return lastPage.hasMore ? { page: allPages.length + 1, size: 10 } : undefined
      },
      refetchOnWindowFocus: false, // Prevent refetching on window focus
      keepPreviousData: true
    }
  )

  const loadMoreRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting && hasNextPage) {
        fetchNextPage();
      }
    });

    if (loadMoreRef.current) {
      observer.observe(loadMoreRef.current);
    }

    return () => observer.disconnect();
  }, [hasNextPage, fetchNextPage]);


  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (isError) {
    return <div>Error: {error.message}</div>
  }

  return (
    <div style={{ height: '80vh', overflow: 'auto', border: '2px solid' }}>
      <p style={{ position: 'sticky', top: 0, backgroundColor: '#fff', padding: 4 }}>
        总共 <strong>{data.pages[0].total}</strong> 条数据。
      </p>
      <ol style={{ opacity: hasPreviousPage ? 0.5 : 1 }}>
        {data.pages.map((page) => (
          <>
            {page.data.map((user) => (
              <li key={user.id}>{user.name}({user.email})</li>
            ))}
          </>
        ))}
      </ol>
      <div className="loadMore" style={{ height: '30px', lineHeight: '30px' }} ref={loadMoreRef}>
        {
          isFetchingNextPage ? <span>Loading...</span> : <span>--- 我是有底线的 ---</span>
        }
      </div>
    </div>
  )
}

npm run dev 启动项目,效果如下:

这里,我们就是实现了用户的分页查询。接下来,我们还要增加一个用户名搜索的功能。

增加查询条件

首先,为组件增加搜索关键字状态 keyword。

jsx 复制代码
const [keyword, setKeyword] = useState('')

然后,在返回 JSX 中增加输入框,设置 keyword。

jsx 复制代码
function handleChange(e) {
  setKeyword(e.target.value)
}

return (
  <div style={{ height: '80vh', overflow: 'auto', border: '2px solid' }}>
    <p style={{ position: 'sticky', top: 0, backgroundColor: '#fff', padding: 4 }}>
      总共 <strong>{data.pages[0].total}</strong> 条数据。
      <input type="text" value={keyword} onChange={handleChange} />
    </p>
    {/* ... */}
  <div>
)

不过给 useInfiniteQuery() 的 keyword 最好要经过防抖处理。这里引入一个 useDebounce Hook 和 debouncedKeyword。

jsx 复制代码
const useDebounce = (value, delay) => {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    // 创建一个定时器
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // 清除定时器,避免内存泄漏
    return () => clearTimeout(timer);
  }, [value, delay]); // 依赖值和延迟时间,确保在值或延迟时间变化时重新设置定时器

  return debouncedValue;
};

const debouncedKeyword = useDebounce(keyword, 220); // 设置 500ms 的延迟时间

接着修改 useInfiniteQuery() 部分的代码。

diff 复制代码
const {/* ... */} = useInfiniteQuery(
- ['users'],
+ ['users', debouncedKeyword],
- ({ pageParam = { page: 1, size: 10 } }) => getUsers(pageParam),
+ ({ pageParam = { page: 1, size: 10 }, queryKey }) => {
+   return getUsers({ ...pageParam, keyword: queryKey[1] /* 即 debo+ uncedKeyword */ })
+ }
)

这里是实现关键字搜索功能的核心代码。我们做了 2 件事情:

  1. 将 useInfiniteQuery() 的 Query Key 数组 ['users', debouncedKeyword] 进行了修改------从 ['users', ''] 变为 ['users', 'as']------当 Query Key 数组发生变化后,useInfiniteQuery() 便会重新发起了请求
  2. 另外,QueryFn 接受的参数中,除了 pageParam,还有一个 queryKey。queryKey 即表示调用 useInfiniteQuery() 时传入的 Query Key 数组,可以通过它获得此时的 debouncedKeyword。

接下来进行测试------输入"as"。效果如下:

观察能够看到,控制台一下子使用关键字"as"发起了 2 个请求,这是因为之前的查询的数据比较多,滚动条位于底部,后续查询后导致探测 DOM 元素 .loadMore 重新被暴露一次,因此多触发了一个请求。

我们刷新页面再重试一下,从第一页开始进行关键字搜索,同样是"as"。

观察就能看到,输入"as"之后,首先查询了第一页,而后滚动页面后续的数据跟着请求了。

封装 usePaginationQuery()

以上的分页场景,其实可以提炼出一个工具 Hook 来使用,我们就叫它 usePaginationQuery() 吧。

jsx 复制代码
function usePaginationQuery(queryKey, { reqFn, reqFnParams, paginationParams }, options) {
  reqFnParams = { ...reqFnParams }
  paginationParams = { pageProp: 'page', sizeProp: 'size', totalProp: 'total', pageSize: 20, ...paginationParams }

  return useInfiniteQuery(
    Array.isArray(queryKey) ? [...queryKey, reqFnParams] : [queryKey, reqFnParams],
    ({ pageParam = { [paginationParams.pageProp]: 1, [paginationParams.sizeProp]: paginationParams.pageSize }, queryKey }) => reqFn({ ...pageParam, ...queryKey[queryKey.length - 1] /* 即 queryParams */ }),
    {
      refetchOnWindowFocus: false,
      keepPreviousData: true,
      ...options,
      getNextPageParam: (lastPage, allPages) => {
        const currPage = allPages.length
        return currPage * paginationParams.pageSize < lastPage[paginationParams.totalProp] ? { [paginationParams.pageProp]: currPage + 1, [paginationParams.sizeProp]: paginationParams.pageSize } : undefined
      },
    }
  )
}

替换掉原来直接使用 useInfiniteQuery() 的地方。

jsx 复制代码
  // const {
  // isLoading,
  // hasPreviousPage,
  // isFetchingNextPage,
  // hasNextPage,
  // isError,
  // error,
  // data,
  // fetchNextPage
  // } = useInfiniteQuery(
  //   ['posts', debouncedKeyword],
  //   ({ pageParam = { page: 1, size: 10 }, queryKey }) => {
  //     console.log('>>>>>>>> ', { pageParam, queryKey })

  //     return getUsers({ ...pageParam, keyword: queryKey[1] })
  //   },
  //   {
  //     getNextPageParam: (lastPage, allPages) => {
  //       return lastPage.hasMore ? { page: allPages.length + 1, size: 10 } : undefined
  //     },
  //     refetchOnWindowFocus: false, // Prevent refetching on window focus
  //     keepPreviousData: true
  //   }
  // )

const {
  isLoading,
  hasPreviousPage,
  isFetchingNextPage,
  hasNextPage,
  isError,
  error,
  data,
  fetchNextPage
} = usePaginationQuery(['users'], { reqFn: getUsers, reqFnParams: { keyword: debouncedKeyword } })

代码精简了很多。usePaginationQuery() Hook 帮助你省去了 queryFn 和 getNextPageParam 的学习成本,也不用关心分页参数的拼接

当然,如果分页参数不是 { page: 1, size: 10 } 的形式,而是 { currPage: 1, pageSize: 10 },那么可以通过设置 paginationParams 来修改:

diff 复制代码
const {
  // ...
} = usePaginationQuery(['users'], { 
  reqFn: getUsers,
  reqFnParams: { keyword: debouncedKeyword },
+ paginationParams: { pageProp: 'currPage', sizeProp: 'pageSize' }
})

想要修改每页条数也可以,还是通过设置 paginationParams:

diff 复制代码
const {
  // ...
} = usePaginationQuery(['users'], { 
  reqFn: getUsers,
  reqFnParams: { keyword: debouncedKeyword },
+ paginationParams: { pageSize: 50 }
})

总结

本文继续探索了 useInfiniteQuery() 在无限查询场景上的应用------分页查询------不仅包含分页请求逻辑,还有关键字搜索逻辑------来帮助大家加深对它的理解。

最后,还封装了一个工具 hook usePaginationQuery(),进一步简化了 useInfiniteQuery() 的使用和学习成本。

希望本文的介绍,对你的工作有所帮助。咱们下篇再见吧。

相关推荐
高山我梦口香糖39 分钟前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_7482352442 分钟前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240252 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar2 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人2 小时前
前端知识补充—CSS
前端·css
GISer_Jing2 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试
m0_748245522 小时前
吉利前端、AI面试
前端·面试·职场和发展
理想不理想v3 小时前
webpack最基础的配置
前端·webpack·node.js
pubuzhixing3 小时前
开源白板新方案:Plait 同时支持 Angular 和 React 啦!
前端·开源·github
2401_857600953 小时前
SSM 与 Vue 共筑电脑测评系统:精准洞察电脑世界
前端·javascript·vue.js