跳到主要内容

React 进阶

1) Ant Design

react 组件库

入门

安装

npm install antd
  • 目前版本是 4.x

引入样式,在 css 文件中加入

@import '~antd/dist/antd.css';

引入 antd 组件

import { Button } from "antd";

export default function A1() {
return <Button type='primary'>按钮</Button>
}

国际化

试试其它组件

import { Button, Modal } from "antd";

export default function A1() {
return <Modal open={true} title='对话框'>内容</Modal>
}

发现确定和取消按钮是英文的,这是因为 antd 支持多种语言,而默认语言是英文

要想改为中文,建议修改最外层的组件 index.tsx

// ...
import { ConfigProvider } from 'antd'
import zhCN from 'antd/es/locale/zh_CN'

root.render(
<ConfigProvider locale={zhCN}>
<A1></A1>
</ConfigProvider>
)

表格

import { Table } from 'antd'
import { ColumnsType } from 'antd/lib/table'
import axios from 'axios'
import { useEffect, useState } from 'react'
import { R, Student } from '../model/Student'

export default function A3() {
const [students, setStudents] = useState<Student[]>([])
const [loading, setLoading] = useState(true)

useEffect(() => {
async function getStudents() {
const resp = await axios.get<R<Student[]>>(
'http://localhost:8080/api/students'
)
setStudents(resp.data.data)
setLoading(false)
}

getStudents()
}, [])

// title: 列标题 dataIndex: 要关联的属性名
const columns: ColumnsType<Student> = [
{
title: '编号',
dataIndex: 'id',
},
{
title: '姓名',
dataIndex: 'name',
},
{
title: '性别',
dataIndex: 'sex',
},
{
title: '年龄',
dataIndex: 'age',
},
]

// columns: 列定义
// dataSource: 数据源,一般是数组包对象
// rowKey: 作为唯一标识的属性名
// loading: 显示加载图片
return (
<Table
columns={columns}
dataSource={students}
rowKey='id'
loading={loading}></Table>
)
}

客户端分页

import { Table } from 'antd'
import { ColumnsType, TablePaginationConfig } from 'antd/lib/table'
import axios from 'axios'
import { useEffect, useState } from 'react'
import { R, Student } from '../model/Student'

export default function A3() {
const [students, setStudents] = useState<Student[]>([])
const [loading, setLoading] = useState(true)
const [pagination, setPagination] = useState<TablePaginationConfig>(
{current:1, pageSize:5}
)

// 参数: 新的分页数据
function onTableChange(newPagination: TablePaginationConfig) {
setPagination(newPagination)
}

useEffect(() => {
async function getStudents() {
const resp = await axios.get<R<Student[]>>(
'http://localhost:8080/api/students'
)
setStudents(resp.data.data)
setLoading(false)
}

getStudents()
}, [])

// ... 省略

// pagination: 分页数据
// onChange: 当页号,页大小改变时触发
return (
<Table
columns={columns}
dataSource={students}
rowKey='id'
loading={loading}
pagination={pagination}
onChange={onTableChange}></Table>
)
}
  • 本例还是查询所有数据,分页是客户端 Table 组件自己实现的

服务端分页

import { Table } from 'antd'
import { ColumnsType, TablePaginationConfig } from 'antd/lib/table'
import axios from 'axios'
import { useEffect, useState } from 'react'
import { PageResp, R, Student } from '../model/Student'

export default function A4() {
const [students, setStudents] = useState<Student[]>([])
const [loading, setLoading] = useState(true)
const [pagination, setPagination] = useState<TablePaginationConfig>({
current: 1,
pageSize: 5,
})

function onTableChange(newPagination: TablePaginationConfig) {
setPagination(newPagination)
}

useEffect(() => {
async function getStudents() {
// params 用来给请求添加 url 后的 ? 参数
const resp = await axios.get<R<PageResp<Student>>>(
'http://localhost:8080/api/students/q',
{
params: {
page: pagination.current,
size: pagination.pageSize,
},
}
)
// 返回结果中:list 代表当前页集合, total 代表总记录数
setStudents(resp.data.data.list)
setPagination((old) => {
return { ...old, total: resp.data.data.total }
})
setLoading(false)
}

getStudents()
}, [pagination.current, pagination.pageSize])
// useEffect 需要在依赖项( current 和 pageSize ) 改变时重新执行

const columns: ColumnsType<Student> = [
{
title: '编号',
dataIndex: 'id',
},
{
title: '姓名',
dataIndex: 'name',
},
{
title: '性别',
dataIndex: 'sex',
},
{
title: '年龄',
dataIndex: 'age',
},
]

return (
<Table
columns={columns}
dataSource={students}
rowKey='id'
loading={loading}
pagination={pagination}
onChange={onTableChange}></Table>
)
}
  • 本例需要服务端配合来实现分页,参见代码中新加的注释

其中 PageResp 类型定义为

export interface PageResp<T> {
list: T[],
total: number
}

