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