本篇继续完成游戏库项目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的中级教程。