React中级教程(一)– React Query(上)

我们已学习了基本的Recat知识,本系列提高一个层次。我们将学习使用React Query管理和缓存数据、使用Zudtand管理全局React状态、使用React Router管理路由。

我们将增强我们在《React入门》中创建的game-hub项目,并添加缓存提高性能,添加无限滚动实现下拉不断获取游戏。我们也将添加一个页面来展示游戏的详细信息。


先决条件

该系列是《React入门》的延续,建议先阅读并理解前一系列,或者已对React有了基本的理解,先决条件包括:

  • 基本的TypeScript知识
  • 理解并能够创建组件
  • 组件的样式
  • 管理组件的状态
  • 连接后端服务

本系列我们将学习什么

首先是React Query。这是一个流行的React库,用于React的数据管理及缓存。使用React Query,我们不必再使用Effect Hook那样来处理结果、异常以及加载指示。

React Query不仅封装了Effect的功能,还带来了额外的好处,比如缓存、自动重试、自动刷新以及分页查询和无限查询。


其次是全局状态管理。我们将介绍Reducers、Context、Providers这些基本的工具来管理全局状态,然后介绍使用一个流行的状态管理库Zustand。本系列我们不使用有名的Redux,有了这些工具,我们可以不必使用Redux。

其三是使用React Router进行路由。React Router使我们有能力创建多页面应用程序,本系列我们将在game-hub项目中添加游戏详细查看页面。

通过本系列的学习,我们将对数据的管理与缓存、全局状态管理、路由有坚实的理解。


源码下载

请先下载初始源码,并解压到目录。

https://github.com/kelemi001/react-course-part2-starter/archive/refs/heads/main.zip

进入目录,然后运行:

npm install
npm run dev

检查能打开页面。


什么是React Query

我们查看 src/react-query/TodoList.tsx组件,这是一个典型的获取后端数据的代码,使用State存储获取到的数据,使用error获取错误。

你能看出这段代码有什么问题吗?

首先,代码没有取消连接HTTP,在组件卸载时http连接还在,我们在前一系列中说过,Effect Hook 一般要返回一个清理函数做这个工作。

其二,关注点没有分离,该组件暴露了后端连接的细节,没有做到模块化,可重用性较差。

另外,还有几点不足:

一、该代码不会重试。我们连接失败了,直接返回用户一个错误信息,这不算好的用户体验,更好的做法是再重连下。

二、代码不会自动刷新。用户停留在页面上,如果后端数据变了,用户是看不到更新的,除非用户手动刷新页面。

三、没有缓存。缓存是将常用的数据放在某个能快速高效获取的地方,有时能显著提升性能。

以上的问题,我们可以写一些代码来解决,但会带来额外的工作及维护成本,这就出现了 React Query 。

有一个非常流行的javascript库 Redux,也可用于获取数据缓存数据等,但它引入了很多复杂的东西到应用程序会使调试维护变得困难,除非必要,一般不再建议使用Redux。


设置React Query

安装。建议装同一版本。

npm i @tanstack/react-query@4.28

修改main.tsx。导入QueryClient和QueryClientProvider,然后创建一个queryClient对象,再将App组件用 QueryClientProvider包起来,QueryClientProvider的client属性就是刚建的queryClient。

// main.tsx
​
......
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
......
​
const queryClient = new QueryClient();
​
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </React.StrictMode>
);
​

这样就完成了React Query的设置。


获取数据

我们使用React Query来获取数据改造TodoList.tsx。

导入useQuery,不再使用 State Hook来保存data和error,也不再使用useEffect来获取数据。useQuery参数是一个对象,有两个必备属性:queryKey和queryFn,分别表示该查询的键值和查询的函数,查询的函数要返回一个Promise。queryKey是一个列表,可以定义多个值,第一个值一般表示查询的类别是一般是字符串,后面还可以再添加值,比如对于todos,可以添加 ‘completed’甚至是一个对象如 {completed:true} 等等,这里为了保持简单列表就一个值 “todos”。

