从零实现一个React+Antd5.0后台管理系统-Redux异步操作与Axios刷新token

前言

Redux基本状态管理与Axios接口请求封装完成后,我们就可以进行Redux异步操作然后根据其结果修改全局状态。在本系统的实际应用是通过登录接口缓存token信息到Redux的state中,当这个token过期的时候会返回401,在axios实例的响应拦截器中拦截到后进行刷新token的操作以此实现页面无感刷新。还有就是登录进入系统以后异步请求用户信息进行存储。接下来我们来看看具体的实现过程。

Redux异步操作

通常在Redux中需要进行异步操作的话,我们需要Redux-Thunk中间件。

Redux Thunk 是一个中间件,允许在 Redux action 中返回函数而不仅仅是纯对象。这使得我们能够在action中进行异步操作,并在操作完成后分发一个新的action。

Redux-Toolkit工具包已经内置了thunk插件,不需要再单独安装Redux-Thunk中间件,可以直接处理异步的action。接下来我们直接编写登录的异步方法。

登录的异步方法

实现异步更新用户信息

1.导入从浏览器localStorage获取以及存储token、refreshToken的方法

src/store/reducers/userSlice.js

javascript 复制代码
import { getRefreshToken, getToken, removeRefreshToken, removeToken, setRefreshToken, setToken } from '@/utils/auth'

2.导入登录和获取用户信息的接口

登录成功返回token、refreshToken

获取用户信息成功返回:用户名、头像等等

javascript 复制代码
import userApi from '@/api/user'

3.编写登录和登出的同步action。作用为 更新state中的tokenrefreshToken

scss 复制代码
...
reducers: {
    login(state, action) {
      state.token = action.payload.token
      state.refreshToken = action.payload.refreshToken
      // 将数据持久化
      setToken(state.token)
      setRefreshToken(state.refreshToken)
    },
    logout(state, action) {
      state.token = null
      state.refreshToken = null
      state.userinfo = null
      // 移除存储中的信息
      removeToken()
      removeRefreshToken()
    }
}
...

4.编写获取用户信息的异步方法。作用为更新用户状态(数据)

kotlin 复制代码
...
// 导出经过redux包装的action对象
export const { login, setUserinfo,logout } = userSlice.actions
// 导出获取登录用户信息的异步方法
export const getUserInfoAsync = () => async (dispatch) => {
  const { data } = await userApi.center.get()
  dispatch(setUserinfo({ ...data, avatar: data.avatar ? process.env.React_APP_IMG_API + '/' + data.avatar : null }))
  return data
}

process.env.React_APP_IMG_API环境变量为本人设置的访问图片的前缀,加上data.avatar即为完整的头像图片地址。可以在.env.development.env.production文件中配置,分别为开发和生产环境。本文示例开发环境地址为http://127.0.0.1:9999

5.编写登录的异步action。作用为调用登录及获取登录的用户信息的接口,并分发同步的登录方法更新token、refreshToken状态

dart 复制代码
...
// 登录异步方法
export const loginAsync = (payload) => async (dispatch) => {
  const { data } = await userApi.login.login(payload)
  dispatch(login(data))
  await dispatch(getUserInfoAsync())
}

但我们还少了一个步骤,即获取登录用户的角色后,还要对角色的菜单和按钮权限进行处理,处理成React router能识别的格式才可以正常地访问系统。对于这部分有问题的可以访问系统简介及设计。这里我们要新加一个Redux切片来处理权限【菜单、按钮】的状态。

新增Redux切片处理用户权限

我们在store下的reducers文件夹新建permissionSlice.js文件,在这里我们来处理服务器返回的权限数组。我们先来看看服务器返回的数据结构

大概就是形如如下的结构(title代表页签显示的标题、comonent为src下pages后拼接的组件地址,children为下一级导航的路由)

css 复制代码
[  {menu_id:40,title:'系统管理',component:"Layout",children:[{menu_id:xx,title:'xx'}]}
  {xxxxx,children:[xxx]}
]

而正常React-Router的路由结构如下所示

yaml 复制代码
[
  // 访问/时重定向到/home
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, element: <Navigate to="/home" replace /> },
      { path: 'home', element: <Home /> },
      {
        path: 'system',
        element: <System />,
        children: [
          { index: true, element: <Navigate to="/system/user" replace /> },
          { path: 'user', element: <User /> },
          { path: 'role', element: <Role /> },
          { path: 'auth', element: <Auth /> }
     ]
   }
 ]

我们要创建一个切片将服务器返回的数据转为如上的路由结构。但是现在的路由还全是静态的,我们得先改造一下,在React-Router的配置文件里只暴露静态路由【无需权限就可访问的路由】,在切片中再进行静态和动态路由的拼接。

改造路由

在本系统中,静态的路由只包含登陆路由和首页。同时,我们还应该将未匹配的url全部跳转404页面。具体的路由结构如下所示:

src/router/index.js

javascript 复制代码
import { lazy } from 'react'
import { Navigate } from 'react-router-dom'
// 用路由懒加载优化加载性能
const Layout = lazy(() => import('/Layout'))
const Home = lazy(() => import('@/pages/Home'))
const Login = lazy(() => import('@/pages/Login'))
const NotFound = lazy(() => import('@/pages/NotFound'))
​
const constantRoutes = [
  { path: 'login', title: '登录', element: <Login /> },
  {
    path: '/',
    title: '首页',
    hidden: true,
    element: <Layout />,
    children: [
      { index: true, element: <Navigate to={'/home'} replace /> },
      // hidden:false代表要显示在侧边导航栏,其余皆不显示
      { path: 'home', title: '首页', element: <Home />, hidden: false }
    ]
  },
  { path: '*', title: '404页面', element: <NotFound /> }
]
export default constantRoutes

创建用户权限切片

基本结构

首先我们先编写正常的切片代码,在initialState中要存储两个路由数组,一个是完整的路由数组(包括静态的路由和权限路由),一个只包含权限路由。再编写对应的reducer处理方法。

src/store/reducers/permissionSlice.js

javascript 复制代码
import { createSlice } from '@reduxjs/toolkit'
import  constantRoutes from '@/router'
const permissionSlice = createSlice({
  name: 'permission',
  initialState: {
    // routers默认为静态路由,之后拼接上服务器返回动态路由
    routes: constantRoutes,
    permissionRoutes: []
  },
  reducers: {
    setRoutes(state, action) {
      state.routes = constantRoutes.map((item) => {
      // 嵌套在layout路由children下
        if (item.path === '/') {
          return { ...item, children: item.children.concat(action.payload.routes) }
        }
        return item
      })
    },
    setPermissionRoutes(state, action) {
      state.permissionRoutes = action.payload.routes
    },
  }
})
export const { setRoutes, setPermissionRoutes } = permissionSlice.actions
export default permissionSlice

转换路由结构

我们单独写一个递归函数(默认不知道有多少层级,所以要递归)来处理服务器返回的结构。主要的作用就是将后端返回的菜单数组转换为Router Router的路由结构。

javascript 复制代码
import { lazy } from 'react'
// 得到后端路由经转换后的路由结构
function filterAsyncRoutes(routes) {
  const res = []
  // 遍历得到的路由转换为前端router结构
  routes.forEach((route) => {
   // 页面路由懒加载
    const Component = lazy(() => import(`@/pages${route.component}`))
    const tmp = {
      path: route.path,
      // 若为Layout则直接用父路由的Layout结构,否则用src/pages目录去拼接
      element: route.component === 'Layout' ? null : <Component />,
      redirect: route.redirect || undefined,
      title: route.title,
      // 将Number类型hidden转为Boolean
      hidden: !!Number(route.hidden),
      children: route.children || undefined
    }
    if (route.icon) {
      tmp.icon = route.icon
    }
    if (tmp.children && tmp.children.length) {
      tmp.children = route.redirect
        ? [{ index: true, element: <Navigate to={route.redirect} replace /> }].concat(filterAsyncRoutes(tmp.children))
        : filterAsyncRoutes(tmp.children)
      // tmp.children = filterAsyncRoutes(tmp.children)
    }
    res.push(tmp)
  })
  return res
}

编写异步方法

scss 复制代码
export const generateRoutes = (payload) => (dispatch) => {
  const accessedRoutes = filterAsyncRoutes(payload)
  // 分发全局路由状态(静态 + 动态)
  dispatch(setRoutes({ routes: accessedRoutes }))
  // 分发动态路由
  dispatch(setPermissionRoutes({ routes: accessedRoutes }))
  return accessedRoutes
}

添加进store

src/store/index.js

php 复制代码
...
import permissionSlice from './reducers/permissionSlice'
// 创建store对象
const store = configureStore({
  reducer: {
    user: userSlice.reducer,
    permission: permissionSlice.reducer
  }
})
...

修改userSlice登录的异步方法

src/reducers/userSlice.js

