React Query 入门教程!

lxf2023-04-12 21:43:01

介绍

Tanstack query is a powerful asynchronous state management for TS/JS, React, Solid, Vue and Svelte. 官网地址

TanStack Query 原先叫 React Query ,后来对其他UI框架也提供了支持,所以现在叫TanStack Query。总之,如果对于React项目来说,你可以叫他React Query 或者TanStack React Query,或者简称为TanStack Query

React Query 入门教程! TanStack 自带如下图所示的20多个功能,从我们的业余小项目到企业级大型应用,用它都能轻松搞定。

React Query 入门教程!

接下来,我将用React例子来上手tanstack query。

# 下载安装
pnpm add @tanstack/react-query
# 下载devtools
pnpm add @tanstack/react-query-devtools

React Query 通常被描述为React的data fetching库,它让React应用程序中的数据获取、缓存、同步和更新服务器状态变得轻而易举。

React 自身并没有开箱即用的获取和更新数据的方法,通常我们使用hook来实现fetching data

如下代码虽然能用,但是实现缓存、错误后重试、避免重复请求会更加麻烦。

import { useEffect, useState } from "react"
const UsingFetchAPI = () => {
  const [user, setUsers] = useState()
  const fetchData = () => {
    fetch('https://jsonplaceholder.typicode.com/users')
      .then((response) => response.json())
      .then((json) =>setUsers(json))
  }
  useEffect(() => {
    fetchData()
  }, [])
  return <></>
}

React Query 不仅仅简化了data fetching的代码,而且能够轻松处理复杂的场景。比如:当用户离开网页后,再回到网页,我们可能想要重新发起请求,这时候我们只需要配置refetchOnWindowFoucs: true 即可; 如果想要实现无限滚动,可以使用它的useInfiniteQuery() API;而且我们可以使用它的开发者工具debug data fetching 逻辑。

import { QueryClient,QueryClientProvider,useQuery} from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'


const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UsingFetchAPI />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

function UsingFetchAPI() {
  const { isLoading, error, data } = useQuery({
    queryKey: ['getUser'],
    queryFn: () =>
      fetch('https://jsonplaceholder.typicode.com/users').then(
        (res) => res.json(),
      ),
  })

  if (isLoading) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return  <></>
}

简单例子

首先为项目配置好React Query和其对应的devtools。

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css' 
import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

//  create a client
const queryClient = new QueryClient()

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      <ReactQueryDevtools />
    </QueryClientProvider>
  </React.StrictMode>,
)

react query不关心我们到底有没有发请求,只要我们返回的是promise就行。接下来,我们就模拟一下请求:

import { useQuery } from '@tanstack/react-query';

const POSTS = [
  { id: '1', title: 'post1' },
  { id: '2', title: 'post2' },
]
function wait(time: number) {
  return new Promise((resolve) => setTimeout(resolve, time))
}
const App = () => {
 // 调用useQuery后,可以解构出data,isLoading,isError,state等
  const {data,isLoading,error} = useQuery({
    queryKey: ['posts'], // 设置query的key,要求独一无二,以数组格式,可以提供多个key
    queryFn:()=>wait(1000).then(()=>[...POSTS]),  // 发起请求的函数
  })
  if(isLoading) return <div>loading...</div>
  if(error) return <pre>{JSON.stringify(error)}</pre>
  if(!data) return <div>no data</div> 
  return (
    <div>
      {data.map((post) => <h1 key={post.title}>{post.title}</h1>)}
    </div>
  );
};

调用useQuery后会返回响应的数据和关于这次请求的信息,除此之外,result中还包含几个非常重要的请求状态数据:

 const result  = useQuery({queryKey: ['posts'], queryFn:fetchPostsList })
  • isLoading 或者status === 'loading' - 还在请求中
  • isError 或者 status === 'error' - 请求报错
  • isSuccess 或者 status === 'success' - 请求成功了,并且data可以使用了

值得注意的是React Query它默认有重试机制,如果请求挂掉了,它会自动重试3次。当然我们可以自定义配置:

import { useQuery } from '@tanstack/react-query'

const result = useQuery({
  queryKey: ['todos'], 
  queryFn: fetchTodoListPage,
  retry: 10, // 重试10次
  // retry: false 禁止重试
  // retry: true 重试无限次直到成功
})

除了获取数据外,最常见的就是就是修改数据(data mutation)。

import { useQuery, useMutation } from '@tanstack/react-query';

const POSTS = [
  { id: '1', title: 'post1' },
  { id: '2', title: 'post2' },
]
function wait(time: number) {
  return new Promise((resolve) => setTimeout(resolve, time))
}
const App = () => {
  const {data,isLoading,error} = useQuery({
    queryKey: ['posts'],
    queryFn:()=>wait(1000).then(()=>[...POSTS]), 
  })
  // 定义修改数据
  const newPostMutation = useMutation({
    mutationFn: (title:string) => wait(1000).then(() => POSTS.push({ id: crypto.randomUUID(), title })),
  })

  if(isLoading) return <div>loading...</div>
  if(error) return <pre>{JSON.stringify(error)}</pre>
  if(!data) return <div>no data</div> 
  return (
    <div>
      {data.map((post) => <h1 key={post.title}>{post.title}</h1>)}
      <button 
      disabled={newPostMutation.isLoading}
      // 调用修改数据的函数
	  onClick={()=>newPostMutation.mutate('new post')}>新增</button>
    </div>
  );
};

此时,我们点击了按钮后,会发现页面数据并没有刷新。因此,当我们新增了post后,我们希望react query能帮我们重新请求下post列表。

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

const App = () => {
  // Access the client
  const queryClient = useQueryClient()
  const {data,isLoading,error} = useQuery({
    queryKey: ['posts'],
    queryFn:()=>wait(1000).then(()=>[...POSTS]), 
  })
  const newPostMutation = useMutation({
    mutationFn: (title:string) => wait(1000).then(() => POSTS.push({ id: crypto.randomUUID(), title })),
    // 当新增一个post后,重新获取posts列表
    onSuccess: () => {
      // Invalidate and refetch 使原先的posts列表失效,重新获取
      queryClient.invalidateQueries({
        queryKey: ['posts']
      })
    }
  })
  // jsx 部分略掉,具体内容如上段代码
  return (
    <div>
    </div>
  );
};

我们可以打开react query 开发者工具,查看详细信息:

React Query 入门教程!

深入了解useQuery

Query Keys

TanStack Query 的核心是基于queryKey为管理查询缓存。queryKey必须是数组,可以像具有单个字符串的数组一样简单,也可以像包含多个字符串和嵌套对象的数组一样复杂。只要queryKey是可序列化(serializable)的,并且对于查询的数据是唯一的,就可以使用它!

// 以下是设置queryKey的例子
// 左侧为restful请求的api 右侧为其对应的queryKey
// /posts -> ['posts']
// /posts/1 -> ['posts', '1']
// /posts/1/comments -> ['posts', '1', 'comments']
// /posts?title=foo -> ['posts', { title: 'foo' }]

无论对象中键的顺序如何,下列所有查询都被视为相等的:

useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ...})
useQuery({ queryKey: ['todos', { page, status, other: undefined }], ... })

但是,以下queryKey 不相等。在数组中的顺序很重要!

useQuery({ queryKey: ['todos', status, page], ... })
useQuery({ queryKey: ['todos', page, status], ...})
useQuery({ queryKey: ['todos', undefined, page, status], ...})

如果data fetching函数依赖于变量,请将其包含在queryKey! 因为如果变量修改后,react-query会自动重新发请求。

function Todos({ todoId }) {
  const result = useQuery({
    queryKey: ['todos', todoId], // todoId 被包含在了queryKey中
    // todoId 修改后,会重新调用queryFn
    queryFn: () => fetchTodoById(todoId),
  })
}

Query Functions

query function 可以是任何返回promise的函数。返回的promise要么resolve数据,要么抛出错误。

以下所有都是有效的query function的配置:

useQuery({ queryKey: ['todos'], queryFn: fetchAllTodos })
useQuery({ queryKey: ['todos', todoId], queryFn: () => fetchTodoById(todoId) })
useQuery({
  queryKey: ['todos', todoId],
  queryFn: async () => {
    const data = await fetchTodoById(todoId)
    return data
  },
})
useQuery({
  queryKey: ['todos', todoId],
  queryFn: ({ queryKey }) => fetchTodoById(queryKey[1]), // 注意queryFn中可以得到queryKey
})

大多数库(如 axios 或 graphql-request)在 HTTP 调用不成功时会自动抛出错误,但是像 fetch 这样的函数默认情况下不会抛出错误。在这种情况下,我们需要自己抛出错误。 这是使用fetch API 的简单方法:

useQuery({
  queryKey: ['todos', todoId],
  queryFn: async () => {
    const response = await fetch('/todos/' + todoId)
    if (!response.ok) {
      throw new Error('Network response was not ok')
    }
    return response.json()
  },
})

queryFn中我们可以便捷地获取到QueryFunctionContext,它包含以下内容:

React Query 入门教程!

  • queryKey: QueryKey: Query Keys
  • pageParam?: unknown
    • only for Infinite Queries
    • the page parameter used to fetch the current page
  • signal?: AbortSignal
    • AbortSignal instance provided by TanStack Query
    • Can be used for Query Cancellation
  • meta: Record<string, unknown> | undefined
    • an optional field you can fill with additional information about your query

以下是使用其中queryKey的例子:

function Todos({ status, page }) {
  const result = useQuery({
    queryKey: ['todos', { status, page }],
    queryFn: fetchTodoList,
  })
}

// Access the key, status and page variables in your query function!
function fetchTodoList({ queryKey }) {
  const [_key, { status, page }] = queryKey
  return new Promise()
}

QueryKey与缓存

之前提了很多次queryKey,那么它对 react query 的缓存到底有什么影响?

改变一下代码结构:切换不同按钮后,生成<PostList1 />或者<PostList2 />,他们的代码结构是一模一样的,使用的也是同样的queryKey。但是,为了让后台知道是<PostList1 />还是<PostList2 />发送的请求,我给了他们一个type标识。

import axios from 'axios'
import { useQuery} from '@tanstack/react-query';
import { useState } from 'react';


const App = () => {
  const [list,setList] = useState(1)
  return (
    <div>
      <button onClick={()=>setList(1)} >posts list 1</button>
      <button onClick={() => setList(2)}>posts list 2</button>
      <br />
      {list === 1 ? <PostList1 /> : <PostList2 />}
    </div>
  )
};


const getPost = async (type:string) => {
  return axios.get('http://localhost:3000/posts',{
    params:{
      type
    }
  })
}

const PostList1 = () => {
  const {data,isLoading,error} = useQuery({
    queryKey:['posts'],
    queryFn:()=>getPost('1')
  })

  return (
    <>
      <h1>Post List 1</h1>
      {isLoading && <div>loading...</div>}
      {error && <pre>{JSON.stringify(error)}</pre>}
      <ol>
        {data &&data.data.map((post:any) => {
          return <li key={post.id}>{post.title} </li>
        })}
      </ol>
    </>
  )
}
const PostList2 = () => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['posts'],
    queryFn: () => getPost('2')
  })
  return (
    <>
      <h1>Post List 1</h1>
      {isLoading && <div>loading...</div>}
      {error && <pre>{JSON.stringify(error)}</pre>}
      <ol>
        {data && data.data.map((post: any) => {
          return <li key={post.id}>{post.title} </li>
        })}
      </ol>
    </>
  )
}

export default App;

后端对应的代码如下:如果是type是1,则立即响应hello world,如果是type是2,则延时1s后再响应lorem假文。

import express from "express";
import cors from "cors";
const app = express();

app.use(cors());

app.get("/posts", (req, res) => {
  if (req.query.type === "1") {
  // 如果是type是1,则立即响应hello world
    res.json([
      {
        id: "1",
        title: "hello world, hello world",
      },
    ]);
    return;
  }
  // 如果是type是2,则延时1s后再响应
  setTimeout(() => {
    res.json([{ id: "2", title: "lorem ipsum dolor sit amet, consectetur adip" }]);
  }, 1000);
});

app.listen(3000, () => {
  console.log("running server at http://localhost:3000");
});

如果在没有缓存的情况下,我们点击按钮切换到<PostList2 />后,会看到1s的loading,然后显示lorem假文。

然后,事实上,点击按钮posts list2后,立马显示了posts list1的内容,尽管<PostList1 />或者<PostList2 />不同的组件!而在1s后,<PostList2 />的请求响应回来后才显示lorem假文,期间并没有任何loading,这就说明了react query有帮我们做了缓存工作。

使用相同的queryKey后,react query对响应结果做了缓存。切换post list后,react query让我们先使用了之前的响应结果。与此同时,它在背后发起了请求,当请求回来后,再用最新的响应替换之前缓存的结果。

React Query 入门教程! 前面提到过调用useQuery后,会返回请求的状态status,除了这个status外,它还有一个fetchStatus,就是来描述这种在背后默默发请求的行为。

// 第1次发送queryKey为posts的请求
status === 'loading'
fetchStatus === 'fetching'
/* 第1次请求成功后*/
status === 'success'
fetchStatus === 'idle'

// --------

// 第2次发送queryKey为posts的请求
status === 'success' // 首先直接用缓存
fetchStatus === 'fetching' // 与此同时背后默默发送请求
/* 第 2次发请求也成功了*/
status === 'success'
fetchStatus === 'idle'

React Query 入门教程! 如果你想第二次请求的时候,连背后的请求也不要发,直接用缓存。可以在请求的时候设置staleTime,告诉react query,响应结果啥时候过期,在没有过期之前,也别给我在背后发请求了,直接用之前的缓存。

const result = useQuery({
  queryKey: ['todos'],
  queryFn: () => fetch('/todos'),
  initialData: initialTodos,
  staleTime: 1000, // 如果过了1000ms后,这个请求才算过期
})

接下来,我们把<PostList1 /><PostList2 />queryKey 改成不相同,看会发生什么。