queryFn指向fetchTodos函数,为了直接使用实际需要的结果,我们在then方法里返回res.data。axios.get方法指定了类型 <Todo[]>。

useQuery返回一个对象,该对象有 data、error等属性,这里我解构使用了data并重命为 todos,然后就可以在jsx中使用todos了。

// TodoList.tsx
​
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
​
interface Todo {
  id: number;
  title: string;
  userId: number;
  completed: boolean;
}
​
const TodoList = () => {
  const fetchTodos = () =>
    axios
      .get<Todo[]>("https://jsonplaceholder.typicode.com/todos")
      .then((res) => res.data);
​
  const { data: todos } = useQuery({
    queryKey: ["todos"],
    queryFn: fetchTodos,
  });
​
  return (
    <ul className="list-group">
      {todos?.map((todo) => (
        <li key={todo.id} className="list-group-item">
          {todo.title}
        </li>
      ))}
    </ul>
  );
};
​
export default TodoList;
​

使用useQuery,就带来了一些好处。改造后的TodoList组件就能够自动在失败时时重试连接,能够定期自动刷新组件,以及自动缓存。当然这些特性都是可以配置的。


处理错误

继续改造TodoList.tsx使之可以处理错误。

给useQuery提供类型,一个是正确的响应返回类型 Todo[],另一个是错误类型 Error,这个错误类在所有浏览器都是支持的。再解构出 useQuery 对象的 error属性。判断是否有错误,有的话就显示error.message。

// TodoList.tsx
​
......
  const { data: todos, error } = useQuery<Todo[], Error>({
    ......
  });
​
  if (error) return <p>{error.message}</p>;
......
​
​

再在App.tsx中调用TodoList。

import "./App.css";
import TodoList from "./react-query/TodoList";
​
function App() {
  return <TodoList />;
}
​
export default App;
​

保存,查看网页,能看到TodoList列表。

我们故意把API端点写错误,刷新时我们能看到它在重试,再过一段时间才显示“Network Error”


显示加载指示器

显示加载指示器非常简单,只需判断isLoading属性即可。

// TodoList.tsx
​
......
  const {
    data: todos,
    error,
    isLoading,
  } = useQuery<Todo[], Error>({
    queryKey: ["todos"],
    queryFn: fetchTodos,
  });
  if (isLoading) return <p>Loading...</p>;
  ......
  

这种是React Query的美妙之处,我们无需自己处理结果的存储和缓存,错误的捕捉,以及加载的判断,它都帮我们封装好了。


创建自定义Query Hook

TodoList组件有个问题,就是包含了Http后端服务的相关细节,我们应该分离。

新建src/react-query/hooks文件件,并在其下新建 useTodos.ts。

将TodoList.tsx中的interface Todo,fetchTodos,useQuery相关代码移过来。返回 useQuery对象。

// useTodos.ts
​
import axios from "axios";
import { useQuery } from "@tanstack/react-query";
​
export interface Todo {
    id: number;
    title: string;
    userId: number;
    completed: boolean;
  }
​
const useTodos = () =>{
    const fetchTodos = () =>
    axios
      .get<Todo[]>("https://jsonplaceholder.typicode.com/todos")
      .then((res) => res.data);
    return useQuery<Todo[], Error>({
        queryKey: ["todos"],
        queryFn: fetchTodos,
      });
}
​
export default useTodos;

这样,TodoList.tsx就变得清晰可维护了,他只需调用 useTodos()获取data, error, isLoading即可,完全不用关心实现的细节。

