本篇继续完成游戏库项目game-hub的开发与部署,也是《React入门》系列的最后一篇。
创建排序选择器
新建 /src/components/SortSelector.tsx组件,先构建UI,这跟平台选择器PlatformSelector组件很类似。
// SortSelector.tsx
import { Button, Menu, MenuButton, MenuItem, MenuList } from "@chakra-ui/react";
import { BsChevronDown } from "react-icons/bs";
const SortSelector = () => {
return (
<Menu>
<MenuButton as={Button} rightIcon={<BsChevronDown />}>
Order by: Relevance
</MenuButton>
<MenuList>
<MenuItem>Relevance</MenuItem>
<MenuItem>Date added</MenuItem>
<MenuItem>Name</MenuItem>
<MenuItem>Release date</MenuItem>
<MenuItem>Popularity</MenuItem>
<MenuItem>Average rating</MenuItem>
</MenuList>
</Menu>
);
};
export default SortSelector;
再在App.tsx中使用SortSelector组件。我们将Platformselector和SortSelector用HStack包起来,设置HStatck的spacing为5表示两组件间的间隔,PaddingLeft为2表示成左侧的间隔。PaddingBottom为5 表示与下面GameGrid的间隔。
// App.tsx
......
<GridItem area="main">
<HStack spacing={5} paddingLeft={2} paddingBottom={5}>
<PlatformSelector
onSelectPlatform={(platform) =>
setGameQuery({ ...gameQuery, platform })
}
selectedPlatform={gameQuery.platform}
/>
<SortSelector />
</HStack>
<GameGrid gameQuery={gameQuery} />
</GridItem>
......
效果。

