Skip to content
目录

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 基本概念对比

VuexPinia说明
statestate存储状态
gettersgetters计算属性
mutations-Pinia中直接在actions中修改state
actionsactions异步操作
modulesstore文件每个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模块:

  1. Auth Store: 用户认证和权限管理
  2. Layout Store: 布局设置和视图调整
  3. 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
			}
		},
	},
})

Released under the MIT License.