整体架构篇

更新时间: 2023-03-25 12:32:54

# 定义路由

# 安装

现在界面光秃秃的不好看,而且只有一页,我们先安装一下react router,老规矩,这是react router的文档 (opens new window),一定要多看文档,一定要多看文档,一定要多看文档,重要的事情说三遍。

npm install react-router-dom -S
1

# 定义路由

react router有两种路由,一种是HashRouter,另一种是BrowserRouter,其中HashRouter并不需要服务端做配置,而是路由中带一个#键,由前端来转发,所以我们选用HashRouter

  • 在App.tsx中引入HashRouter,并让HashRouter包裹在最外层

 



 

 





// app.tsx
import { HashRouter } from "react-router-dom"

const App = () => {
  return (
    <HashRouter>
      App
    </HashRouter>
  )
}

export default App
1
2
3
4
5
6
7
8
9
10
11
12
  • 新建views文件夹,定义login.tsx和home.tsx两个页面

  • 新建routers文件夹,里面定义路由相关的东西,首先创建 index.tsx,我们使用useRoute钩子

import {useRoutes} from "react-router-dom"

import Login from "@/views/login/index"
import Home from "@/views/home/index"

export const rootRouter = [
    {
        path:"/",
        element: <Home></Home>
    },
    {
        path:'/login',
        element: <Login></Login>
    }
]

const Router = () => {
    const routes = useRoutes(rootRouter)

    return routes
}

export default Router
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  • 在App.tsx中引入Router

 




 






import { HashRouter } from "react-router-dom"
import Router from "@/routers/index"

const App = () => {
  return (
    <HashRouter>
      <Router />
    </HashRouter>
  )
}

export default App
1
2
3
4
5
6
7
8
9
10
11
12

访问地址http://localhost:5000/,页面渲染出刚刚定义的home.tsx的内容
访问地址http://localhost:5000/#/login,页面渲染出刚刚定义的login.tsx的内容

# 安装antd

因为后面会用到antd中的一些组件,所以安装antd,antd官网 (opens new window)

npm install antd -S
1

然后在main.tsx中引入样式文件

import "antd/dist/reset.css"
1

后面需要使用antd组件的时候再按需加载即可

# 路由懒加载

随着页面越来越多,一次性加载所有页面资源非常的占用时间,有没有一种办法,可以只加载当然要访问的那个页面呢?

在react官方文档上其实给出了解决方案,React.Lazy (opens new window)
但是懒加载会花上一段时间,在这段时间内会有白屏的现象,可以将懒加载的组件用Suspense标签包裹起来,Suspensefallback属性是组件没有加载出来时显示的内容。

  • 在routers下新建 utils/lazyLoad.tsx
  • lazyLoad接收React.lazy()加载的组件,加载过程中显示antd的Spin组件
import React, { Suspense } from 'react';
import { Spin } from 'antd';

/**
 * @description 路由懒加载
 * @param 需要访问的组件
 * @returns 
 */
const lazyLoad = (Comp:React.LazyExoticComponent<() => JSX.Element>) => { 
  return (
    <>
      <Suspense fallback={
        <Spin
            size="large"
            style={{
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                height: "100%"
            }}
        />
      }>
        <Comp />
      </Suspense>
    </>
  );
}

export default lazyLoad
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
  • 在router/index.tsx中将路由中加载的组件改写为懒加载方式

 
 




 



 











import {useRoutes} from "react-router-dom"
import lazyLoad from "./utils/lazyLoad"
import React from "react"

export const rootRouter = [
    {
        path:"/",
        element: lazyLoad(React.lazy(() => import("@/views/home")))
    },
    {
        path:'/login',
        element: lazyLoad(React.lazy(() => import("@/views/login")))
    }
]

const Router = () => {
    const routes = useRoutes(rootRouter)

    return routes
}

export default Router
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

切换页面地址,可以看到对应组件被加载了

# 登录界面

登录界面比较简陋,简单用antd的Form组件写个登录界面

  // login.tsx
  import LoginForm from "./components/loginForm"
  import logo from "@/assets/images/react.svg"
  import "./index.less"

  const Login = () => {
      return (
          <div className="login-container">
              <div className="login-box">
                  <div className="login-form">
                      <div className="login-logo">
                          <img className="login-icon" src={logo} alt="logo" />
                          <span className="logo-text">My-React-Admin</span>
                      </div>
                      <LoginForm />
                  </div>
              </div>
          </div>
      )
  }

  export default Login
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

定义一下登录界面的ts类型, 登录界面表单一共有两个字段,username和passord,它们都是string类型:

// types/login.ts
//  * 登录
export namespace Login {
    export interface ReqLoginForm {
        username: string;
        password: string;
    }
}
1
2
3
4
5
6
7
8
  // login/components/loginForm.tsx
  import {Button, Form, Input} from "antd"
  import { UserOutlined, LockOutlined, CloseCircleOutlined } from "@ant-design/icons";
  import { useState } from "react";
  import { Login } from "@/types/login";

  const LoginForm = () => {
      const [loading, setLoading] = useState<boolean>(false)
      const [form] = Form.useForm()

      //login
      const onFinish = async (loginForm: Login.ReqLoginForm) => {
          console.log(loginForm)
      }

      const onFinishFailed = (errorInfo: any) => {
          console.log("Failed", errorInfo)
      }

      return (
          <Form
              form={form}
              name="basic"
              labelCol={{span:5}}
              initialValues={{remenber: true}}
              size="large"
              onFinish={onFinish}
        onFinishFailed={onFinishFailed}
              autoComplete="off"
          >
              <Form.Item name="username" rules={[{required:true, message:"请输入用户名"}]}>
                  <Input placeholder="用户名:admin/user" prefix={<UserOutlined />}/>
              </Form.Item>
              <Form.Item name="password" rules={[{required:true, message:'请输入密码'}]}>
                  <Input.Password autoComplete="new-password" placeholder="密码:123456" prefix={<LockOutlined />}/>
              </Form.Item>
              <Form.Item className="login-btn">
                  <Button
                      onClick={() => {
                          form.resetFields()
                      }}
                      icon={<CloseCircleOutlined />}
                  >
                      重置
                  </Button>
                  <Button type="primary" loading={loading} htmlType="submit" icon={<UserOutlined />}>登录</Button>
              </Form.Item>
          </Form>
      )
  }

  export default LoginForm
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
51
52