提交Git。“Build sort selector”
游戏排序
查看API,得知排序是 ?ordering=name 这样的格式,前面加上负号 – 表示是倒序。
定义 sortOrders列表,设置排序的键值对,在MenuItem中,使用map将sortOrders列表填充进去,onClick里回调函数onSelectSortOrder传递选中的排序字段。
同时获取当前选择的排序字段并显示在MenuButton上。跟PlatformSelector一致。
// SortSelector.tsx
import { Button, Menu, MenuButton, MenuItem, MenuList } from "@chakra-ui/react";
import { BsChevronDown } from "react-icons/bs";
interface Props {
onSelectSortOrder: (sortOrder: string) => void;
sortOrder: string;
}
const SortSelector = ({ onSelectSortOrder, sortOrder }: Props) => {
const sortOrders = [
{ value: "", label: "Relevance" },
{ value: "-added", label: "Date added" },
{ value: "name", label: "Name" },
{ value: "-released", label: "Release date" },
{ value: "-metacritic", label: "Popularity" },
{ value: "-rating", label: "Aveage rating" },
];
const currentSortOrder = sortOrders.find(
(order) => order.value === sortOrder
);
return (
<Menu>
<MenuButton as={Button} rightIcon={<BsChevronDown />}>
Order by: {currentSortOrder?.label || "Relevance"}
</MenuButton>
<MenuList>
{sortOrders.map((order) => (
<MenuItem
onClick={() => onSelectSortOrder(order.value)}
key={order.value}
value={order.value}
>
{order.label}
</MenuItem>
))}
</MenuList>
</Menu>
);
};
export default SortSelector;
useGames.ts调用useData时需要添加排序ordering键值对。
// useGames.ts
......
const useGames = (gameQuery:GameQuery)=>useData<Game>('/games',{params:{
genres:gameQuery.genre?.id,
platforms:gameQuery.platform?.id,
ordering:gameQuery.sortOrder,
}},[gameQuery]);
export default useGames;
有些游戏是没有图像的,所以要修改getCroppedImageUrl函数,如果没有图像的话,不处理直接返回。
// image-urls.ts
const getCroppedImageUrl=(url:string)=>{
if(!url) return '';
......
最后App.tsx的State的对象GameQuery需要添加sortOrder属性。调用SortSelector组件时提供处理函数及当前排序字段。
// App.tsx
export interface GameQuery {
genre: Genre | null;
platform: Platform | null;
sortOrder: string;
}
......
<SortSelector
sortOrder={gameQuery.sortOrder}
onSelectSortOrder={(sortOrder) =>
setGameQuery({ ...gameQuery, sortOrder })
}
/>
......
提交Git:“Sort the games”
处理没有图像的游戏
前面我们临时处理了没有图像的游戏,直接返回空字符串。这一节我们处理成没有图像时用一个占位图像代替。
首先选择一张代替图像放在 src/assets里,比如no-image-placeholder.webp。
然后修改image-url.ts。注意先导入并命名为 noImage,然后再引用。如果直接return 路径是不工作的。
// image-url.ts
import noImage from '../assets/no-image-placeholder.webp'
const getCroppedImageUrl=(url:string)=>{
if(!url) return noImage;
......
效果。

提交Git。“handle games with on image”
修复Chakra的Menu组件问题。
稍低版本的Chakra的Menu组件,像前面这样包在HStack里会警告提示,可以用Flex组件代替,Flex组件没有spacing属性,可以将某个Menu包在Box组件时,然后设置边距即可。
// App.tsx
......
<GridItem area="main">
<Flex paddingLeft={2} paddingBottom={5}>
<Box marginRight={5}>
<PlatformSelector
......
/>
</Box>
<SortSelector
......
/>
</Flex>
......
提交Git。“Fix the issue with Chakra menus”
创建搜索框
在导航栏中间我们创建一个搜索框。先创建组件SearchInput.tsx。
使用 Input组件,在左侧显示搜索图标,使用了InputLeftElement组件,并用InputGroup包起来。
//SearchInput.tsx
import { Input, InputGroup, InputLeftElement } from "@chakra-ui/react";
import { BsSearch } from "react-icons/bs";
const SearchInput = () => {
return (
<InputGroup>
<InputLeftElement children={<BsSearch />} />
<Input
borderRadius={20}
variant="filled"
placeholder="Search games ..."
/>
</InputGroup>
);
};
export default SearchInput;
再到NavBar组件,使用SearchInput组件。HStack去掉
justifyContent=”space-between”属性,加不加都一样。
// NavBar.tsx
......
const NavBar = () => {
return (
<div>
<HStack padding="10px">
<Image src={logo} boxSize="60px"></Image>
<SearchInput />
<ColorModeSwitch />
</HStack>
</div>
);
};
export default NavBar;
另外,要设置ColorModeSwitch的Text不换行,whiteSpace=”nowrap”。
// ColorModeSwitch.tsx
......
<HStack>
<Switch
...
></Switch>
<Text whiteSpace={"nowrap"}>Dark Mode</Text>
</HStack>
......
效果。

提交Git。“Build search Input”
搜索游戏
在搜索框键入搜索词条,传给App组件,然后再传给GameGrid。技术与之前按类型、平台筛选游几乎一样。
SearchInput组件添加 form,用于提交。只有一个Input,使用Ref比较简单,用于跟Input关联。再处理 onSubmit事件,传Input的值给Props的onSearch函数。
另外,form默认是宽度不是 100%,我们在src/index.css里全局修改下。免得使我们的搜索框挤在左侧一小块部分。
// SearchInput.tsx
......
interface Props {
onSearch: (searchText: string) => void;
}
const SearchInput = ({ onSearch }: Props) => {
const ref = useRef<HTMLInputElement>(null);
return (
<form
onSubmit={(event) => {
event.preventDefault();
if (ref.current) onSearch(ref.current.value);
}}
>
<InputGroup>
<InputLeftElement children={<BsSearch />} />
<Input
ref={ref}
......
/>
</InputGroup>
</form>
);
};
export default SearchInput;
/* index.css */
form{
width: 100%;
}
SearchInput的父组件还不是App.tsx,而是NavBar组件,首先得传递给它,然后再由NavBar传给App组件,显然,这样嵌套很丑,以后我们会处理,这一节先实现功能再说。
// NavBar.tsx
......
interface Props {
onSearch: (searchText: string) => void;
}
const NavBar = ({ onSearch }: Props) => {
return (
......
<SearchInput onSearch={onSearch} />
......
);
};
export default NavBar;
再到App组件,GameQuery添加属性searchText,处理onSearch事件设置gameQuery的searchText。
// App.tsx
export interface GameQuery {
......
searchText: string;
}
function App() {
......
<GridItem area="nav">
<NavBar
onSearch={(searchText: string) =>
setGameQuery({ ...gameQuery, searchText })
}
/>
</GridItem>
......
最后,在useGame.ts里处理搜索结果。
// useGames.ts
......
const useGames = (gameQuery:GameQuery)=>useData<Game>('/games',{params:{
......
search:gameQuery.searchText
}},[gameQuery]);
export default useGames;
完成后,提交Git。“Implement searching”
添加动态标题
添加一个动态标题,显示当前筛选的游戏,比如是Xbox Action Games。
很简单。
新建文件 /src/components/GameHeading.tsx,使用了Heading的组件,当然可以使用h1,但为了一致,语法是 <Heading as=’h1′ />。传入的Props是整个gameQuery。
// GameHeading.tsx
import { Heading } from "@chakra-ui/react";
import { GameQuery } from "../App";
interface Props {
gameQuery: GameQuery;
}
const GameHeading = ({ gameQuery }: Props) => {
return (
<Heading as="h1" marginY={5} fontSize="5xl">
{`${gameQuery.platform?.name || ""} ${gameQuery.genre?.name || ""} Games`}
</Heading>
);
};
export default GameHeading;
App组件调用GameHeading组件。为了对齐,我们再套一层<Box paddingLeft={2}>,这样包在里面的组件都自动与左侧间隔 2 。
// App.tsx
......
<GridItem area="main">
<Box paddingLeft={2}>
<GameHeading gameQuery={gameQuery} />
<Flex paddingBottom={5}>
......
</Flex>
</Box>
<GameGrid gameQuery={gameQuery} />
</GridItem>
......
效果。

提交Git。“Add a dynamic page heading”
整理侧边类型栏
目前侧边栏视觉上的问题需要处理。

第一,左侧类型栏,类型名称太长的话,会被覆盖着图片。原因是这是一个按钮组件,默认文本不换行的,我们修改whiteSpace=’normal’。
第二,类型栏的图片是框成32*32的,而源图我们是600*400的,这样造成纵横比失真,修改objectFit为’cover‘能保持纵横比。
第三,我们给类型栏添加一个标题:Genres。
// GenreList.tsx
......
return (
<>
<Heading fontSize="2xl" marginBottom={3}>
Genres
</Heading>
<List>
......
<Image
......
objectFit={"cover"}
/>
<Button
......
whiteSpace={"normal"}
textAlign={"left"}
>
{genre.name}
</Button>
......
</List>
</>
);
......
调整后,看上去舒服多了。

提交Git。“Clean up the genres”
整理游戏卡片
根据观感,游戏卡片做一些调整:
- 支持平台的图标列表在上,标题在下。
- 图标列与与标题间隔太紧,增加到 3。
- 游戏卡片之间间隔太小,由3增加到6。
- 目前我们在超宽屏幕时显示卡片是5,有点多,调整为4个。
// GameCard.tsx
......
<CardBody>
<HStack justifyContent="space-between" marginBottom={3}>
......
</HStack>
<Heading fontSize="2xl">{game.name}</Heading>
</CardBody>
......
// GameGrid.tsx
......
return (
......
<SimpleGrid
columns={{ sm: 1, md: 2, lg: 3, xl: 4 }}
padding="10px"
spacing={6}
>
......
);
......

提交Git。“Clean up the game cards”
添加表情符号
我们给游戏卡片增加有趣的表情,根据评分给出不同的表情。
先添加Game的一个属性 rating_top,它是一个1-5的数值。
// useGame.ts
......
export interface Game {
......
rating_top:number;
}
......
在 src/assets文件夹下放置3个表情符号,分别是meh.webp、thumbs-up.webp、bulls-eye.webp。
再新建 src/components/Emoji.tsx文件。先导入表情符号图片,然后判断小于3的返回null,大于等于3的用map分别匹配不同分数的表情符号。每个表情符号的boxSize动态指定。指定项部间隔为 1。
// Emoji.tsx
import { Image, ImageProps } from "@chakra-ui/react";
import meh from "../assets/meh.webp";
import thumbsUp from "../assets/thumbs-up.webp";
import bullsEye from "../assets/bulls-eye.webp";
interface Props {
rating_top: number;
}
const Emoji = ({ rating_top }: Props) => {
if (rating_top < 3) return null;
const emojiMap: { [key: number]: ImageProps } = {
3: { src: meh, alt: "meh", boxSize: "25px" },
4: { src: thumbsUp, alt: "recommended", boxSize: "25px" },
5: { src: bullsEye, alt: "exceptional", boxSize: "35px" },
};
return <Image {...emojiMap[rating_top]} marginTop={1} />;
};
export default Emoji;
再在GameCard组件调用Emoji组件。
// GameCard.tsx
......
<Heading fontSize="2xl">
{game.name}
<Emoji rating_top={game.rating_top} />
</Heading>
......
效果如下。

提交Git。“Add Emojis”
搬运静态数据
我们看到,genres数据基本不变,而我们每次都从后端获取并在获取过程中给出一个spinner提示,实际上不算好的用户体验,我们可以直接将genres作为静态数据保存使用。

新建src/data文件夹,并新建 src/data/genres.ts文件。
// genres.ts
export default [
......
// 这里是上一步复制的静态数据
......
]
再到 src/hooks/useGenres.ts,不调用后端,直接返回数据。
// useGenres.ts
import genres from '../data/genres'
......
const useGenres = () => ({data:genres,error:null,isLoading:false});
export default useGenres;
刷新页面,genres加载很快,不会显示spinner的圈圈了。
同样的方法,也可以改造下 usePlatforms,因为平台列表也几乎不变。
提交Git。“Ship genres with the app”
自定义Chakra主题
网站的黑暗模式很不错,不过比较rawg.ip网页,似乎他们的更漂亮。我们可以自定义的与他们的一样酷。
查看chakraUI官网,我们知道默认的主题的设置。
https://chakra-ui.com/docs/styled-system/theme
得用工具可以自定义相关设置,比如smart-swatch。
https://smart-swatch.netlify.app/
根据官网说明,修改src/theme.ts文件,添加灰度调色板
// theme.ts
import { extendTheme, type ThemeConfig } from '@chakra-ui/react'
const config: ThemeConfig = {
initialColorMode: 'dark',
useSystemColorMode: false,
}
const theme = extendTheme({ config,
colors: {
transparent: 'transparent',
black: '#000',
white: '#fff',
gray: {
50: '#f9f9f9',
100: '#ededed',
200: '#d3d3d3',
300: '#b3b3b3',
400: '#a0a0a0',
500: '#898989',
600: '#6c6c6c',
700: '#202020',
800: '#121212',
900: '#111',
},
},
})
export default theme
效果与rawg.io基本一致了。

提交Git。“Customize the theme to get darker grays”
重构GameGrid
修改一点GameGrid组件的实现,error与SimpleGrid放在一起不是很舒服,分开比较好。
// GameGrid.tsx
......
const GameGrid = ({ gameQuery }: Props) => {
......
if (error) return <Text>{error}</Text>;
return (
<SimpleGrid
.......
</SimpleGrid>
);
};
......
提效Git。“Refactor GameGrid”
构建生产环境
我们的项目完成了,准备部署到生产环境。
在部署到生产环境前,我们在本地部署下,这样更快更容易也能发现问题。
npm run build
如果不喜欢命令行,可以在vscode下按 ctrl + shift +p,输入build,选择 npm:build,也可以进行构建。

如果有错误,React会有提示,而有些之前在npm run dev时是不会报错的。我们可以根据提示进行错误修复。
这里我们有警告,提示打包的js文件超过500K了,先忽略,以后再处理。
我们看到,生成的部署文件都在 dist文件夹下,js也被打包成了一个文件。dist文件夹就是我们部署到生产环境的全部内容。
部署到vercel
终于到了最后一步,我们将项目部署到vercel。
当然,vercel不是唯一选项,其他还有很多可以部署React,比如Github,GitLab,Netlify等,可以查看文档。
https://vitejs.dev/guide/static-deploy.html
我们先创建一个github库 game-hub。
接着将我们的项目推送到github。
git remote add origin https://github.com/chenyongping001/game-hub.git git branch -M main git push -u origin main
、
创建vercel账号,与github关联。
安装vercel CLI
sudo npm i -g vercel
进行部署,只需简单输入命令:
vercel
与github关联,然后一路默认就完成了了部署。

得到的部署网址是:
https://game-hub-flame-phi.vercel.app
我们将vercel与github连接。
登陆vercel,再与github的库连接。

这样之后,当github有更新之后,vercel也自动更新部署。
我们将title修改一下。再push到github。
// index.html
......
<title>Gamehub</title>
......
提交Git。“Update page title”
再提交到Github。
git push
我们再去vercel就能看到更新的主页标题了。
结束语
终于完成了长长的一个React系列,但这只是入门,旅程还没结束。敬请期待React的中级教程。
