1. 程式人生 > 其它 >後端程式設計師必會的前端知識-05:React

後端程式設計師必會的前端知識-05:React

五. React

1. React 基礎

react 是前端三大框架之一

  • 沒有 vue 的基礎更好,因為兩者思想不太一樣,不能用 vue 的習慣學習 react
  • 需要有 js 基礎,視訊 19-58
  • 需要有 ts 基礎,視訊 110-116
  • 本教程採用更流行的【函式式元件 + hooks】方式進行講解

1) 環境準備

建立專案

首先,通過 react 腳手架建立專案

npx create-react-app client --template typescript
  • client 是專案名
  • 目前 react 版本是 18.x

執行專案

cd client
npm start
  • 會自動開啟瀏覽器,預設監聽 3000 埠

修改埠

在專案根目錄下新建檔案 .env.development,它可以定義開發環境下的環境變數

PORT=7070

重啟專案,埠就變成了 7070

瀏覽器外掛

外掛地址 New React Developer Tools – React Blog (reactjs.org)

VSCode

推薦安裝 Prettier 程式碼格式化外掛

2) 入門案例

Hello

編寫一個 src/pages/Hello.tsx 元件

export default function Hello()  {
  return <h3>Hello, World!</h3>
}
  • 元件中使用了 jsx 語法,即在 js 中直接使用 html 標籤或元件標籤
  • 函式式元件必須返回標籤片段

在 index.js 引入元件

import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import reportWebVitals from './reportWebVitals'
// 1. 引入元件
import Hello from './pages/Hello'

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
  <React.StrictMode>
    {/* 2. 將原來的 <App/> 改為 <Hello></Hello> */}
    <Hello></Hello>
  </React.StrictMode>
)

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()

將歡迎詞作為屬性傳遞給元件

<Hello msg='你好'></Hello>
  • 字串值,可以直接使用雙引號賦值
  • 其它型別的值,用 {值}

而元件修改為

export default function Hello(props: { msg: string }) {
  return <h3>{props.msg}</h3>
}

jsx 原理

export default function Hello(props: { msg: string }) {
  return <h3>{props.msg}</h3>
}

在 v17 之前,其實相當於

import { createElement } from "react";
export default function Hello(props: {msg: string}) {
  return createElement('h3', null, `${props.msg}`)
}

3) 人物卡片案例

樣式已經準備好 /src/css/P1.css

#root {
  display: flex;
  width: 100vw;
  height: 100vh;
  justify-content: center;
  align-items: center;
}

div.student {
  flex-shrink: 0;
  flex-grow: 0;
  position: relative;
  width: 128px;
  height: 330px;
  /* font-family: '華文行楷'; */
  font-size: 14px;
  text-align: center;
  margin: 20px;
  display: flex;
  justify-content: flex-start;
  background-color: #7591AD;
  align-items: center;
  flex-direction: column;
  border-radius: 5px;
  box-shadow: 0 0 8px #2c2c2c;
  color: #e8f6fd;
}

.photo {
  position: absolute;
  width: 100%;
  height: 100%;
  top: 0;
  border-radius: 0%;
  overflow: hidden;
  transition: 0.3s;
  border-radius: 5px;
}

.photo img {
  width: 100%;
  height: 100%;
  /* object-fit: scale-down; */
  object-fit: cover;
}