登录界面现在的样子如下:
输入用户名和密码点击登录,控制台会打印出刚刚的输入:

# 使用easy mock模拟登录接口

使用easy mock来造一个简单的登录接口,Easy Mock (opens new window),可以去看一下基本的使用方法,还是很简单的。 创建post方式的登录接口如下,其实就是自己写个假的

{
  code: function({
    _req,
    Mock
  }) {
    if ((_req.body.username == 'admin' || _req.body.username == 'user') && _req.body.password == 'e10adc3949ba59abbe56e057f20f883e') {
      return 200
    } else {
      return 500
    }
  },
  msg: function({
    _req,
    Mock
  }) {
    if ((_req.body.username == 'admin' || _req.body.username == 'user') && _req.body.password == 'e10adc3949ba59abbe56e057f20f883e') {
      return '登录成功'
    } else {
      return '密码错误'
    }
  },
  data: function({
    _req,
    Mock
  }) {
    if (_req.body.username == 'admin' && _req.body.password == 'e10adc3949ba59abbe56e057f20f883e') {
      return {
        access_token: "bqddxxwqmfncffacvbpkuxvwvqrhln"
      }
    } else if (_req.body.username == 'user' && _req.body.password == 'e10adc3949ba59abbe56e057f20f883e') {
      return {
        access_token: "unufvdotdqxuzfbdygovfmsbftlvbn"
      }
    } else {
      return null
    }
  }
}
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

# 使用redux

Redux (opens new window)是应用的状态管理容器,对标vue的vuex,redux官方推荐使用redux toolkit,先安装一下:

npm install @reduxjs/toolkit react-redux -S
1

# store设计

redux toolkit 可以使用 createSlice 来创建store的切片,然后通过combineReducers再将它们组装起来。 先创建一个切片专门存放token和用户信息。

// redux/modules/global.ts
 import { GlobalState } from "@/types/redux";
 import { createSlice } from "@reduxjs/toolkit"

 const globalState: GlobalState = {
     token:"",
     userInfo:{
         username:""
     }
 }

 const globalSlice = createSlice({
     name: "global",
     initialState: globalState,
     reducers: {
         setToken(state: GlobalState, {payload}) {
             state.token = payload
         },
         setUserInfo(state: GlobalState, {payload}) {
             state.userInfo = payload
         }
     }
 })

 export const {setToken, setUserInfo} = globalSlice.actions
 export default globalSlice.reducer
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

在types/redux.ts中定义一下GlobalState的类型:

export interface UserInfo {
    username:string;
}

/* GlobalState */
export interface GlobalState {
    token:string;
    userInfo:UserInfo;
}

/* State */
export interface State {
    global: GlobalState
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

然后在redux/index.ts中创建store:

import { configureStore } from "@reduxjs/toolkit";
import global from "./modules/global";
import { combineReducers } from "@reduxjs/toolkit";

// combineReducers合并reducer
const reducers = combineReducers({
    global
})

export const store = configureStore({
    reducer: reducers
})
1
2
3
4
5
6
7
8
9
10
11
12

store创建完,需要在main.ts中使用Provider来包裹住需要使用store的组件:






 
 


 

 



import React from 'react'
import ReactDOM from 'react-dom/client'
import App from '@/App'
import "@/styles/reset.less"
import "antd/dist/reset.css"
import { Provider } from 'react-redux'
import { store } from "@/redux"

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
    <Provider store={store}>
        <App />
    </Provider>
)

1
2
3
4
5
6
7
8
9
10
11
12
13
14

# redux-persist持久化

redux-persist 主要用于帮助我们实现redux的状态持久化 所谓状态持久化就是将状态与本地存储联系起来,达到刷新或者关闭重新打开后依然能得到保存的状态。 先安装redux-persist,这个插件配合redux-toolkit使用时,需要redux-thunk

npm install redux-persist redux-thunk -S
1

安装完毕后改造一下redux/index.ts,创建persistedReducer,并且使用reduxThunk




 
 
 






 
 
 
 

 


 
 


 

import { configureStore } from "@reduxjs/toolkit";
import global from "./modules/global";
import { combineReducers } from "@reduxjs/toolkit";
import storage from "redux-persist/lib/storage";
import { persistReducer, persistStore } from "redux-persist"; //数据持久化
import reduxThunk from "redux-thunk"

// combineReducers合并reducer
const reducers = combineReducers({
    global
})

const presistConfig = {
    key:'react-admin',
    storage
}

const persistedReducer = persistReducer(presistConfig, reducers)

export const store = configureStore({
    reducer: persistedReducer,
    middleware: [reduxThunk]
})

export const persistor = persistStore(store)
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

在main.tsx中使用PersistGate标签包裹App组件:







 
 



 

 



import React from 'react'
import ReactDOM from 'react-dom/client'
import App from '@/App'
import "@/styles/reset.less"
import "antd/dist/reset.css"
import { Provider } from 'react-redux'
import { store, persistor } from "@/redux"
import { PersistGate } from 'redux-persist/integration/react'

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
    <Provider store={store}>
        <PersistGate persistor={persistor}>
            <App />
        </PersistGate>
    </Provider>
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 测试redux

在home.tsx中,简单写点代码测试一下store,点击按钮修改token:

 
 



 
 


 












import { useDispatch, useSelector } from "react-redux"
import { setToken } from "@/redux/modules/global"
import { State } from "@/types/redux"

const Home = () => {
    const dispatch = useDispatch()
    const {token} = useSelector((state:State) => state.global)

    const handleClick = () => {
        dispatch(setToken('123456789'))
    }

    return (
        <div>
            {token}
            <button onClick={handleClick}>修改token</button>
        </div>
    )
}

export default Home
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

使用useSelector钩子来获取state.global中的token。 setToken是global.ts中导出的修改token的reducers方法,传入dispath方法中来修改token。 点击按钮后,token成功变为123456789。

在控制台中可以看到数据持久化也是成功的。

# 封装axios

先安装axios和nprogress

npm install axios nprogress qs -S
npm install @types/nprogress @types/qs -D
1
2

# 定义枚举

// enum/httpEnum.ts
// * 请求枚举配置
/**
 * @description: 请求配置
 */
export enum ResultEnum {
    SUCCESS = 200,
    ERROR = 500,
    OVERDUE = 599,
    TIMEOUT = 10000,
    TYPE = "success"
}
1
2
3
4
5
6
7
8
9
10
11
12

# 定义判断类型的工具函数

// utils/is.ts
const toString = Object.prototype.toString

/**
 * @description: 判断值是否为某个类型
 */
export function is(val: unknown, type: string) {
    return toString.call(val) === `[object ${type}]`
}

/**
 * @description: 是否为函数
 */
