前端所有代码已经同步到 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
中,显然这个方式并不好。
我们希望以路由的方式对页面显示的内容进行控制。
原来的 Content
这个命名也不够标准,我们将其名字从 Content.tsx
改成 index.tsx
,表示主页的界面。
同时,将其导出的组件名称修改为 HomePage
,同时把原来外面那个 Container
组件去掉,我们将其作为公共组件直接写到 App.tsx
中去。
基础路由配置
现在就可以根据我们当前已经写的界面来修改根组件啦!
整理下我们现在有的几个界面:
- 首页界面,
<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。
- 这边我把
Header
和 Footer
放置在 Route
的外侧作为两个固定渲染的组件,因为就目前需求而言,这两个组件每个页面都会用到。 - 在写路由的时候在外侧包裹
Switch
的原因保证只会渲染一个路由,加上 exact
关键字是为了实现精确匹配(避免 /login
匹配到 /
)。 - 把 404 组件的路由项放到最后一个,保证只有在无法匹配到已有路由时才会显示该界面。
404 界面效果图:
课程列表组件
为了更加能够通过更加真实的体验去了解路由配置的过程,我决定先把课程相关的界面大致写出来。
在 src/screens
下新建两个文件夹,course
文件夹中存放课程详情界面的组件,course-list
文件夹中存放课程列表的组件。
先把复杂的课程列表写完,包含三个组件 card.tsx
用来以卡片格式展示某个课程的信息,index.tsx
用来渲染整个课程列表界面,list.tsx
用来组织课程列表。
在定义 Course(课程类)
的数据结构时,由于课程应该是公用,所以我将其提取到 src/type/course.ts
中(新建)。
接下去编写最外层的框架 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>
|
效果图如下:
记得要运行 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 numberOfStudents: number }
export interface CourseDetailProps extends CourseProps { courseId: number info: string }
|
先写个大概,之后根据具体设计修改。
设置 courseId
的原因是因为需要将 Course
和 CourseDetail
通过外键关联起来,另一方面 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
的类型包含了路由信息。
我们通过访问任意一个课程详情界面来分别研究一下这三个对象:
history
:类型为 History
,存储了历史记录相关的信息,包括历史记录长度和其他 H5 规范中的 API
location
:类型为 Location
,包含了 URL 相关信息。
match
:一个对象,存储了路由相关信息。
打印出来之后用哪个来定位 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
会报警告 ⚠
大致含义就是因为在 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); `
|
当前效果如下: