Vue3后台管理系统(三):状态管理与Pinia集成
一、从Vuex到Pinia的转变
1.1 为什么选择Pinia?
在Vue2中,Vuex是官方推荐的状态管理库。但随着Vue3的发布,Pinia作为下一代的状态管理工具出现了,并且已经成为了Vue官方推荐的状态管理解决方案。Pinia相比Vuex有以下优势:
- 更简洁的API:没有mutations,只有state、getters和actions
- 完整的TypeScript支持:自动享受到类型推断
- 去除命名空间:更加扁平化的设计,减少模块嵌套
- 更好的代码拆分:每个store可以独立导入和使用
- 极轻的体积:约1KB的大小
- 支持多个Store实例:可以创建多个独立的状态管理实例
1.2 基本概念对比
Vuex | Pinia | 说明 |
---|---|---|
state | state | 存储状态 |
getters | getters | 计算属性 |
mutations | - | Pinia中直接在actions中修改state |
actions | actions | 异步操作 |
modules | store文件 | 每个store文件相当于一个module |
二、Pinia的基本配置
javascript
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import autofit from 'autofit.js'
import 'element-plus/dist/index.css'
import './styles/index.scss'
import App from './App.vue'
import router from './router'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import { setupPermissionDirective } from './directives/permission'
import { useLayoutStore } from '@/stores/layout'
// 应用实例
const app = createApp(App)
// Pinia 实例
const pinia = createPinia()
// 安装 Pinia
app.use(pinia)
// 其他配置
// ...
// 监听窗口大小变化,更新scale值
window.addEventListener('resize', () => {
const layoutStore = useLayoutStore()
layoutStore.setScale(autofit.scale)
})
// 页面挂载
app.mount('#app')
三、Store模块设计
在后台管理系统中,我设计了几个核心的Store模块:
- Auth Store: 用户认证和权限管理
- Layout Store: 布局设置和视图调整
- Loading Store: 全局加载状态管理
3.1 Auth Store - 用户认证与权限
javascript
/**
* @description 用户状态管理
* @Author Wangkaibing
* @Date 2025-04-18
*
* Usage:
* import { useAuthStore } from '@/stores/auth'
*
* // 在组件中使用
* const authStore = useAuthStore()
*
* // 访问状态
* console.log(authStore.state.userInfo) // 用户信息
* console.log(authStore.state.roles) // 用户角色列表
* console.log(authStore.isAuthenticated) // 是否已登录
* console.log(authStore.hasRoles) // 是否有角色信息
*
* // 修改状态
* authStore.setUserInfo({ // 设置用户信息
* fullName: '张三',
* mobile: '13800138000',
* roles: [{ authority: 'ROLE_ADMIN' }]
* })
*
* // 权限检查
* if (authStore.hasPermission('SOME_PERMISSION')) {
* // 有权限时的逻辑
* }
*
* // 清除状态
* authStore.reset() // 清除所有状态(退出时使用)
*/
import { defineStore } from 'pinia' // Pinia状态管理库核心API
import { ref, computed } from 'vue' // Vue3响应式API
import { TokenService } from '@/utils/token' // 自定义Token管理服务
export const useAuthStore = defineStore('auth', () => {
// 使用ref创建响应式状态对象
const state = ref({
userInfo: null, // 用户基础信息
roles: [], // 用户角色列表
permissions: [], // 用户权限列表
})
// 使用单一state对象而非多个ref
// 1. 便于统一管理所有用户相关状态
// 2. 调试工具中可以清晰看到完整状态结构
// 3. 方便一次性重置所有状态
// 是否已登录 - 基于token判断的计算属性
const isAuthenticated = computed(() => TokenService.isAuthenticated())
// 使用计算属性代替状态变量
// 1. 登录状态依赖于token是否存在,通过计算属性可保持同步
// 2. 避免状态不一致问题,计算属性总是返回最新结果
// 3. 无需手动维护登录状态,减少出错可能
// 是否有角色信息 - 用于判断是否已加载用户信息
const hasRoles = computed(() => state.value.roles && state.value.roles.length > 0)
// 设置用户信息并提取关键数据
const setUserInfo = (userInfo) => {
if (!userInfo) return // 避免设置空值
state.value.userInfo = userInfo
// 提取角色信息 - 将嵌套结构转换为扁平数组
if (userInfo.roles && Array.isArray(userInfo.roles)) {
state.value.roles = userInfo.roles.map((item) => item.authority)
}
// 提取权限信息
state.value.permissions = userInfo.permissions || []
}
// 清除用户信息
const clearUserInfo = () => {
state.value.userInfo = {}
state.value.roles = []
state.value.permissions = []
}
// 重置所有状态 - 用于退出登录
const reset = () => {
TokenService.clearTokens() // 先清除存储的token
clearUserInfo() // 再清除内存中的状态
}
// 权限检查方法
const hasPermission = (permission) => {
if (!state.value.permissions || state.value.permissions.length === 0) {
return false // 没有权限列表时直接返回false
}
if (Array.isArray(permission)) {
// 如果传入权限数组,只要满足其中一个权限即可
return permission.some((p) => state.value.permissions.includes(p))
// 使用Array.some实现"或"逻辑
// 灵活支持多种权限检查场景
}
// 检查单个权限
return state.value.permissions.includes(permission)
}
return {
state, // 状态对象
isAuthenticated, // 登录状态
hasRoles, // 是否有角色信息
setUserInfo, // 设置用户信息
clearUserInfo, // 清除用户信息
hasPermission, // 权限检查
reset, // 重置状态
}
})
这个Auth Store处理了:
- 用户认证状态管理
- 用户信息和角色权限的存储
- 权限检查逻辑
- token管理(通过TokenService)
3.2 Loading Store - 全局加载状态
javascript
/**
* @description loading状态
* @Author Wangkaibing
* @Date 2025-04-18
* Usage:
* import { storeToRefs } from 'pinia'
* import { useLoadingStore } from '@/stores/loading'
*
* // 在组件中使用
* const { loading } = storeToRefs(useLoadingStore())
*
* // 在模板中使用
* <div v-loading="loading">...</div>
*
* // 手动控制loading
* const loadingStore = useLoadingStore()
* loadingStore.showLoading() // 显示loading
* loadingStore.hideLoading() // 隐藏loading
*
* // 在请求中自动(已在request.js中集成)
* useGet('/api/data') // 自动显示loading
* useGet('/api/data', {}, { loading: false }) // 禁用loading
*/
import { defineStore } from 'pinia' // Pinia状态管理库
import { ref } from 'vue' // Vue3响应式API
export const useLoadingStore = defineStore('loading', () => {
// 使用ref创建响应式状态,默认为false(不加载)
const loading = ref(false)
// 简单状态管理
// 1. 对于简单状态,直接使用ref而不是复杂对象
// 2. 布尔值足以表示loading状态,无需额外字段
// 显示loading状态
const showLoading = () => {
loading.value = true
}
// 隐藏loading状态
const hideLoading = () => {
loading.value = false
}
return {
loading, // 导出状态,供组件使用
showLoading, // 显示loading的方法
hideLoading, // 隐藏loading的方法
}
})
Loading Store主要用于:
- 全局loading状态控制
- 与请求库集成,自动处理加载状态
- 可以被任何组件订阅,实现统一的加载提示
3.3 Layout Store - 布局设置
javascript
/**
* @description 记录页面的缩放比例
* @Author Wangkaibing
* @Date 2025-04-15
* Usage:
* import { useLayoutStore } from '@/stores/layout'
* 根据scale自动计算是否收起
* const isCollapse = computed(() => layoutStore.scale < 0.75)
*/
import { defineStore } from 'pinia' // Pinia状态管理库
import { ref } from 'vue' // Vue3响应式API
export const useLayoutStore = defineStore('layout', () => {
// 页面缩放比例 - 默认为1(原始大小)
const scale = ref(1)
// 使用ref存储简单数值
// 1. 对于简单数据类型,直接使用ref而非reactive
// 2. 缩放比例作为全局共享状态,便于不同组件访问
// 设置缩放比例
const setScale = (value) => {
scale.value = value
// 提供setter方法
// 1. 虽然可以直接修改scale.value,但封装成方法更规范
}
return {
scale, // 当前缩放比例
setScale, // 设置缩放比例的方法
}
})
Layout Store负责管理应用的布局状态:
- 页面缩放比例管理:根据不同屏幕尺寸计算缩放比例
- 控制侧边栏的展开和收起:通过计算属性结合缩放比例自动控制
- 适应不同设备:确保应用在各种尺寸屏幕上都能良好展示
四、组合式API与Pinia的结合
4.1 在组合式API中使用Store
vue
<script setup>
import { useAuthStore } from '@/stores/auth' // 引入Auth Store
import { useLoadingStore } from '@/stores/loading' // 引入Loading Store
import { storeToRefs } from 'pinia' // 引入storeToRefs辅助函数,用于保持响应式
// 获取Auth Store实例
const authStore = useAuthStore()
// 从Loading Store中解构loading状态,并保持响应式
const { loading } = storeToRefs(useLoadingStore())
// 使用storeToRefs进行解构
// 1. 直接解构store会失去响应性,storeToRefs可以保持响应式
// 2. 只提取需要的状态,减少不必要的依赖
// 3. 解构后可以直接在模板中使用,无需前缀
// 使用Auth Store的方法
const handleLogout = () => {
authStore.reset() // 调用store方法重置状态
router.push('/login') // 跳转到登录页
// 复用store提供的方法
// 封装常用操作,代码更简洁,逻辑更清晰
}
// 权限检查,使用计算属性结合store方法
const canDelete = computed(() => {
return authStore.hasPermission('user:delete')
// 基于store状态的计算属性
// 1. 自动跟踪依赖并在权限变化时重新计算
// 2. 结合Vue3的响应式系统,优雅处理权限判断
// 3. 可用于控制UI元素的显示/隐藏
})
</script>
使用storeToRefs
可以在解构store时保持响应式
4.2 创建自定义组合式函数
javascript
// src/composables/useUser.js
/**
* @description 用户相关业务逻辑hook
* @Author Wangkaibing
* @Date 2025-04-18
*
* Usage:
* import { useUser } from '@/composables/useUser'
*
* // 在组件中使用
* const { getUserInfo, userInfo, hasPermission } = useUser()
*
* // 获取用户信息
* await getUserInfo()
*
* // 访问用户信息
* console.log(userInfo.value.fullName)
* console.log(userInfo.value.mobile)
* console.log(userInfo.value.roles)
*
* // 检查权限
* if (hasPermission('SOME_PERMISSION')) {
* // 有权限时的逻辑
* }
*/
import { useGet } from '@/api/request'
import { useAuthStore } from '@/stores/auth'
export function useUser() {
const authStore = useAuthStore()
/**
* 获取用户信息
* @returns {Promise<Object>} 用户信息
*/
const getUserInfo = async () => {
try {
const res = await useGet('/api/xxxxxxx')
if (!res?.success) {
throw new Error('获取用户信息失败')
}
authStore.setUserInfo(res.data)
return res.data
} catch (error) {
console.log('🚀 ~ [error]', error)
throw error
}
}
return {
getUserInfo,
// 导出store中的一些常用方法,方便使用
hasPermission: authStore.hasPermission,
isAuthenticated: authStore.isAuthenticated,
hasRoles: authStore.hasRoles,
userInfo: authStore.state.userInfo,
}
}
五、持久化与状态恢复
5.1 Token持久化
javascript
// src/utils/token.js
/**
* @description Token服务类
* @Author Wangkaibing
* @Date 2025-05-06
*
* Usage:
* import { TokenService } from '@/utils/token'
*
* // 获取token
* TokenService.getToken()
*
* // 设置token
* TokenService.setTokens(accessToken, refreshToken, expiresIn)
*
* // 清除token
* TokenService.clearTokens()
*/
import { setCookie, getCookie, removeCookie } from '@/utils/cookie'
export class TokenService {
static TOKEN_KEY = 'xxxx'
static REFRESH_TOKEN_KEY = 'xxxxx'
static getToken() {
return getCookie(this.TOKEN_KEY)
}
static getRefreshToken() {
return getCookie(this.REFRESH_TOKEN_KEY)
}
static setTokens(accessToken, refreshToken = '', expiresIn = 7200) {
if (accessToken) {
setCookie(this.TOKEN_KEY, accessToken, expiresIn)
}
if (refreshToken) {
setCookie(this.REFRESH_TOKEN_KEY, refreshToken, expiresIn)
}
}
static clearTokens() {
removeCookie(this.TOKEN_KEY)
removeCookie(this.REFRESH_TOKEN_KEY)
}
static isAuthenticated() {
return !(!this.getToken() && !this.getRefreshToken())
}
}
5.2 用户状态恢复
在应用初始化时,我们可以自动恢复用户状态:
javascript
// 在路由守卫中恢复用户状态
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore() // 获取认证状态管理
const { getUserInfo } = useUser() // 获取用户信息相关方法
// 如果已登录但没有用户信息,尝试获取
if (authStore.isAuthenticated && !authStore.hasRoles) {
try {
await getUserInfo() // 异步获取并更新用户信息
// 按需加载用户信息
// 1. 只在必要时请求用户信息,减少不必要的网络请求
// 2. 使用计算属性hasRoles判断是否需要加载,避免重复请求
} catch (error) {
// 获取失败,可能是token过期
authStore.reset() // 重置认证状态
next('/login') // 跳转到登录页
return
// 错误处理与恢复
// 1. 捕获并处理可能的错误,防止应用崩溃
// 2. 遇到认证错误时清除无效状态并重定向
// 3. return提前结束函数,避免执行后面的next()
}
}
next() // 继续导航流程
// 异步路由守卫
// 1. 路由守卫支持异步操作,等待getUserInfo完成
// 2. 在合适的条件下调用next()继续导航
// 3. 结合Pinia和组合式API实现优雅的状态恢复
})
六、与网络请求的集成
6.1 自动处理全局loading
javascript
// src/api/request.js
/**
* @description API请求封装
* @Author Wangkaibing
* @Date 2025-04-22
*
* Usage:
* import { useGet, usePost, usePut } from '@/api/request'
*
* // GET请求
* const getData = () => useGet('/api/data', {
* page: 1,
* size: 10
* }, {
* loading: false, // 是否显示loading
* meta: { noAuth: true } // 是否需要token
* })
*
* // POST请求 (需要token认证)
* const createData = () => usePost('/api/data', {
* name: '示例',
* type: 1
* })
*
* // PUT请求 (需要token认证)
* const updateData = () => usePut('/api/data/1', {
* name: '更新'
* })
*
* // 登录请求 (不需要token认证)
* const login = () => usePost('/api/login', loginData, {
* meta: { noAuth: true }
* })
*
* // 在组件中使用
* const { loading, data, error } = getData()
*/
import { createAlova } from 'alova'
import VueHook from 'alova/vue'
import adapterFetch from 'alova/fetch'
import { baseConfig, whiteList } from './config'
import { showLoading, hideLoading } from './loading'
import { showError } from './error'
import { onAuthRequired, onResponseRefreshToken } from './auth'
// 创建 alova 实例
const alovaInstance = createAlova({
...baseConfig,
statesHook: VueHook,
requestAdapter: adapterFetch(),
// 请求拦截
beforeRequest: (method) => {
// 检查是否是白名单接口
const url = method.url.replace(baseConfig.baseURL, '')
if (whiteList.includes(url)) {
method.meta = { ...method.meta, noAuth: true }
}
// 调用认证拦截器
onAuthRequired((method) => {
// 如果配置了 loading: false 或 是接口缓存请求,则不显示 loading
if (method.config.loading !== false && !method.config?.cacheKey) {
showLoading()
}
})(method)
},
// 响应拦截
responded: onResponseRefreshToken({
onSuccess: async (response) => {
try {
// 检查响应内容类型和长度
const contentType = response.headers.get('content-type');
const contentLength = response.headers.get('content-length');
let responseData = null;
// 只有当内容类型包含json且内容长度不为0时才尝试解析JSON
if (contentType && contentType.includes('application/json') && (!contentLength || parseInt(contentLength) > 0)) {
// 使用clone()来克隆response,避免"body stream already read"错误
responseData = await response.clone().json();
}
// HTTP错误和业务错误
if (!response.ok || (responseData && responseData.success === false)) {
const urlPath = new URL(response.url).pathname
const error = {
status: response.status,
data: responseData,
message: responseData?.message || (!response.ok ? '请求失败' : '操作失败'),
url: urlPath,
}
showError(error)
return Promise.reject(error)
}
return responseData
} catch (err) {
console.log('🚀 ~ [err]', err)
showError({ status: response.status })
return Promise.reject(err)
}
},
onError: (error) => {
console.log('🚀 ~ [error]', error)
// 如果是401错误,让auth拦截器处理
if (error.status === 401) {
return Promise.reject(error)
}
let urlPath = ''
try {
if (error.request?.url) {
urlPath = new URL(error.request.url).pathname
}
} catch (e) {}
const errorObj = {
status: error.status || 500,
message: error.message || '网络请求失败',
url: urlPath,
}
showError(errorObj)
return Promise.reject(errorObj)
},
onComplete: () => {
hideLoading()
},
}),
})
// 导出请求方法
export const useGet = (url, params = {}, config = {}) => {
return alovaInstance.Get(url, {
params,
...config,
headers: {
...config.headers,
},
meta: {
...config.meta,
},
})
}
export const usePost = (url, data = {}, config = {}) => {
return alovaInstance.Post(url, data, {
...config,
headers: {
...config.headers,
},
meta: {
...config.meta,
},
})
}
6.2 错误处理与自动登出
javascript
// src/api/auth.js
/**
* @description Token认证拦截器
* @Author Wangkaibing
* @Date 2025-04-22
*/
import { createClientTokenAuthentication } from 'alova/client'
import { ElNotification } from 'element-plus'
import { TokenService } from '@/utils/token'
import { createAlova } from 'alova'
import VueHook from 'alova/vue'
import adapterFetch from 'alova/fetch'
import { baseConfig } from '@/api/config'
// 创建专用的alova实例用于刷新token
const alovaInstance = createAlova({
baseURL: baseConfig.baseURL,
statesHook: VueHook,
requestAdapter: adapterFetch(),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
},
})
// 刷新token方法
export const refreshTokenRequest = () => {
const refreshToken = TokenService.getRefreshToken()
if (!refreshToken) {
throw new Error('未找到token')
}
const method = alovaInstance.Post('/api/oauth/access_token', {
grant_type: 'refresh_token',
refresh_token: refreshToken,
})
method.meta = {
noAuth: true, // 不需要token验证的请求
}
return method
}
// 创建客户端token认证拦截器
export const { onAuthRequired, onResponseRefreshToken } = createClientTokenAuthentication({
assignToken: (method) => {
const token = TokenService.getToken()
if (token) {
method.config.headers.Authorization = token
}
},
// 刷新token配置
refreshToken: {
// 判断token是否过期
isExpired: (method, error) => {
// 如果是白名单接口不判断过期
if (method.meta?.noAuth) {
return false
}
// 检查是否是401错误
if (error?.status === 401) {
return true
}
const refreshToken = TokenService.getRefreshToken()
// 检查refresh token是否存在
if (!refreshToken) {
TokenService.clearTokens()
window.location.href = '/login'
return false
}
// 检查access token是否存在
const accessToken = TokenService.getToken()
if (!accessToken) {
return true
}
return false
},
// 刷新token的处理函数
handler: async () => {
try {
const response = await refreshTokenRequest().send()
const responseData = await response.json()
if (!response.ok || !responseData?.data) {
throw new Error('Token刷新失败')
}
const { token_type, access_token, refresh_token, expires_in } = responseData
if (!access_token || !refresh_token) {
throw new Error('无效令token刷新响应')
}
// 更新token
TokenService.setTokens(token_type ? token_type + ' ' + access_token : access_token, refresh_token, expires_in * 1000)
window.location.reload()
return responseData
} catch (err) {
ElNotification({
title: '登录失败',
message: '登录已过期,请重新登录',
type: 'error',
})
console.error('Token刷新失败:', err)
// 刷新失败,清除token并跳转到登录页
TokenService.clearTokens()
window.location.href = '/login'
throw err
}
},
},
})