条件查询

import { Input, Select, Table } from 'antd'
import { ColumnsType, TablePaginationConfig } from 'antd/lib/table'
import axios from 'axios'
import React, { useEffect, useState } from 'react'
import { PageResp, R, Student, StudentQueryForm } from '../model/Student'

const { Option } = Select

export default function A5() {
const [students, setStudents] = useState<Student[]>([])
const [loading, setLoading] = useState(true)
const [pagination, setPagination] = useState<TablePaginationConfig>({
current: 1,
pageSize: 5,
})
// 代表查询条件的状态数据
const [form, setForm] = useState<StudentQueryForm>({})

function onTableChange(newPagination: TablePaginationConfig) {
setPagination(newPagination)
}

useEffect(() => {
async function getStudents() {
const resp = await axios.get<R<PageResp<Student>>>(
'http://localhost:8080/api/students/q',
{
params: {
page: pagination.current,
size: pagination.pageSize,
...form // 补充查询参数
},
}
)
setStudents(resp.data.data.list)
setPagination((old) => {
return { ...old, total: resp.data.data.total }
})
setLoading(false)
}

getStudents()
}, [pagination.current, pagination.pageSize, form.name, form.sex, form.age])
// 依赖项除了分页条件外,新加了查询条件依赖

const columns: ColumnsType<Student> = [
{
title: '编号',
dataIndex: 'id',
},
{
title: '姓名',
dataIndex: 'name',
},
{
title: '性别',
dataIndex: 'sex',
},
{
title: '年龄',
dataIndex: 'age',
},
]

// name 条件改变时处理函数
function onNameChange(e: React.ChangeEvent<HTMLInputElement>) {
setForm((old)=>{
return {...old, name: e.target.value}
})
}

// sex 条件改变时处理函数
function onSexChange(value: string) {
setForm((old)=>{
return {...old, sex: value}
})
}

// age 条件改变时处理函数
function onAgeChange(value: string) {
setForm((old)=>{
return {...old, age: value}
})
}

return (
<div>
<div>
<Input
style={{ width: 120 }}
placeholder='请输入姓名'
value={form.name}
onChange={onNameChange}></Input>
<Select
style={{ width: 120 }}
placeholder='请选择性别'
allowClear={true}
value={form.sex}
onChange={onSexChange}>
<Option value=''></Option>
<Option value=''></Option>
</Select>
<Select
style={{ width: 120 }}
placeholder='请选择年龄'
allowClear={true}
value={form.age}
onChange={onAgeChange}>
<Option value='1,19'>20以下</Option>
<Option value='20,29'>20左右</Option>
<Option value='30,39'>30左右</Option>
<Option value='40,120'>40以上</Option>
</Select>
</div>
<Table
columns={columns}
dataSource={students}
rowKey='id'
loading={loading}
pagination={pagination}
onChange={onTableChange}></Table>
</div>
)
}
  • 建议 axios 发请求是用 params 而不要自己拼字符串,因为自己拼串需要去掉值为 undefined 的属性

其中 StudentQueryForm 为

export interface StudentQueryForm {
name?: string,
sex?: string,
age?: string,
[key: string]: any
}

删除

import { Button, message, Popconfirm } from 'antd'
import axios from 'axios'
import { R } from '../model/Student'

export default function A6Delete({ id, onSuccess }: { id: number, onSuccess:()=>void }) {
async function onConfirm() {
const resp = await axios.delete<R<string>>(
`http://localhost:8080/api/students/${id}`
)
message.success(resp.data.data)
// 改变 form 依赖项
onSuccess()
}
return (
<Popconfirm title='确定要删除学生吗?' onConfirm={onConfirm}>
<Button danger size='small'>
删除
</Button>
</Popconfirm>
)
}

使用删除组件

import { Button, Input, Select, Space, Table } from 'antd'
import { ColumnsType, TablePaginationConfig } from 'antd/lib/table'
import axios from 'axios'
import React, { useEffect, useState } from 'react'
import { PageResp, R, Student, StudentQueryForm } from '../model/Student'
import A6Delete from './A6Delete'

const { Option } = Select

export default function A6() {
// ... 省略

function onDeleteSuccess() {
setForm((old)=>{
return {...old}
})
}

const columns: ColumnsType<Student> = [
// ... 省略
{
title: '操作',
dataIndex: 'operation',
// value: 属性值, student
render: (_, student)=>{
return <>
<Space>
<A6Delete id={student.id} onSuccess={onDeleteSuccess}></A6Delete>
<Button type='default' size='small'>修改</Button>
</Space>
</>
}
}
]

// ... 省略
}

修改

import { Form, Input, InputNumber, message, Modal, Radio } from 'antd'
import { Rule } from 'antd/lib/form'
import axios from 'axios'
import { useEffect } from 'react'
import { R, Student } from '../model/Student'

