@tanstack/query简介与基础使用
一只在奥森拍到的玳瑁猫,颜色像打翻的颜料盘
@tanstack/query是什么?
@tanstack/query (下文简称query)是一个异步状态管理工具,支持react/vue等主流前端框架。
无论是github的star数量还是周下载量都非常夸张,query是一个在国外非常主流的技术选择。
平均每6个react项目中就有一个使用了query。
我们为什么需要它
简朴的fetch
如果没有这个库,我们在通过 fetch 获取数据时,通常是这样的
type Student = {
id: number;
name: string;
age: number;
gender: string;
};
// 一个简单的获取学生列表的组件
const StudentList = () => {
// 定义状态存储学生数据
const [students, setStudents] = useState<Array<Student>>([]);
// 组件挂载时获取数据
useEffect(() => {
fetch("https://api.example.com/students")
.then((res) => res.json())
.then((data) => {
setStudents(data);
});
}, []);
return (
<ul>
{students.map((student) => (
<li key={student.id}>
<p>姓名: {student.name}</p>
<p>年龄: {student.age}</p>
<p>性别: {student.gender}</p>
</li>
))}
</ul>
);
};
如果对交互细节有追求,我们需要额外管理loading状态和错误处理。
这个代码将变成这样:
// 一个简单的获取学生列表的组件
type Student = {
id: number;
name: string;
age: number;
gender: string;
};
const StudentList = () => {
// 定义状态存储学生数据和加载状态
const [students, setStudents] = useState<Array<Student>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
// 组件挂载时获取数据
useEffect(() => {
const fetchStudents = async () => {
try {
setIsLoading(true);
const response = await fetch("https://api.example.com/students");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setStudents(data);
} catch (err) {
setError(err instanceof Error ? err : new Error("发生未知错误"));
} finally {
setIsLoading(false);
}
};
fetchStudents();
}, []);
// 加载状态显示
if (isLoading) {
return <div>加载中...</div>;
}
// 错误状态显示
if (error) {
return <div>发生错误: {error.message}</div>;
}
return (
<ul>
{students.map((student) => (
<li key={student.id}>
<p>姓名: {student.name}</p>
<p>年龄: {student.age}</p>
<p>性别: {student.gender}</p>
</li>
))}
</ul>
);
};
这就 ok 了吗,并没有。为了避免每次网络请求都重复写这一堆逻辑,我们不得不对这些代码再做一次抽象。
抽象后的hook为了避免多次调用导致的重复发送请求,需要把请求到的数据作为状态缓存起来,再分发到各个组件。然后我们又面临计算机中最复杂的问题之一:缓存何时失效。
为了把缓存的状态分发到各个组件,这意味着服务端的数据得和状态管理结合起来。
问题变得越来越复杂。
竞态问题
另一个复杂的场景是:由于 fetch 是异步操作,当多个请求同时发出,由于我们不知道哪一步先完成,所以我们不能保证最终渲染的是否是最后一次请求结果。
想象一下,有一个支持翻页的列表组件,如果用户频繁的翻页,从第一页快速的翻到第十页。
此时用户期望看到的是第十页的数据,但因为我们不能保证第十页的请求最后完成,所以第十页的请求结果可能被其他页码的请求结果覆盖。
这就是我们要解决的“race condition—竞态问题”。
一个简单的处理静态问题的例子:通过闭包的active来让旧的请求在组件重渲染时不再执行后续逻辑。
useEffect(() => {
let active = true;
const fetchData = async () => {
setTimeout(async () => {
const response = await fetch(`https://swapi.dev/api/people/${props.id}/`);
const newData = await response.json();
if (active) {
setFetchedId(props.id);
setData(newData);
}
}, Math.round(Math.random() * 12000));
};
fetchData();
return () => {
active = false;
};
}, [props.id]);
异步状态管理
以上只是明显的缺陷,还有一个隐含的不那么容易暴露出来的缺陷:客户端状态和服务端状态的差异导致的状态维护困难。
客户端状态总是同步的,完全由我们控制的。但服务端状态并不总是听话,所以我们管理这些状态时就很乏力。
传统的方案是每次使用服务端状态前都要发送一个次网络请求,经过一个 loading,以保证数据都有效性。但一些页面展示的数据并不总是变化的:
- 基金的收益只有结算时会揭露,且每天一次。
- 一个用户的个人简介可能几个月也不会变化一次。
- ”我的关注”列表什么时候发生了变化只有用户自己最清楚。
如果我们既不想要每次使用异步状态都弹出一个loading阻塞用户操作,又不想自己手动管理复杂的异步状态的话,可以尝试使用query,query 提供了较完善的方案。所以我们可以轻松的完成以下功能:
- Cache 管理
- 轮询
- 翻页缓存
- 自动重载
- 无限滚动
- 窗口聚焦自动重载
- 离线数据
- 失败重试
query 基础使用
query的基础使用进需要两个参数:queryKey和queryFn。
useQuery({
queryKey: any[],
queryFn: ()=>Promise
})
简单示例
一个基础示例,查看其他用户的信息,首先我们需要封装一个信息卡片组件:
interface InfoCardProps {
name: string;
age: number;
gender: string;
id: number;
onClick: (id: number) => void;
}
const InfoCard = ({ name, age, gender, id, onClick }: InfoCardProps) => {
return (
<li className="border border-gray-300 rounded-md p-4">
<p className="text-lg font-bold">姓名:{name}</p>
<p className="text-gray-500">年龄:{age}</p>
<p className="text-gray-500">性别:{gender}</p>
<button onClick={() => onClick(id)}>查看详情</button>
</li>
);
};
接下来,我们需要一个列表,列表通过useQuery获取数据,并渲染了三张卡片:
import { useQuery } from "@tanstack/react-query";
const fetchStudentList = async () => {
// 模拟API延迟
await new Promise((resolve) => setTimeout(resolve, 1000));
return [
{ name: "张三", age: 18, gender: "男", id: 1 },
{ name: "李四", age: 19, gender: "女", id: 2 },
{ name: "王五", age: 20, gender: "男", id: 3 },
];
};
// CardList 是一个典型的 container 组件
const CardList = () => {
const {
data: infoList,
isPending,
error,
} = useQuery({
queryKey: ["studentList"],
queryFn: fetchStudentList,
});
const handleClick = (id: number) => {
// 处理点击事件
};
if (isPending) {
return <div>加载中...</div>;
}
if (error) {
return <div>发生错误:{error.message}</div>;
}
return (
<ul>
{infoList?.map((info) => (
<InfoCard {...info} key={info.id} onClick={handleClick} />
))}
</ul>
);
};
这里需要注意infoList?.map是需要问号的,因为infoList通过异步操作才从undefined变成可用的数据。
关于queryFn
值得注意的是示例中没有发送真实网络请求是有意为之的。
因为query并不在乎queryFn中是否有一个真实的网络请求,query只在乎Promise的状态。
如果用浏览器的fetch Api来编写网络请求,queryFn看起来是这样的。
queryFn: async ()=>{
const response = await fetch(url)
if(!response.ok){
throw new Error(`Error status: ${response.status}`)
}
return response.json()
}
不需要在这里嵌套try-catch,直接throw Error是为了告诉query发生了错误。
query的定位和axios等网络请求库完全不冲突,queryFn可以是基于axios发起的网络请求。
关于queryKey
queryKey是一个数组,通常首位是对状态的简要描述。比如上面例子中的studentList。
请求相关的参数也应该放到queryKey列表中,每当queryKey变更就会重新执行查询函数,并把查询结果缓存起来。
// id/排序/页码/每页条数/排序 等等都应该放到query-key中
useQuery({ queryKey: ['todo', 5], ... })
如果queryKey命中缓存中的key,query就会直接返回上次的缓存结果(如果没有设置缓存有效期的话,query会静默的发起网络请求更新缓存)。跳过了阻塞用户操作的loading,提升了用户体验。
queryKey也许会让人联想到useEffect的依赖列表,但不完全相同。query 会基于queryKey计算hash值,所以你可以大胆的在queryKey里使用对象和数组,同时query会无视对象键值对的顺序。
// 这些query-key是一回事
useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ...})
useQuery({ queryKey: ['todos', { page, status, other: undefined }], ... })