前端所有代码已经同步到 GitHub Repo: EthanLuu/soomooc: React + TypeScript 实践,在线教学直播平台。 (github.com)

作为一款 SPA 应用,我们通过路由对页面显示的内容进行动态调整。

react-router安装

由于我们是在 Web 中进行开发,所以安装的是 react-router-dom 库。

1
2
3
4
# 安装 react-router-dom
yarn add react-router-dom
# 适配 TypeScript
yarn add @types/react-router-dom -D

组件调整

在上一次写主页的时候,我们把主页的组件组成直接写在了 App.tsx 中,显然这个方式并不好。

我们希望以路由的方式对页面显示的内容进行控制。

code

原来的 Content 这个命名也不够标准,我们将其名字从 Content.tsx 改成 index.tsx,表示主页的界面。

image-20210515213754966

同时,将其导出的组件名称修改为 HomePage,同时把原来外面那个 Container 组件去掉,我们将其作为公共组件直接写到 App.tsx 中去。

code

基础路由配置

现在就可以根据我们当前已经写的界面来修改根组件啦!

整理下我们现在有的几个界面:

  • 首页界面,<HomePage /> 组件
  • 写一个注册界面 <LoginScreen />,直接返回一个 H1 标题。
  • 写一个登陆界面 <RegisterScreen />,直接返回一个 H1 标题。
  • 写一个 NotFoundPage 组件,作为 404 界面,当输入的路由无法匹配时显示该界面。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Result, Button } from 'antd'
import { Link } from 'react-router-dom'

export const NotFoundPage = () => {
return (
<Result
status="404"
title="404"
subTitle="未知资源"
extra={
<Button type="primary">
<Link to={'/'}>返回首页</Link>
</Button>
}
/>
)
}

引入 BrowserRouter 包裹页面,并且新建路由项 Route,将其指向根目录并且将需要显示的组件设定为 HomePage 组件。

似乎给 BrowserRouter 起个别名 Router 已经成了传统,那我们就干脆继承过来吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import './App.css'
import { ErrorBoundary } from 'components/error-boundary'
import { FullPageErrorFallback } from 'components/lib'
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
import { HomePage } from 'screens/home'
import { Header } from 'components/header'
import { Footer } from 'components/footer'
import { NotFoundPage } from 'screens/404'
import styled from '@emotion/styled'
import { LoginPage } from 'screens/login'
import { RegisterPage } from 'screens/register'

function App() {
return (
<div className="App">
<ErrorBoundary fallbackRender={FullPageErrorFallback}>
<Router>
<Header />
<Main>
<Switch>
<Route exact path={'/'} component={HomePage} />
<Route exact path={'/login'} component={LoginPage} />
<Route exact path={'/register'} component={RegisterPage} />
<Route path={'*'} component={NotFoundPage} />
</Switch>
</Main>
<Footer />
</Router>
</ErrorBoundary>
</div>
)
}

export default App

const Main = styled.div`
padding: 3rem 10rem;
flex: 1;
`

有几个小 tips。

  1. 这边我把 HeaderFooter 放置在 Route 的外侧作为两个固定渲染的组件,因为就目前需求而言,这两个组件每个页面都会用到。
  2. 在写路由的时候在外侧包裹 Switch 的原因保证只会渲染一个路由,加上 exact 关键字是为了实现精确匹配(避免 /login 匹配到 /)。
  3. 把 404 组件的路由项放到最后一个,保证只有在无法匹配到已有路由时才会显示该界面。

404 界面效果图:

image-20210515224335670

课程列表组件

为了更加能够通过更加真实的体验去了解路由配置的过程,我决定先把课程相关的界面大致写出来。

src/screens 下新建两个文件夹,course 文件夹中存放课程详情界面的组件,course-list 文件夹中存放课程列表的组件。

image-20210517161721967

先把复杂的课程列表写完,包含三个组件 card.tsx 用来以卡片格式展示某个课程的信息,index.tsx 用来渲染整个课程列表界面,list.tsx 用来组织课程列表。

无标题

在定义 Course(课程类) 的数据结构时,由于课程应该是公用,所以我将其提取到 src/type/course.ts 中(新建)。