export default function A6Update({
open,
student,
onSuccess,
onCancel,
}: {
open: boolean
student: Student
onSuccess?: () => void
onCancel?: () => void
}) {
const { Item } = Form
const { Group } = Radio
const options = [
{ label: '男', value: '男' },
{ label: '女', value: '女' },
]

const [form] = Form.useForm() // 代表了表单对象

const nameRules: Rule[] = [
{ required: true, message: '姓名必须' },
{ min: 2, type: 'string', message: '至少两个字符' },
]

const ageRules: Rule[] = [
{ required: true, message: '年龄必须' },
{ min: 1, type: 'number', message: '最小1岁' },
{ max: 120, type: 'number', message: '最大120岁' },
]

async function onOk() {
// 验证并获取表单数据
try {
const values = await form.validateFields()
console.log(values)
const resp = await axios.put<R<string>>(
`http://localhost:8080/api/students/${values.id}`,
values
)
message.success(resp.data.data)
onSuccess && onSuccess()
} catch (e) {
console.error(e)
}
}

useEffect(() => {
// 修改表单数据
form.setFieldsValue(student) // id, name, sex, age
}, [student])

return (
<Modal
open={open}
title='修改学生'
onOk={onOk}
onCancel={onCancel}
forceRender={true}>
<Form form={form}>
<Item label='编号' name='id'>
<Input readOnly></Input>
</Item>
<Item label='姓名' name='name' rules={nameRules}>
<Input></Input>
</Item>
<Item label='性别' name='sex'>
<Group
options={options}
optionType='button'
buttonStyle='solid'></Group>
</Item>
<Item label='年龄' name='age' rules={ageRules}>
<InputNumber></InputNumber>
</Item>
</Form>
</Modal>
)
}
  • forceRender 是避免因为使用 useForm 后,表单套在 Modal 中会出现的警告错误

使用组件

import { Button, Input, Select, Space, Table } from 'antd'
import { ColumnsType, TablePaginationConfig } from 'antd/lib/table'
import axios from 'axios'
import React, { useEffect, useState } from 'react'
import { PageResp, R, Student, StudentQueryForm } from '../model/Student'
import A6Delete from './A6Delete'
import A6Update from './A6Update'

const { Option } = Select

export default function A6() {
// ... 省略
const columns: ColumnsType<Student> = [
// ... 省略
{
title: '操作',
dataIndex: 'operation',
// value: 属性值, student
render: (_, student) => {
return (
<>
<Space>
<A6Delete id={student.id} onSuccess={onDeleteSuccess}></A6Delete>
<Button
type='default'
size='small'
onClick={() => {
onUpdateClick(student)
}}>
修改
</Button>
</Space>
</>
)
},
},
]

// -------------- 修改功能开始 -------------
function onUpdateClick(student: Student) {
setUpdateOpen(true)
setUpdateForm(student)
}

function onUpdateCancel() {
setUpdateOpen(false)
}

function onUpdateSuccess() {
setUpdateOpen(false)
setForm((old) => {
return { ...old }
})
}

const [updateOpen, setUpdateOpen] = useState(false)
const [updateForm, setUpdateForm] = useState<Student>({
id: 0,
name: '',
sex: '男',
age: 18,
})
// -------------- 修改功能结束 -------------

return (
<div>
<A6Update
open={updateOpen}
student={updateForm}
onSuccess={onUpdateSuccess}
onCancel={onUpdateCancel}></A6Update>
<!-- ... 省略 -->
</div>
)
}

新增

import { Form, Input, InputNumber, message, Modal, Radio } from 'antd'
import { Rule } from 'antd/lib/form'
import axios from 'axios'
import { useEffect } from 'react'
import { R, Student } from '../model/Student'

export default function A6Insert({
open,
student,
onSuccess,
onCancel,
}: {
open: boolean
student: Student
onSuccess?: () => void
onCancel?: () => void
}) {
const { Item } = Form
const { Group } = Radio
const options = [
{ label: '男', value: '男' },
{ label: '女', value: '女' },
]

const [form] = Form.useForm() // 代表了表单对象

const nameRules: Rule[] = [
{ required: true, message: '姓名必须' },
{ min: 2, type: 'string', message: '至少两个字符' },
]

const ageRules: Rule[] = [
{ required: true, message: '年龄必须' },
{ min: 1, type: 'number', message: '最小1岁' },
{ max: 120, type: 'number', message: '最大120岁' },
]

async function onOk() {
// 验证并获取表单数据
try {
const values = await form.validateFields()
console.log(values)
const resp = await axios.post<R<string>>(
`http://localhost:8080/api/students`,
values
)
message.success(resp.data.data)
onSuccess && onSuccess()
form.resetFields() // 重置表单
} catch (e) {
console.error(e)
}
}

return (
<Modal
open={open}
title='新增学生'
onOk={onOk}
onCancel={onCancel}
forceRender={true}>
<Form form={form} initialValues={student}>
<Item label='姓名' name='name' rules={nameRules}>
<Input></Input>
</Item>
<Item label='性别' name='sex'>
<Group
options={options}
optionType='button'
buttonStyle='solid'></Group>
</Item>
<Item label='年龄' name='age' rules={ageRules}>
<InputNumber></InputNumber>
</Item>
</Form>
</Modal>
)
}

  • initialValues 只会触发一次表单赋初值
  • form.resetFields() 会将表单重置为 initialValues 时的状态

