- Published on
不要再使用 useEffect 来发送请求,管理服务端状态了
- Authors

- Name
- pkl
- @2yyyyyy
在 react 中如何管理服务端状态?相信很多小伙伴的第一反应就是: 在 useEffect 里发送请求,然后结合 useState 来管理状态。
下面我们来看下,使用这种方式存在什么问题,以及如何解决这些问题,最后我们会看看其他更好的方式。
在useEffect里发送请求,然后结合useState来管理状态
通常我们会这样写:
function App() {
const [users, setUsers] = useState([])
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/users')
.then((res) => res.json())
.then((data) => setUsers(data))
}, [])
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
这作为一个 demo 演示代码或许可行,但是以上代码存在如下问题:
- 没有处理接口请求中的状态,用户不知道我们在获取数据
- 没有处理接口请求中的错误,如果接口发生了异常,用户没有感知
我们修复上面的代码:
function App() {
const [users, setUsers] = useState()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
setIsLoading(true)
fetch('https://jsonplaceholder.typicode.com/users')
.then((res) => {
if (!res.ok) {
throw new Error('Failed to fetch users')
}
return res.json()
})
.then((data) => {
setUsers(data)
setIsLoading(false)
setError(null)
})
.catch((error) => {
setError(error)
setIsLoading(false)
})
}, [])
if (isLoading) {
return <div>Loading...</div>
}
if (error) {
return <div>Error: {error.message}</div>
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
乍看一下,虽然多个 useState 的引入,让代码变得更加复杂,但是你可能还是能接受的。 但你的老板找到你,想让你加上搜索功能,于是乎你可能会写这样的代码:
function App() {
const [users, setUsers] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const [search, setSearch] = useState('')
useEffect(() => {
setIsLoading(true)
fetch(`https://jsonplaceholder.typicode.com/users?q=${search}`)
.then((res) => {
if (!res.ok) {
throw new Error('Failed to fetch users')
}
return res.json()
})
.then((data) => {
setUsers(data)
setIsLoading(false)
setError(null)
})
.catch((error) => {
setError(error)
setIsLoading(false)
})
}, [search])
if (error) {
return <div>Error: {error.message}</div>
}
return (
<div>
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} />
{isLoading ? (
<div>Loading...</div>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
)
}
这段代码虽然看上去还可以,但是其实存在一个问题,当我们每次输入的时候,都会发送一个请求,假设我们先输入 a,然后输入 b,那么就会以 a 和 ab 为查询字符串分别发送两次请求,而服务端接口的速度快慢是不一定的,如果请求 a 响应比 请求 ab 更慢,那么我们的页面上就会先显示 ab 请求对应的结果, 然后再显示 a 请求对应的结果。这里存在如下问题:
- 最终结果与 ui 不符,我们希望看到的是 ab 请求对应的结果。
如何解决这个问题呢?
你可能会想到,我们能不能对请求做个 debounce,即在输入时延迟一会儿再发送请求,这样就不会发送两次请求了。但 debounce 解决不了根本的问题, 因为服务端的接口快慢是不一定的,debounce 之后,用户输入稍微有些停顿,可能还是会发送两次请求,还是会造成多个请求竞争的问题。
而事实上我们要做的是,只要有新的请求发出,那么就取消之前的请求或者忽略之前的请求。这样就不会出现多个请求竞争的问题了。我们可以从 react 的官网上找到类似的解决方案。
function App() {
const [users, setUsers] = useState()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const [search, setSearch] = useState('')
useEffect(() => {
let ignore = false
setIsLoading(true)
fetch(`https://jsonplaceholder.typicode.com/users?q=${search}`)
.then((res) => {
if (!res.ok) {
throw new Error('Failed to fetch users')
}
return res.json()
})
.then((data) => {
if (ignore) return
setUsers(data)
setIsLoading(false)
setError(null)
})
.catch((error) => {
if (ignore) return
setError(error)
setIsLoading(false)
})
return () => {
ignore = true
}
}, [search])
if (error) {
return <div>Error: {error.message}</div>
}
return (
<div>
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} />
{isLoading ? (
<div>Loading...</div>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
)
}
由于 react 会在每次 执行 effect 之前去执行 cleanup,所以我们可以在 fetch 的 then 里做个 ignore 判断,如果 ignore 是 true,那么就不会执行 setState。
此时你会发现我们处理请求的代码已经越来越复杂了,而且这段代码里解决的问题,可能是我们每个请求都需要处理的,于是你可能会想到抽出一个公用的自定义 hook。
export function useQuery({ url }) {
const [data, setData] = useState()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
let ignore = false
setIsLoading(true)
fetch(url)
.then((res) => {
if (!res.ok) {
throw new Error('Failed to fetch users')
}
return res.json()
})
.then((data) => {
if (ignore) return
setData(data)
setIsLoading(false)
setError(null)
})
.catch((error) => {
if (ignore) return
setError(error)
setIsLoading(false)
})
return () => {
ignore = true
}
}, [url])
return {
data,
isLoading,
error,
}
}
import { useQuery } from './useQuery'
function App() {
const [search, setSearch] = useState('')
const {
data: users,
isLoading,
error,
} = useQuery({
url: `https://jsonplaceholder.typicode.com/users?q=${search}`,
})
if (isLoading) {
return <div>Loading...</div>
}
if (error) {
return <div>Error: {error.message}</div>
}
return (
<div>
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} />
{isLoading ? (
<div>Loading...</div>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
)
}
好了,我们的自定义 hook 完成了,看上去很不错,但是如果你在项目中大量使用它,你就会发现它可能会遇到如下问题:
- 请求去重。我们的 useQuery 没有做请求去重,如果我们在多个组件中使用 useQuery, 传递同一个 url 参数,会发送多次请求,这其实是没有必要的,对服务端资源来说是一种浪费。
- 请求缓存。我们的 useQuery 没有做缓存。对于一个追求高性能体验的客户端应用程序而言,缓存是必不可少的,因为我们并不是所有场景都要求非常实时的数据。而使用缓存可以帮助我们大大提高用户体验。但是我们知道在程序的世界里有句说法: 缓存和命名 是最难的。
- 重新请求的机制。我们是否需要在用户离开页面一端时间再次返回页面之后(也许是去上了个厕所, 喝个咖啡, 锁屏回来重新打开屏幕),重新请求刷新数据?
- 我们的 useQuery 内部将 fetch 封装进来了。这个耦合太大了,其实只需要传递一个 queryFn 和 queryKey 就可以了,queryFn 里去返回一个 promise,这里就又会牵扯到 queryKey 结构的问题,因为我们要监听 queryKey 的变化。我们如何高效比对两个 queryKey 是否相同?
使用三方库
好了,说了那么多,无非是想说明,维护服务端请求状态确实是一件很复杂的事情,如果你追求极致的用户体验,那么我的建议是使用三方库来完成这个工作,目前在这个领域使用最广泛的是 react-query, 他提供了如下特性:
- 声明式的 api,我们只需声明一个 query,改变 query 的参数,就会触发 query 的重新执行。
- 自动处理缓存
- 自动处理接口调用去重
- 对后端不感知。
- 自动重试
- 预加载
- 请求取消机制
- 其他
如果你想要了解更多的细节,可以参考 react-query 的文档。
我们使用 react-query 对我们的示例程序改写:
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
const queryClient = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
)
}
function Example() {
const [search, setSearch] = useState('')
const { data, isPending, error } = useQuery({
queryKey: ['users', search],
queryFn: () =>
fetch(`https://jsonplaceholder.typicode.com/users?q=${search}`).then((res) => {
if (!res.ok) {
throw new Error('Failed to fetch users')
}
return res.json()
}),
})
if (error) {
return <div>Error: {error.message}</div>
}
return (
<div>
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} />
{isPending ? (
<div>Loading...</div>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
)
}
好了,改造完成,看起来如此优雅的 api,希望在以后的项目中能够有机会去使用它。