极简版 useFetch 钩子:何时可以跳过 React Query 及其局限性
使用 AbortController、TTL 过期机制构建一个小型缓存版 useFetch 钩子及其对应的 useMutation 配套函数,同时明确了解你因此放弃了哪些 React Query 功能。
React Query(现名为 TanStack Query)已成为 React 中处理数据加载的默认解决方案,而且确实是一款出色的工具。然而在许多代码库中,几乎所有的调用都在做同样的事情:获取数据、显示加载指示器、显示错误信息,并避免重复请求。本指南为此类场景创建了一个无需依赖的小型 useFetch 钩子,还加入了 TTL 缓存和数据修改功能,并坦诚地指出其存在的局限性,以便你能够根据具体项目需求而非默认设置来做出决策。
典型用法示例
通常,React Query 的使用都遵循同一模式:一个键值以及一个用于获取并解析 JSON 的函数。
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});
它仅需处理加载、错误和缓存功能。该库还提供了后台数据重新获取、无效数据处理、分页与无限滚动相关功能,以及乐观更新、开发者工具、服务器状态同步和可回滚的变更功能。如果这些功能都不使用,却仍需为三个变量承担额外的包体积和代码复杂度。请自行查看当前的包大小,不同版本的数据会有所差异。
React Query的真正优势
它的核心功能是服务器状态管理,即让用户界面与服务器上的数据保持同步。它在以下方面表现优异:
- 用户在页面上时自动后台重新获取数据。
- 在数据发生变更后刷新相关查询结果。
- 实现分页与无限滚动功能。
- 在服务器确认之前先进行乐观更新。
- 去重处理:当两个组件请求相同数据时,仅发送一次请求。
如果需要这些功能,它确实有其存在的价值。许多页面只需在加载时获取数据、显示加载状态、处理失败情况,而不必不必要的重新获取。
为何手动实现的特效存在不足
传统方法使用三个状态变量和一个效应函数:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user.name}</div>;
}
这种方法虽然可行,但也存在一些缺陷:
- 过时的响应数据。如果组件被卸载,或者请求过程中
userId发生变化,旧的响应数据仍会触发setState。React 18虽然取消了关于已卸载组件的更新警告,但之前请求的慢响应数据仍可能覆盖新的数据。 - 无法去重。两个组件就会产生两次请求。
- 没有缓存机制。返回该页面时仍需再次获取数据。
- 每个组件中都存在重复代码的问题。
构建useFetch钩子
该钩子会维护一个以 URL 为键的模块级 Map。初始状态会从中同步读取,因此缓存的数据可以立即显示而不会出现加载闪烁。当查询命中缓存时就会提前返回;否则会随请求一同发送 AbortController 信号,非 2xx 状态码会被视为错误,结果则会被缓存。当 URL 发生变化或组件被卸载时,清理机制会中止请求,AbortError 会被忽略,而无效的 URL 则不会触发数据获取。
// useFetch.js
import { useState, useEffect, useRef } from 'react';
const cache = new Map();
export function useFetch(url, options = {}) {
const [state, setState] = useState({
data: cache.get(url) ?? null,
loading: !cache.has(url),
error: null
});
const abortRef = useRef(null);
useEffect(() => {
if (!url) return;
if (cache.has(url)) {
setState({ data: cache.get(url), loading: false, error: null });
return;
}
abortRef.current = new AbortController();
setState(prev => ({ ...prev, loading: true, error: null }));
fetch(url, { signal: abortRef.current.signal, ...options })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
cache.set(url, data);
setState({ data, loading: false, error: null });
})
.catch(err => {
if (err.name === 'AbortError') return;
setState({ data: null, loading: false, error: err.message });
});
return () => abortRef.current?.abort();
}, [url]);
return state;
}
虽然标称只有“25 行”,但实际上更接近 40 行,不过依然属于简短代码。它实现了加载状态显示、错误处理、缓存功能、过期请求的取消(参见 防抖无法解决的竞态条件问题)、组件卸载时的清理操作以及即时的缓存渲染功能。
有两个需要注意的地方:效果依赖项中缺少 options,因此仅更改请求头不会触发重新获取数据;而且仅基于 URL 的缓存键会导致具有不同请求体或认证头的请求发生冲突。
使用该钩子
数据访问只需一行代码。其结构与 React Query 类似,但对应的标志是 loading,而非 isLoading。
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user.name}</div>;
}
两个组件,同一个 URL
在这里,顶部导航栏和侧边栏读取的是同一用户的数据。
function Header({ userId }) {
const { data: user } = useFetch(`/api/users/${userId}`);
return <div>Hello, {user?.name}</div>;
}
function Sidebar({ userId }) {
const { data: user } = useFetch(`/api/users/${userId}`);
return <div>{user?.email}</div>;
}
这就是该钩子承诺过多之处。如果两个组件同时加载,它们都无法找到缓存条目,因此会发出两次请求;只有后来加载的组件才能受益。真正的去重应是对正在处理的 Promise 进行缓存,而不仅仅是已解析的数据。
条件性及参数化获取
传入 null 可以禁用请求,这与 React Query 的 enabled 选项功能相同。
function UserPosts({ userId, enabled }) {
const { data, loading } = useFetch(enabled ? `/api/users/${userId}/posts` : null);
// Pass null to skip fetching
}
由于URL是数据依赖项,因此当category或page发生变化时,基于这些属性构建的URL会重新获取数据。请使用encodeURIComponent对用户输入进行编码。
function ProductList({ category, page }) {
const url = `/api/products?category=${category}&page=${page}`;
const { data, loading, error } = useFetch(url);
// Refetches automatically when category or page changes
}
具有三个数据源的控制面板
三个请求同时执行,每个部分都有独立的结构,没有提供者或配置,并且会从缓存中重新渲染。
function Dashboard({ userId }) {
const { data: user, loading: userLoading } = useFetch(`/api/users/${userId}`);
const { data: stats, loading: statsLoading } = useFetch(`/api/stats/${userId}`);
const { data: activity, loading: actLoading } = useFetch(`/api/activity/${userId}`);
return (
<div>
<Header>
{userLoading ? <Skeleton /> : <UserInfo user={user} />}
</Header>
<StatsPanel>
{statsLoading ? <Skeleton /> : <Stats data={stats} />}
</StatsPanel>
<ActivityFeed>
{actLoading ? <Skeleton /> : <Activity items={activity} />}
</ActivityFeed>
</div>
);
}
具有有效时间限制的条目
永不过期的缓存并不适合处理变化的数据。为每个条目存储时间戳,并删除超过五分钟的条目,即可解决这个问题:
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
function setCache(url, data) {
cache.set(url, { data, timestamp: Date.now() });
}
function getCache(url) {
const entry = cache.get(url);
if (!entry) return null;
if (Date.now() - entry.timestamp > CACHE_TTL) {
cache.delete(url);
return null;
}
return entry.data;
}
钩子函数通过这些辅助函数进行读写操作;仅显示发生变化的部分:
const cached = getCache(url);
if (cached) {
setState({ data: cached, loading: false, error: null });
return;
}
// ... fetch logic ...
.then(data => {
setCache(url, data);
setState({ data, loading: false, error: null });
})
对于每次调用的生命周期,从选项中提取ttl,并将其余参数传递给fetch函数:短值用于价格信息,长值用于配置信息。示例在一个作用域内两次声明data,仅为展示其两种用法。
export function useFetch(url, options = {}) {
const { ttl = 5 * 60 * 1000, ...fetchOptions } = options;
// Use ttl in cache logic
}
// Usage
const { data } = useFetch('/api/live-prices', { ttl: 30 * 1000 }); // 30 second cache
const { data } = useFetch('/api/user-profile', { ttl: 60 * 60 * 1000 }); // 1 hour cache
只有在被读取时才会删除过期的条目,因此在需要处理大量URL的长时间运行的应用中,Map的结构是没有上限的。
配套的useMutation钩子
数据的写入是由调用方触发,而非在组件挂载时自动执行。该钩子负责跟踪加载状态、错误信息及数据内容,发送JSON格式的数据体,将非2xx状态的响应视为失败,并同时存储错误信息并重新抛出异常。
// useMutation.js
import { useState } from 'react';
export function useMutation(url, method = 'POST') {
const [state, setState] = useState({
loading: false,
error: null,
data: null
});
const mutate = async (body) => {
setState({ loading: true, error: null, data: null });
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
setState({ loading: false, error: null, data });
return data;
} catch (err) {
setState({ loading: false, error: err.message, data: null });
throw err;
}
};
return { ...state, mutate };
}
在表单中,请求期间按钮会被禁用,错误信息会显示在下方。需要注意的是,onSubmit接收到的是事件而非表单数据,因此实际代码必须调用event.preventDefault(),并通过new FormData(event.currentTarget)等方式构建表单内容。
function CreatePost() {
const { mutate, loading, error } = useMutation('/api/posts');
const handleSubmit = async (formData) => {
try {
const post = await mutate(formData);
console.log('Created:', post);
} catch (err) {
// error state handles this
}
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button disabled={loading}>
{loading ? 'Creating...' : 'Create Post'}
</button>
{error && <p>{error}</p>}
</form>
);
}
成功的数据更新不会影响useFetch的缓存,因此相关列表会保持过时状态,直到其有效时间结束或手动清除缓存键。
你需要放弃的功能
后台自动刷新
React Query会在窗口获得焦点时自动刷新数据。你可以通过在焦点获取时删除缓存项来模拟这一行为,但仅此操作无法重新执行相关逻辑;还需要状态计数器之类的触发机制。
useEffect(() => {
const handleFocus = () => {
cache.delete(url); // Invalidate cache
// Trigger refetch
};
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}, [url]);
乐观更新、开发者工具、分页与数据失效处理
- 具备正确回滚功能的乐观更新实现十分困难,这方面该库明显更胜一筹。
- 开发工具可显示缓存、查询及执行时间;自制版本则仅能使用
console.log。 useInfiniteQuery的设计十分合理;而自定义分页功能则需要付出多得多的努力。- 全局失效处理,比如重新获取所有“文章”查询结果,实际上就变成了手动清除缓存的操作。
两者之间的选择
对于需要基于变更触发数据失效、后台自动刷新、无限滚动或复杂分页功能、开发者工具支持,或是团队已熟悉该技术的场景,建议使用 React Query。当界面主要负责数据获取与展示、只需永久缓存或基于时间戳的临时缓存、对依赖项数量有严格要求,且应用规模为小型到中型时,使用简单的钩子函数即可。若决定继续使用该库,请参阅如何构建 TanStack Query 数据层。
逐步迁移
一次只修改一个组件:更改导入语句和钩子调用方式,同时将 isLoading 更名为 loading。
// Before
import { useQuery } from '@tanstack/react-query';
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});
// After
import { useFetch } from './useFetch';
const { data, loading, error } = useFetch(`/api/users/${userId}`);
当没有任何代码再导入该库时,即可将其卸载。
npm uninstall @tanstack/react-query
核心要点
- 大多数数据获取操作都很简单,对于这类需求,使用带有时间戳的
Map作为缓存是完全可行的。
AbortController 是解决过时或顺序错误响应的原生方案。