我们已学习了基本的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。