使用组件

import { Button, Input, Select, Space, Table } from 'antd'
import { ColumnsType, TablePaginationConfig } from 'antd/lib/table'
import axios from 'axios'
import React, { useEffect, useState } from 'react'
import { PageResp, R, Student, StudentQueryForm } from '../model/Student'
import A6Delete from './A6Delete'
import A6Insert from './A6Insert'
import A6SelectedDelete from './A6SelectedDelete'
import A6Update from './A6Update'

const { Option } = Select

export default function A6() {
// ... 省略

// -------------- 新增功能开始 -------------
function onInsertClick() {
setInsertOpen(true)
}

function onInsertCancel() {
setInsertOpen(false)
}

function onInsertSuccess() {
setInsertOpen(false)
setForm((old) => {
return { ...old }
})
}

const [insertOpen, setInsertOpen] = useState(false)
const [insertForm, setInsertForm] = useState<Student>({
id: 0,
name: '',
sex: '男',
age: 18,
})
// -------------- 新增功能结束 -------------


return (
<div>
<A6Insert
open={insertOpen}
student={insertForm}
onSuccess={onInsertSuccess}
onCancel={onInsertCancel}></A6Insert>
<A6Update
open={updateOpen}
student={updateForm}
onSuccess={onUpdateSuccess}
onCancel={onUpdateCancel}></A6Update>
<div>
<Space>
<Input
style={{ width: 120 }}
placeholder='请输入姓名'
value={form.name}
onChange={onNameChange}></Input>
<Select
style={{ width: 120 }}
placeholder='请选择性别'
allowClear={true}
value={form.sex}
onChange={onSexChange}>
<Option value=''></Option>
<Option value=''></Option>
</Select>
<Select
style={{ width: 120 }}
placeholder='请选择年龄'
allowClear={true}
value={form.age}
onChange={onAgeChange}>
<Option value='1,19'>20以下</Option>
<Option value='20,29'>20左右</Option>
<Option value='30,39'>30左右</Option>
<Option value='40,120'>40以上</Option>
</Select>

<Button type='primary' onClick={onInsertClick}>新增</Button>
</Space>
</div>
<Table
columns={columns}
dataSource={students}
rowKey='id'
loading={loading}
pagination={pagination}
onChange={onTableChange}></Table>
</div>
)
}

删除选中

import { Button, message, Popconfirm } from "antd";
import axios from "axios";
import React from "react";
import { R } from "../model/Student";

export default function A6DeleteSelected(
{ids, onSuccess}: {ids:React.Key[], onSuccess?:()=>void} // Key[] 是 number 或 string 的数组
){
const disabled = ids.length === 0
async function onConfirm() {
const resp = await axios.delete<R<string>>('http://localhost:8080/api/students', {
data: ids
})
message.success(resp.data.data)
onSuccess && onSuccess()
}
return (
<Popconfirm title='真的要删除选中的学生吗?' onConfirm={onConfirm} disabled={disabled}>
<Button danger type='primary' disabled={disabled}>
删除选中
</Button>
</Popconfirm>
)
}

与 A6 结合

import { Button, Input, Select, Space, Table } from 'antd'
import { ColumnsType, TablePaginationConfig } from 'antd/lib/table'
import axios from 'axios'
import React, { useEffect, useState } from 'react'
import { PageResp, R, Student, StudentQueryForm } from '../model/Student'
import A6Delete from './A6Delete'
import A6Insert from './A6Insert'
import A6SelectedDelete from './A6SelectedDelete'
import A6Update from './A6Update'

const { Option } = Select

export default function A6() {
// ... 省略

// -------------- 删除选中功能开始 -------------
const [ids, setIds] = useState<React.Key[]>([])
function onIdsChange(ids:React.Key[]) {
// console.log(ids)
setIds(ids)
}
function onDeleteSelectedSuccess() {
setForm((old)=>{
return {...old}
})
setIds([])
}
// -------------- 删除选中功能结束 -------------
return (
<div>
<A6Insert
open={insertOpen}
student={insertForm}
onSuccess={onInsertSuccess}
onCancel={onInsertCancel}></A6Insert>
<A6Update
open={updateOpen}
student={updateForm}
onSuccess={onUpdateSuccess}
onCancel={onUpdateCancel}></A6Update>
<div>
<Space>
<!-- ... 省略 -->
<A6SelectedDelete ids={ids} onSuccess={onDeleteSelectedSuccess}></A6SelectedDelete>
</Space>
</div>
<Table
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: onSelectChange,
}}
columns={columns}
dataSource={students}
rowKey='id'
loading={loading}
pagination={pagination}
onChange={onTableChange}></Table>
</div>
)
}

useRequest

安装

npm install ahooks

使用

import { useRequest } from 'ahooks'
import { Table } from 'antd'
import { ColumnsType } from 'antd/lib/table'
import axios from 'axios'
import { Student, R } from '../model/Student'

export default function A3() {
function getStudents() {
return axios.get<R<Student[]>>('http://localhost:8080/api/students')
}

const { loading, data } = useRequest(getStudents)

const columns: ColumnsType<Student> = [
{
title: '编号',
dataIndex: 'id',
},
{
title: '姓名',
dataIndex: 'name',
},
{
title: '性别',
dataIndex: 'sex',
},
{
title: '年龄',
dataIndex: 'age',
},
]

return (
<Table
dataSource={data?.data.data}
columns={columns}
rowKey='id'
loading={loading}
pagination={{ hideOnSinglePage: true }}></Table>
)
}

useAndtTable

import { useAntdTable } from 'ahooks'
import { Table } from 'antd'
import { ColumnsType } from 'antd/lib/table'
import axios from 'axios'
import { Student, R } from '../model/Student'

interface PageResp<T> {
total: number
list: T[]
}

interface PageReq {
current: number
pageSize: number
sorter?: any
filter?: any
}

export default function A3() {
async function getStudents({ current, pageSize }: PageReq) {
const resp = await axios.get<R<PageResp<Student>>>(
`http://localhost:8080/api/students/q?page=${current}&size=${pageSize}`
)
return resp.data.data
}

const { tableProps } = useAntdTable(getStudents, {
defaultParams: [{ current: 1, pageSize: 5 }],
})
console.log(tableProps)

const columns: ColumnsType<Student> = [
{
title: '编号',
dataIndex: 'id',
},
{
title: '姓名',
dataIndex: 'name',
},
{
title: '性别',
dataIndex: 'sex',
},
{
title: '年龄',
dataIndex: 'age',
},
]

return <Table {...tableProps} columns={columns} rowKey='id'></Table>
}

2) MobX

介绍

需求,组件0 改变了数据,其它组件也想获得改变后的数据,如图所示

image-20221025104453534

这种多个组件之间要共享状态数据,useState 就不够用了,useContext 也不好用了

能够和 react 配合使用的状态管理库有

  • MobX
  • Redux

其中 Redux API 非常难以使用,这里选择了更加符合人类习惯的 MobX,它虽然采用了面向对象的语法,但也能和函数式的代码很好地结合

文档

安装

npm install mobx mobx-react-lite
  • mobx 目前版本是 6.x
  • mobx-react-lite 目前版本是 3.x

名词

Action, State, View

  • Actions 用来修改状态数据的方法
  • Observable state 状态数据,可观察
  • Derived values 派生值,也叫 Computed values 计算值,会根据状态数据的改变而改变,具有缓存功能
  • Reactions 状态数据发生变化后要执行的操作,如 react 函数组件被重新渲染

使用

首先,定义一个在函数之外存储状态数据的 store,它与 useState 不同:

  • useState 里的状态数据是存储在每个组件节点上,不同组件之间没法共享
  • 而 MobX 的 store 就是一个普通 js 对象,只要保证多个组件都访问此对象即可
import axios from 'axios'
import { makeAutoObservable } from 'mobx'
import { R, Student } from '../model/Student'

class StudentStore {
student: Student = { name: '' }

constructor() {
makeAutoObservable(this)
}

async fetch(id: number) {
const resp = await axios.get<R<Student>>(
`http://localhost:8080/api/students/${id}`
)
runInAction(() => {
this.student = resp.data.data
})
}

get print() {
const first = this.student.name.charAt(0)
if (this.student.sex === '男') {
return first.concat('大侠')
} else if (this.student.sex === '女') {
return first.concat('女侠')
} else {
return ''
}
}
}

export default new StudentStore()

其中 makeAutoObservable 会

  • 将对象的属性 student 变成 Observable state,即状态数据
  • 将对象的方法 fetch 变成 Action,即修改数据的方法
  • 将 get 方法变成 Computed values

在异步操作里为状态属性赋值,需要放在 runInAction 里,否则会有警告错误

使用 store,所有使用 store 的组件,为了感知状态数据的变化,需要用 observer 包装,对应着图中 reactions

import Search from 'antd/lib/input/Search'
import { observer } from 'mobx-react-lite'
import studentStore from '../store/StudentStore'
import A71 from './A71'
import Test2 from './Test2'

const A7 = () => {
return (
<div>
<Search
placeholder='input search text'
onSearch={(v) => studentStore.fetch(Number(v))}
style={{ width: 100 }}
/>
<h3>组件0 {studentStore.student.name}</h3>
<A71></A71>
<A72></A72>
</div>
)
}

export default observer(A7)

其它组件

import { observer } from 'mobx-react-lite'
import studentStore from '../store/StudentStore'

