本文用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中级教程系统的最后一篇,希望本系列有帮助到您。