继续设计游戏库项目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入门》的最后一篇,敬请期待。
