我们已学习了React Query的相关知识,是时候将知识运用在项目上了,本文将改造之前的game-hub应用程序,将缓存及无限下拉获取等特性带入该应用中。
练习:获取Genres
在React入门系列中,game-hub项目的Genres是用静态数据的,因为它基本不会变,使用静态数据可以大大提升性能。本节我们则通过React Query来获取Genres。
先安装ReactQuery和ReactQueryDevTools。
npm i @tanstack/react-query@4.28
npm i @tanstack/react-query-devtools@4.28
再在mail.tsx里添加ReactQuery相关代码,这个前面都已介绍过。
// mail.tsx
......
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
......
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
......
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools />
</QueryClientProvider>
......
)
完成ReactQuery的设置之后,接着修改hooks/useGenres.ts。
之前使用静态数据,现在利用ReactQuery从后端获取。静态数据结构是 Genre[]列表,而获取的数据结构是FetchResponse<Genre>,这个结构定义在useData里,所以在useData模块里给这个数据结构打上export标志,然后在useGenres里import进来。apiClient的get方法获取的类型设置为FetchResponse<Genre>。
接着再优化,由于Genre基本不变,设置缓存过期时间为24小时(24*60*60*1000),其实可以更极端一点,比如一周。另外设置initialData为之前的静态数据结构 genres,为了与get方法得到的数据结构FetchResponse<Genre>一致,手动构造了initialData的结构有count和 results两个属性。
// useGenres.ts
import {FetchResponse} from "./useData";
import { useQuery } from '@tanstack/react-query';
import apiClient from '../services/api-client';
import genres from '../data/genres';
export interface Genre {
id: number;
name: string;
image_background: string;
}
const useGenres = () =>
useQuery({
queryKey:["genres"],
queryFn:()=>{
return apiClient.get<FetchResponse<Genre>>("/genres").then(res=>res.data)
},
staleTime:24*60*60*1000,
initialData:{count:genres.length,results:genres}
})
export default useGenres;
最后,在GenreList进行调用。将之前的data.map改成data?.results.map。data?表示后端调用可能为空所以加了问号,后端调用得到的数据需要在results属性里才可以map.
// GenreList.tsx
......
<List>
{data?.results.map((genre) => (
......
练习:获取Platforms
Platforms用ReactQuery重构几乎与Genres一模一样。usePlatforms代码如下。
// usePlatforms.ts
import { useQuery } from '@tanstack/react-query';
import platforms from '../data/platforms'
import apiClient from '../services/api-client';
import { FetchResponse } from './useData';
export interface Platform{
id:number;
name:string;
slug:string;
}
const usePlatforms = () =>useQuery({
queryKey:["platforms"],
queryFn:()=>apiClient.get<FetchResponse<Platform>>('/platforms/lists/parents').then(res=>res.data),
staleTime: 24 * 60 * 60 * 1000,
initialData: {count:platforms.length, results:platforms}
})
export default usePlatforms;
接着是PlatformSelector.tsx,将data.map改成data?.reults.map。
// PlatformSelector.tsx
......
<MenuList>
{data?.results.map((platform) => (
......
练习:获取Games
对于useGames,之前是调用useData,我们使用ReactQuery改造。
没什么新鲜的,useQuery<FetchResponse<Game>,Error>(…) 表明返回的类型,主要用于GameGrid.tsx的error类型识别,否则TypeScript会报错。
接着可以查一下useData的引用(右键–Go to References),发现已没有引用了。useData.ts文件里还有 接口 FetchResponse,可以将之移至api-client.ts里。
再像之前一样,在GameGrid.tsx中将data.map改成data?.results.map,再将error改成error.message即可。
// useGames.ts
import { useQuery } from "@tanstack/react-query";
import { GameQuery } from "../App";
import apiClient from "../services/api-client";
import { FetchResponse } from "../services/api-client";
export interface Platform{
id:number;
name:string;
slug:string;
}
export interface Game {
id: number;
name: string;
background_image: string;
parent_platforms:{platform:Platform}[];
metacritic: number;
rating_top:number;
}
const useGames = (gameQuery:GameQuery)=>useQuery<FetchResponse<Game>,Error>({
queryKey:[gameQuery],
queryFn:()=>apiClient.get<FetchResponse<Game>>('/games',{
params:{
genres:gameQuery.genre?.id,
parent_platforms:gameQuery.platform?.id,
ordering:gameQuery.sortOrder,
search:gameQuery.searchText
}
}).then(res=>res.data)
})
;
export default useGames;
由于将FetchResponse移了位置,一些模块调用了它,会产生一错误,需要修改路径。可以按 Ctrl+Shift+P,输入 Tasks:Run Build Task,再选择 npm:build tsx && vite build,查看有无Typescript错误,根据提示进行处理。然后再执行一次,确保无错误。
完成后,提交。“Refactoring – fetch games with React Query”
练习:删除重复的Interfaces
在useGames.ts中,有一个Platform的Interface,而在usePlatforms.ts中,有同样的Interface,我们消除这个重复。
做法很简单,首先要看一下有哪些模块引用了useGames.ts中的Platform,然后逐个更改成引用usePlatforms里的Platform。
很简单,就不详列了。
完成修改后,也进行一次”tsc && vite build” 检查,确保无错误。
最后,提交。“Refactoring- removing duplicate interfaces”
练习:创建可重用的API Client
我们看到,在useGames, useGenre以及usePlatforms里都用到了get().then()的结构,有点重复。我们来重构。
我们将get().then()结构放在api-client里实现。
将基本的axios操作实例改成const类型命名为axiosInstance并不再export。然后创建APIClient类,构造函数传入端点信息,getAll方法封装各个模块需要使用的get().then()结构。APIClient带上泛型<T>,调用时可以提供相应的类型。
// api-client.ts
import axios, { AxiosRequestConfig } from "axios";
export interface FetchResponse<T> {
count: number;
results: T[];
}
const axiosInstance = axios.create({
baseURL:'https://api.rawg.io/api',
params:{
'key':'01b29da6c41841458b00aa22c947a464',
}
})
class APIClient<T> {
endpoint:string;
constructor(endpoint:string){
this.endpoint = endpoint
}
getAll=(config?:AxiosRequestConfig)=>
axiosInstance
.get<FetchResponse<T>>(this.endpoint,config)
.then(res=>res.data)
}
export default APIClient;
再在各个模块中使用。先创建APIClient的实例apiClient,然后在useQuery的queryFn属性中设置 apiClient.getAll。
// useGenres.ts
......
import APIClient from '../services/api-client';
......
const apiClient = new APIClient<Genre>("/genres");
const useGenres = () =>
useQuery({
queryKey:["genres"],
queryFn:()=>apiClient.getAll(),
....
// usePlatforms.ts
......
const apiClient = new APIClient<Platform>("/platforms/lists/parents")
const usePlatforms = () =>useQuery({
queryKey:["platforms"],
queryFn:apiClient.getAll,
......
// useGames.ts
......
const apiClient = new APIClient<Game>("/games");
const useGames = (gameQuery:GameQuery)=>useQuery<FetchResponse<Game>,Error>({
queryKey:[gameQuery],
queryFn:()=>apiClient.getAll({
params:{
genres:gameQuery.genre?.id,
parent_platforms:gameQuery.platform?.id,
ordering:gameQuery.sortOrder,
search:gameQuery.searchText
}
})
})
export default useGames;
完成后,提交。“Refactoring- create a reusable API client”
练习:实现无限循环
将useGames.ts里的useQuery改成useInfiniteQuery,再在其属性queryFn函数中解构 pageParam,默认为1表示第1页。通过查看后端API的说明,知道有查询参数 page, 并将pageParam赋给它。接着添加 getNextPageParam,用于获取下一页相关信息,参数为lastpage和allpages表示当前已获取的页面,根据后端API的结构,判断lastpage.next是否为null,不为null的话就将下一页码加1。
// useGames.ts
......
const useGames = (gameQuery:GameQuery)=>useInfiniteQuery<FetchResponse<Game>,Error>({
queryKey:[gameQuery],
queryFn:({pageParam=1})=>apiClient.getAll({
params:{
......,
page:pageParam
}
}),
getNextPageParam:(lastpage,allpages)=>{
return lastpage.next? allpages.length+1:undefined;
}
})
export default useGames;
前面用到了FetchResponse的next属性,所以在api-client.ts的FetchResponse接口中添加next属性。
// api-client.ts
......
export interface FetchResponse<T> {
count: number;
next: string | null;
results: T[];
}
最后,修改GameGrid,解构属性isFetchingNextPage, hasNextPage, fetchNextpage。循环处理各页面列表 pages, 每个页面 page.results进行map处理,展示在SimpleGrid上。然后,添加 Button组件,根据相关信息,显示信息并获取下一页数据 fetchNextPage。
// GameGrid.tsx
......
const GameGrid = ({ gameQuery }: Props) => {
const {
...,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useGames(gameQuery);
......
{data?.pages.map((page) =>
page.results.map((game) => (
<GameCardContainer key={game.id}>
<GameCard game={game} />
</GameCardContainer>
))
)}
</SimpleGrid>
{hasNextPage && (
<Button
marginY={5}
disabled={isFetchingNextPage}
onClick={() => fetchNextPage()}
>
{isFetchingNextPage ? "Loading..." : "Load More"}
</Button>
)}
......
export default GameGrid;
效果如下图,当还有未加载的游戏时,点“Load More”可以不断加载。
完成后,提交。“Implement infinite queries”
练习:实现无限滚动
再进一步,我们希望实现当滚动到底部时自动加载下一页。需要用到组件:react-infinite-scroll-component。查询官方介绍。
https://www.npmjs.com/package/react-infinite-scroll-component
安装:
npm i react-infinite-scroll-component@6.1
改造GameGrid。用InfiniteScroll组件将整个SimpleGrid包起来。并给InfiniteScroll组件提供相关属性。dataLength表示目前已取得的项目数量,我们用reduce方法算出,后面加个||0 为了防止出现undefined避免Typescript报错;hasMore我们用hasNextPage提供,同样为了类型匹配,使用了两个!;loader用Spinner组件显示;next属性设置调用获取下一页的函数。
// GameGrid.tsx
......
const fetchedGamesCount =
data?.pages.reduce((total, page) => total + page.results.length, 0) || 0;
...
return (
<InfiniteScroll
dataLength={fetchedGamesCount}
next={() => fetchNextPage()}
hasMore={!!hasNextPage}
loader={<Spinner />}
>
<SimpleGrid
...
>
......
</SimpleGrid>
</InfiniteScroll>
);
};
export default GameGrid;
完成。提交。“Implement Infinite Scroll”
练习:简化查询对象
打开页面,选择某一Genre及Platform,再点击ReactQuery开发工具查看,发现缓存的key明显过于复杂了。
对于queryKey而言,只需要genre、platform的ID即可,而不是冗长的整个对象,我们来优化它。
上面复杂的原因是查询接口GameQuery的结构不合理,它定义在App.tsx中,我们修改genre为genreID,类型为number; 修改platform为platformID,类型也为number。
右键查看这两个属性的引用,逐项进行重构修改。
首先是本身App.tsx中,调用组件GenreList,PlatformSelector提供的Props进行相应修改。
// App.tsx
export interface GameQuery {
genreID?: number;
platformID?: number;
......
}
......
<GenreList
selectedGenreID={gameQuery.genreID}
onSelectGenre={(genreID) => setGameQuery({ ...gameQuery, genreID })}
/>
......
<PlatformSelector
onSelectPlatform={(platformID) =>
setGameQuery({ ...gameQuery, platformID })
}
selectedPlatformID={gameQuery.platformID}
......
再修改其他引用这两个属性的模块。
// GenreList.tsx
......
interface Props {
onSelectGenre: (genreID: number) => void;
selectedGenreID?: number;
}
const GenreList = ({ onSelectGenre, selectedGenreID }: Props) => {
......
<Button
fontSize="lg"
fontWeight={genre.id === selectedGenreID ? "bold" : "normal"}
variant="link"
onClick={() => onSelectGenre(genre.id)}
......
// PlatformSelector.tsx
......
interface Props {
onSelectPlatform: (platformID: number) => void;
selectedPlatformID?: number;
}
const PlatformSelector = ({ onSelectPlatform, selectedPlatformID }: Props) => {
......
const selectedPlatform = data?.results.find(
(p) => p.id === selectedPlatformID
);
return (
......
onClick={() => onSelectPlatform(platform.id)}
.....
完成之后,测试点击Genre和Platform,QueryKey就非常简洁了。
提交。“Refactor- simplify game query”
练习:创建查寻Hooks
之前在PlatformSelector.tsx和GameHeading.tsx中,有重复的代码用于通过platformID获取platform。我们可以提取成一个Hooks。
创建 src/hooks/usePlatform.ts。
// usePlatform.ts
import usePlatforms from "./usePlatforms";
const usePlatform = (id?:number)=>{
const {data} = usePlatforms()
return data?.results.find(
(p) => p.id === id
);
}
export default usePlatform
同样的,现创建 src/hooks/useGenre.ts.
// useGenre.ts
import useGenres from "./useGenres";
const useGenre = (id?:number)=>{
const { data: genres } = useGenres();
return genres?.results.find((genre) => genre.id === id)
}
export default useGenre;
然后在GameHeading.tsx中调用。
// GameHeading.tsx
......
const GameHeading = ({ gameQuery }: Props) => {
const genre = useGenre(gameQuery.genreID);
const platform = usePlatform(gameQuery.platformID);
......
};
export default GameHeading;
在PlatformSelector.tsx调用。
// PlatformSelector.tsx
......
const PlatformSelector = ({ onSelectPlatform, selectedPlatformID }: Props) => {
......
const { data, error } = usePlatforms();
if (error) return null;
const selectedPlatform = usePlatform(selectedPlatformID);
return (
......
);
};
export default PlatformSelector;
完成后,提交。“Refactor- create lookup hooks”
练习:简化时间计算
设置staleTime时,我们通过计算24* 60 * 60 *1000毫秒表示24小时,这个数值通常需要注释,否则别人一下子不确定是多长时间,另外也可能输错数字导致设置了一个错误的时间。可以简化直观些,用到的就是ms库。
https://www.npmjs.com/package/ms
安装ms。由于ms是用纯javascript写的,不认识一些标记,所以再安装@types/ms,加了-D 表示仅用于开发环境,实际生产环境不会部署这个。
npm i ms@2.1.3
npm i -D @types/ms
然后就可以使用了, 只需使用 ms(’24h’)就表示24小时,非常直观。
// useGenres.ts
import ms from 'ms';
......
staleTime:ms('24h'),
......
同样的,在其他usePlatforms.ts,useGames.ts中也作同样的修改。
完成后,提交。“Refactor- simplify time calculations”。
小结
本文是实际项目练习。利用ReactQuery优化了我们在《React入门》系列中创建的game-hub项目。
下一篇介绍全局状态管理。