const PostList1 = () => {
  const {data,isLoading,error} = useQuery({
    queryKey:['posts','1'],
    queryFn:()=>getPost('1')
  })
  return (
    // 略
  )
}
const PostList2 = () => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['posts','2'],
    queryFn: () => getPost('2')
  })
  return (
    // 略
  )
}

在设置不同的queryKey后,切换到<PostList2 />后,看到了1s的loading,然后才显示lorem假文。

React Query 入门教程!

这个例子就充分说明了queryKey的重要性:

  1. 如果是完全相同的请求,设置好完全相同的queryKey后,react query会自动帮我们做缓存工作。
  2. 如果不是完全相同的请求,设置相同的queryKey后,可能会出现你不想要的结果。

深入了解useMutation

基础内容

query不同,mutation主要用来干create,update,delete数据或者执行server副作用的操作。为此,react query给我们提供了一个useMutation的钩子。

function App() {
  // 定义后并不会像useQuery一样立即发请求
  const mutation = useMutation({
    mutationFn: (newPost) => {
      return axios.post('/createPost', newPost)
    },
  })
  return (
    <div>
         <button
            onClick={() => {
	         // ✅点击按钮后,才会触发请求
              mutation.mutate({ id: new Date(), title: 'Do Laundry' })
            }}
          >
            Create Todo
          </button>
    </div>
  )
}

useQuery是共享状态的,可以在不同的组件中多次调用相同的useQuery,并得到相同的缓存结果。但是useMutation不是这样的。

useMutation提供了一些其他配置选项,允许在mutation生命周期的任何阶段快速而简单地产生副作用:

useMutation({
  mutationFn: addTodo,
  onMutate: (variables) => {
    // 在调用mutationFn前会执行此函数

    // Optionally return a context containing data to use when for example rolling back
    return { id: 1 }
  },
  onError: (error, variables, context) => {
    // An error happened!
    console.log(`rolling back optimistic update with id ${context.id}`)
  },
  onSuccess: (data, variables, context) => {
    // mutationFn执行成功后的回调
  },
  onSettled: (data, error, variables, context) => {
    // 不管mutationFn执行成功还是失败,都会调用这个
  },
})

除了在useMutation的时候提供这些回调配置,还可以在具体调用mutate的时候提供:

 mutation.mutate({ id: new Date(), title: 'Do Laundry' },{
	  onSuccess: (data, variables, context) => {
	  },
	  onError: (error, variables, context) => {
	  },
	  onSettled: (data, error, variables, context) => {
	  },
 })

如果同时在useMutation和执行mutate都提供了这些生命周期的回调,它们的执行顺序是:

useMutation({
  mutationFn: addTodo,
  onSuccess: (data, variables, context) => {
    // I will fire first
  },
  onError: (error, variables, context) => {
    // I will fire first
  },
  onSettled: (data, error, variables, context) => {
    // I will fire first
  },
})

mutate(todo, {
  onSuccess: (data, variables, context) => {
    // I will fire second!
  },
  onError: (error, variables, context) => {
    // I will fire second!
  },
  onSettled: (data, error, variables, context) => {
    // I will fire second!
  },
})

useQuery不同,useMutation在失败后默认是不会重试的,但是可以自行配置重试:

const mutation = useMutation({
  mutationFn: addTodo,
  retry: 3,
})

mutation后更新query

如果使用useMutation新增了一个 post,我们下一步就是要query更新整个博客列表。我们有两种方法实现mutation后重新query。

invalidation

只需要告诉react query哪个query请求的数据已经失效了,它就会重新自动请求!当增加一个post,原来的获取posts的query就失效了,我们告诉react query这点就行了。

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function App() {
  const queryClient = useQueryClient()
  const {data,isLoading,error} = useQuery({
    queryKey: ['posts'],
    queryFn:()=>axios.get('/postsList'), 
  })
  const mutation = useMutation({
    mutationFn: (newPost) => {
      return axios.post('/createPost', newPost)
    },
    onSuccess:()=>{
	    // 新增一条post后,使获取postslist的请求失效,发起发起query请求
	    queryClient.invalidateQueries({ queryKey: ['posts'] })
    }
  })
  return (
    <div>
         <button
            onClick={() => {
              mutation.mutate({ id: new Date(), title: 'Do Laundry' })
            }}
          >
            Create Todo
          </button>
    </div>
  )
}

直接更新数据

有的时候,我们不想重新获取query数据再更新。特别是如果mutation后已经返回了所需要的一切。比如:如果我们更新了某篇博客的标题后,后端返回了这篇博客的已经更新成功了并且返回了这篇博客的所有数据,那我们其实可以拿这个数据用setQueryData直接更新query。

const useUpdateTitle = (id) => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (newTitle) =>
      axios
        .patch(`/posts/${id}`, { title: newTitle })
        .then((response) => response.data),
    //