// TodoList.tsx
​
import useTodos, { Todo } from "./hooks/useTodos";
​
const TodoList = () => {
  const { data: todos, error, isLoading } = useTodos();
  ......
  

使用React Query开发工具

像其他前端库一样,React Query也有自己的开发工具,这是一个强大的调试和监视工具。

npm i @tanstack/react-query-devtools@4.28

再到main.tsx修改。导入ReactQueryDevTools,然后在App组件下面添加该组件。

这个只在开发环境下生效,实际部署在生产环境时不会生效。

// main.tsx
​
......
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
......
​
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      <ReactQueryDevtools />
    </QueryClientProvider>
  </React.StrictMode>
);
​

网页就出现了ReactQueryDevTools图标,点击能看到一些非常有用的调试和监视功能。


自定义Query设置

React Query有一些默认设置,比如自动重试的次数,缓存的时间,自动刷新的时间等,可以自定义修改全局配置或具体某个Query。

// main.tsx
​
......
​
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 3,
      cacheTime: 300_000, // 也就是5分钟
      staleTime: 10 * 1000,
      refetchOnWindowFocus: false,
      refetchOnReconnect: false,
      refetchOnMount: false,
    },
  },
});
......
​

在main.tsx中,创建QueryClient时,可以提供全局配置。

retry:3 ,表示获取数据失败后重试的次数,默认是3次,我们可以覆盖这个值;

cacheTime:300_000, 表示缓存的有效的时间,默认是300000毫秒,也就是5分钟;

staleTime:10*1000,表示数据是否新鲜的,默认是0,表示一得到数据就是旧了,这里我们改成10秒,可以通过查看QueryDevTool看到过了10秒,数据状态就变成了stale。

默认在3种情形下,会刷新数据:从其他页面重回到组件页面,后端断线重连,以及加载组件时。我们可以修改该默认设置,如上面的代码里,我们分别都设置了false。

自动刷新的过程是这样的,当数据变成stale了,组件尝试联系后端获取新鲜数据,与此同时组件还是从缓存取stale的数据,等到后端的新鲜数据获取到了再更新组件。

大多数情况下,默认的设置就已很好,一般无需自定义配置,自定义修改比较多的是cacheTime,不同的应用缓存的时间有所不同。一般而言,要自定义配置,也不在全局修改,而是在具体某个Query里修改。

如下面我们就在useTodos.ts里的useQuery里添加 staleTime。

// useTodos.ts
​
......
const useTodos = () =>{
    ......
    
    return useQuery<Todo[], Error>({
        queryKey: ["todos"],
        queryFn: fetchTodos,
        staleTime:10*1000,
      });
}
​
export default useTodos;

练习:获取数据

我们下载的源码有一个PostList.tsx组件,使用之前的State,Effect。我们用React Query修改。

// PostList.tsx
​
import axios from 'axios';
import { useEffect, useState } from 'react';
​
interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}
​
const PostList = () => {
  const [posts, setPosts] = useState<Post[]>([]);
  const [error, setError] = useState('');
​
  useEffect(() => {
    axios
      .get('https://jsonplaceholder.typicode.com/posts')
      .then((res) => setPosts(res.data))
      .catch((error) => setError(error));
  }, []);
​
  if (error) return <p>{error}</p>;
​
  return (
    <ul className="list-group">
      {posts.map((post) => (
        <li key={post.id} className="list-group-item">
          {post.title}
        </li>
      ))}
    </ul>
  );
};
​
export default PostList;

解答:

添加文件 hooks/usePosts.ts

// usePosts.ts
​
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
​
interface Post {
    id: number;
    title: string;
    body: string;
    userId: number;
  }
const usePosts = ()=>useQuery<Post[], Error>({
        queryKey: ["posts"],
        queryFn: ()=>
        axios
            .get("https://jsonplaceholder.typicode.com/posts")
            .then((res) => res.data),
        staleTime:1*60*1000 
      });
export default usePosts;

修改PostList.tsx

// PostList.tsx
​
import usePosts from "./hooks/usePosts";
​
const PostList = () => {
  const { data: posts, error, isLoading } = usePosts();
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>{error.message}</p>;
  return (
    <ul className="list-group">
      {posts?.map((post) => (
        <li key={post.id} className="list-group-item">
          {post.title}
        </li>
      ))}
    </ul>
  );
};
​
export default PostList;
​