const A71 = () =>{
return <h3 style={{color:'red'}}>组件1 {studentStore.student.name}</h3>
}

export default observer(A71)
import { observer } from 'mobx-react-lite'
import studentStore from '../store/StudentStore'

const A72 = () =>{
return <h3 style={{color:'red'}}>组件1 {studentStore.student.name}</h3>
}

export default observer(A72)

注解方式

import { R, Student } from "../model/Student";
import { action, computed, makeAutoObservable, makeObservable, observable, runInAction } from 'mobx'
import axios from "axios";

class StudentStore {
// 属性 - 对应状态数据 observable state
@observable student: Student = { id: 0, name: '' }
// 方法 - 对应 action 方法
@action setName(name: string) {
this.student.name = name
}
@action async fetch(id: number) {
const resp = await axios.get<R<Student>>(`http://localhost:8080/api/students/${id}`)
runInAction(() => {
this.student = resp.data.data
})
}
// get 方法 - 对应 derived value
@computed get displayName() {
const first = this.student.name.charAt(0)
if (this.student.sex === '男') {
return first + '大侠'
} else if (this.student.sex === '女') {
return first + '女侠'
} else {
return ''
}
}
// 构造器
constructor() {
makeObservable(this)
}
}

export default new StudentStore()

需要在 tsconifg.json 中加入配置

{
"compilerOptions": {
// ...
"experimentalDecorators": true
}
}

3) React Router

安装

npm install react-router-dom
  • 目前版本是 6.x

使用

新建文件 src/router/router.tsx

import { lazy } from 'react'
import { Navigate, RouteObject, useRoutes } from 'react-router-dom'

export function load(name: string) {
const Page = lazy(() => import(`../pages/${name}`))
return <Page></Page>
}

const staticRoutes: RouteObject[] = [
{ path: '/login', element: load('A8Login') },
{
path: '/',
element: load('A8Main'),
children: [
{ path: 'student', element: load('A8MainStudent') },
{ path: 'teacher', element: load('A8MainTeacher') },
{ path: 'user', element: load('A8MainUser') }
],
},
{ path: '/404', element: load('A8Notfound') },
{ path: '/*', element: <Navigate to={'/404'}></Navigate> },
]

export default function Router() {
return useRoutes(staticRoutes)
}

index.tsx 修改为

import ReactDOM from 'react-dom/client';
import './index.css';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/es/locale/zh_CN'

import { BrowserRouter } from 'react-router-dom';
import Router from './router/router';

const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);

root.render(
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<Router></Router>
</BrowserRouter>
</ConfigProvider>
)

A8Main 的代码

import { Layout } from "antd";
import { Link, Outlet } from "react-router-dom";

export default function A8Main () {
return <Layout>
<Layout.Header>头部导航</Layout.Header>
<Layout>
<Layout.Sider>侧边导航
<Link to='/student'>学生管理</Link>
<Link to='/teacher'>教师管理</Link>
<Link to='/user'>用户管理</Link>
</Layout.Sider>
<Layout.Content>
<Outlet></Outlet>
</Layout.Content>
</Layout>
</Layout>
}
  1. Navigate 的作用是重定向
  2. load 方法的作用是懒加载组件,更重要的是根据字符串找到真正的组件,这是动态路由所需要的
  3. children 来进行嵌套路由映射,嵌套路由在跳转后,并不是替换整个页面,而是用新页面替换父页面的 Outlet 部分

动态路由

路由分成两部分:

  • 静态路由,固定的部分,如主页、404、login 这几个页面
  • 动态路由,变化的部分,经常是主页内的嵌套路由,比如 Student、Teacher 这些

动态路由应该是根据用户登录后,根据角色的不同,从后端服务获取,因为这些数据是变化的,所以用 mobx 来管理

import axios from 'axios'
import { makeAutoObservable, runInAction } from 'mobx'
import { Navigate, RouteObject } from 'react-router-dom'
import { MenuAndRoute, R, Route } from '../model/Student'
import { load } from '../router/MyRouter'

class RoutesStore {
dynamicRoutes: Route[]

async fetch(username: string) {
const resp = await axios.get<R<MenuAndRoute>>(
`http://localhost:8080/api/menu/${username}`
)
runInAction(() => {
this.dynamicRoutes = resp.data.data.routeList
localStorage.setItem('dynamicRoutes', JSON.stringify(this.dynamicRoutes))
})
}

constructor() {
makeAutoObservable(this)
const r = localStorage.getItem('dynamicRoutes')
this.dynamicRoutes = r ? JSON.parse(r) : []
}

reset() {
this.dynamicRoutes = []
localStorage.removeItem('dynamicRoutes')
}

get routes() {
const staticRoutes: RouteObject[] = [
{ path: '/login', element: load('A8Login') },
{ path: '/', element: load('A8Main') },
{ path: '/404', element: load('A8Notfound') },
{ path: '/*', element: <Navigate to={'/404'}></Navigate> },
]
const main = staticRoutes[1]

main.children = this.dynamicRoutes.map((r) => {
console.log(r.path, r.element)
return {
path: r.path,
element: load(r.element),
}
})
return staticRoutes
}
}