export function isFunction<T = Function>(val: unknown): val is T {
    return is(val, "Function")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 定义response返回类型

// types/api.ts
// * 请求响应参数(不包含data)
export interface Result {
    code: string;
    msg: string;
}

// * 请求响应参数(包含data)
export interface ResultData<T = any> extends Result {
    data?:T;
}
1
2
3
4
5
6
7
8
9
10
11

# 配置nprogress

// api/helper/nprogress.ts
import NProgress from "nprogress"
import "nprogress/nprogress.css"

NProgress.configure({
    easing: "ease", //动画方式
    speed: 500, // 递增进度条的速度
    showSpinner: true, // 是否显示加载ico
    trickleSpeed: 200, // 自动递增间隔
    minimum: 0.3 //初始化时的最小百分比
})

export default NProgress
1
2
3
4
5
6
7
8
9
10
11
12
13

# 封装错误状态判断方法

// api/helper/checkStatus.ts
import {message} from "antd"

/**
 * @description: 校验网络请求状态码
 * @param {Number} status
 * @return void
 */
export const checkStatus = (status: number): void => {
	switch (status) {
		case 400:
			message.error("请求失败!请您稍后重试");
			break;
		case 401:
			message.error("登录失效!请您重新登录");
			break;
		case 403:
			message.error("当前账号无权限访问!");
			break;
		case 404:
			message.error("你所访问的资源不存在!");
			break;
		case 405:
			message.error("请求方式错误!请您稍后重试");
			break;
		case 408:
			message.error("请求超时!请您稍后重试");
			break;
		case 500:
			message.error("服务异常!");
			break;
		case 502:
			message.error("网关错误!");
			break;
		case 503:
			message.error("服务不可用!");
			break;
		case 504:
			message.error("网关超时!");
			break;
		default:
			message.error("请求失败!");
	}
};
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

# 封装取消请求方法

// api/helper/axiosCancel.ts
import axios, {AxiosRequestConfig, Canceler} from "axios"
import {isFunction} from "@/utils/is"
import qs from "qs"

// * 声明一个Map用于存储每个请求的标识和取消函数
let pendingMap = new Map<string, Canceler>()

// * 序列化参数
export const getPendingUrl = (config: AxiosRequestConfig) => 
    [config.method,config.url,qs.stringify(config.data), qs.stringify(config.params)].join("&")

export class AxiosCanceler {
    /**
     * @description: 添加请求
     * @param {Object} config
     */
    addPending(config: AxiosRequestConfig) {
        // * 在请求开始前,对之前的请求做检查取消操作
        this.removePending(config)
        const url = getPendingUrl(config)
        config.cancelToken = 
            config.cancelToken || new axios.CancelToken(cancel => {
                if(!pendingMap.has(url)) {
                    // 如果pending中不存在当前请求,则添加进去
                    pendingMap.set(url, cancel)
                }
            })
    }

    /**
     * @description: 移除请求
     * @param {Object} config
     */
    removePending(config: AxiosRequestConfig) {
        const url = getPendingUrl(config)

        if(pendingMap.has(url)) {
            //如果在pending中存在当前请求标识,需要取消当前请求,并且移除
            const cancel = pendingMap.get(url)
            cancel && cancel()
            pendingMap.delete(url)
        }
    }

    /**
     * @description: 清空所有pending
     */
    removeAllPending() {
        pendingMap.forEach(cancel => {
            cancel && isFunction(cancel) && cancel()
        })
        pendingMap.clear()
    }

    /**
     * @description: 重置
     */
    reset():void {
        pendingMap = new Map<string, Canceler>()
    }
}
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
51
52
53
54
55
56
57
58
59
60
61
62

# 封装axios

import NProgress from "./helper/nprogress";
import axios, {AxiosInstance, AxiosRequestConfig, AxiosError} from "axios"
import { store } from "@/redux"
import { setToken } from "@/redux/modules/global"
import { ResultEnum } from "@/enums/httpEnum"
import { message } from "antd"
import { checkStatus } from "./helper/checkStatus"
import { ResultData } from "@/types/api"
import { AxiosCanceler } from "./helper/axiosCancel"

const axiosCanceler = new AxiosCanceler()

const config = {
    //默认地址请求地址,可在.env开头文件中修改
    baseURL: import.meta.env.VITE_API_URL as string,
    // 设置超时时间(10s)
    timeout: ResultEnum.TIMEOUT as number,
    // 跨域时允许携带凭证
    widthCredentials: true
}

class RequestHttp {
    service: AxiosInstance;
    constructor(config: AxiosRequestConfig) {
        // 实例化axios
        this.service = axios.create(config)

        /**
         * @description 请求拦截器
         * 客户端发送请求 -> [请求拦截器] -> 服务器
         * token校验(JWT): 接受服务器返回的token,存储到redux/本地存储当中
         */
        this.service.interceptors.request.use(
            (config) => {
                NProgress.start()
                // * 将当前请求添加到 pending 中
                axiosCanceler.addPending(config)
                const token:string = store.getState().global.token
                config.headers["x-access-token"] = token
                return config
            },
            (error: AxiosError) => {
                return Promise.reject(error)
            }
        )

        /**
         * @description 响应拦截器
         * 服务器返回信息 -> [拦截统一处理] -> 客户端JS获取到信息
         */
        this.service.interceptors.response.use(
            (response) => {
                const {data, config} = response
                NProgress.done()
                // * 在请求结束后,移除本次请求(关闭loading)
                axiosCanceler.removePending(config)
                // * 登录失败(code == 599)
                if (data.code == ResultEnum.OVERDUE) {
                    store.dispatch(setToken(""))
                    message.error(data.msg)
                    window.location.hash = "/login"
                    return Promise.reject(data)
                }
                // * 全局错误信息拦截(防止下载文件的时候返回数据流,没有code,直接报错)
                if(data.code && data.code !== ResultEnum.SUCCESS) {
                    message.error(data.msg)
                    return Promise.reject(data)
                }
                // * 请求成功(在页面上除非特殊情况,否则不用处理失败逻辑)
                return data;
            },
            (error: AxiosError) => {
                const {response} = error
                NProgress.done()
                // 请求超时单独判断,请求超时没有response
                if(error.message.indexOf("timeout") !== -1) {
                    message.error("请求超时,请稍后再试")
                }
                // 根据响应的错误状态码, 做不同的处理
                if(response) {
                    checkStatus(response.status)
                }
                // 服务器结果都没有返回(可能服务器错误可能客户端断网) 断网处理:可以跳转到断网页面
                if(!window.navigator.onLine) {
                    window.location.hash = "/500"
                }
                return Promise.reject(error)
            }
        )
    }

    // * 常用请求方法封装
    get<T>(url:string,params?:object,_object = {}): Promise<ResultData<T>> {
        return this.service.get(url, {...params, ..._object})
    }
    post<T>(url:string,params?:object,_object = {}): Promise<ResultData<T>> {
        return this.service.post(url, {...params, ..._object})
    }
    put<T>(url:string,params?:object,_object = {}): Promise<ResultData<T>> {
        return this.service.put(url, {...params, ..._object})
    }
    delete<T>(url:string,params?:object,_object = {}): Promise<ResultData<T>> {
        return this.service.delete(url, {...params, ..._object})
    }
}

export default new RequestHttp(config)
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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107

# 封装全局Loading

封装一个全局的Loading条,首先封装一下组件

// components/Loading/index
import {Spin} from "antd"
import "./index.less"

const Loading = ({tip = "Loading"}: {tip?: string}) => {
    return <Spin tip={tip} size="large" className="request-loading"></Spin>
}

export default Loading
1
2
3
4
5
6
7
8
9

然后定义一下显示和隐藏loading的方法

// api/helper/serviceLoading.tsx
import ReactDOM from "react-dom/client"
import Loading from "@/components/Loading/index"

let needLoadingRequestCount = 0

// * 显示loading
export const showFullScreenLoading = () => {
    if(needLoadingRequestCount === 0) {
        let dom = document.createElement("div")
        dom.setAttribute("id", "loading")
        document.body.appendChild(dom)
        ReactDOM.createRoot(dom).render(<Loading />)
    }
    needLoadingRequestCount++
}

// * 隐藏loading
export const tryHideFullScreenLoading = () => {
    if(needLoadingRequestCount <= 0) return
    needLoadingRequestCount--
    if(needLoadingRequestCount == 0) {
        document.body.removeChild(document.getElementById("loading") as HTMLElement)
    }
}
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

然后在封装的RequestHttp类中使用:


 












 












 




 









//...
import { showFullScreenLoading, tryHideFullScreenLoading } from "./helper/serviceLoading"
//...

const axiosCanceler = new AxiosCanceler()

//...
class RequestHttp {
    service: AxiosInstance;
    constructor(config: AxiosRequestConfig) {
        //...
        this.service.interceptors.request.use(
            (config) => {
               //...
                config?.headers!.noLoading || showFullScreenLoading()
                //...
            },
            //...
        )

        /**
         * @description 响应拦截器
         * 服务器返回信息 -> [拦截统一处理] -> 客户端JS获取到信息
         */
        this.service.interceptors.response.use(
            (response) => {
                //...
                tryHideFullScreenLoading()
               //...
            },
            (error: AxiosError) => {
                //...
                tryHideFullScreenLoading()
                //...
            }
        )
    }
  //...
}

export default new RequestHttp(config)
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

刚刚新增的文件比较多,现在项目的目录结构如下:

# 对接登录

做了这么多,终于可以对接之前的登录接口了,需要做以下几件事

  1. 封装登录api
    新建/api/modules/login.ts,调用刚刚封装好的HttpRequest类中的post方法:
  import {Login} from "@/types/login"
  import http from "@/api"

  /**
  * @name 登录模块
  */
  // 用户登录接口
  export const loginApi = (params: Login.ReqLoginForm) => {
      return http.post<Login.ResLogin>(`/login`, params)
  }
1
2
3
4
5
6
7
8
9
10

补全一下Login命名空间下登录接口返回的类型








 
 
 


  //  * 登录
  export namespace Login {
      export interface ReqLoginForm {
          username: string;
          password: string;
      }

      export interface ResLogin {
          access_token: string;
      }
  }
1
2
3
4
5
6
7
8
9
10
11
  1. 在登录页面调用
  2. 登录完保存用户信息以及token 登录接口上传的密码用md5加过密,因此安装一下 js-md5
npm install js-md5 -S
npm install @types/js-md5 -D
1
2

然后在 views/login/components/loginForm.tsx中调用





 
 
 
 
 
 
 

































import {Button, Form, Input} from "antd"
import { UserOutlined, LockOutlined, CloseCircleOutlined } from "@ant-design/icons";
import { useState } from "react";
import { Login } from "@/types/login";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import md5 from "js-md5"
import { loginApi } from "@/api/modules/login";
import { setToken, setUserInfo } from "@/redux/modules/global";
// HOME_URL就是'/',我们在编写代码的时候要尽量少写重复的字符串
import { HOME_URL } from "@/utils/config"; 

const LoginForm = () => {
    const dispatch = useDispatch()
    const navigate = useNavigate()
    const [loading, setLoading] = useState<boolean>(false)
    const [form] = Form.useForm()

    //login
    const onFinish = async (loginForm: Login.ReqLoginForm) => {
        try { // 使用try catch来捕获代码块里所有的抛出异常
            // 将登录按钮的Loading改为true
            setLoading(true)
            // 密码使用md5加密
            loginForm.password = md5(loginForm.password)
            // 调用登录Api
            const {data} = await loginApi(loginForm)
            // token存入store
            dispatch(setToken(data?.access_token))
            // 存入userInfo
            dispatch(setUserInfo({userName: loginForm.username}))
            // 跳转到home主页
            navigate(HOME_URL)
        } catch(e) {
            console.log(e)
        } finally {
            // Loading置为false
            setLoading(false)
        }
    }

    //...后面的省略

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

登录成功后看一下token和用户信息已经保存成功了。

# 导航守卫

刚刚登录成功保存了token,新的需求就来了,在我们没有token的时候应该是禁止访问主页的,应该直接跳转回登录界面。
新建 routers/utils/authRouter.tsx,AuthRouter组件包裹在Router外,当token和pathname发生变化时,判断有没有token,如果没有就跳转到登录页面。

// routers/utils/authRouter.tsx
import {useLocation,useNavigate} from "react-router-dom"
import { useSelector } from "react-redux"
import { useEffect } from "react"
import { State } from "@/types/redux"

const AuthRouter = (props:any) => {
    const { token } = useSelector((state:State) => state.global)

    const { pathname } = useLocation()
    const navigate = useNavigate()

    useEffect(() => {
        !token && navigate('/login')
    },[token, pathname])

    return props.children
}

export default AuthRouter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20



 




 

 







// App.tsx
import { HashRouter } from "react-router-dom"
import Router from "@/routers/index"
import AuthRouter from "./routers/utils/authRouter"

const App = () => {
  return (
    <HashRouter>
      <AuthRouter>
        <Router />
      </AuthRouter>
    </HashRouter>
  )
}

export default App

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# Layout

我们要做的后台管理系统,除了登录界面,几乎所有的其他界面布局都类似,拥有一个头部信息栏,以及左侧导航栏,这部分布局没必要重复写多次,因此我们可以写一个通用的布局

# Layout布局

Layout主要由侧边菜单,头部,中间内页部分,页脚组成,每个组件我们先简单写点东西占位

  // layouts/index.tsx
  import { Layout } from "antd"
  import { Outlet } from "react-router-dom"
  import LayoutMenu from "./components/Menu"
  import LayoutHeader from "./components/Header"
  import LayoutFooter from "./components/Footer"
  import "./index.less"

  const LayoutIndex = () => {
      const {Sider, Content} = Layout

      return (
          <section className="container">
              <Sider trigger={null} collapsed={false} width={220} theme="dark">
                  <LayoutMenu></LayoutMenu>
              </Sider>
              <Layout>
                  <LayoutHeader></LayoutHeader>
                  <Content>
                      <div className="content">
                          <Outlet />
                      </div>
                  </Content>
                  <LayoutFooter />
              </Layout>
          </section>
      )
  }

  export default LayoutIndex
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

# 关于二级路由以及Outlet

将home改造成二级路由并且单独抽出,以后方便渲染侧边栏。

// router/routerList.tsx
  import Layout from "@/layouts/index"
  import Home from "@/views/home"

  export const routerList = [
      {
          element: <Layout />,
          children: [
              {
                  path:"/",
                  element: <Home />,
                  meta: {
                      reqiureAuth: true,
                      title: "首页",
                      key: "home",
                      icon:'HomeOutlined'
                  }
              }
          ]
      }
  ]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21




 






 










// routers/index.tsx
  import {useRoutes} from "react-router-dom"
  import lazyLoad from "./utils/lazyLoad"
  import React from "react"
  import { routerList } from "./routerList"

  export const rootRouter = [
      {
          path:'/login',
          element: lazyLoad(React.lazy(() => import("@/views/login")))
      },
      ...routerList
  ]

  const Router = () => {
      const routes = useRoutes(rootRouter)

      return routes
  }

  export default Router
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

在父路由中的Outlet组件可以用来加载子路由,再次进入首页,现在首页的效果如下:

# 侧边栏

现在的侧边栏是我复制的antd官方的例子,实际上我是想让侧边栏显示出路由配置的页面。因此我们随意新增几条路由,这是一个三层嵌套的路由

// routers/routerList.tsx
 import Layout from "@/layouts/index"
 import Home from "@/views/home"

 export const routerList = [
     {
         element: <Layout />,
         children: [
             {
                 path:"/",
                 element: <Home />,
                 meta: {
                     reqiureAuth: true,
                     title: "首页",
                     key: "home",
                     icon:'HomeOutlined'
                 }
             }
         ]
     },
     {
         element: <Layout />,
         path:'/test',
         meta: {
             title:"测试菜单",
             icon:'DatabaseOutlined'
         },
         children: [
             {
                 path:"/test/test1",
                 element: <div>测试1</div>,
                 meta: {
                     reqiureAuth: true,
                     title: "测试菜单1",
                     key: "test1",
                     icon:'AppstoreOutlined'
                 }
             },
             {
                 path:"/test/test2",
                 element: <Outlet />,
                 meta: {
                     reqiureAuth: true,
                     title: "测试菜单2",
                     key: "test2",
                     icon:'AppstoreOutlined'
                 },
                 children:[{
                     path:"/test/test2/test3",
                     element: <div>测试3</div>,
                     meta: {
                         reqiureAuth: true,
                         title: "测试菜单3",
                         key: "test3",
                         icon:'AppstoreOutlined'
                     },
                 }]
             }
         ]
     }
 ]
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
51
52
53
54
55
56
57
58
59
60
61

# 根据路由表循环出侧边栏数据

首先应该从上面的路由列表作为原始数据,生成正确的数据结构。 先定义一下接口类型:

// types/menu.ts
 export interface Meta {
     title?:string; //路由名称
     icon?:string; //图标
     key?:string; //唯一标识
     isLink?:string; //是否为外链
     [itemName:string]:any; //还可以根据需要自己增减
 }

 // 原始router类型
 export interface RouterItem {
     element: JSX.Element; // 组件
     path?:string; // 路由地址
     meta?: Meta;
     children?: RouterItem[] // 子路由
 }

 // Menu数据类型
 export interface MenuOptions {
     path: string; // 菜单一定要有path
     title: string;  // 菜单一定要有title
     icon?: string;
     isLink?: string;
     close?: boolean;
     children?: MenuOptions[]
 }

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

然后使用递归获取正确的侧边栏数据:

// utils/menu.ts
import { MenuOptions, RouterItem } from "@/types/menu"

 /**
  * @description 通过路由表生成侧边栏数据
  * @param {RouterItem[]} 原始路由表
  * @return {Menu.MenuOptions[]}
  */
 export const generateMenuData = (routerList: RouterItem[]):any[] => {
     const menuList:MenuOptions[] = []
     routerList.forEach(item => {
         // 如果没有子菜单,直接显示该菜单
         if(!item.children || item.children.length == 0 ) {
             // 有path和title的才可以被添加进来
             if(item.path && item?.meta?.title) {
                 menuList.push({
                     path: item.path,
                     title: item.meta.title,
                     icon: item?.meta?.icon,
                     isLink: item?.meta?.isLink
                 })
             }
         }else{
             // 如果子路由长度为1,并且该路由没有title,就直接将子路由往上提一级
             if(item?.children?.length == 1 && !item?.meta?.title){
                 menuList.push(...generateMenuData(item.children))
             }else{
                 // 有path和title的才可以被添加进来
                 if(item.path && item?.meta?.title) {
                     menuList.push({
                         path: item.path,
                         title: item.meta.title,
                         icon: item?.meta?.icon,
                         isLink: item?.meta?.isLink,
                         children: generateMenuData(item.children)
                     })
                 }
             }
         }
     })
     return menuList
 }
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

生成了侧边栏数据后,这个数据还不能直接被用到antd中,还要被转换一次:

// layouts/components/Menu/index.tsx
// ....
 const LayoutMenu = () => {
 type MenuItem = Required<MenuProps>['items'][number];

 function getItem(
     label: React.ReactNode,
     key: React.Key,
     icon?: React.ReactNode,
     children?: MenuItem[],
     type?: 'group',
     ): MenuItem {
     return {
         key,
         icon,
         children,
         label,
         type,
     } as MenuItem;
     }
     
     // 将icon字符串转换为ReactIcon
     const customIcons: {[key:string]: any} = Icons

     const addIcon = (name:string|undefined) => {
     return name ? React.createElement(customIcons[name]) : null
     }

     // 将menuList处理为antd所需的格式
     const deepLoopFloat = (menuList: MenuOptions[], newArr:MenuItem[] = []) => {
     menuList.forEach((item: MenuOptions) => {
         if (!item?.children?.length) {
             newArr.push(getItem(item.title, item.path, addIcon(item.icon)))
         } else {
             newArr.push(getItem(item.title, item.path, addIcon(item.icon), deepLoopFloat(item.children)))
         }
     })
     return newArr
     }

     // 生成menuList的数据
     const [menuList, setMenuList] = useState<MenuProps['items']>([])
     const getMenuList = () => {
     const data:MenuOptions[] = generateMenuData(routerList)
         setMenuList(deepLoopFloat(data))
     }

     useEffect(() => {
         getMenuList()
     },[])
     
     return (
     <div className="menu">
         <Menu
             theme="dark"
             mode="inline"
             items={menuList}
         />
     </div>
     );
 }

 export default LayoutMenu
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
51
52
53
54
55
56
57
58
59
60
61
62
63

现在侧边栏就显示出来了

# 点击跳转链接

给Menu组件绑定onClick方法,并根据点击的key来跳转

 /**
  * @description 递归查询对应的路由
  * @param {String} path 当前访问地址
  * @param {Array} routes 路由列表
  * @return array
  */
 export const searchRoute = (path:string, menuData: MenuOptions[] = []): any => {
     let result = {}
     for(let item of menuData) {
         if(item.path === path) {
             return item
         }
         if(item.children) {
             const res = searchRoute(path, item.children)
             if (Object.keys(res).length) {
                 result = res
             }
         }
     }
     return result
 }

// 点击当前菜单跳转页面
 const navigate = useNavigate()
 const clickMenu:MenuProps['onClick'] = ({key}) => {
   const route = searchRoute(key, menuData)
   if(route.isLink) {
       window.open(route.isLink, "_blank")
   }else{
       navigate(key)
   }
 }
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

# 如何判断当前高亮菜单和展开菜单

原理就是利用useLocation钩子,来获取当前页面的信息,打印一下useLocation(),

因此我们应该用pathname字段来判断高亮的是哪个菜单。

假设当前的pathname为'/test/test2/test3',那么需要被展开的菜单应该是 '/test','/test/test2',

/**
  * @description 获取需要展开的subMenu
  * @param {String} path 当前访问地址
  * @return array
  */
 export const getOpenKeys = (path:string) => {
     let arr = path.split("/")
     let openArr = []
     let openString = ''
     for(let i = 0; i < arr.length - 1; i++) {
         if(arr[i]){
             openString += `/${arr[i]}`
             openArr.push(openString)
         }
     }
     return openArr
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 展开收起侧边栏

展开收起侧边栏原理是将isCollapse存入store中,然后监听window的resize事件,当屏幕宽度小于1200时,收起侧边栏,屏幕宽度大于1200时,展开侧边栏。 我们来改造一下layouts/index.tsx










 







 
 
 
 
 
 
 
 
 

 
 
 
 
 
 
 



 

















 import { Layout } from "antd"
 import { Outlet } from "react-router-dom"
 import LayoutMenu from "./components/Menu"
 import LayoutHeader from "./components/Header"
 import LayoutFooter from "./components/Footer"
 import "./index.less"
 import { useDispatch, useSelector } from "react-redux"
 import { State } from "@/types/redux"
 import { useEffect } from "react"
 import { updateCollapse } from "@/redux/modules/menu"

 const LayoutIndex = () => {
     const dispatch = useDispatch()
     const {Sider, Content} = Layout
     const {isCollapse} = useSelector((state:State) => state.menu)

     // 监听窗口大小变化
     const listeningWindow = () => {
         let screenWidth = document.body.clientWidth;
         if(!isCollapse && screenWidth < 1200) {
             dispatch(updateCollapse(true))
         }
         if(isCollapse && screenWidth > 1200) {
             dispatch(updateCollapse(false))
         }
     }

     useEffect(() => {
         window.addEventListener('resize', listeningWindow)
         // 清除副作用
         return () => {
             window.removeEventListener('resize', listeningWindow)
         }
     },[isCollapse])

     return (
         <section className="container">
             <Sider trigger={null} collapsed={isCollapse} width={220} theme="dark">
                 <LayoutMenu></LayoutMenu>
             </Sider>
             <Layout>
                 <LayoutHeader></LayoutHeader>
                 <Content>
                     <div className="content">
                         <Outlet />
                     </div>
                 </Content>
                 <LayoutFooter />
             </Layout>
         </section>
     )
 }

 export default LayoutIndex
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
51
52
53
54

# 面包屑导航

一般的网站都有个面包屑导航,我们使用存在redux中的menuData递归找到所有的path,并使用递归,给每个path找到对应的面包屑的数据。

/**
  * @description 递归当前路由的所有关联路由,生成面包屑导航
  * @param {String} path 当前访问地址
  * @param {Array} menuList 菜单列表
  * @return array
  */
 export const getBreadcrumbList = (path: string, menuList: MenuOptions[]) => {
     let tempPath: MenuOptions[] = []
     try {
         const getNodePath = (node: MenuOptions) => {
             tempPath.push(node);
             // 找到符合条件的节点,通过throw终止掉递归
             if (node.path === path) {
                 throw new Error("GOT IT!");
             }
             if (node.children && node.children.length > 0) {
                 for (let i = 0; i < node.children.length; i++) {
                     getNodePath(node.children[i]);
                 }
                 // 当前节点的子节点遍历完依旧没找到,则删除路径中的该节点
                 tempPath.pop();
             } else {
                 // 找到叶子节点时,删除路径当中的该叶子节点
                 tempPath.pop();
             }
         };
         for (let i = 0; i < menuList.length; i++) {
             getNodePath(menuList[i]);
         }
     } catch (e) {
         return tempPath.map(item => item.title);
     }
 }

 /**
  * @description 双重递归 找出所有 面包屑 生成对象存到 redux 中,就不用每次都去递归查找了
  * @param {String} menuList 当前菜单列表
  * @return object
  */
 export const findAllBreadcrumb = (menuList:MenuOptions[]) : {[key: string]:string[]|undefined} => {
     let handleBreadcrumbList: {[key: string]:string[]|undefined} = {}
     const loop = (menuItem: MenuOptions) => {
         if(menuItem?.children?.length) {
             menuItem.children.forEach(item => loop(item))
         }else{
             handleBreadcrumbList[menuItem.path] = getBreadcrumbList(menuItem.path, menuList)
         }
     }
     menuList.forEach(item => loop(item))
     return handleBreadcrumbList
 }
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
51

可能这样比较难理解,我们看一下得到的handleBreadcrumbList是什么,就清楚我在做什么了。

只需要用页面useLocation钩子中的pathname作为键就能快速得到面包屑导航的数据了。

# 个人信息窗口弹出,以及如何暴露方法给父组件


这是写好的个人信息下拉菜单,现在希望点击个人信息的时候能弹出个人信息的窗口来,我希望个人信息组件能够向外暴露出一个打开窗口的方法。 react中其实有解决办法:

参考上图,使用useImperativeHandle Hooks,封装一个个人信息组件:

 import { Modal } from "antd"
 import { useImperativeHandle, useState, forwardRef, Ref, ForwardedRef } from "react"

 export interface InfoRef {
     showModal():void
 }

 const InfoModal = (props:{},ref:ForwardedRef<InfoRef>) => {

     const [modalVisible, setModalVisible] = useState(false)

     const showModal = () => {
         setModalVisible(true)
     }

     useImperativeHandle(
         ref,
         () => ({
             showModal() {
                 showModal()
             }
         }),
         []
     );

     const handleOk = () => {
         setModalVisible(false)
     }

     const handleCancel = () => {
         setModalVisible(false)
     }

     return (
         <Modal
             title="个人信息"
             open={modalVisible}
             onOk={handleOk}
             onCancel={handleCancel}
             destroyOnClose={true}
         >
             <p>个人信息:不过暂时没有咯</p>
             <p>你们就随便看看就行了</p>
             <p>好了别看了,知道我长得帅了</p>
         </Modal>
     )
 }

 export default forwardRef(InfoModal)
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

然后在父组件中使用ref就可以获取到InfoModal:

const infoRef = useRef<InfoRef>(null)

// 调用 infoRef.current?.showModal()就可以打开弹窗
// infoRef.current?.showModal()

<InfoModal ref={infoRef} />
1
2
3
4
5
6


打开成功啦

# tabs


简单说一下这个tab菜单的原理吧:

  • 使用useLocation获取到pathname
  • 用useEffect监听pathname,打开了新页面就往tabs列表里加一项
    import { useDispatch, useSelector } from "react-redux"
    import { setTabsList } from "@/redux/modules/tabs"
    import { useLocation, useNavigate } from "react-router-dom"
    import { searchRoute } from "@/utils/menu"
    import React, { useEffect, useState } from "react"
    import { Tabs } from "antd"
    import { HOME_URL } from "@/utils/config"
    import "./index.less"
    import { State } from "@/types/redux"
    import { MenuOptions } from "@/types/menu"

    const LayoutTabs = () => {
        const dispatch = useDispatch()
        const { tabsList } = useSelector((state:State) => state.tabs)
        const { menuData } = useSelector((state:State) => state.menu)
        const { pathname } = useLocation()
        const navigate = useNavigate()
        const [activeValue, setActiveValue] = useState(pathname)

        useEffect(() => {
            addTabs()
        }, [pathname])

        const addTabs = () => {
            const route = searchRoute(pathname, menuData)
            let newTabsList = JSON.parse(JSON.stringify(tabsList))
            if(tabsList.every((item:MenuOptions) => item.path !== route.path) && (route.path !== HOME_URL)) {
                newTabsList.push({title: route?.title, path: route.path})
            }
            dispatch(setTabsList(newTabsList))
            setActiveValue(pathname)
        }

        const delTabs = (path: React.MouseEvent | React.KeyboardEvent | string) => {
            if(pathname === path) {
                let index = tabsList.findIndex((item:MenuOptions) => item.path === path)
                if(tabsList.length > 1) {
                    if(tabsList[index + 1]) {
                        navigate(tabsList[index + 1].path)
                    }else{
                        navigate(tabsList[index - 1].path)
                    }
                }else{
                    navigate(HOME_URL)
                }
            }
            dispatch(setTabsList(tabsList.filter((item:MenuOptions) => item.path !== path)))
        }

        const clickTabs = (path:string) => {
            navigate(path)
        }

        return (
            <div className="tabs">
                <Tabs
                    activeKey={activeValue}
                    onChange={clickTabs}
                    hideAdd
                    type="editable-card"
                    onEdit={delTabs}
                    items={
                    [
                        {
                            label:'首页',
                            key:HOME_URL,
                            closable: false
                        },
                        ...tabsList.map((item:MenuOptions) => {
                            return {
                                label: item.title,
                                key: item.path
                            }
                        })
                    ]
                }/>
            </div>
        )
    }

    export default LayoutTabs
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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81