本文是 React Query 系列第五篇,你可以通过以下链接了解过去的内容。
- React Query 是做什么的?
- 一个数据获取竟然被 React Query 玩出这么多花样来!
- React Query 的 useQuery 竟也内置了分页查询支持!
- 如何使用 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 件事情:
- 将 useInfiniteQuery() 的 Query Key 数组
['users', debouncedKeyword]
进行了修改------从['users', '']
变为['users', 'as']
------当 Query Key 数组发生变化后,useInfiniteQuery() 便会重新发起了请求。 - 另外,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() 的使用和学习成本。
希望本文的介绍,对你的工作有所帮助。咱们下篇再见吧。