code

接下去编写最外层的框架 index.tsx,写一个组件 CourseListScreen

该组件需要完成的功能很简单,展示页面的标题,获取所有的课程信息,并且渲染 course-list.tsx 中的 CourseList 组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useEffect, useState } from 'react'
import { Course } from 'type/course'
import { useHttp } from 'utils/http'
import { CourseList } from './list'
import { Divider, Row } from 'antd'

export const CourseListScreen: React.FC = () => {
const client = useHttp()
const [courses, setCourses] = useState<Course[]>([])
useEffect(() => {
client('course').then((courses) => {
setCourses(courses)
})
}, [client])

return (
<>
<Row style={{ justifyContent: 'center' }}>
<h1>全部课程</h1>
</Row>
<Divider />
<CourseList courses={courses}></CourseList>
</>
)
}

course-list.tsx 中,新建 CourseList 组件,利用 grid 布局,每行放最多 4 个课程的卡片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Col, Row } from 'antd'
import { Course } from 'type/course'
import { CourseCard } from './card'

interface ListProps {
courses: Course[]
}

export const CourseList: React.FC<ListProps> = ({ courses }) => {
return (
<>
<Row gutter={24}>
{courses?.map((course) => {
return (
<Col span={6} key={course.id}>
<CourseCard course={course}></CourseCard>
</Col>
)
})}
</Row>
</>
)
}

参考慕课网的课程列表展示方式,我利用 <Card /> 组件来展示课程信息。

课程信息卡片组件收到的 props 应该包含一个课程对象,这个课程对象包含了 课程id,课程标题,封面,方向,类别,学生人数

正常来说,点击课程的卡片就会进入课程的详情页面,所以我们在卡片外部包裹跳转链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import styled from '@emotion/styled'
import { Card } from 'antd'
import { Course } from 'type/course'
import { Link } from 'react-router-dom'

export const CourseCard: React.FC<{ course: Course }> = ({ course }) => {
const { id, title, cover, direction, type, numberOfStudents } = course
return (
<Link to={`course/detail/${id}`}>
<Card
hoverable
style={{
borderRadius: 10,
marginBottom: 20,
}}
cover={<Cover src={cover}></Cover>}
>
<Card.Meta title={title} />
<Description>{`${direction} | ${type} | ${numberOfStudents}人报名`}</Description>
</Card>
</Link>
)
}

const Cover = styled.img`
height: 15rem;
border-radius: 10px 10px 0 0 !important;
object-fit: cover;
`
const Description = styled.div`
margin-top: 10px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`

再往 db.json 文件中加入我们需要的 mock data(拷贝于慕课网)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
"course": [
{
"id": 1,
"title": "2小时极速入门 TypeScript",
"cover": "//img3.mukewang.com/607fc1a4097d454805400304.png",
"direction": "前端",
"type": "TypeScript",
"numberOfStudents": 2569
},
{
"id": 2,
"title": "vue3.0实现todolist",
"cover": "//img4.mukewang.com/600ebd8b08a2013605400304.jpg",
"direction": "前端",
"type": "Vue",
"numberOfStudents": 4873
},
{
"id": 3,
"title": "当React遇上TypeScript开发Antd组件",
"cover": "//img2.mukewang.com/5fe4430e0001057c05400304.jpg",
"direction": "前端",
"type": "React",
"numberOfStudents": 5699
},
{
"id": 4,
"title": "趣味 C++ 入门",
"cover": "//img2.mukewang.com/606c41a60914530f05400304.png",
"direction": "后端",
"type": "C++",
"numberOfStudents": 2285
},
{
"id": 5,
"title": "Python3 进阶教程 2020全新版",
"cover": "//img2.mukewang.com/5fe4430f0001cbe605400304.jpg",
"direction": "后端",
"type": "Python",
"numberOfStudents": 20341
},
{
"id": 6,
"title": "Javascript实现二叉树算法",
"cover": "//img2.mukewang.com/5fe442fd00018a1405400304.jpg",
"direction": "计算机基础",
"type": "JavaScript",
"numberOfStudents": 44848
}
]