.photo::before {
  position: absolute;
  content: '';
  width: 100%;
  height: 100%;
  background-image: linear-gradient(to top, #333, transparent);
}

div.student h2 {
  position: absolute;
  font-size: 20px;
  width: 100%;
  height: 68px;
  font-weight: normal;
  text-align: center;
  margin: 0;
  line-height: 68px;
  visibility: hidden;
}

h2::before {
  position: absolute;
  top: 0;
  left: 0;
  content: '';
  width: 100%;
  height: 68px;
  background-color: rgba(0, 0, 0, 0.3);
  border-top-left-radius: 5px;
  border-top-right-radius: 5px;  
}

div.student h1 {
  position: absolute;
  top: 250px;
  font-size: 22px;
  margin: 0;
  transition: 0.3s;
  font-weight: normal;
}

div.student p {
  margin-top: 300px;
  width: 80%;
  font-weight: normal;
  text-align: center;
  padding-bottom: 5px;
  border-bottom: 1px solid #8ea2b8;
}

.student:hover .photo::before {
  display: none;
}

.student:hover .photo {
  width: 90px;
  height: 90px;
  top: 90px;
  border-radius: 50%;
  box-shadow: 0 0 15px #111;
}

.student:hover img {
  object-position: 50% 0%;
}

.student:hover h1 {
  position: absolute;
  top: 190px;
  width: 40px;
}

div.student:hover h2 {
  visibility: visible;
}

型別 /src/model/Student.ts

export interface Student {
  id: number,
  name: string,
  sex: string,
  age: number,
  photo: string
}

元件 /src/pages/P1.tsx

import { Student } from '../model/Student'
import '../css/P1.css'
export default function P1(props: { student: Student }) {
  return (
    <div className='student'>
      <div className='photo'>
        <img src={props.student.photo}/>
      </div>
      <h1>{props.student.name}</h1>
      <h2>{props.student.id}</h2>      
      <p>性別 {props.student.sex} 年齡 {props.student.age}</p>
    </div>
  )
}

使用元件

const stu1 = { id: 1, name: '風清揚', sex: '男', age: 99, photo: '/imgs/1.png' }
const stu2 = { id: 2, name: '瑋哥', sex: '男', age: 20, photo: '/imgs/2.png' }
const stu3 = { id: 3, name: '長巨集', sex: '男', age: 30, photo: '/imgs/3.png'}

<P1 student={stu1}></P1>
<P1 student={stu2}></P1>
<P1 student={stu3}></P1>

路徑

  • src 下的資源,要用相對路徑引入
  • public 下的資源,記得 / 代表路徑的起點

標籤命名

  • 元件標籤必須用大駝峰命名
  • 普通 html 標籤必須用小寫命名

事件處理

import { Student } from '../model/Student'
import '../css/P1.css'
export default function P1(props: { student: Student }) {
    
  function handleClick(e : React.MouseEvent){
    console.log(student)
    console.log(e)
  }
  
  return (
    <div className='student'>
      <div className='photo' onClick={handleClick}>
        <img src={props.student.photo}/>
      </div>
      <h1>{props.student.name}</h1>
      <h2>{props.student.id}</h2>
      <p>性別 {props.student.sex} 年齡 {props.student.age}</p>
    </div>
  )
}
  • 事件以小駝峰命名
  • 事件處理函式可以有一個事件物件引數,可以獲取事件相關資訊

列表 & Key

import { Student } from '../model/Student'
import P1 from './P1'

export default function P2(props: { students: Student[] }) {
  return (
    <>
      {props.students.map((s) => ( <P1 student={s} key={s.id}></P1> ))}
    </>
  )
}
  • key 在迴圈時是必須的,否則會有 warning

也可以這麼做

import { Student } from '../model/Student'
import P1 from './P1'

export default function P2(props: { students: Student[] }) {
  const list = props.students.map((s) => <P1 student={s} key={s.id}></P1>)
  return <>{list}</>
}

使用元件

const stu1 = { id: 1, name: '風清揚', sex: '男', age: 99, photo: '/1.png' }
const stu2 = { id: 2, name: '瑋哥', sex: '男', age: 45, photo: '/2.png' }
const stu3 = { id: 3, name: '長巨集', sex: '男', age: 45, photo: '/3.png'}

<P2 students={[stu1,stu2,stu3]}></P2>

條件渲染

P1 修改為

import { Student } from '../model/Student'
import '../css/P1.css'
export default function P1(props: { student: Student; hideAge?: boolean }) {
  function handleClick() {
    console.log(props.student)
  }

  const ageFragment = !props.hideAge && <span>年齡 {props.student.age}</span>

  return (
    <div className='student'>
      <div className='photo' onClick={handleClick}>
        <img src={props.student.photo} />
      </div>
      <h1>{props.student.name}</h1>
      <h2>{props.student.id}</h2>
      <p>
        性別 {props.student.sex} {ageFragment}
      </p>
    </div>
  )
}
  • 子元素如果是布林值,nullish,不會渲染

P2 修改為

import { Student } from '../model/Student'
import P1 from './P1'

export default function P2(props: { students: Student[]; hideAge?: boolean }) {
  const list = props.students.map((s) => (
    <P1 student={s} hideAge={props.hideAge} key={s.id}></P1>
  ))
  return <>{list}</>
}

使用元件

const stu1 = { id: 1, name: '風清揚', sex: '男', age: 99, photo: '/1.png' }
const stu2 = { id: 2, name: '瑋哥', sex: '男', age: 45, photo: '/2.png' }
const stu3 = { id: 3, name: '長巨集', sex: '男', age: 45, photo: '/3.png'}

<P2 students={[stu1,stu2,stu3]} hideAge={true}></P2>

引數解構

以 P1 元件為例

import { Student } from '../model/Student'
import '../css/P1.css'
export default function P1
({ student, hideAge = false }: { student: Student, hideAge?: boolean }) {
  
  function handleClick() {
    console.log(student)
  }

  const ageFragment = !hideAge && <span>年齡 {student.age}</span>

  return (
    <div className='student'>
      <div className='photo' onClick={handleClick}>
        <img src={student.photo} />
      </div>
      <h1>{student.name}</h1>
      <h2>{student.id}</h2>
      <p>
        性別 {student.sex} {ageFragment}
      </p>
    </div>
  )
}
  • 可以利用解構賦值語句,讓 props 的使用更為簡單
  • 物件解構賦值還有一個額外的好處,給屬性賦預設值

使用元件

const stu1 = { id: 1, name: '風清揚', sex: '男', age: 99, photo: '/1.png' }

<P1 student={stu1}></P1>

4) 處理變化的資料

入門案例側重的是資料展示,並未涉及到資料的變動,接下來我們開始學習 react 如何處理資料變化

axios

首先來學習 axios,作用是傳送請求、接收響應,從伺服器獲取真實資料

安裝

npm install axios

定義元件

import axios from 'axios'
export default function P4({ id }: { id: number }) {
  async function updateStudent() {
    const resp = await axios.get(`http://localhost:8080/api/students/${id}`)
    console.log(resp.data.data)
  }

  updateStudent()

  return <></>
}
  • 其中 /api/students/${id} 是提前準備好的後端服務 api,會延遲 2s 返回結果

使用元件

<P4 id={1}></P4>

在控制檯上列印

{
    "id": 1,
    "name": "宋遠橋",
    "sex": "男",
    "age": 40
}

當屬性變化時,會重新觸發 P4 元件執行,例如將 id 從 1 修改為 2

執行流程

  • 首次呼叫函式元件,返回的 jsx 程式碼會被渲染成【虛擬 dom 節點】(也稱 Fiber 節點)
    • 根據【虛擬 dom 節點】會生成【真實 dom 節點】,由瀏覽器顯示出來
  • 當函式元件的 props 或 state 發生變化時,才會重新呼叫函式元件,返回 jsx
    • jsx 與上次的【虛擬 dom 節點】對比
      • 如果沒變化,複用上次的節點
      • 有變化,建立新的【虛擬 dom 節點】替換掉上次的節點
  • 由於嚴格模式會觸發兩次渲染,為了避免干擾,請先註釋掉 index.tsx 中的 <React.StrictMode>

狀態

先來看一個例子,能否把伺服器返回的資料顯示在頁面上

import axios from 'axios'
let count = 0
export default function P5(props: { id: number }) {
  
  function getTime() {
    const d = new Date()
    return d.getHours() + ':' + d.getMinutes() + ':' + d.getSeconds()
  }
  
  async function updateStudent() {
    const resp = await axios.get(
      `http://localhost:8080/api/students/${props.id}`
    )
    Object.assign(student, resp.data.data)
    console.log(current, student, getTime())
  }

  const current = count++
  let student = { name: 'xx' }
  console.log(current, student, getTime())
  updateStudent()
  
  return <h3>姓名是:{student.name}</h3>
}
  • count 是一個全域性變數,記錄 P5 函式第幾次被呼叫

執行效果,控制檯上

0 {name: 'xx'} '16:22:16'
0 {id: 1, name: '宋遠橋', sex: '男', age: 40} '16:22:18'

此時頁面仍顯示 姓名是:xx

那麼修改一下 props 的 id 呢?進入開發工具把 id 從 1 修改為 2,控制檯上

1 {name: 'xx'} '16:24:0'
1 {id: 2, name: '俞蓮舟', sex: '男', age: 38} '16:24:2'

此時頁面仍顯示 姓名是:xx

為什麼頁面沒有顯示兩秒後更新的值?

  • 第一次,頁面顯示的是 P5 函式的返回結果,這時 student.name 還未被更新成宋遠橋,頁面顯示 xx
    • 雖然 2s 後資料更新了,但此時並未觸發函式執行,頁面不變
  • 第二次,雖然 props 修改觸發了函式重新執行,但既然函式重新執行,函式內的 student 又被賦值為 { name: 'xx' },頁面還是顯示 xx
    • 2s 後資料更新,跟第一次一樣,並未重新觸發函式執行,頁面不變

