前言
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中的token
和refreshToken
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
我们完成两个切片userSlice
和permissionSlice
的编写后,就可以在页面中进行分发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转换