React中级教程(三)– React Query项目实践

我们已学习了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项目。

下一篇介绍全局状态管理。

发表评论

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