1. React useQuery 基础概念解析
在React生态中,数据获取一直是前端开发的核心挑战之一。传统方式中,我们通常需要在组件中手动管理loading状态、错误处理和数据缓存,这不仅增加了代码复杂度,还容易引入难以追踪的bug。useQuery作为React Query库的核心hook,彻底改变了这种局面。
我第一次接触useQuery是在一个电商后台管理系统的开发中。当时项目中有大量需要实时更新的数据表格和图表,手动管理这些数据的状态让我苦不堪言。引入useQuery后,代码量减少了近40%,而且缓存策略让用户体验得到了显著提升。
useQuery本质上是一个自定义hook,它封装了数据获取的全生命周期管理。你只需要提供一个唯一的查询key和一个返回Promise的数据获取函数,它就会自动处理以下事情:
- 请求的发起和取消
- 数据的缓存和更新
- loading和error状态的同步
- 窗口重新聚焦时的自动刷新
- 轮询请求的实现
javascript复制import { useQuery } from 'react-query'
function fetchProducts() {
return fetch('/api/products').then(res => res.json())
}
function Products() {
const { data, isLoading, error } = useQuery('products', fetchProducts)
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<ul>
{data.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
)
}
这个简单的例子展示了useQuery的基本用法。'products'是查询的key,它用于在内部缓存系统中标识这个查询。fetchProducts是数据获取函数,它返回一个Promise。useQuery会自动调用这个函数,并返回包含data、isLoading和error等属性的对象。
提示:查询key不仅可以是字符串,还可以是数组。当需要传递参数时,数组形式的key特别有用,例如:['product', productId]
1.1 useQuery的核心优势
相比传统的数据获取方式,useQuery带来了几个革命性的改进:
-
自动缓存管理:默认情况下,获取的数据会被缓存,当组件卸载后重新挂载时,会先显示缓存数据,同时在后台静默更新。这消除了页面闪烁问题,大幅提升了用户体验。
-
请求去重:如果在同一时刻有多个组件使用相同的查询key发起请求,useQuery会自动合并这些请求,避免重复的网络调用。
-
自动重试:当请求失败时,useQuery会根据配置的retry策略自动重试,而不需要开发者手动实现重试逻辑。
-
依赖请求:可以轻松实现"先获取A数据,再根据A获取B数据"这样的依赖请求链,代码比传统的then链更加清晰。
-
后台刷新:可以配置staleTime和cacheTime等参数,控制数据何时被视为"过时"以及何时从缓存中移除。
在我的实际项目中,这些特性显著减少了代码复杂度。特别是在处理复杂表单和多步骤流程时,依赖请求和自动缓存让开发体验变得异常顺畅。
2. useQuery高级配置详解
2.1 查询配置选项
useQuery的第三个参数是一个配置对象,它提供了丰富的控制选项。以下是一些最常用的配置:
javascript复制const { data } = useQuery('todos', fetchTodos, {
enabled: !!userId, // 只有当userId存在时才启用查询
retry: 3, // 失败时自动重试3次
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
staleTime: 5 * 60 * 1000, // 数据在5分钟内不会重新获取
cacheTime: 24 * 60 * 60 * 1000, // 不活动的数据24小时后从缓存移除
refetchOnWindowFocus: true, // 窗口重新聚焦时自动刷新
refetchInterval: 60000, // 每分钟自动刷新一次
onSuccess: data => {
console.log('获取数据成功:', data)
},
onError: error => {
console.error('获取数据失败:', error)
},
select: data => data.filter(todo => todo.completed) // 转换数据
})
关键配置解析:
-
enabled:这个布尔值参数控制查询是否自动执行。当设置为false时,查询不会自动运行。这在实现依赖查询时特别有用。
-
staleTime vs cacheTime:
- staleTime决定数据在被视为"过时"前保持新鲜的时间。在此期间内,即使组件重新挂载,也不会触发后台刷新。
- cacheTime决定不活动的查询数据在缓存中保留的时间。在此期间内,如果查询再次变为活动状态,将立即返回缓存数据。
-
select:这个函数允许你在数据返回给组件前对其进行转换。它类似于Array.map,但具有记忆功能,只有当数据变化时才会重新计算。
注意:staleTime和cacheTime的单位都是毫秒。设置合理的值可以显著提升应用性能。对于很少变化的数据,可以设置较长的staleTime;对于实时性要求高的数据,可以设置为0。
2.2 分页和无限加载
useQuery非常适合实现分页和无限加载场景。React Query提供了useInfiniteQuery hook专门处理这类需求,但基础useQuery也能很好地完成这个任务。
分页实现示例:
javascript复制function fetchProjects(page = 0) {
return fetch(`/api/projects?page=${page}`).then(res => res.json())
}
function Projects() {
const [page, setPage] = useState(0)
const { data, isLoading } = useQuery(['projects', page], () => fetchProjects(page), {
keepPreviousData: true // 保持上一页数据直到新数据到达
})
return (
<div>
{isLoading ? (
<div>Loading...</div>
) : (
<div>
{data.projects.map(project => (
<p key={project.id}>{project.name}</p>
))}
</div>
)}
<button
onClick={() => setPage(old => Math.max(old - 1, 0))}
disabled={page === 0}
>
Previous Page
</button>
<button
onClick={() => setPage(old => old + 1)}
disabled={data && !data.hasMore}
>
Next Page
</button>
</div>
)
}
关键点说明:
-
查询key包含页码:['projects', page]确保不同页码的查询被分别缓存。
-
keepPreviousData:这个选项使得在获取新数据时,组件仍然可以访问旧数据,避免了页面内容的突然消失。
-
禁用按钮逻辑:根据当前页码和是否有更多数据来禁用翻页按钮,提供更好的用户体验。
在实际项目中,我发现这种实现方式比传统的redux方案简洁得多。特别是在处理复杂的过滤和排序组合时,查询key的灵活性让代码保持整洁。
3. useQuery实战技巧与优化
3.1 性能优化策略
经过多个项目的实践,我总结出以下useQuery性能优化技巧:
- 批量请求:对于需要同时获取的多个数据源,可以使用Promise.all结合单个useQuery,而不是多个独立的useQuery调用。
javascript复制const { data } = useQuery('dashboardData', () =>
Promise.all([
fetch('/api/stats'),
fetch('/api/notifications'),
fetch('/api/user')
]).then(([stats, notifications, user]) => ({
stats,
notifications,
user
}))
)
- 部分订阅:对于大型数据对象,可以使用select选项只订阅需要的部分数据,避免不必要的重新渲染。
javascript复制const { data } = useQuery('user', fetchUser, {
select: user => ({
name: user.name,
avatar: user.avatar
})
})
-
按需获取:使用enabled选项延迟非关键数据的获取,例如只在用户点击某个选项卡时才获取该选项卡的内容。
-
预加载:在用户可能执行操作前预先获取数据。例如,当用户hover到一个按钮上时,可以开始预加载该按钮点击后需要的数据。
javascript复制function UserProfile() {
const queryClient = useQueryClient()
const onHover = () => {
queryClient.prefetchQuery('userDetails', fetchUserDetails)
}
return <button onMouseEnter={onHover}>View Details</button>
}
3.2 错误处理最佳实践
健壮的错误处理是生产级应用的关键。以下是几种处理useQuery错误的实用方法:
- 全局错误处理:配置QueryClient时设置默认的onError回调,统一处理常见错误。
javascript复制const queryClient = new QueryClient({
defaultOptions: {
queries: {
onError: (error) => {
if (error.response?.status === 401) {
// 处理未授权错误
} else if (error.response?.status === 404) {
// 处理未找到资源错误
}
}
}
}
})
- 本地错误处理:在组件级别使用onError回调处理特定于该查询的错误。
javascript复制const { data } = useQuery('user', fetchUser, {
onError: error => {
toast.error(`获取用户数据失败: ${error.message}`)
}
})
-
错误边界:结合React的ErrorBoundary组件捕获并优雅地处理渲染过程中的错误。
-
重试策略:根据错误类型配置不同的重试行为。例如,对于404错误可能不需要重试,而对于网络错误可以尝试多次重试。
javascript复制const { data } = useQuery('user', fetchUser, {
retry: (failureCount, error) => {
if (error.response?.status === 404) return false
return failureCount < 3
}
})
在实际项目中,我发现组合使用这些策略可以显著提升应用的健壮性。特别是在移动网络环境下,合理的重试策略和错误反馈机制对用户体验至关重要。
4. useQuery常见问题与解决方案
4.1 典型问题排查指南
在长期使用useQuery的过程中,我遇到过各种问题,以下是几个最常见的问题及其解决方案:
-
查询不自动执行:
- 检查enabled选项是否被设置为false
- 确认查询key是否发生了变化(特别是当使用数组作为key时)
- 确保数据获取函数确实返回了一个Promise
-
数据更新但UI不刷新:
- 检查select函数是否正确处理了数据变化
- 确认数据确实是新的(console.log原始数据)
- 检查是否有其他因素阻止了组件重新渲染
-
无限循环请求:
- 检查是否在render方法中创建了新的函数引用(应该将函数移到组件外部或使用useCallback)
- 确认staleTime和refetchInterval的设置是否合理
- 检查是否有组件不断卸载和重新挂载
-
缓存行为不符合预期:
- 确认cacheTime和staleTime的设置是否符合需求
- 检查是否有多个查询使用了相同的key但不同的配置
- 考虑使用queryClient.invalidateQueries手动控制缓存失效
4.2 测试策略
测试useQuery相关的组件需要特殊考虑,以下是我的经验总结:
- Mock查询客户端:在测试环境中创建一个带有适当配置的QueryClient实例。
javascript复制const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
function renderWithClient(ui) {
const testQueryClient = createTestQueryClient()
return render(
<QueryClientProvider client={testQueryClient}>
{ui}
</QueryClientProvider>
)
}
- Mock API响应:使用jest.mock或其他mock工具模拟API响应。
javascript复制jest.mock('../api', () => ({
fetchUser: jest.fn().mockResolvedValue({
id: 1,
name: 'Test User'
})
}))
- 测试加载和错误状态:确保测试覆盖所有可能的状态(loading, error, success)。
javascript复制test('displays loading state', async () => {
const { getByText } = renderWithClient(<UserProfile />)
expect(getByText('Loading...')).toBeInTheDocument()
})
test('displays error state', async () => {
jest.spyOn(api, 'fetchUser').mockRejectedValue(new Error('Failed to fetch'))
const { findByText } = renderWithClient(<UserProfile />)
expect(await findByText('Error: Failed to fetch')).toBeInTheDocument()
})
- 验证缓存行为:测试组件是否正确与查询缓存交互。
javascript复制test('prefetches data on hover', async () => {
const queryClient = createTestQueryClient()
const { getByText } = renderWithClient(<UserProfile />, queryClient)
fireEvent.mouseEnter(getByText('View Details'))
await waitFor(() => {
expect(queryClient.getQueryData('userDetails')).not.toBeUndefined()
})
})
在实际项目中,建立完善的测试套件可以显著减少与数据获取相关的bug。特别是在团队协作环境中,良好的测试覆盖率让重构和迭代变得更加安全。
