logo

解决fetch的困境 —— react-query 【1】简介与快速入门

Authors
  • avatar
    Name
    White Play
    Twitter

玳瑁猫

一只在奥森拍到的玳瑁猫,颜色像打翻的颜料盘

前言

嘿,我是老白。

在开始前,我想讨论一下标题中说的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,这些衍生状态都需要我们手动管理,每个请求都这么写显然很麻烦。

竞态问题

另一个常见但可能被忽视的场景是竞态问题:在发送多个相似的请求时,由于我们不知道哪一个请求先完成,所以我们不能保证最终渲染的是否是最后一次请求结果。

上述有点抽象,我们想象两个场景:

  1. 有一个支持翻页的表格,如果用户从第一页快速的翻到第十页。此时用户期望看到的是第十页的数据,但我们不能保证第十页的请求一定早于其他页码的请求返回,所以第十页的请求结果可能被其他页码的请求结果覆盖。

  2. 一个自动完成组件,用户输入一部分内容,组件会立即访问远程服务器获取智能补全的提示词。但因为用户输入变更很快,最终显示的提示词可能被延期返回的请求结果覆盖。

这就是我们要解决的“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被创建,只有当activetrue才会执行网络请求的后续操作。

当组件重新渲染或者说页面状态过期的时候 active 变成 false,这个false作为网络请求的闭包被缓存到了回调函数中,从而阻止了过期的后续操作。

something else

react官方文档也列举了一些直接在useEffect中发起网络请求的缺点与替代方案。并推荐了我想向大家介绍的react-query

介绍

@tanstack/query (曾用名 react-query,下文简称 query)是一个异步状态管理工具。

它支持 react/vue/angular 等主流前端框架。我自己在公司的项目中有深度使用过 react 和 vue 的版本,体验都还不错。

异步状态管理工具,简单地说,就是管理基于异步操作而获得的状态。通常是管理网络请求返回的数据以及衍生状态。这个后续会详细介绍。

先看一下这个库的成绩:

github 42.7k ⭐️,npm周下载量 4422006。

这个数据看起来非常夸张,实际上也确实如此,query 在国外是非常主流的技术选择。

query 在国内似乎比较冷门,也缺少完善的中文翻译。这也是我想做这个技术分享的原因,想把好的工具介绍给更多的国内开发者。

query 基础使用

这节讲一下query 的基础使用,发起一个网络请求很简单,只需要调用useQuery,该函数需要两个参数:queryKeyqueryFn

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变成可用的数据。