解决fetch的困境 —— react-query 【1】简介与快速入门
- Authors
- Name
- White Play
一只在奥森拍到的玳瑁猫,颜色像打翻的颜料盘
前言
嘿,我是老白。
在开始前,我想讨论一下标题中说的fetch困境是什么?
过于简朴的 fetch
在使用原生的 fetch api
获取数据时,大家通常会这么写。
useEffect(() => {
fetch('https://api.example.com/students')
.then((res) => res.json())
.then((data) => {
setStudents(data)
})
}, [])
很简洁,但这种写法只能在demo里使用。
在生产环境中这么写是不能接受的。因为你对这个请求没有做任何处理,它的成功与否被完全的放任自由了。换句话说,这个页面能否正常工作也被放任自由了。
一旦 loading 时间过长却没有任何反馈、或者请求出错了但前端没有任何响应,对用户体验来说都是致命打击。
那让我们优化一下吧!添加loading和错误处理逻辑,这个代码将变成这样:
// 一个简单的获取学生列表的组件
const StudentList = () => {
// 定义状态存储学生数据和加载状态
const [students, setStudents] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(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>
)
}
我们只是为了处理一个网络请求的衍生逻辑,但代码已经开始变得复杂化,模板化了。
isLoading,error,这些衍生状态都需要我们手动管理,每个请求都这么写显然很麻烦。
竞态问题
另一个常见但可能被忽视的场景是竞态问题:在发送多个相似的请求时,由于我们不知道哪一个请求先完成,所以我们不能保证最终渲染的是否是最后一次请求结果。
上述有点抽象,我们想象两个场景:
有一个支持翻页的表格,如果用户从第一页快速的翻到第十页。此时用户期望看到的是第十页的数据,但我们不能保证第十页的请求一定早于其他页码的请求返回,所以第十页的请求结果可能被其他页码的请求结果覆盖。
一个自动完成组件,用户输入一部分内容,组件会立即访问远程服务器获取智能补全的提示词。但因为用户输入变更很快,最终显示的提示词可能被延期返回的请求结果覆盖。
这就是我们要解决的“race condition—竞态问题”。
一个简单的处理竞态问题的思路:通过闭包的active
来让旧的请求在组件重渲染时不再执行后续逻辑。
useEffect(() => {
let active = true
const fetchData = async () => {
const response = await fetch(`https://swapi.dev/api/people/${props.id}/`)
const newData = await response.json()
if (active) {
setFetchedId(props.id)
setData(newData)
}
}
fetchData()
return () => {
active = false
}
}, [props.id])
每次发起请求都有一个新的active
被创建,只有当active
为true
才会执行网络请求的后续操作。
当组件重新渲染或者说页面状态过期的时候 active 变成 false,这个false
作为网络请求的闭包被缓存到了回调函数中,从而阻止了过期的后续操作。
something else
react官方文档也列举了一些直接在useEffect
中发起网络请求的缺点与替代方案。并推荐了我想向大家介绍的react-query
。
介绍
@tanstack/query
(曾用名 react-query
,下文简称 query)是一个异步状态管理工具。
它支持 react/vue/angular 等主流前端框架。我自己在公司的项目中有深度使用过 react 和 vue 的版本,体验都还不错。
异步状态管理工具,简单地说,就是管理基于异步操作而获得的状态。通常是管理网络请求返回的数据以及衍生状态。这个后续会详细介绍。
先看一下这个库的成绩:
这个数据看起来非常夸张,实际上也确实如此,query 在国外是非常主流的技术选择。
query 在国内似乎比较冷门,也缺少完善的中文翻译。这也是我想做这个技术分享的原因,想把好的工具介绍给更多的国内开发者。
query 基础使用
这节讲一下query 的基础使用,发起一个网络请求很简单,只需要调用useQuery
,该函数需要两个参数:queryKey
和queryFn
。
useQuery({
queryKey: any[],
queryFn: ()=>Promise
})
关于 queryFn
queryFn
总是返回一个 Promise,因为 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 的定位是异步状态管理工具,和pinia/jotai
这种同步状态管理库、还有axios
这种网络请求库是完全不冲突的,因为定位不一样。
所以正式项目推荐把基于axios
发起的网络请求作为queryFn
。
关于 queryKey
queryKey
是一个数组,通常首位是对状态的简要描述。比如如果获取学生列表就可以是['student']
。如果获取学生详情就可以是['student',id]
。
请求相关的参数也应该放到queryKey
列表中,每当queryKey
变更就会重新执行查询函数,并把查询结果缓存起来。
// id/排序/页码/每页条数/排序 等等都应该放到query-key中
useQuery({ queryKey: ['todo', sort], ... })
如果queryKey
命中缓存中的 key,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 }], ... })
简单示例
一个基础示例,基于所选的书名查看该书的详情:
import { useState } from 'react'
import { fetchBookDetail } from '../api/book'
import { useQuery } from '@tanstack/react-query'
const books = [
{ id: 1, title: 'React 从入门到精通' },
{ id: 2, title: '深入理解 Vue' },
{ id: 3, title: '你不知道的 Angular' },
]
const Intro = () => {
const [bookId, setBookId] = useState<number>(1)
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setBookId(parseInt(e.target.value))
}
const {
data: book,
isLoading,
isError,
error,
} = useQuery({
queryKey: ['book', bookId],
queryFn: () => fetchBookDetail(bookId),
})
if (isLoading) {
return <p>加载中...</p>
}
if (isError) {
return <p>错误: {error.message}</p>
}
return (
<>
<select value={bookId} onChange={handleChange}>
{books.map((book) => (
<option key={book.id} value={book.id}>
{book.title}
</option>
))}
</select>
<>
<h1>{book?.title}</h1>
<p>{book?.author}</p>
<p>{book?.description}</p>
</>
</>
)
}
export default Intro
这里需要注意针对book
的操作是需要问号的,因为book
通过异步操作才从undefined
变成可用的数据。