1. 为什么我们需要React useQuery?
在现代前端开发中,数据获取是一个永恒的话题。传统的React应用中,我们通常会在组件挂载时(useEffect)发起数据请求,然后手动管理加载状态、错误处理和缓存逻辑。这种模式虽然直接,但随着应用复杂度上升,很快就会遇到几个典型问题:
- 重复请求:多个组件需要相同数据时,每个组件都会独立发起请求
- 状态管理混乱:loading、error、data等状态需要手动维护
- 缓存策略缺失:相同数据在不同时间点会被重复请求
- 竞态条件:快速切换页面可能导致旧请求覆盖新请求的结果
React Query(特别是其中的useQuery hook)正是为解决这些问题而生。它不是一个状态管理库,而是一个专门为异步数据管理设计的工具。我在多个大型项目中实践后发现,合理使用useQuery可以减少约40%的数据管理代码量,同时显著提升应用性能。
2. useQuery核心用法详解
2.1 基础查询配置
一个最基本的useQuery调用包含三个关键参数:
javascript复制const { data, isLoading, isError, error } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then(res => res.json())
})
这里有几个关键点需要注意:
-
queryKey:这是查询的唯一标识符,不仅用于缓存,还用于后续的手动查询控制。我建议总是使用数组形式,即使只有一个元素。对于分页查询,应该包含页码:
['todos', { page: 1 }] -
queryFn:实际执行数据获取的函数。注意这里应该返回一个Promise。在实际项目中,我通常会抽象出统一的API客户端:
javascript复制const apiClient = {
getTodos: () => axios.get('/api/todos').then(res => res.data),
// 其他API方法...
}
// 使用时
queryFn: apiClient.getTodos
2.2 状态管理进阶
useQuery返回的对象包含多个状态标志:
javascript复制const {
data, // 成功获取的数据
error, // 错误对象
isFetching, // 正在后台刷新
isLoading, // 首次加载中(无缓存数据)
isError, // 查询失败
isSuccess, // 查询成功
status, // 'loading' | 'error' | 'success'
fetchStatus, // 'fetching' | 'paused' | 'idle'
} = useQuery(/* ... */)
实际开发中,我发现很多开发者会混淆isLoading和isFetching:
isLoading:表示查询正在首次加载(无缓存数据)isFetching:表示查询正在获取数据(包括后台刷新)
2.3 查询配置选项
useQuery接受丰富的配置选项,以下是我在项目中常用的几个:
javascript复制useQuery({
// ...其他参数
staleTime: 5 * 60 * 1000, // 数据过期时间(5分钟)
cacheTime: 30 * 60 * 1000, // 缓存保留时间(30分钟)
retry: 2, // 失败后自动重试次数
refetchOnWindowFocus: true, // 窗口聚焦时重新获取
refetchOnMount: true, // 组件挂载时重新获取
refetchOnReconnect: true, // 网络恢复时重新获取
enabled: shouldFetch, // 控制查询是否执行
})
关于staleTime和cacheTime的实践经验:
- 对于实时性要求高的数据(如股票价格),设置较短的staleTime(如10秒)
- 对于几乎不变的数据(如用户资料),可以设置较长的staleTime(如24小时)
- cacheTime应该总是大于staleTime,否则会出现奇怪的行为
3. 实际项目中的高级用法
3.1 依赖查询
有时我们需要根据一个查询结果发起另一个查询。传统方式会导致"回调地狱",而useQuery可以通过enabled选项优雅处理:
javascript复制const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
const { data: projects } = useQuery({
queryKey: ['projects', user?.teamId],
queryFn: () => fetchProjects(user.teamId),
enabled: !!user, // 只有user存在时才执行
})
3.2 分页和无限加载
对于分页数据,React Query提供了专门的useInfiniteQuery:
javascript复制const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam = 1 }) => fetchProjects(pageParam),
getNextPageParam: (lastPage, allPages) =>
lastPage.hasMore ? allPages.length + 1 : undefined,
})
// 渲染时
{data.pages.map((page) => (
<React.Fragment key={page.nextPage}>
{page.data.map(project => (
<ProjectCard key={project.id} project={project} />
))}
</React.Fragment>
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? '加载中...' : '加载更多'}
</button>
)}
3.3 预加载和缓存管理
React Query提供了QueryClient实例来手动控制缓存:
javascript复制const queryClient = useQueryClient();
// 预加载数据
queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// 更新缓存数据
queryClient.setQueryData(['todo', todoId], newTodo)
// 使缓存失效
queryClient.invalidateQueries(['todos'])
我在实际项目中常用的一种模式是:在用户hover到某个链接时预加载数据:
javascript复制const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
}
<Link to={`/user/${userId}`} onMouseEnter={handleMouseEnter}>
查看用户
</Link>
4. 性能优化和常见问题
4.1 查询去重与合并
React Query会自动合并同时发起的相同查询。例如:
javascript复制// 组件A
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
// 组件B
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
实际上只会发起一个网络请求,两个组件都会订阅相同的数据源。这在大型应用中能显著减少不必要的请求。
4.2 调试技巧
React Query提供了专门的开发者工具:
javascript复制import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
function App() {
return (
<>
{/* 你的应用 */}
<ReactQueryDevtools initialIsOpen={false} />
</>
)
}
这个工具可以:
- 查看所有活跃的查询
- 检查查询状态和数据
- 手动触发查询刷新
- 模拟网络错误
4.3 常见陷阱与解决方案
问题1:查询不更新
可能原因:
- queryKey没有变化(特别是当使用对象作为key的一部分时)
- 父组件重新渲染导致子组件重新挂载
解决方案:
- 确保queryKey中包含所有依赖变量
- 使用React.memo避免不必要的重新渲染
问题2:竞态条件
场景:快速切换用户ID可能导致旧响应覆盖新响应
解决方案:
javascript复制useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const data = await fetchUser(userId)
// 检查查询是否仍然相关
const [currentUserId] = queryClient.getQueryData(['user']) || []
if (currentUserId !== userId) {
throw new Error('Query outdated')
}
return data
},
})
问题3:内存泄漏
当组件卸载时,默认情况下查询会保留在缓存中(根据cacheTime)。对于大量临时数据,这可能导致内存问题。
解决方案:
javascript复制// 在组件卸载时立即移除查询
useEffect(() => {
return () => {
queryClient.removeQueries(['tempData'])
}
}, [])
5. 与状态管理库的配合
很多开发者会问:React Query能否替代Redux/MobX?我的经验是:
- React Query负责:服务器状态(异步数据)
- 状态管理库负责:客户端状态(UI状态、表单数据等)
两者可以完美共存。例如:
javascript复制// 使用Redux管理客户端状态
const darkMode = useSelector(state => state.ui.darkMode)
// 使用React Query管理服务器状态
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
})
在最新项目中,我通常的架构选择是:
- React Query + Context API:适用于中小型应用
- React Query + Zustand:适用于需要更复杂客户端状态管理的应用
- React Query + Redux Toolkit:适用于大型企业级应用(特别是需要中间件的情况)
6. TypeScript集成
React Query对TypeScript的支持非常优秀。以下是一些类型安全的使用模式:
typescript复制interface Todo {
id: number
title: string
completed: boolean
}
// 定义查询函数类型
const fetchTodos = async (): Promise<Todo[]> => {
const response = await axios.get('/api/todos')
return response.data
}
// 使用泛型指定返回类型
const { data } = useQuery<Todo[]>({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// 自动推断data为Todo[]类型
data?.map(todo => todo.title)
对于更复杂的场景,可以创建自定义hook:
typescript复制function useTodos() {
return useQuery<Todo[]>({
queryKey: ['todos'],
queryFn: fetchTodos,
})
}
// 使用时自动获得类型推断
const { data: todos } = useTodos()
7. 测试策略
测试useQuery相关的组件时,需要特别注意异步行为。我的常用测试方案:
7.1 使用Mock Service Worker (MSW)
javascript复制import { setupWorker, rest } from 'msw'
const worker = setupWorker(
rest.get('/api/todos', (req, res, ctx) => {
return res(
ctx.delay(150),
ctx.json([
{ id: 1, title: 'Test todo', completed: false }
])
)
})
)
beforeAll(() => worker.start())
afterAll(() => worker.stop())
7.2 组件测试示例
javascript复制import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
test('displays todos', async () => {
render(
<QueryClientProvider client={queryClient}>
<TodoList />
</QueryClientProvider>
)
// 初始加载状态
expect(screen.getByText(/loading/i)).toBeInTheDocument()
// 等待数据加载完成
await waitFor(() => {
expect(screen.getByText('Test todo')).toBeInTheDocument()
})
})
7.3 测试自定义hook
javascript复制import { renderHook, waitFor } from '@testing-library/react'
import { useTodos } from './todoHooks'
test('useTodos returns data', async () => {
const { result } = renderHook(() => useTodos(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual([
{ id: 1, title: 'Test todo', completed: false }
])
})
8. 实战经验分享
经过多个项目的实践,我总结出以下最佳实践:
- 统一错误处理:创建自定义hook封装公共错误处理逻辑
javascript复制function useAppQuery(options) {
const toast = useToast()
return useQuery({
...options,
onError: (error) => {
toast.error(error.message)
options.onError?.(error)
}
})
}
- 分页参数处理:创建标准化的分页hook
javascript复制function usePaginatedQuery(key, fetchFn, options = {}) {
const [page, setPage] = useState(1)
const query = useQuery({
queryKey: [...key, page],
queryFn: () => fetchFn(page),
...options
})
return {
...query,
page,
setPage,
isFirstPage: page === 1,
}
}
- 乐观更新模式:在修改操作时立即更新UI
javascript复制const queryClient = useQueryClient()
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// 取消当前查询以避免覆盖
await queryClient.cancelQueries(['todos'])
// 保存之前的数据以便回滚
const previousTodos = queryClient.getQueryData(['todos'])
// 乐观更新
queryClient.setQueryData(['todos'], old =>
old.map(todo =>
todo.id === newTodo.id ? newTodo : todo
)
)
return { previousTodos }
},
onError: (err, newTodo, context) => {
// 出错时回滚
queryClient.setQueryData(['todos'], context.previousTodos)
},
onSettled: () => {
// 操作完成后重新获取确保一致性
queryClient.invalidateQueries(['todos'])
}
})
- 请求取消:在组件卸载时取消进行中的请求
javascript复制useQuery({
queryKey: ['data'],
queryFn: async ({ signal }) => {
const response = await fetch('/api/data', { signal })
return response.json()
}
})
- 离线处理:通过persistQueryClient插件支持离线缓存
javascript复制import { persistQueryClient } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
const persister = createSyncStoragePersister({
storage: window.localStorage,
})
persistQueryClient({
queryClient,
persister,
maxAge: 24 * 60 * 60 * 1000, // 24小时
})
9. 与其他库的集成
9.1 与Next.js集成
在Next.js中,React Query可以与getServerSideProps或getStaticProps配合使用:
javascript复制export async function getServerSideProps() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
function App({ dehydratedState }) {
const [queryClient] = useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
<Hydrate state={dehydratedState}>
{/* 你的应用 */}
</Hydrate>
</QueryClientProvider>
)
}
9.2 与GraphQL集成
虽然React Query本身不限于REST,但与GraphQL配合使用时,我推荐使用graphql-request或Apollo Client:
javascript复制import { request } from 'graphql-request'
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => request(
'/graphql',
`
query User($id: ID!) {
user(id: $id) {
id
name
email
}
}
`,
{ id: userId }
)
})
9.3 与SWR的比较
SWR是另一个流行的数据获取库,与React Query相比:
React Query优势:
- 更丰富的缓存控制
- 更完善的开发工具
- 更强大的突变(mutation)处理
- 更灵活的查询生命周期
SWR优势:
- 更小的包体积
- 更简单的API
- 内置的请求去重
我的选择标准:
- 复杂应用:React Query
- 简单应用或需要最小化包体积时:SWR
10. 性能监控与优化
10.1 查询性能分析
React Query提供了内置的性能监控工具:
javascript复制const queryClient = new QueryClient({
defaultOptions: {
queries: {
onSuccess: (data) => {
performance.mark('querySuccess')
},
onSettled: (data, error) => {
const measure = performance.measure('queryDuration', {
start: 'queryStart',
end: 'querySuccess'
})
console.log(`Query took ${measure.duration}ms`)
}
}
}
})
10.2 关键性能指标
在实际项目中,我通常会监控:
- 缓存命中率:从缓存获取数据的比例
- 查询持续时间:从发起请求到获取响应的时间
- 重复请求率:相同查询被重复发起的次数
- 内存使用:缓存占用的内存大小
10.3 优化策略
- 批量查询:合并多个小查询为一个大的查询
- 数据分片:只请求当前视图需要的数据
- 智能预加载:基于用户行为预测性地预加载数据
- 压缩响应:使用gzip等压缩算法减少传输数据量
javascript复制// 批量查询示例
const fetchDashboardData = async () => {
const [user, notifications, messages] = await Promise.all([
fetchUser(),
fetchNotifications(),
fetchMessages(),
])
return { user, notifications, messages }
}
const { data } = useQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboardData,
})
11. 安全考虑
11.1 敏感数据处理
对于敏感数据(如用户凭证、支付信息),需要特别注意:
- 不在URL中传递敏感参数:避免查询key包含敏感信息
- 短期缓存:设置较短的staleTime和cacheTime
- 手动清理:在适当的时候手动清除缓存
javascript复制// 不推荐 - 敏感信息在queryKey中
useQuery({
queryKey: ['payment', creditCardNumber],
queryFn: fetchPaymentDetails,
})
// 推荐 - 使用ID代替
useQuery({
queryKey: ['payment', paymentId],
queryFn: () => fetchPaymentDetails(paymentId),
})
11.2 CSRF防护
确保后端API实现了CSRF防护,前端可以这样配合:
javascript复制const queryClient = new QueryClient({
defaultOptions: {
queries: {
fetchOptions: {
credentials: 'include', // 包含cookies
}
}
}
})
11.3 速率限制处理
当API有速率限制时,需要合理配置:
javascript复制useQuery({
queryKey: ['data'],
queryFn: fetchData,
retryDelay: (attempt) => Math.min(attempt * 1000, 30 * 1000), // 指数退避
})
12. 移动端考虑
在移动端使用React Query时,需要特别注意:
- 网络状态感知:根据网络条件调整查询行为
- 离线优先:支持离线访问和后续同步
- 数据量控制:减少不必要的数据传输
javascript复制import { onlineManager } from '@tanstack/react-query'
// 监听网络状态
onlineManager.setEventListener((setOnline) => {
return window.addEventListener('online', () => setOnline(true), false)
})
// 离线时暂停查询
const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'online', // 只在在线时执行
}
}
})
13. 未来演进
React Query的维护团队一直在积极开发新功能。根据官方路线图,未来可能会包含:
- 更智能的缓存策略:基于使用频率自动管理缓存
- 更强大的离线支持:内置的冲突解决和同步机制
- 更细粒度的订阅:只订阅数据的一部分变化
- 服务端组件支持:更好的Next.js集成
作为开发者,我建议定期查看官方文档和GitHub讨论,及时了解最新进展。同时,React Query的API设计非常稳定,现有代码通常不需要频繁调整就能兼容新版本。