export default new RoutesStore()
  • 其中用 localStorage 进行了数据的持久化,避免刷新后丢失数据

MyRouter 文件修改为

import { observer } from 'mobx-react-lite'
import { lazy } from 'react'
import { Navigate, RouteObject, useRoutes } from 'react-router-dom'
import RoutesStore from '../store/RoutesStore'

// 把字符串组件 => 组件标签
export function load(name: string) {
// A8Login
const Page = lazy(() => import(`../pages/${name}`))
return <Page></Page>
}

// 路由对象
function MyRouter() {
const router = useRoutes(RoutesStore.routes)
return router
}

export default observer(MyRouter)

注意导入 router 对象时,用 observer 做了包装,这样能够在 store 发生变化时重建 router 对象

动态菜单

图标要独立安装依赖

npm install @ant-design/icons

图标组件,用来将字符串图标转换为标签图标

import * as icons from '@ant-design/icons'

interface Module {
[p: string]: any
}

const all: Module = icons

export default function Icon({ name }: { name: string }) {
const Icon = all[name]
return <Icon></Icon>
}

修改 RoutesStore.tsx

import axios from 'axios'
import { makeAutoObservable, runInAction } from 'mobx'
import { Link, Navigate, RouteObject } from 'react-router-dom'
import { Menu, MenuAndRoute, R, Route } from '../model/Student'
import { load } from '../router/MyRouter'
import Icon from './Icon'

function convertMenu(m: Menu): any {
const Label = m.routePath ? <Link to={m.routePath}>{m.label}</Link> : m.label
return {
label: Label,
key: m.key,
icon: <Icon name={m.icon}></Icon>,
children: m.children && m.children.map(convertMenu)
}
}

class RoutesStore {
// 动态部分
dynamicRoutes: Route[] = []
dynamicMenus: Menu[] = []

async fetch(username: string) {
const resp = await axios.get<R<MenuAndRoute>>(
`http://localhost:8080/api/menu/${username}`
)
runInAction(() => {
this.dynamicRoutes = resp.data.data.routeList
localStorage.setItem('dynamicRoutes', JSON.stringify(this.dynamicRoutes))

this.dynamicMenus = resp.data.data.menuTree
localStorage.setItem('dynamicMenus', JSON.stringify(this.dynamicMenus))
})
}

get menus() {
return this.dynamicMenus.map(convertMenu)
}

get routes() {
const staticRoutes: RouteObject[] = [
{ path: '/login', element: load('A8Login') },
{ path: '/', element: load('A8Main'), children: [] },
{ path: '/404', element: load('A8Notfound') },
{ path: '/*', element: <Navigate to={'/404'}></Navigate> },
]
staticRoutes[1].children = this.dynamicRoutes.map((r) => {
return {
path: r.path,
element: load(r.element),
}
})
return staticRoutes
}

constructor() {
makeAutoObservable(this)
const json = localStorage.getItem('dynamicRoutes')
this.dynamicRoutes = json ? JSON.parse(json) : []

const json2 = localStorage.getItem('dynamicMenus')
this.dynamicMenus = json2 ? JSON.parse(json2) : []
}

reset() {
localStorage.removeItem('dynamicRoutes')
this.dynamicRoutes = []
localStorage.removeItem('dynamicMenus')
this.dynamicMenus = []
}
}

export default new RoutesStore()

其中 convertMenu 为核心方法,负责将服务器返回的 Menu 转换成 antd Menu 组件需要的 Menu

使用

<Menu items={RoutesStore.menus} mode='inline' theme="dark"></Menu>

跳转若发生错误,可能是因为组件懒加载引起的,需要用 Suspense 解决

root.render(
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<Suspense fallback={<h3>加载中...</h3>}>
<MyRouter></MyRouter>
</Suspense>
</BrowserRouter>
</ConfigProvider>
)

登录

import { ItemType } from 'antd/lib/menu/hooks/useItems'
import axios from 'axios'
import { makeAutoObservable, runInAction } from 'mobx'
import { Link, Navigate, RouteObject } from 'react-router-dom'
import { LoginReq, LoginResp, Menu, MenuAndRoute, R, Route } from '../model/Student'
import { load } from '../router/MyRouter'
import Icon from './Icon'

function convertMenu(m: Menu): ItemType {
const Label = m.routePath? <Link to={m.routePath}>{m.label}</Link> : m.label
return {
key: m.key,
label: Label,
icon: <Icon name={m.icon}></Icon>,
children: m.children && m.children.map(convertMenu)
}
}

