React中级教程(七)– React Route项目实践

本文用React Route修改game-hub项目,并将添加game的详细信息的页面。


设置Route

安装ReactRouter

npm i react-router-dom@6.10.0

在 src下添加文件夹,并新建 Layout 组件。该组件用于布局,首先是NavBar,然后是占位符 Outlet。

// Layout.tsx
​
import { Outlet } from "react-router-dom";
import NavBar from "../components/NavBar";
​
const Layout = () => {
  return (
    <>
      <NavBar />
      <Outlet />
    </>
  );
};
​
export default Layout;
​

再新建src/pages/HomePage.tsx,这基本与App一致。我们将App组件中的NavBar相关的代码去掉(NavBar组件已在 Layout组件中体现),并复制到HomePage组件中。然后将App.tsx删除,顺便也删除App.css。

// Homepage.tsx
​
import { Box, Flex, Grid, GridItem, Show } from "@chakra-ui/react";
import GameGrid from "../components/GameGrid";
import GameHeading from "../components/GameHeading";
import GenreList from "../components/GenreList";
import PlatformSelector from "../components/PlatformSelector";
import SortSelector from "../components/SortSelector";
​
const HomePage = () => {
  return (
    <Grid
      templateAreas={{
        base: `"main"`,
        lg: `"aside main"`,
      }}
      templateColumns={{
        base: "1fr",
        lg: "200px 1fr",
      }}
    >
      <Show above="lg">
        <GridItem area="aside" paddingX={5}>
          <GenreList />
        </GridItem>
      </Show>
      <GridItem area="main">
        <Box paddingLeft={2}>
          <GameHeading />
          <Flex paddingBottom={5}>
            <Box marginRight={5}>
              <PlatformSelector />
            </Box>
            <SortSelector />
          </Flex>
        </Box>
        <GameGrid />
      </GridItem>
    </Grid>
  );
};
​
export default HomePage;

新建 src/routes.tsx。根路径指向 Layout组件,Layout的子路径有HomePage,以及明细页面GameDetailPage.

​
import { createBrowserRouter } from "react-router-dom";
import Layout from "./pages/Layout";
import HomePage from "./pages/HomePage";
import GameDetailPage from "./pages/GameDetailPage";
​
const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    children: [
      {
        index: true,
        element: <HomePage />,
      },
      {
        path: "games/:id",
        element: <GameDetailPage />,
      },
    ],
  },
]);
​
export default router;

再修改main.tsx, 用RouterProvider代替App组件。

// main.tsx
​
......
​
      <QueryClientProvider client={queryClient}>
        <RouterProvider router={router} />
        <ReactQueryDevtools />
      </QueryClientProvider>
​
......

在 src/pages下新建GameDetailPage组件文件,用于展示Game细节。

// GameDetailPage.tsx
​
import React from "react";
​
const GameDetailPage = () => {
  return <div>GameDetailPage</div>;
};
​
export default GameDetailPage;

完成后,我们访问 /games/1,能看到刚测试的页面。

提交:“Setup routing”


处理错误

页面找不到或出现异常时,我们希望给出我们自定义的页面,而不是通用的错误提示。

先创建一个错误页面。src/pages/ErrorPage.tsx。

之前有介绍过,没什么新鲜的。错误页面与 根页面是同一级的,不会有NavBar组件,为了简单处理,这里暂时也加了个NavBar,后续我们会来修正。

import { Box, Heading, Text } from "@chakra-ui/react";
import React from "react";
import { isRouteErrorResponse, useRouteError } from "react-router-dom";
import NavBar from "../components/NavBar";
​
const ErrorPage = () => {
  const error = useRouteError();
  return (
    <>
      <NavBar />
      <Box padding={5}>
        <Heading>Oops</Heading>
        <Text>
          {isRouteErrorResponse(error)
            ? "Page does not exist."
            : "An unexpected error occurred."}
        </Text>
      </Box>
    </>
  );
};
​
export default ErrorPage;

再修改 routes.tsx,添加errorElement属性为 ErrorPage。

// routes.tsx
​
......
const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    errorElement: <ErrorPage />,
 ......

 在访问不存在的页面时的效果如下。

提交。”Handle errors”


获取游戏

在游戏列表中,我们点击某一游戏,可以查看游戏明细。首先修改GameCard, 添加链接 Link组件,我们修改使用slug,而不是ID,这样更显友好。

// GameCard.tsx
​
......
​
        <Heading fontSize="2xl">
          <Link to={"/games/" + game.slug}>{game.name}</Link>
          <Emoji rating_top={game.rating_top} />
        </Heading>
......

相应修改routes.tsx的参数名称。由 :id 改成 :slug。

// routes.tsx
​
......
​
const router = createBrowserRouter([
  {
    path: "/",
    ......
    children: [
      ....
      {
        path: "games/:slug",
        element: <GameDetailPage />,
      },
    ],
  },
]);
​
export default router;
​

为了使鼠标移到游戏卡片时会动态效果,修改 GameCardContainer.tsx。添加 _hover属性。这个属于CSS的技术,细讲超过本教程范围,有兴趣可以了解下CSS相关知识。

// GameCardContainer.tsx
​
......
​
    <Box
      _hover={{
        transform: "scale(1.03)",
        transition: "transform .15s ease-in",
      }}
      borderRadius={10}
      overflow="hidden"
    >
      {children}
    </Box>
......

获取具体的Game信息,需要修改api-client.ts. 添加 get 方法,用于获取游戏。

// api-client.ts
​
......
​
class APIClient<T> {
    ......
​
    get = (id:string | number)=>
        axiosInstance
            .get<T>(this.endpoint + "/" + id)
            .then(res=>res.data);
}
​
export default APIClient;

添加 useGame.ts,使用useQuery获取数据。

useGame用到 Game数据结构,我们引入了useGames。一个Hook依赖另一个Hook,有点丑,但目前暂不分散注意力处理这个问题。

另外还需要在useGames的Game数据结构里添加 slug、description_raw属性,这一节要使用。

// useGame.ts
​
​
import { useQuery } from '@tanstack/react-query';
import APIClient from '../services/api-client';
import { Game } from './useGames';
​
const apiClient = new APIClient<Game>("/games");
const useGame = (slug: string)=>useQuery<Game>({
    queryKey: ["games", slug],
    queryFn: ()=>apiClient.get(slug)
})
export default useGame;

最后修改GameDetailPage.tsx. 

先用useParams获取参数slug,再通过slug 获取游戏数据及 error、isLoading等属性。注意我们在调用useGame(slug!) 时,slug后加了感叹号,它的意思告诉TypeScript编译器,不用在意这个常数的类型匹配。不加的话会有警告,因为useParams得到的slug有可能为空,而useGame的参数要求slug是string。

后面的判断 “error || !game ” 加感叹号表求逻辑非,这样后面的 game.name就不用加?号了。

// GameDetailPage.tsx
​
import { Heading, Spinner, Text } from "@chakra-ui/react";
import { useParams } from "react-router-dom";
import useGame from "../hooks/useGame";
​
const GameDetailPage = () => {
  const { slug } = useParams();
  const { data: game, error, isLoading } = useGame(slug!);
  if (isLoading) return <Spinner />;
  if (error || !game) throw error;
  return (
    <>
      <Heading>{game.name}</Heading>
      <Text>{game.description_raw}</Text>
    </>
  );
};
​
export default GameDetailPage;

最后,查看页面时,发现间隔有点挤,于是修改Layout.tsx,添加Box组件包装并提供padding属性。

// Layout.tsx
​
......
​
const Layout = () => {
  return (
    <>
      <NavBar />
      <Box padding={5}>
        <Outlet />
      </Box>
    </>
  );
};
​
export default Layout;

目前,查看具体的游戏是这样的。

提交:“Fetch game details”。


重构实体

前面我们提到过 useGame依赖于useGames比较丑,主要用到Game结构,本节将这些实体提取重构。

可以通过复制粘贴的方式进行重构,但要将相关import进行调整,有点麻烦,利用VS code的重构功能可以很方便进行。

右键点击 Game, 选 “Refactor…”,然后选 Move to a new file,就会将用这些代码生成一个新文件Game.ts,并自动处理好依赖关系。

同样的重构,处理Genre, Platform 的结构。

完成后,新建文件夹Entities,并将重构生成的新文件移至该文件夹。

提交。”Refactor- move entity interfaces”


构建可扩展文本

可护展文本是通用的,不是GameDetailPage独有的,可以创建一个新组件ExpandableText。

设置一个状态 expanded 表示是否展开,开定义一个limit常数,表示缩略显示多少字节。

// ExpandableText.tsx
​
import { Button, Text } from "@chakra-ui/react";
import { useState } from "react";
​
interface Props {
  children: string;
}
const ExpandableText = ({ children }: Props) => {
  const [expanded, setExpanded] = useState(false);
  const limit = 300;
​
  if (!children) return null;
​
  if (children.length <= limit) return <Text>{children}</Text>;
​
  const summary = children.substring(0, limit) + "...";
​
  return (
    <Text>
      {expanded ? children : summary}
      <Button
        size="xs"
        fontWeight="bold"
        colorScheme="yellow"
        marginLeft={1}
        onClick={() => setExpanded(!expanded)}
      >
        {expanded ? "Show Less" : "Read More"}
      </Button>
    </Text>
  );
};
​
export default ExpandableText;
​

再在 GameDetailPage中调用即可。

// GameDetailPage.tsx
​
......
​
  return (
    <>
      <Heading>{game.name}</Heading>
      <ExpandableText>{game.description_raw}</ExpandableText>
    </>
  );
};
​
export default GameDetailPage;

查看页面效果。

提交。“Building Expandable Text”


构建游戏属性

游戏的具体情况由一块块组成,我们创建一个通用的DefinitionItem。

该组件接收两个参数,item用于标识属性类别,children是具体的属性列表。用<dt>、<dd>描述,其中dt用heading代替,as = ‘dt’ 用于更好描述HTML结构。

// DefinitionItem.tsx
​
import { Box, Heading } from "@chakra-ui/react";
import { ReactNode } from "react";
​
interface Props {
  item: string;
  children: ReactNode | ReactNode[];
}
​
const DefinitionItem = ({ item, children }: Props) => {
  return (
    <Box marginY={5}>
      <Heading as="dt" fontSize="md" color="gray.600">
        {item}
      </Heading>
      <dd>{children}</dd>
    </Box>
  );
};
​
export default DefinitionItem;​

再由一个个DefinitionItem组成GameAttributes。

我们用SimpleGrid包装它为了显示2列,同时加上as=’dl’ 更好地描述HTML结构。后面就是具体的Item,在Game结构没有属性如genres、publishers在 Game.ts中加上去,注意Publisher结构还需另外创建,当然得比对后端的API。

// GameAttrivutes.tsx
​
import { SimpleGrid, Text } from "@chakra-ui/react";
import { Game } from "../entities/Game";
import CriticScore from "./CriticScore";
import DefinitionItem from "./DefinitionItem";
​
interface Props {
  game: Game;
}
​
const GameAttributes = ({ game }: Props) => {
  return (
    <SimpleGrid columns={2} as="dl">
      <DefinitionItem item="Platforms">
        {game.parent_platforms.map(({ platform }) => (
          <Text key={platform.id}>{platform.name}</Text>
        ))}
      </DefinitionItem>
​
      <DefinitionItem item="Metascore">
        <CriticScore score={game.metacritic} />
      </DefinitionItem>
​
      <DefinitionItem item="Genres">
        {game.genres.map((genre) => (
          <Text key={genre.id}>{genre.name}</Text>
        ))}
      </DefinitionItem>
​
      <DefinitionItem item="Publishers">
        {game.publishers.map((publish) => (
          <Text key={publish.id}>{publish.name}</Text>
        ))}
      </DefinitionItem>
    </SimpleGrid>
  );
};
​
export default GameAttributes;
// Game.ts
​
import { Genre } from './Genre';
import { Platform } from './Platform';
import Publisher from './Publisher';
​
​
export interface Game {
    ......
    genres: Genre[];
    publishers: Publisher[];
    ......
}
// Publisher.ts
​
export default interface Publisher{
    id:number;
    name:string;
}

最后在GameDetailPage里加上GameAttributes。

// GameDetailPage.tsx
​
......
​
  return (
    <>
      <Heading>{game.name}</Heading>
      <ExpandableText>{game.description_raw}</ExpandableText>
      <GameAttributes game={game} />
    </>
  );
};
​
export default GameDetailPage;

页面展现。

提交。“Build game attributes”


构建游戏预告片

查后端API,得知每一个游戏有不少预告片,根据API先创建预告片Trailer的结构Interface。其中 data属性官网并未给出结构,根据实际的情况获得的,480表示小一点的预告影片,max则表示最大的预告影片。

// Trailer.ts
​
export default interface Trailer{
    id:number;
    name:string;
    priview:string;
    data:{480:string,max:string};
}

添加 Hook, useTrailers。

// useTrailers.ts
​
import { useQuery } from "@tanstack/react-query";
import Trailer from "../entities/Trailer";
import APIClient from "../services/api-client";
​
​
const useTrailers = (id:number) =>{
    const apiClient = new APIClient<Trailer>(`/games/${id}/movies`);
    return useQuery({
        queryKey: ['trailers',id],
        queryFn: ()=>apiClient.getAll(),
    })
}
​
export default useTrailers;

再创建GameTrailer.tsx组件。没什么新鲜的,主要工作就是根据gameId获取Trailer,我们选择了第1个 480p的预告片来展示。