結論:

  • 函式是無狀態的,執行完畢後,它內部用的資料都不會儲存下來
  • 要想讓函式有狀態,就需要使用 useState 把資料儲存在函式之外的地方,這些資料,稱之為狀態

useState

import axios from 'axios'
import { useReducer, useState } from 'react'
import { Student } from '../model/Student'
let count = 0
export default function P5(props: { id: number }) {

  // ...

  async function updateStudent() {
    const resp = await axios.get(
      `http://localhost:8080/api/students/${props.id}`
    )
    Object.assign(student, resp.data.data)
    console.log(current, student, getTime())
  }

  const current = count++
  let [student] = useState<Student>({ name: 'xx' })
  console.log(current, student, getTime())
  updateStudent()

  return <h3>姓名是:{student.name}</h3>
}

接下來使用 setXXX 方法更新 State

import axios from 'axios'
import { useState } from 'react'
import { Student } from '../model/Student'
export default 

function P5(props: { id: number }) {
  async function updateStudent() {
    const resp = await axios.get(`/api/students/${props.id}`)
    setStudent(resp.data.data)
  }

  let [student, setStudent] = useState<Student>({ name: 'xx' })
  updateStudent()

  return <h3>姓名是:{student.name}</h3>
}

工作流程如下

首次使用 useState,用它的引數初始化 State

2s 後資料更新,setStudent 函式會更新 State 資料,並會觸發下一次渲染(P5 的呼叫)

再次呼叫 useState,這時返回更新後的資料

這時再返回 jsx,內容就是 姓名是:宋遠橋

P.S.

使用了 useState 之後,會執行兩次 xhr 請求,後一次請求是 react 開發工具傳送的,不用理會

問題還未結束,第二次 P5 呼叫時,updateStudent 還會執行,結果會導致 2s 後響應返回繼續呼叫 setStudent,這會導致每隔 2s 呼叫一次 P5 函式(渲染一次)

如何讓 updateStudent 只執行一次呢?一種土辦法是再設定一個布林 State

接下來資料更新

第二次進入 P5 函式時,由於 fetch 條件不成立,因此不會再執行兩個 setXXX 方法

函式式元件的工作流程

  • 首次呼叫函式元件,返回的 jsx 程式碼會被渲染成【虛擬 dom 節點】(也稱 Fiber 節點)
    • 此時使用 useState 會將元件工作過程中需要資料繫結到【虛擬 dom 節點】上
    • 根據【虛擬 dom 節點】會生成【真實 dom 節點】,由瀏覽器顯示出來
  • 當函式元件的 props 或 state 發生變化時,才會重新呼叫函式元件,返回 jsx
    • props 變化由父元件決定,state 變化由元件自身決定
    • jsx 與上次的【虛擬 dom 節點】對比
      • 如果沒變化,複用上次的節點
      • 有變化,建立新的【虛擬 dom 節點】替換掉上次的節點

useEffect

Effect 稱之為副作用(沒有貶義),函式元件的主要目的,是為了渲染生成 html 元素,除了這個主要功能以外,管理狀態,fetch 資料 ... 等等之外的功能,都可以稱之為副作用。

useXXX 打頭的一系列方法,都是為副作用而生的,在 react 中把它們稱為 Hooks

useEffect 三種用法

import axios from "axios"
import { useEffect, useState } from "react"

/*
useEffect
  引數1:箭頭函式, 在真正渲染 html 之前會執行它
  引數2:
    情況1:沒有, 代表每次執行元件函式時, 都會執行副作用函式
    情況2:[], 代表副作用函式只會執行一次
    情況3:[依賴項], 依賴項變化時,副作用函式會執行
*/
export default function P6({ id, age }: { id: number, age: number }) {

  console.log('1.主要功能')
    
  // useEffect(() => console.log('3.副作用功能')) 
  // useEffect(() => console.log('3.副作用功能'), []) 
  useEffect(() => console.log('3.副作用功能'), [id]) 

  console.log('2.主要功能')

  return <h3>{id}</h3>
}

用它改寫 P5 案例

import axios from "axios"
import { useEffect, useState } from "react"

export default function P6({ id, age }: { id: number, age: number }) {

  const [student, setStudent] = useState({name:'xx'})

  useEffect(()=>{
    async function updateStudent() {
      const resp = await axios.get(`http://localhost:8080/api/students/${id}`)    
      setStudent(resp.data.data)
    }
    updateStudent()
  }, [id])

  return <h3>{student.name}</h3>
}

useContext

import axios from 'axios'
import { createContext, useContext, useEffect, useState } from 'react'
import { R, Student } from '../model/Student'