写完这三个组件后,我们将 CourseListScreen 组件写入 App.tsx 的路由中。

1
<Route exact path={'/course'} component={CourseListScreen} />

适当修改一下页头中的菜单项。

1
2
3
4
5
6
7
8
9
10
11
12
13
<Menu
theme="light"
mode="horizontal"
selectedKeys={[pathname]}
style={{ flex: 1 }}
>
<Menu.Item key={'/'}>
<Link to={'/'}>首页</Link>
</Menu.Item>
<Menu.Item key={'/course'}>
<Link to={'/course'}>课程列表</Link>
</Menu.Item>
</Menu>

效果图如下:

image-20210517190835754

记得要运行 json-server 服务器哦

课程详情组件

接着继续写课程详情界面。

课程详情信息的接口如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export interface CourseProps {
id: number
title: string
cover: string // 封面图片地址
direction: string // 方向:前端,后端...
type: string // 类别:Vue,React
numberOfStudents: number // 学生人数
}

export interface CourseDetailProps extends CourseProps {
courseId: number // 对应的课程id
info: string // 课程的详细介绍
}

先写个大概,之后根据具体设计修改。

设置 courseId 的原因是因为需要将 CourseCourseDetail 通过外键关联起来,另一方面 json-server 自带非常好用的外键功能。

通过 /course/1/detail,我就能查找到 id 为 1 的 course 对应的 detail

伪造数据如下:

1
2
3
4
5
6
7
8
9
10
11
12
"detail": [
{
"id": 1,
"courseId": 1,
"info": "让你通过2小时,实现 TypeScript 的从入门到精通"
},
{
"id": 2,
"courseId": 2,
"info": "Vue 入门第一步:TodoList"
}
]

在之前定义路由的时候,我们设计的是通过链接 course/detail/courseId 来访问课程的详情界面。

先在 App.tsx 中加入对应的路由信息。

1
2
3
4
5
6
7
8
9
10
11
12
<Switch>
<Route exact path={'/'} component={HomePage} />
<Route exact path={'/course'} component={CourseListScreen} />
<Route
exact
path={'/course/detail/:courseId'}
component={CourseDetailScreen}
/>
<Route exact path={'/login'} component={LoginScreen} />
<Route exact path={'/register'} component={RegisterScreen} />
<Route path={'*'} component={NotFoundPage} />
</Switch>

src/screens/course/index.tsx 下写出大概的组件样式,并且打印出路由相关属性研究一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Divider, Row } from 'antd'
import { RouteComponentProps } from 'react-router'

export const CourseDetail: React.FC<RouteComponentProps> = (props) => {
console.log(props.history)
console.log(props.location)
console.log(props.match)
return (
<>
<Row justify={'center'}>
<h1>title</h1>
</Row>
<Row justify={'center'}>{`direction | type | numberOfStudents`}</Row>
<Divider />
<Row justify={'center'}>{`info`}</Row>
</>
)
}

这里使用 ReactComponentProps 来声明 props 的类型包含了路由信息。

我们通过访问任意一个课程详情界面来分别研究一下这三个对象:

  1. history:类型为 History,存储了历史记录相关的信息,包括历史记录长度和其他 H5 规范中的 API

    image-20210517203629577
  2. location:类型为 Location,包含了 URL 相关信息。

    image-20210517204011525
  3. match:一个对象,存储了路由相关信息。

    image-20210517204316443

打印出来之后用哪个来定位 courseId 应该也很清楚了,显然是 match.params.courseId

在写组件的时候遇到了一个很尬尴的问题,这是我本来写的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const courseId = props.match.params.courseId
const client = useHttp()

const [courseDetail, setCourseDetail] =
useState<CourseDetailProps | null>(null)

useEffect(() => {
const fetchData = async () => {
const course: CourseProps = await client(`course/${courseId}`)
const detail: CourseDetailProps[] = await client(
`course/${courseId}/detail`
)
setCourseDetail({ ...courseDetail, ...detail?.[0], ...course })
}
fetchData()
}, [client, courseId])