javascript 复制代码
// 导入加载用户路由的方法
import { generateRoutes } from './permissionSlice'
...
// 登录异步方法
export const loginAsync = (payload) => async (dispatch) => {
  const { data } = await userApi.login.login(payload)
  dispatch(login(data))
+  const userinfo = await dispatch(getUserInfoAsync())
+  dispatch(generateRoutes(userinfo.menus))
}
...

修改入口组件App.jsx

我们完成两个切片userSlicepermissionSlice的编写后,就可以在页面中进行分发action进行全局状态的存储与使用。

大致修改流程如下:

1.判断浏览器localStorage是否存在token

  • 存在,则获取当前用户的信息,并加载当前用户的路由
  • 不存在,则跳到登录页面让其登录,登录后再执行上一步操作

2.编写Loading组件,这里用到了Antd提供的Spin组件

由于使用了React的路由动态加载lazy,其需要在React.Suspense组件下进行渲染,Suspense 又支持传入 fallback 属性,作为动态加载模块完成前组件渲染的内容。

src/components/Loading/index.jsx

javascript 复制代码
import { Spin } from 'antd'
import './Loading.scss'
export default function Loading(props) {
  let { loadingText = '数据加载中...' } = props
​
  return (
    <div className="Loading">
      <Spin size="large" />
      <h3 className="loadingText">{loadingText}</h3>
    </div>
  )
}

src/components/Loading/Loading.scss

css 复制代码
.Loading{
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  background: rgba(0, 0, 0, 0.05);
  .loadingText{
    color: #408fff;
  }
}

3.根组件App修改如下

src/App.jsx

javascript 复制代码
import React, { Suspense, useEffect } from 'react'
// 导入路由及react-redux钩子
import { useNavigate, useRoutes } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
// 导入api
import { getUserInfoAsync } from './store/reducers/userSlice'
import { generateRoutes } from './store/reducers/permissionSlice'
import { getToken } from './utils/auth'
// 导入loading组件
import Loading from '@/components/Loading'
​
export default function App() {
  // redux hook
  const dispatch = useDispatch()
  const routes = useSelector((state) => state.permission.routes)
  // 跳转方法
  const navigate = useNavigate()
​
  useEffect(() => {
    const fetchData = async () => {
      if (getToken()) {
        const userInfo = await dispatch(getUserInfoAsync())
        dispatch(generateRoutes(userInfo.menus))
      } else {
        navigate('/login', { replace: true })
      }
    }
    fetchData()
  }, [dispatch])
  // 利用hook转换路由表
  const element = useRoutes(routes)
  return (
    <>
      <Suspense fallback={<Loading />}>{routes && element}</Suspense>
    </>
  )
}

测试

由于我们登录页面还没实现,我们先通过接口测试工具简单测试一下(这里以postman为例)

获取验证码

注意,这步需要安装好Redis!然后参考从零实现一个React+Antd5.0后台管理系统-接口请求封装所需准备中的接口文档输入请求url和请求参数

登录

命令行获取验证码

测试

用刚刚自定义的uuid和默认账号:Alan 密码:123456(需要添加账号可以用登录获取的token放在请求头中去添加)

然后把获取到的token放到浏览器的localStorage中

刷新即可进入首页,点击系统管理重定向至用户管理的路由

但因为我们在转化后端路由的时候把element设为组件了,是个Symbol类型的数据,所以没有通过redux 的数据序列化检查,我们在store中添加中间件配置把它关掉即可。

src/store/index.js

php 复制代码
...
const store = configureStore({
  reducer: {
    user: userSlice.reducer,
    permission: permissionSlice.reducer
  },
+  middleware: (getDefaultMiddleware) =>
+    getDefaultMiddleware({
+      //关闭redux序列化检测
+      serializableCheck: false
    })
})
...

到这里我们的测试就算成功了,完成了Redux异步操作用户信息。但是还有一点,我们存储在localStorage 中的token传到后端检测后会存在过期返回状态码401 的现象,所以我们在axios拦截器拦截到这个状态码的时候要刷新一下token,并重新载入因过期未执行的接口。

Axios响应拦截器中刷新token

现在响应拦截器中无非就两种情况,当状态码为0的时候表示响应成功正常返回数据,当不为0的时候判断是否为401,如果是的话就刷新token并执行未响应队列。

src/utils/request.js