再在App.tsx中调用:

// App.tsx
​
import "./App.css";
import PostList from "./react-query/PostList";
​
function App() {
  return <PostList />;
}
​
export default App;

参数化查询

假设PostList组件添加下拉框,分别选择User 1、User 2等用户,根据选择的用户显示该用户的Posts,这涉及到参数的传递。缓存的Key也要根据查询结构动态变化。

修改PostList组件,添加select的Html元素,为了不分散注意力,硬编码一些用户Id的选项。当选择不同的用户时,设置名为userId的State。

将 userId传递给 usePosts处理。

// PostList.tsx
​
......
const PostList = () => {
  const [userId, setUserId] = useState<number>();
  const { data: posts, error, isLoading } = usePosts(userId);
  ......
  return (
    <>
      <select
        onChange={(event) => setUserId(parseInt(event.target.value))}
        value={userId}
        className="form-select mb-3"
      >
        <option value=""></option>
        <option value="1">User 1</option>
        <option value="2">User 2</option>
        <option value="3">User 3</option>
      </select>
      ......
​
    </>
  );
};
​
export default PostList;
​

接着修改usePosts。

传递参数userId,设置number类型或未定义,因组件刚加载时userId为空即未定义。

queryKey,我们设置跟url类似的结构 users/<id>/posts 。如果userId为空的话,就直接使用 posts 的key。

再在queryFn中传递参数查询,利用params属性传递查询参数userId。

// usePosts.ts
​
......
const usePosts = (userId:number| undefined)=>useQuery<Post[], Error>({
        queryKey: userId?["users",userId,'posts']:['posts'],
        queryFn: ()=>
        axios
            .get("https://jsonplaceholder.typicode.com/posts",{
              params:{
                userId,
              }
            })
            .then((res) => res.data),
        staleTime:1*60*1000
      });
export default usePosts;

保存,当我们选择不同的用户时,会显示 loading…,然后显示后端响应的列表。当我们回来再先择刚已查过的用户时,发现不会再显示loading…,而是直接给出结果,因为这是直接从缓存里取数据。这是使用React Query的大优点。


分页查询

为了清晰,先去掉前一节的User下拉选择框相关代码。

修改usePosts.ts。

新增PostQuery接口,用于封装查询Posts的数据结构,目前只有page和pageSize。usePosts函数中传递PostQuery对象query,在queryKey的列表中,第2个值就是整个 query;queryFn获取后端数据,提供了 _start和 _limit 参数,这个具体要查后端公布的API规范。

这里我们添加了useQuery的另一个属性 keepPreviousData,设置为true。这样在翻页时,首先保持原有数据,等新页数据已获取后,再更新页面。如果不设的话,在翻页时会同出现页面上下跳动。

// usePosts.ts
​
......
interface PostQuery{
  page:number;
  pageSize:number;
}
const usePosts = (query:PostQuery)=>useQuery<Post[], Error>({
        queryKey: ['posts',query],
        queryFn: ()=>
        axios
            .get("https://jsonplaceholder.typicode.com/posts",{
              params:{
                _start:(query.page-1)*query.pageSize,
                _limit:query.pageSize
              }
            })
            .then((res) => res.data),
        staleTime:1*60*1000,
        keepPreviousData:true
      });
export default usePosts;

再到PostList.tsx组件调用。

设置一个State为page,默认为1;另一个pageSize是不会变的,不必设为State,作为常数就好。调用usePosts时传递 {page,pageSize}过去。

页面增加两个按钮 Previous和Next,判断page是否为1,是的话Previous禁用。

另外,提一下bootstrap的my-3表示margin的纵向间隔为3,新版本的bootstrap的ms-1的 s为 start,之前使用 ml-1,也就是left。相应地新版本的 me-1表示右侧间隔,e是end的缩写。

