React入门(八)– 游戏库应用(中)

继续设计游戏库项目game-hub。


优化图像

检查网页,网络–选择图片,复制图片地址并打开,我们发现图是很大的,这会影响网络传输性能。

我们发现,在rawg中,可以在路径中裁剪图片,比如原地址是:

https://media.rawg.io/media/games/618/618c2031a07bbff6b4f611f10b6bcdbc.jpg

在 “media/” 添加 “crop/”就可以达到截剪的目的,比如”crop/600/400/”就表示截剪成600*400的图片,形成的新图片url就是:

https://media.rawg.io/media/crop/600/400/games/618/618c2031a07bbff6b4f611f10b6bcdbc.jpg

这样就可以大大提高性能,我们来实现一下。

添加文件:/src/services/image-url.ts,创建函数getCroppedImageUrl,用于将原图片Url变成载剪后的Url。

// image-url.ts
​
const getCroppedImageUrl=(url:string)=>{
    const target = 'media/';
    const index = url.indexOf(target)+target.length;
    return url.slice(0,index) + 'crop/600/400/' + url.slice(index);
}
export default getCroppedImageUrl;

然后在 GameCard调用 getCroppedImageUrl函数。

// GameCard.tsx
​
......
​
      <Image src={getCroppedImageUrl(game.background_image)}></Image>
​
......

提交git:Get optimized images.


加载框架改善用户体验

从后端获取游戏列表需要时间,等待过程中,我们先加载框架。如下图。

等游戏列表获取完成后,再加载游戏信息,这样会提升用户的体验。

我们来实现下。

useGames.ts 添加 isLoading 的State。表示是否在加载状态。

在准备获取游戏列表时设置 isLoading为true,then和catch里分别设置回false。返回增加 isLoading。

// useGames.ts
​
......
​
const useGames = ()=>{ 
    ......  
    const [isLoading, setLoading] = useState(false);
    useEffect(() => {
        ......     
        setLoading(true);
        apiClient
            .get<......>(......)        
            .then((res) => {
                ......                
                setLoading(false)
            })
            .catch((err) => {
                ......                
                setLoading(false);
            })
    }, []); 
    return {......,isLoading};           
}
......

增加一个组件 /src/components/GameCardSkeleton.tsx,在加载时显示框架,当加载完成后,再显示GameCard。

与实际的GameCard组件相对应,GameCardSkeleton组件里的Skeleton组件模仿Image组件,SkeletonText类似Heading。width, heigh属性只是大致设的值,没什么特殊,主要是为了视觉上与实际一致大小,borderRadius, overflow 设置的与实际的GameCard一致。

// GameCardSkeleton.tsx
​
import { Card, CardBody, Skeleton, SkeletonText } from "@chakra-ui/react";
​
const GameCardSkeleton = () => {
  return (
    <Card width="260px" borderRadius={10} overflow="hidden">
      <Skeleton height="200px"/>
      <CardBody>
        <SkeletonText />
      </CardBody>
    </Card>
  );
};
​
export default GameCardSkeleton;

再到实际的GameCard中,Card组件的width属性也添加设成与GameCardSkeleton的Card的width一致:width=”260px”。

这两个组件的CSS设置是重复的,但本节暂不分散注意力,下一节会优化。

再到GameGrid.tsx,添加显示GameCardSkeleton组件的代码。在加载时就显示6个框架,skeletons 列表我们就随意赋值 1,2,3,4,5,6。这无关紧要,只是为了后面用map展示6个框架。

// GameGrid.tsx
​
......
​
const GameGrid = () => {
  ......
  const skeletons = [1, 2, 3, 4, 5, 6];
  
......
​
        {isLoading &&
          skeletons.map((skeleton) => <GameCardSkeleton key={skeleton} />)}
        {games.map((game) => (
          <GameCard key={game.id} game={game} />
        ))}
​
......

完成后,加载时就达到了先显示框架的效果。

提交 git,“Show loading skeletons”


重构 – 删除重复的样式

前面说了,GameCardSkeleton 和 GameCard重复设置了width, borderRadius等属性,如何重构?

利用ChakraUI 的Box组件作为容器,而重复的CSS属性就设在Box中。

创建 src/components/GameCardContainer.tsx文件。

将GameCard的属性移到 Box组件里。并设置childred的Props可以容纳子组件。

// GameCardContainer.tsx
​
import { Box } from "@chakra-ui/react";
import { ReactNode } from "react";
​
interface Props {
  children: ReactNode;
}
const GameCardWrapper = ({ children }: Props) => {
  return (
    <Box width="260px" borderRadius={10} overflow="hidden">
      {children}
    </Box>
  );
};
​
export default GameCardContainer;
​

然后到 GameGrid组件里,用GameCardContainer包装GameCard、GameCardSkeleton。

// GameGrid.tsx

......
​
        {isLoading &&
          skeletons.map((skeleton) => (
            <GameCardContainer>
              <GameCardSkeleton key={skeleton} />
            </GameCardContainer>
          ))}
        {games.map((game) => (
          <GameCardContainer>
            <GameCard key={game.id} game={game} />
          </GameCardContainer>
        ))}
​
......

提交git,“Reflactor: Remove duplicated sytles”


获取类型

这与获取Games非常类似。创建 /src/services/useGenres.ts。查看genres的API,暂时只获取id 和 name,实现基本与 useGames是一样的。

// useGenres.ts
​
import { useEffect, useState } from "react";
import apiClient from "../services/api-client";
import { CanceledError } from "axios";
​
export interface Genre {
    id: number;
    name: string;
}
interface FetchGenresResponse {
    count: number;
    results: Genre[];
}
const useGenres = () =>{
    const [genres, setGenres] = useState<Genre[]>([]);
    const [error, setError] = useState("");
    const [isLoading, setLoading] = useState(false);
    useEffect(() => {
        const controller = new AbortController();
        setLoading(true);
        apiClient
            .get<FetchGenresResponse>("/genres",{signal:controller.signal})
            .then((res) => {
                setGenres(res.data.results);
                setLoading(false)
            })
            .catch((err) => {
                if (err instanceof CanceledError) return;
                setError(err.message)
                setLoading(false);
            });
        return ()=>controller.abort();
    }, []);
    return {genres,error,isLoading};
}
export default useGenres;

创建 src/components/GenreList.tsx组件。使用 <ul>列出genre的名字。

// GenreList.tsx
​
import useGenres from "../hooks/useGenres";
​
const GenreList = () => {
  const { genres } = useGenres();
  return (
    <ul>
      {genres.map((genre) => (
        <li key={genre.id}>{genre.name}</li>
      ))}
    </ul>
  );
};
​
export default GenreList;
​

再在Aside中展示。修改App.tsx。

// App.tsx
​
......
​
      <Show above="lg">
        <GridItem area="aside">
          <GenreList />
        </GridItem>
      </Show>
​
......

效果。

提交Git。“Fetch the genres”


创建通用的获取数据Hook

useGenres与useGames非常类似,很多代码都重复了,我们重构下。

新建 src/hooks/useData.ts,先将useGenres.ts的代码复制过来,逐行修改,去除特定的Genre相关代码,改造成通用的。

FetchGenresResponse改成 FetchResponse<T>,类型T由调用者提供。调用 useData需提供 对象数据结构 T 以及 端点 endpoint,代码里的genres也改成 data。

// useData.ts
​
import { useEffect, useState } from "react";
import apiClient from "../services/api-client";
import { CanceledError } from "axios";
​
interface FetchResponse<T> {
    count: number;
    results: T[];
}
const useData = <T>(endpoint:string) =>{
    const [data, setData] = useState<T[]>([]);
    const [error, setError] = useState("");
    const [isLoading, setLoading] = useState(false);
    useEffect(() => {
        const controller = new AbortController();
        setLoading(true);
        apiClient
            .get<FetchResponse<T>>(endpoint,{signal:controller.signal})
            .then((res) => {
                setData(res.data.results);
                setLoading(false)
            })
            .catch((err) => {
                if (err instanceof CanceledError) return;
                setError(err.message)
                setLoading(false);
            });
        return ()=>controller.abort();
    }, []);
    return {data,error,isLoading};
}
export default useData;

然后在useGenres.ts里使用 useData,只保留数据结构 Genre,去掉原来的实现代码,只调用useData即可:useData<Genre>(‘/genres’);

// useGenres.ts
​
import useData from "./useData";
​
export interface Genre {
    id: number;
    name: string;
}
​
const useGenres = () =>useData<Genre>('/genres');
export default useGenres;

同样的,useGames.ts也作同样修改。

// useGames.ts
​
......
  
const useGames = ()=>useData<Game>('/games');
​
export default useGames;

由于目前Hook返回的是data,不是之前的genres和games,也需要在GenreList和GameGrid中做修改。

// GenreList.tsx, GameGrid.tsx 使用 data.map(......)
// 而不是之前的 genres.map()或 games.map()
......
​
        {data.map(
          ......
        )}
​
......

提交Git,“Create a generic data fetching hook”


显示类型

侧边栏显示图标和类型名称,查看API,在useGenres.ts中添加图像属性image_background。

// useGenres.ts
​
......
​
export interface Genre {
    ......
    image_background: string;
}
​
......

修改GenreList组件,将ul\li 改成 List和ListItem,为使列表项之间不靠得太近,ListItem添加属性 paddingY=’5px’,图像和文件在同一行,需用HStack包起来。图像<Image>限定boxsize为32px,类型名称<Text>设置文字大小为 ‘lg’。

// GenreList.tsx
​
......
const GenreList = () => {
  const { data } = useGenres();
  return (
    <List>
      {data.map((genre) => (
        <ListItem key={genre.id} paddingY="5px">
          <HStack>
            <Image
              src={getCroppedImageUrl(genre.image_background)}
              boxSize="32px"
              borderRadius={8}
            />
            <Text fontSize="lg">{genre.name}</Text>
          </HStack>
        </ListItem>
      ))}
    </List>
  );
};
​
......

调整侧边栏的布局。在App.tsx中,Grid添加 templateColumns属性,指定测边栏aside固定宽度为 200px,同时paddingX为 5。

// App.tsx
​
......
​
function App() {
  return (
    <Grid
      templateAreas={{
        base: `"nav""main"`,
        lg: `"nav nav""aside main"`,
      }}
      templateColumns={{
        base: "1fr",
        lg: "200px 1fr",
      }}
    >
      ......
      
      <Show above="lg">
        <GridItem area="aside" paddingX={5}>
          <GenreList />
        </GridItem>
      </Show>
​
......

查看效果时,发现右侧 main区域挤在一起了,原因是指定固定的width,我们调整下GameCardContainer。将之前Box组件指定的width属性删除,这样默认就是 100%。

// GameCardContainer.tsx
​
......
​
const GameCardContainer = ({ children }: Props) => {
  return (
    <Box borderRadius={10} overflow="hidden">
      {children}
    </Box>
  );
};
​
export default GameCardContainer;

再检查发现GameGrid中GameCard间隔有点大,将SimpleGrid的 spacing属性也10调整到3。

// GameGrid.tsx
​
......
​
      <SimpleGrid
        columns={{ sm: 1, md: 2, lg: 3, xl: 5 }}
        padding="10px"
        spacing={3}
      >
 
 ......
​

效果截图。

提交Git,“Display Genres”


显示旋转等待

与GameGrid一样,侧边栏在等待后端的数据时,给出等待提示会提升用户体验。我们当然也可以像之前一样使用Skeleton,不过这里我们使用一个旋转等待提示<Spinner>。

代码非常简单,判断isLoading,如果是的话就返回 <Spinner>。同时也处理了error,这里不显示错误信息,直接返回 null。网页每个组件都显示错误的话会很凌乱。

// GenreList.tsx
​
......
const GenreList = () => {
  const { data, isLoading, error } = useGenres();
  if (error) return null;
  if (isLoading) return <Spinner />;
  return (
    ......
  );
};
​
export default GenreList;

提交Git,“Show a spinner while fetching the genres”


按类型过滤游戏

先修正一下之下的疏忽,GameGrid用map加载GameCardContainer时未设置key,另外PlatformIconList用map加载也未加key,这时一并修复下,具体就不列了。

下面开始实现按类型过滤游戏。

首先修改边栏GenreList组件,将里里的 Text组件改成 Button组件,设置 variant=’link’,这样看上去像链接,设置onClick事件处理由 Props传入的onSelectGenre来处理,并将当前的 genre 作为参数传出去。

// GenreList.tsx
​
......
​
interface Props {
  onSelectGenre: (genre: Genre) => void;
}
​
const GenreList = ({ onSelectGenre }: Props) => {
......
  return (
    <List>
      {data.map((genre) => (
        <ListItem key={genre.id} paddingY="5px">
          ......
            <Button
              fontSize="lg"
              variant="link"
              onClick={() => onSelectGenre(genre)}
            >
              {genre.name}
            </Button>
           ......
        </ListItem>
      ))}
    </List>
​
......

GenreList 和 GameGrid 互动,需要将State提升到它们的父级组件来维持,父级组件就是App.tsx。

添加selectedGenre的State。App调用GenreList时,提供onSelectGenre处理函数,该函数获取并设置 selectedGenre。接着传 selectedGenre给GameGrid,GameGrid根据选中的genre来重新渲染组件。

// App.tsx
​
......
function App() {
  const [selectedGenre, setSelectedGenre] = useState<Genre | null>(null);
......
​
      <Show above="lg">
        <GridItem area="aside" paddingX={5}>
          <GenreList onSelectGenre={(genre) => setSelectedGenre(genre)} />
        </GridItem>
      </Show>
      <GridItem area="main">
        <GameGrid genre={selectedGenre} />
      </GridItem>
​
......

GameGrid组件获取 genre,再传给useGames。这里Props定义的genre为Genre类型或 null。

// GameGrid.tsx
​
......
​
interface Props {
  genre: Genre | null;
}
const GameGrid = ({ genre }: Props) => {
  const { data, error, isLoading } = useGames(genre);
​
......

接着修改useGames.ts。它向useData继续传递,增加两个参数。一个是params,构造查询字符串,查询API知道是 … ?genres=<id> 的结构;另一个是Effect Hook中有到的依赖项,这里就是 genre.id。

// useGames.ts
​
......
​
const useGames = (genre:Genre|null)=>useData<Game>('/games',{params:{genres:genre?.id}},[genre?.id]);
......

最后,就要修改useData.ts了。useData接收3个参数,除了端点外,还有一个请求的 AxiosRequestConfig,以及一个依赖列表。依赖列表不确定类型,类型是 any,并加上可选 ?,因为前一个参数是可选的,后一个也必须可能选,否则编译时出错。

接着,apiClient.get里添加 AxiosRequestConfig对象。Effect添加第2个参数defs,注意语法是先判断 defs是否是空。

// useData.ts
​
......
​
const useData = <T>(endpoint:string, requestConfig?:AxiosRequestConfig,defs?:any[]) =>{
......
​
    useEffect(() => {
        ......
        apiClient
            .get<FetchResponse<T>>(endpoint,{signal:controller.signal, ...requestConfig})
            ......
            
    }, defs?[...defs]:[]);
​
......

由于我们重构成通用的获取数据的useData,这个筛选实现稍略复杂。实际上只要逻辑清晰,也是很简单的。

完成后,提交Git,“Filter games by genre ”


突出显示选择的类型

侧边栏的GenreList选中某一类型时,应该加粗显示。这很简单。

在GenreList添加Props的一个属性 selectedGenre,再判断,选中的话加粗。

// GenreList.tsx
​
.....
​
interface Props {
  onSelectGenre: (genre: Genre) => void;
  selectedGenre: Genre | null;
}
​
const GenreList = ({ onSelectGenre, selectedGenre }: Props) => {
......
​
            <Button
              fontSize="lg"
              fontWeight={genre.id === selectedGenre?.id ? "bold" : "normal"}
              ......
            </Button>
​
......

接着在 App组件调用GenreList时传递selectedGenre。

// App.tsx
​
......
​
          <GenreList
            selectedGenre={selectedGenre}
            onSelectGenre={(genre) => setSelectedGenre(genre)}
          />
​
......

提交Git,“Highlight the selected genre”


创建平台选择器

为达到通过平台筛选游戏的目的,我们创建一个平台选择器。

查看了rawg的API,了解Platform的结构及API访问端点,创建usePlatforms.ts。

// usePlatforms.ts
​
import useData from "./useData";
export interface Platform{
    id:number;
    name:string;
    slug:string;
}
const usePlatforms = ()=>useData<Platform>('/platforms/lists/parents');
export default usePlatforms;

再创建PlatformSelector.tsx组件。Menu组件参考官网代码,将获取到的platforms填充到 MenuItem中。如果遇到错误,直接返回,不显示,这样可能更好。

// PlatformSelector.tsx
​
import { Button, Menu, MenuButton, MenuItem, MenuList } from "@chakra-ui/react";
import { BsChevronDown } from "react-icons/bs";
import usePlatforms from "../hooks/usePlatforms";
​
const PlatformSelector = () => {
  const { data, error } = usePlatforms();
  if (error) return null;
  return (
    <Menu>
      <MenuButton as={Button} rightIcon={<BsChevronDown />}>
        Platforms
      </MenuButton>
      <MenuList>
        {data.map((platform) => (
          <MenuItem key={platform.id}>{platform.name}</MenuItem>
        ))}
      </MenuList>
    </Menu>
  );
};
​
export default PlatformSelector;
​

再在App组件中调用PlatformSelector组件。

// App.tsx
​
......
​
      <GridItem area="main">
        <PlatformSelector />
        <GameGrid genre={selectedGenre} />
      </GridItem>
​
......

完成效果。

提交Git,“Build platform selector ”


按平台过滤游戏

过滤类似按类型过滤,一样的方法。

PlatformSelecttor组件添加 Props,onSelectPlatform用于传platform给App组件,而selectedPlatform用于设置Menu组件的显示,选中子项时显示子项名称,比如筛选Xbox就显示Xbox。

// PlatformSelector.tsx
​
......
​
interface Props {
  onSelectPlatform: (platform: Platform) => void;
  selectedPlatform: Platform | null;
}
​
const PlatformSelector = ({ onSelectPlatform, selectedPlatform }: Props) => {
  ......
      <MenuButton as={Button} rightIcon={<BsChevronDown />}>
        {selectedPlatform?.name || "Platforms"}
      </MenuButton>
      <MenuList>
        {data.map((platform) => (
          <MenuItem
            key={platform.id}
            onClick={() => onSelectPlatform(platform)}
          >
            {platform.name}
          </MenuItem>
        ))}
      </MenuList>
    </Menu>
  );
};
​
export default PlatformSelector;
​

GameGrid组件添加 selectedPlatform的Props,再传给useGames处理。

// GameGrid.tsx
​
......
​
interface Props {
  selectedGenre: Genre | null;
  selectedPlatform: Platform | null;
}
const GameGrid = ({ selectedGenre, selectedPlatform }: Props) => {
  const { data, error, isLoading } = useGames(selectedGenre, selectedPlatform);
​
......
​

useGame.ts里,也仿照前面的genre添加就行。

// useGame.ts
​
......
​
const useGames = (selectedGenre:Genre|null,selectedPlatform:Platform|null)=>useData<Game>('/games',{params:{
    genres:selectedGenre?.id,
    platforms:selectedPlatform?.id,
}},[selectedGenre?.id,selectedPlatform?.id]);
​
export default useGames;

最后在App.tsx里添加 State为 selectedPlatform,然后按照之前genre传给PlatformSelector和 GameGrid即可。

// App.tsx
​
​
......
function App() {
  const [selectedGenre, setSelectedGenre] = useState<Genre | null>(null);
  const [selectedPlatform, setSelectedPlatform] = useState<Platform | null>(
    null
  );
......
​
      <GridItem area="main">
        <PlatformSelector
          onSelectPlatform={(platform) => setSelectedPlatform(platform)}
          selectedPlatform={selectedPlatform}
        />
        <GameGrid
          selectedGenre={selectedGenre}
          selectedPlatform={selectedPlatform}
        />
      </GridItem>
​
......

保存运行。

提交Git。“Filter games by platform”


重构-提取查询对象

目前使用了genre和platform筛选查询games,如果参数再多一些的话,会显示很凌乱。我们来提取成查询对象,使代码更清晰。

在App组件里,创建GameQuery查询结构,这样State也只需设置一个 gameQuery。传给GenreList、PlatformSelector以及GameGrid的Props都作相应的调整。

// App.tsx
​
......
​
export interface GameQuery {
  genre: Genre | null;
  platform: Platform | null;
}
function App() {
  const [gameQuery, setGameQuery] = useState<GameQuery>({} as GameQuery);
  ......
  
      <Show above="lg">
        <GridItem area="aside" paddingX={5}>
          <GenreList
            selectedGenre={gameQuery.genre}
            onSelectGenre={(genre) => setGameQuery({ ...gameQuery, genre })}
          />
        </GridItem>
      </Show>
      <GridItem area="main">
        <PlatformSelector
          onSelectPlatform={(platform) =>
            setGameQuery({ ...gameQuery, platform })
          }
          selectedPlatform={gameQuery.platform}
        />
        <GameGrid gameQuery={gameQuery} />
      </GridItem>
​
......

再到GameGrid组件。Props也改成GameQuery,调用useGames也只需传gameQuery即可,这样代码更清晰。

// GameGrid.tsx
​
......
​
interface Props {
  gameQuery: GameQuery;
}
const GameGrid = ({ gameQuery }: Props) => {
  const { data, error, isLoading } = useGames(gameQuery);
​
......

再修改useGames.ts,调整调用useData的参数引用。注意Effect依赖的第3个参数,我们直接给出 gameQuery,不像之前逐个传。

// useGames.ts
​
......
  
const useGames = (gameQuery:GameQuery)=>useData<Game>('/games',{params:{
    genres:gameQuery.genre?.id,
    platforms:gameQuery.platform?.id,
}},[gameQuery]);
​
export default useGames;

保存,测试页面未出问题。Git提交。”Refactor: Extract a query object”


小结

本文继续构建游戏库项目,添加了按类型、平台搜索游戏,在构建过程中随时进行优化重构,保持代码的整洁。

下一篇将完成游戏库项目的构建,同时也是本系列《React入门》的最后一篇,敬请期待。

发表评论

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