javascript 复制代码
import store from '../store'
import { setToken, setRefreshToken } from '../utils/auth'
import { login } from '../store/reducers/userSlice'
import { logout } from '@/store/reducers/userSlice'
​
...
​
// 是否正在刷新的标记
let isRefreshing = false
// 重试队列,每一项将是一个待执行的函数形式
let requests = []
// 添加响应拦截器
instance.interceptors.response.use(
  (response) => {
    // 如果返回的类型为二进制文件类型
    if (response.config.responseType === 'blob') {
      if (response.status !== 200) {
        message.error('请求失败' + response.status)
        return Promise.reject()
      } else if (!response.headers['content-disposition']) {
        message.error('暂无接口访问权限')
        return Promise.reject()
      }
      return response
    } else {
      // 如果接口请求失败
      if (response.data.code !== 0) {
        let errMsg = response.data.message || '系统错误'
        // token过期
        if (response.data.code === 401) {
          const config = response.config
          // token失效,判断请求状态
          if (!isRefreshing) {
            isRefreshing = true
            // 刷新token
            return Axios({
              url: 'http://127.0.0.1:9999/user/refreshToken',
              method: 'POST',
              data: { refreshToken: store.getState().user.refreshToken }
            })
              .then((res) => {
                // 刷新token成功,更新最新token
                const { token, refreshToken } = res.data.data
                store.dispatch(login({ token, refreshToken }))
                setToken(token)
                setRefreshToken(refreshToken)
                //已经刷新了token,将所有队列中的请求进行重试
                requests.forEach((cb) => {
                  cb(token)
                })
                // 重试完了别忘了清空这个队列
                requests = []
                return instance({
                  ...config,
                  headers: {
                    ...config.headers,
                    Authorization: token
                  }
                })
              })
              .catch((e) => {
                store.dispatch(logout())
                // 重置token失败,跳转登录页
                console.log(e.message)
                window.location.href = '/login'
              })
              .finally(() => {
                isRefreshing = false
              })
          } else {
            // 返回未执行 resolve 的 Promise
            return new Promise((resolve) => {
              // 用函数形式将 resolve 存入,等待刷新后再执行
              requests.push((token) => {
                config.baseURL = '/api'
                config.headers && (config.headers['Authorization'] = token)
                resolve(
                  instance({
                    ...config,
                    headers: {
                      ...config.headers,
                      Authorization: token
                    }
                  })
                )
              })
            })
          }
        } else {
          message.error(errMsg)
        }
        return Promise.reject(errMsg)
      }
      return response.data
    }
  },
  (error) => {
    return Promise.reject(error)
  }
)

然后,不出意外的话就会报错,原因是我们循环引用了(在切片中我们异步代码用的是axios封装的接口,然后我们又在axios封装文件中将store引入造成了循环引用)

要解决也很简单,我在入口文件里面引用不就好了,在axios我就抛出一个方法执行响应拦截器的方法。

src/utils/request.js

javascript 复制代码
import store from '../store'
import { setToken, setRefreshToken } from '../utils/auth'
import { login } from '../store/reducers/userSlice'
import { logout } from '@/store/reducers/userSlice'
export function setResponseInterceptor(store, login, logout) {
  // 填写上面的响应拦截器代码
  ...
}

src/index.js

javascript 复制代码
...
import store from './store'
import { login, logout } from './store/reducers/userSlice'
// 导入axios的响应拦截器方法
import { setResponseInterceptor } from './utils/request'
// 设置axios的响应拦截器
setResponseInterceptor(store, login, logout)
...

这样就设置成功,也能够进行token的刷新操作。

总结

本篇文章我们完成了Redux异步操作Axios响应拦截器刷新token。主要涉及到的知识有

1.在切片文件中导出异步函数,并在组件中导入后进行dispatch分发

2.如何进行路由动态加载(懒加载),并用React.Suspense包裹

3.如何在axios响应拦截器中刷新token

4.用户路由如何动态生成路由表,并用useRoutes hook转换

相关推荐
PleaSure乐事12 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro
getaxiosluo12 小时前
react jsx基本语法,脚手架,父子传参,refs等详解
前端·vue.js·react.js·前端框架·hook·jsx
新星_13 小时前
函数组件 hook--useContext
react.js
阿伟来咯~14 小时前
记录学习react的一些内容
javascript·学习·react.js
吕彬-前端14 小时前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱14 小时前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
bysking15 小时前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
September_ning20 小时前
React.lazy() 懒加载
前端·react.js·前端框架
web行路人20 小时前
React中类组件和函数组件的理解和区别
前端·javascript·react.js·前端框架
番茄小酱00120 小时前
Expo|ReactNative 中实现扫描二维码功能
javascript·react native·react.js