class RoutesStore {
// 动态部分
dynamicRoutes: Route[] = []
dynamicMenus: Menu[] = []

token: string = ''
state: string = 'pending' // 取值 pending done error
message: string = '' // 取值: 1. 空串 正常 2. 非空串 错误消息

async login(loginReq: LoginReq) {
this.state = 'pending'
this.message = ''
const resp1 = await axios.post<R<LoginResp>>(
'http://localhost:8080/api/loginJwt',
loginReq
)
if(resp1.data.code === 999) {
const resp2 = await axios.get<R<MenuAndRoute>>(
`http://localhost:8080/api/menu/${loginReq.username}`
)
runInAction(()=>{
this.token = resp1.data.data.token
localStorage.setItem('token', this.token)

this.dynamicRoutes = resp2.data.data.routeList
localStorage.setItem('dynamicRoutes', JSON.stringify(this.dynamicRoutes))

this.dynamicMenus = resp2.data.data.menuTree
localStorage.setItem('dynamicMenus', JSON.stringify(this.dynamicMenus))

this.state = 'done'
})
} else {
runInAction(()=>{
this.message = resp1.data.message || '未知错误'
this.state = 'error'
})
}
}

async fetch(username: string) {
const resp = await axios.get<R<MenuAndRoute>>(
`http://localhost:8080/api/menu/${username}`
)
runInAction(() => {
this.dynamicRoutes = resp.data.data.routeList
localStorage.setItem('dynamicRoutes', JSON.stringify(this.dynamicRoutes))

this.dynamicMenus = resp.data.data.menuTree
localStorage.setItem('dynamicMenus', JSON.stringify(this.dynamicMenus))
})
}

get routes() {
const staticRoutes: RouteObject[] = [
{ path: '/login', element: load('A8Login') },
{ path: '/', element: load('A8Main'), children: [] },
{ path: '/404', element: load('A8Notfound') },
{ path: '/*', element: <Navigate to={'/404'}></Navigate> },
]
staticRoutes[1].children = this.dynamicRoutes.map((r) => {
return {
path: r.path,
element: load(r.element),
}
})
return staticRoutes
}

get menus() {
return this.dynamicMenus.map(convertMenu)
}

constructor() {
makeAutoObservable(this)
const json = localStorage.getItem('dynamicRoutes')
this.dynamicRoutes = json ? JSON.parse(json) : []

const json1 = localStorage.getItem('dynamicMenus')
this.dynamicMenus = json1 ? JSON.parse(json1) : []

const token = localStorage.getItem('token')
this.token = token ?? ''

this.message = ''
this.state = 'pending'
}

reset() {
localStorage.removeItem('dynamicRoutes')
this.dynamicRoutes = []

localStorage.removeItem('dynamicMenus')
this.dynamicMenus = []

localStorage.removeItem('token')
this.token = ''

this.message = ''
this.state = 'pending'
}
}

export default new RoutesStore()

登录页面

function A8Login() {
function onFinish(values: { username: string; password: string }) {
RoutesStore.login(values)
}

const nav = useNavigate()
useEffect(() => {
if (RoutesStore.state === 'done') {
nav('/')
} else if (RoutesStore.state === 'error') {
message.error(RoutesStore.message)
}
}, [RoutesStore.state])

// ...
}

export default observer(A8Login)
  • 用 useNavigate() 返回的函数跳转的代码不能包含在函数式组件的主逻辑中,只能放在
    • 其它事件处理函数中
    • 写在副作用函数 useEffect 之中

注销、欢迎词、登录检查

Store 中增加 get username 方法

class RoutesStore {
// ...

// eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.-l-MjMPGJVOf3zoIJgoqpV3LWoqvCCgcaI1ga86ismU
get username() {
if(this.token.length === 0) {
return ''
}
const json = atob(this.token.split('.')[1])
return JSON.parse(json).sub
}

// ...
}
  • token 的前两部分都可以解码出来,其中 [1] 就是 token 的内容部分

主页组件改为

import { Button, Layout, Menu } from 'antd'
import { observer } from 'mobx-react-lite'
import { useEffect } from 'react'
import { Navigate, Outlet, useNavigate } from 'react-router-dom'
import RoutesStore from '../store/RoutesStore'

function A8Main() {
const nav = useNavigate()

function onClick() {
RoutesStore.reset()
nav('/login')
}

/* useEffect(()=>{
if(RoutesStore.username === '') {
nav('/login')
}
}, []) */

if(RoutesStore.username === '') {
return <Navigate to='/login'></Navigate>
}

return (
<Layout>
<Layout.Header>
<span>欢迎您【{RoutesStore.username}</span>
<Button size='small' onClick={onClick}>注销</Button>
</Layout.Header>
<Layout>
<Layout.Sider>
<Menu items={RoutesStore.menus} theme='dark' mode='inline'></Menu>
</Layout.Sider>
<Layout.Content>
<Outlet></Outlet>
</Layout.Content>
</Layout>
</Layout>
)
}

export default observer(A8Main)
  • 这个例子中推荐用 Navigate 来完成跳转
  • /student,/teacher 等路由不需要检查,因为登录成功后才有