// GameTrailer.tsx
​
import useTrailers from "../hooks/useTrailers";
​
interface Props {
  gameId: number;
}
​
const GameTrailer = ({ gameId }: Props) => {
  const { data, error, isLoading } = useTrailers(gameId);
  if (isLoading) return null;
  if (error) throw error;
  const first = data?.results[0];
  return first ? (
    <video src={first.data[480]} poster={first.priview} controls />
  ) : null;
};
​
export default GameTrailer;
​

最后,GameDetailPage调用GameTrailer。

// GameDetailPage.tsx
​
......
​
  return (
    <>
      ......
      <GameTrailer gameId={game.id} />
    </>
  );
};
​
export default GameDetailPage;
​

页面展示。

提交。”Build game trailer”


构建游戏截图

这个与构建预告影片基本一致。

首先构建实体Interface Screenshot。属性当然要查后端API得知。

// Screenshot.ts
​
export default interface Screenshot{
    id: number;
    image: string;
    width: number;
    height: number;
}

Hook也基本与前面的一致。API端点查rawg.io文档。

// useScreenshots.ts
​
import { useQuery } from "@tanstack/react-query";
import Screenshot from "../entities/Screenshot";
import APIClient from "../services/api-client";
​
const useScreenshots = (gameId: number) => 
{
    const apiClient =new APIClient<Screenshot>(`/games/${gameId}/screenshots`);
    return useQuery({
        queryKey: ['screenshots', gameId],
        queryFn: ()=>apiClient.getAll(),
    })
}
export default useScreenshots;

创建GameScreenshot组件。我们使用 SimpleGrid将截图分成两列。

// GameScreenshot.tsx
​
import React from "react";
import useScreenshots from "../hooks/useScreenshots";
import { Image, SimpleGrid } from "@chakra-ui/react";
​
interface Props {
  gameId: number;
}
const GameScreenshot = ({ gameId }: Props) => {
  const { data, error, isLoading } = useScreenshots(gameId);
  if (isLoading) return null;
  if (error) throw error;
​
  return (
    <SimpleGrid columns={{ base: 1, md: 2 }} spacing={2}>
      {data?.results.map((s) => (
        <Image key={s.id} src={s.image} />
      ))}
    </SimpleGrid>
  );
};
export default GameScreenshot;

最后,GameDetailPage调用即可。

// GameDetailPage.tsx
​
......
​
  return (
    <>
      ......
      <GameScreenshot gameId={game.id} />
    </>
  );
};
​
export default GameDetailPage;

页面展示。

提交。”Build game screenshot”


改进布局

我们将GameDetailPage改进成两列显示。

很简单,用SimpleGrid分隔,小屏单列,中屏或大屏2列

// GameDetailPage.tsx
​
......
​
  return (
    <SimpleGrid columns={{ base: 1, md: 2 }} spacing={5}>
      <GridItem>
        <Heading>{game.name}</Heading>
        <ExpandableText>{game.description_raw}</ExpandableText>
        <GameAttributes game={game} />
      </GridItem>
      <GridItem>
        <GameTrailer gameId={game.id} />
        <GameScreenshot gameId={game.id} />
      </GridItem>
    </SimpleGrid>
  );
};
​
export default GameDetailPage;

分隔成2列的效果。


修复导航栏

GameDetailPage页面目前有二个问题,一是点击 logo图标不会返回主页,二是搜索没有任何响应。

这个非常容易修复。

首先给logo加上链接Link。Image加上 objectFit属性,是为了处理logo图标让其保持原纵横比。

// NavBar.tsx
​
......
​
      <HStack padding="10px">
        <Link to="/">
          <Image src={logo} boxSize="60px" objectFit={"cover"}></Image>
        </Link>
        ......
        
      </HStack>
......

接着是搜索问题,主要是未转到主页上,实际是生效的。只需跳转一下即可。

// SearchInput.tsx
​
......
​
import { useNavigate } from "react-router-dom";
......
​
    <form
      onSubmit={(event) => {
        event.preventDefault();
        if (ref.current) {
          setSearchText(ref.current.value);
          navigate("/");
        }
      }}
    >
......

提交。”Fix the navbar”


重构Entities

将Game.ts的export改成默认,只有一个导出的的模块建议使用default,可以简化调用。

// Game.ts
​
......
​
export default interface Game {
    ......
}

调整其他使用Game的代码:

import Game  from “../entities/Game”;

而不用在 Game上加大括号 {Game}。

其他的实体也作同样调整,不作细讲了。

完成之后提交。”Refactor entity interface” 


小结

本文扩展了game-hub项目,运用了React Router等知识创建了游戏详细页面。

本文是React中级教程系统的最后一篇,希望本系列有帮助到您。

发表评论

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