/*
  createContext         建立上下文物件
  useContext            讀取上下文物件的值
  <上下文物件.Provider>  修改上下文物件的值
*/
const HiddenContext = createContext(false)

// 給以下元件提供資料,控制年齡隱藏、顯示
export default function P7() {
  const [students, setStudents] = useState<Student[]>([])
  const [hidden, setHidden] = useState(false)
  useEffect(()=>{
    async function updateStudents() {
      const resp = await axios.get<R<Student[]>>("http://localhost:8080/api/students")
      setStudents(resp.data.data)
    }
    updateStudents()
  }, [])

  function hideOrShow() {
    // 引數:上一次狀態值,舊值
    // 返回值:要更新的新值
    setHidden((old)=>{
      return !old
    })
  }
  return <HiddenContext.Provider value={hidden}>
    <input type="button" value={hidden?'顯示':'隱藏'} onClick={hideOrShow}/>
    <P71 students={students}></P71>
  </HiddenContext.Provider>  
}

// 負責處理學生集合
function P71({ students }: { students: Student[] }) {
  const list = students.map(s=><P72 student={s} key={s.id}></P72>)
  return <>{list}</>
}

// 負責顯示單個學生
function P72({ student }: { student: Student }) {
  const hidden = useContext(HiddenContext)
  const jsx = !hidden && <span>{student.age}</span>
  return <div>{student.name} {jsx}</div>
}
  • 如果元件分散在多個檔案中,HiddenContext 應該 export 匯出,用到它的元件 import 匯入
  • React 中因修改觸發的元件重新渲染,都應當是自上而下的
  • setHidden 方法如果更新的是物件,那麼要返回一個新物件,而不是在舊物件上做修改

表單

import axios from 'axios'
import React, { useState } from 'react'
import '../css/P8.css'

export default function P8() {

  const [student, setStudent] = useState({name:'', sex:'男', age:18})
  const [message, setMessage] = useState('')

  const options = ['男', '女']
  const jsx = options.map(e => <option key={e}>{e}</option>)

  // e 事件物件, e.target 事件源
  function onChange(e : React.ChangeEvent<HTMLInputElement|HTMLSelectElement>) {
    setStudent((old)=>{
      // 返回的新值,不能與舊值是同一個物件
      return {...old, [e.target.name]:e.target.value}
    })
  }

  async function onClick() {
    const resp = await axios.post('http://localhost:8080/api/students', student)
    setMessage(resp.data.data)
  }
  
  const messageJsx = message && <div className='success'>{message}</div>

  return (
    <form>
      <div>
        <label>姓名</label>
        <input type="text" value={student.name} onChange={onChange} name='name'/>
      </div>
      <div>
        <label>性別</label>
        <select value={student.sex} onChange={onChange} name='sex'>
          {jsx}
        </select>
      </div>
      <div>
        <label>年齡</label>
        <input type="text" value={student.age} onChange={onChange} name='age' />
      </div>
      <div>
        <input type='button' value='新增' onClick={onClick}/>
      </div>
      {messageJsx}
    </form>
  )
}

2. 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 改變了資料,其它元件也想獲得改變後的資料,如圖所示

這種多個元件之間要共享狀態資料,useState 就不夠用了,useContext 也不好用了

能夠和 react 配合使用的狀態管理庫有

  • MobX
  • Redux

其中 Redux API 非常難以使用,這裡選擇了更加符合人類習慣的 MobX,它雖然採用了面向物件的語法,但也能和函式式的程式碼很好地結合

文件

安裝

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

名詞

  • 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 等路由不需要檢查,因為登入成功後才有

附錄

程式碼片段

ctrl+shift+p 輸入關鍵詞程式碼

定義 fun.code-snippets

{
	"函式元件": {
		"scope": "javascript,typescript,typescriptreact",
		"prefix": "fun",
		"body": [
			"export default function ${1:函式名} () {",
      "  $0",      
      "  return <></>",
			"}"
		],
		"description": "快速生成react函式式元件"
	}
}

定義 ofun.code-snippets

{
	"mobx函式元件": {
		"scope": "javascript,typescript,typescriptreact",
		"prefix": "ofun",
		"body": [
      "import { observer } from \"mobx-react-lite\"",
      "",
			"function ${1:函式名} () {",
      "  $0",      
      "  return <></>",
			"}",
      "export default observer($1)",
		],
		"description": "快速生成mobx react函式式元件"
	}
}

這樣可以在 tsx 中用快捷鍵 fun 以及 ofun 建立相應的程式碼片段