// PostList.tsx
​
......
const PostList = () => {
  const pageSize = 10;
  const [page, setPage] = useState(1);
  const { data: posts, error, isLoading } = usePosts({ page, pageSize });
 ......
  return (
    <>
      ......
      
      <button
        disabled={page === 1}
        onClick={() => setPage(page - 1)}
        className="btn btn-primary my-3"
      >
        Previous
      </button>
      <button
        onClick={() => setPage(page + 1)}
        className="btn btn-primary my-3 ms-1"
      >
        Next
      </button>
    </>
  );
};
​
export default PostList;
​

无限查询

实际场景中,经常有滚到到页面最后自动加载新的一页的情况,这一节我们来实现。使用useInfiniteQuery,而不是useQuery。

interface PostQuery中,无限查询不再需要page属性,我们去掉。

useInfiniteQuery添加一个属性getNextPageParam,这是一个函数,用于获取下一页,它有两个参数 lastPage和allPages,lastPage为列表,表示最后一页的列表,allPages则是二维列表,每一个无素均是列表,表示具体的是某一页的列表。这里我们用getNextPageParam获取下一页页码,具体实现还要看后端,jsonplaceholder的API不算一个理想的API,它不提供总页数,如果调用的某一页不存在,就返回一个空列表,这里我们就判断lastPage的列表是否为空,如果不空的话,下一节就是allPages.length+1,否则就是undefined表示到底了。

getNextPageParam获取下一页信息后,就传给queryFn,我们解构出来{pageParam=1}并使用,注意我们给了一个默认值 1,首次调用时 pageParam为1。

// usePosts.ts
​
import { useInfiniteQuery } from "@tanstack/react-query";
......
​
interface PostQuery{
  pageSize:number;
}
const usePosts = (query:PostQuery)=>useInfiniteQuery<Post[], Error>({
        queryKey: ['posts',query],
        queryFn: ({pageParam=1})=>
        axios
            .get("https://jsonplaceholder.typicode.com/posts",{
              params:{
                _start:(pageParam-1)*query.pageSize,
                _limit:query.pageSize
              }
            })
            .then((res) => res.data),
        staleTime:1*60*1000,
        keepPreviousData:true,
        getNextPageParam:(lastPage, allPages)=>{
          return lastPage.length>0?allPages.length+1:undefined;
        }
      });
export default usePosts;

再来修改PostList.tsx。

先去掉 page的State,再去掉之前的Previous按钮。将Next按钮改成 Load More。

useInfiniteQuery有fetchNextPage属性可以调用获取下一页,isFetchingNextPage表示是否正在获取。

useInfiniteQuery获取的结果不再是一个列表,而是pageParams和pages,我们需要循环处理pages的各页,这里不想添加<div>,就增加了React.Fragment,key为 index,注意index的用法。然后再将各页page进行map。

onClick调用fetchNextPage,在isFetchingNextPage为true时禁用 Load More按扭并显示’Loading…’

// PostList.tsx
​
......
const PostList = () => {
  const pageSize = 10;
  const { ......, fetchNextPage, isFetchingNextPage } =
    usePosts({ pageSize });
......
​
  return (
    <>
      <ul className="list-group">
        {data.pages.map((page, index) => (
          <React.Fragment key={index}>
            {page.map((post) => (
              <li key={post.id} className="list-group-item">
                {post.title}
              </li>
            ))}
          </React.Fragment>
        ))}
      </ul>
      <button
        onClick={() => fetchNextPage()}
        disabled={isFetchingNextPage}
        className="btn btn-primary my-3 ms-1"
      >
        {isFetchingNextPage ? "Loading..." : "Load More"}
      </button>
    </>
  );
};
​
export default PostList;
​

保存,效果如下。


小结

本文介绍了React Query管理和缓存数据的第一部分,包括React Query的设置、调试、参数化查询、分页及无限查询等用法。下一篇继续介绍React Query。

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注