组件的逻辑是这样的:

  • 将课程详情定义为一个状态,并且初始化为 null
  • useEffect 函数中,通过异步函数获取课程详情信息。
  • 通过 SetState 函数,设置当前的课程详情。

可以看到我写了一行代码:setCourseDetail({ ...courseDetail, ...detail?.[0], ...course }),当时我写的时候的心里想法是:

通过异步获取课程详细信息 => 解构原状态并且更新状态

但是这样写 eslint 会报警告 ⚠

image-20210524203221152

大致含义就是因为在 useEffect 里用到了外部定义的变量 courseDetail ,提示你应该将其放到依赖项中。但是因为我们在这个 useEffect 中会更新 courseDetail,所以放到依赖项中的结果就是组件会无限重新加载。

于是我便开始在网上搜索解决办法,直到我找到了这篇文章,才发现事实上 eslint 已经提示了解决办法。

有一说一,考虑到 React 的生态还是直接用英文在谷歌上搜索比较好。

1
setCourseDetail(c => ({ ...c,...detail?.[0], ...course }))

以回调形式的函数作为参数,避免了直接传入原状态,因此得以去掉警告。

但是话又说回来了,为什么我一定要原状态呢?该组件的逻辑难道不是只需要根据路由获取课程 id,然后异步获取课程详情并且加载吗?本质上组件只会随着页面的加载而加载,状态也只需要在加载页面的时候获取一次。这个函数完全可以不用调用原状态,写成以下样子就行了:

1
2
3
4
5
6
7
8
9
10
useEffect(() => {
const fetchData = async () => {
const course: CourseProps = await client(`course/${courseId}`)
const detail: CourseDetailProps[] = await client(
`course/${courseId}/detail`
)
setCourseDetail({ ...detail?.[0], ...course })
}
fetchData()
}, [client, courseId])

🙄 好家伙直接白忙活。

course/index.tsx 组件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { CourseBanner } from './banner'
import { Row } from 'antd'
import { FullPageLoading } from 'components/lib'
import { useEffect, useState } from 'react'
import { RouteComponentProps } from 'react-router'
import { CourseDetailProps, CourseProps } from 'type/course'
import { useHttp } from 'utils/http'

interface MatchParams {
courseId: string
}

export const CourseDetailScreen: React.FC<RouteComponentProps<MatchParams>> = (
props
) => {
const courseId = props.match.params.courseId
const client = useHttp()

const [courseDetail, setCourseDetail] =
useState<CourseDetailProps | null>(null)

useEffect(() => {
const fetchData = async () => {
const course: CourseProps = await client(`course/${courseId}`)
const detail: CourseDetailProps[] = await client(
`course/${courseId}/detail`
)
setCourseDetail({ ...detail?.[0], ...course })
}
fetchData()
}, [client, courseId])
return !courseDetail ? (
<FullPageLoading />
) : (
<>
<CourseBanner courseDetail={courseDetail} />
<Row justify={'center'}>{`${courseDetail?.info}`}</Row>
</>
)
}

course/banner.tsx 组件用于展示课程详细信息界面的头图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import styled from '@emotion/styled'
import { CourseDetailProps } from 'type/course'

export const CourseBanner = ({
courseDetail,
}: {
courseDetail: CourseDetailProps
}) => {
return (
<TitleContainer style={{ backgroundImage: `url(${courseDetail.cover})` }}>
<Title>
<div
style={{ fontSize: '3.5rem', fontWeight: 600 }}
>{`${courseDetail?.title}`}</div>
<div
style={{ fontSize: '1.5rem' }}
>{`${courseDetail?.direction} | ${courseDetail?.type} | ${courseDetail?.numberOfStudents} 人正在学习`}</div>
</Title>
</TitleContainer>
)
}

const TitleContainer = styled.div`
display: flex;
height: 20rem;
overflow: hidden;
background-size: cover;
background-repeat: no-repeat;
`

const Title = styled.div`
display: flex;
flex: auto;
flex-direction: column;
align-items: center;
justify-content: space-around;
color: #fff;
padding: 3rem 0;
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px);
`

当前效果如下:

动画 (1)

Thumbnail (3)-tuya