1. 为什么需要重新理解useQuery
在React生态中,数据获取一直是个令人头疼的问题。我见过太多项目里充斥着这样的代码:在useEffect里写fetch调用,手动管理loading状态,处理错误,还要考虑缓存和依赖更新。这些重复劳动不仅浪费时间,还容易引入难以追踪的bug。
React Query的出现改变了这一切。它的useQuery钩子将数据获取抽象成了一个声明式的接口,开发者只需关心"要什么数据",而不必操心"怎么获取数据"。但真正理解它的工作原理,远比简单调用API更有价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. useQuery的核心设计思想
2.1 查询键(Query Key)的魔法
查询键是React Query最精妙的设计之一。它不仅仅是一个字符串ID,而是一个可序列化的数组,能够精确描述你的数据依赖。比如:
javascript复制useQuery(['todos', { status: 'done' }], fetchTodos)
这个键由两部分组成:'todos'作为基础标识,{ status: 'done' }作为查询变量。React Query会基于这个键自动管理缓存,当组件重新渲染时,如果键不变就直接返回缓存数据。
2.2 状态管理的自动化
传统数据获取需要手动维护多个状态:
javascript复制const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useQuery将这些状态统一封装在一个对象中:
javascript复制const { data, isLoading, isError, error } = useQuery(...)
更关键的是,这些状态的更新是原子性的,不会出现loading为false但data还是null的中间状态。
3. 从零实现基础版useQuery
3.1 基本骨架搭建
我们先实现最基础的版本:
javascript复制function useQuery(queryKey, queryFn) {
const [state, setState] = useState({
data: undefined,
isLoading: true,
isError: false,
error: null
})
useEffect(() => {
let isMounted = true
queryFn()
.then(data => {
if (isMounted) {
setState({
data,
isLoading: false,
isError: false,
error: null
})
}
})
.catch(error => {
if (isMounted) {
setState({
data: undefined,
isLoading: false,
isError: true,
error
})
}
})
return () => {
isMounted = false
}
}, [queryKey, queryFn])
return state
}
这个版本已经能处理基本的数据获取,但缺少很多关键特性。
3.2 添加缓存功能
真正的useQuery会在全局存储查询结果。我们需要创建一个缓存管理器:
javascript复制const queryCache = new Map()
function useQuery(queryKey, queryFn) {
const cacheKey = JSON.stringify(queryKey)
const [state, setState] = useState(() => {
// 初始化时检查缓存
return queryCache.get(cacheKey) || {
data: undefined,
isLoading: true,
isError: false,
error: null
}
})
useEffect(() => {
let isMounted = true
// 如果缓存中有数据且不是错误状态,直接使用
const cached = queryCache.get(cacheKey)
if (cached && !cached.isError) {
setState(cached)
return
}
queryFn()
.then(data => {
if (isMounted) {
const newState = {
data,
isLoading: false,
isError: false,
error: null
}
queryCache.set(cacheKey, newState)
setState(newState)
}
})
// ...错误处理同上
}, [cacheKey, queryFn])
// ...返回状态
}
4. 高级特性实现
4.1 过期和重新获取
真正的useQuery会在特定条件下自动重新获取数据。我们需要添加staleTime和refetchInterval:
javascript复制function useQuery(queryKey, queryFn, options = {}) {
const {
staleTime = 0, // 默认立即过期
refetchInterval
} = options
// ...其他状态
const [lastFetchedAt, setLastFetchedAt] = useState(null)
useEffect(() => {
let isMounted = true
let refetchTimer = null
const fetchData = () => {
return queryFn()
.then(data => {
if (isMounted) {
const newState = {
data,
isLoading: false,
isError: false,
error: null,
lastFetchedAt: Date.now()
}
queryCache.set(cacheKey, newState)
setState(newState)
}
})
// ...错误处理
}
const shouldRefetch = !lastFetchedAt ||
(staleTime > 0 && Date.now() - lastFetchedAt > staleTime)
if (shouldRefetch) {
fetchData()
}
if (refetchInterval) {
refetchTimer = setInterval(fetchData, refetchInterval)
}
return () => {
isMounted = false
if (refetchTimer) clearInterval(refetchTimer)
}
}, [cacheKey, queryFn, staleTime, refetchInterval])
}
4.2 依赖追踪和自动更新
React Query会在依赖变化时自动重新获取数据。我们的实现已经通过useEffect的依赖数组实现了这一点,但可以优化:
javascript复制useEffect(() => {
// 只有当queryKey真正变化时才重新获取
// 而不是queryFn变化就获取
}, [cacheKey]) // 只依赖cacheKey而不是整个queryFn
5. 性能优化关键点
5.1 请求去重
当多个组件使用相同的queryKey时,不应该发起重复请求。我们需要一个全局的请求追踪系统:
javascript复制const inFlightRequests = new Map()
function useQuery(queryKey, queryFn) {
// ...
useEffect(() => {
const fetchData = async () => {
if (inFlightRequests.has(cacheKey)) {
// 如果已经有相同请求在进行中,等待它完成
try {
const data = await inFlightRequests.get(cacheKey)
// 更新状态...
} catch (error) {
// 处理错误...
}
return
}
const request = queryFn()
inFlightRequests.set(cacheKey, request)
try {
const data = await request
// 更新状态...
} catch (error) {
// 处理错误...
} finally {
inFlightRequests.delete(cacheKey)
}
}
// ...
}, [cacheKey])
}
5.2 内存管理
当组件卸载时,应该考虑是否保留缓存。React Query提供了gcTime选项:
javascript复制const queryCache = new Map()
const cacheTimers = new Map()
function setCacheWithExpiry(key, value, gcTime) {
queryCache.set(key, value)
if (cacheTimers.has(key)) {
clearTimeout(cacheTimers.get(key))
}
if (gcTime !== Infinity) {
const timer = setTimeout(() => {
queryCache.delete(key)
cacheTimers.delete(key)
}, gcTime)
cacheTimers.set(key, timer)
}
}
6. 实际应用中的经验教训
6.1 查询键的最佳实践
经过多个项目实践,我总结了这些经验:
- 将查询键结构化,比如
['posts', 'list', { page: 1 }]比'posts-page-1'更好 - 避免在键中使用大型对象,保持最小化
- 对于依赖props的查询,确保键包含所有必要依赖
6.2 错误处理的正确方式
很多人只检查isError,但更健壮的做法是:
javascript复制const { data, error, isError, isLoading } = useQuery(...)
if (isLoading) return <Loader />
if (isError) {
// 根据error类型显示不同UI
if (error.status === 404) return <NotFound />
return <ErrorDisplay error={error} />
}
return <DataDisplay data={data} />
6.3 与Suspense的配合使用
React 18的Suspense与React Query完美配合:
javascript复制// 启用Suspense模式
const { data } = useQuery(..., { suspense: true })
// 在组件树上层
<Suspense fallback={<Loader />}>
<DataComponent />
</Suspense>
7. 测试策略
7.1 单元测试要点
测试自定义hook需要使用@testing-library/react-hooks:
javascript复制import { renderHook, waitFor } from '@testing-library/react'
import { useQuery } from './useQuery'
test('should fetch data', async () => {
const mockFn = jest.fn().mockResolvedValue('test data')
const { result } = renderHook(() => useQuery(['test'], mockFn))
expect(result.current.isLoading).toBe(true)
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data).toBe('test data')
})
7.2 集成测试技巧
在完整应用中测试时,注意:
- 模拟网络请求(使用msw等工具)
- 测试缓存行为
- 验证错误边界处理
8. 与官方实现的差异
我们的简易版缺少了这些重要特性:
- 乐观更新(optimistic updates)
- 预取(prefetching)
- 分页查询的自动处理
- 更精细的缓存策略
- 开发工具集成
但理解了这个基础实现,再去研究官方源码会容易得多。React Query的完整实现大约有1万行代码,但核心思想与我们这个300行的版本是一致的。
