React入门(九)– 游戏库应用(下)

​本篇继续完成游戏库项目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”


整理游戏卡片

根据观感,游戏卡片做一些调整:

  1. 支持平台的图标列表在上,标题在下。
  2. 图标列与与标题间隔太紧,增加到 3。
  3. 游戏卡片之间间隔太小,由3增加到6。
  4. 目前我们在超宽屏幕时显示卡片是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。

https://vercel.com/

当然,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的中级教程。

发表评论

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