导读:后台管理系统,用户登录后刷新页面就掉登录态,路由守卫形同虚设,接口 401 也不处理。后来用 Pinia 把登录态做成一个 store:token 持久化到 localStorage、刷新时重新拉用户信息、路由守卫统一拦截、axios 拦截器自动带 token。一版到位,这篇把完整写法贴出来。
Vue3 + Pinia 登录态管理:token 持久化 + 路由守卫 + 用户信息刷新,一版到位
先说痛点。后台管理页,用户登录完一切正常,一刷新页面就跳回登录页;token 存内存里,localStorage没写;路由守卫里next()逻辑写反,直接死循环跳转。三个问题叠一起,登录态管理等于没有。
第一步:userStore,token + 用户信息一个地方管
Pinia 的 store 把登录态收拢:
// stores/user.jsimport{defineStore}from'pinia'exportconstuseUserStore=defineStore('user',{state:()=>({token:localStorage.getItem('token')||'',// 初始化就从 localStorage 读userInfo:JSON.parse(localStorage.getItem('userInfo')||'null')}),getters:{isLoggedIn:(state)=>!!state.token},actions:{asynclogin(loginForm){const{token,userInfo}=awaitloginApi(loginForm)this.token=tokenthis.userInfo=userInfo// 持久化:token 和用户信息都要写 localStoragelocalStorage.setItem('token',token)localStorage.setItem('userInfo',JSON.stringify(userInfo))},asyncfetchUserInfo(){// 刷新页面后重新拉用户信息,保证权限和头像是最新的constuserInfo=awaitgetUserInfoApi()this.userInfo=userInfo localStorage.setItem('userInfo',JSON.stringify(userInfo))},logout(){this.token=''this.userInfo=nulllocalStorage.removeItem('token')localStorage.removeItem('userInfo')}}})刷新丢登录态的根因:token 只存在内存里。state 初始化时直接localStorage.getItem('token')读回来,刷新就不掉了。
第二步:路由守卫,登录拦截 + 死循环防坑
// router/index.jsimport{useUserStore}from'@/stores/user'router.beforeEach((to,from,next)=>{constuserStore=useUserStore()// 白名单:登录页、注册页、404 不拦截constwhiteList=['/login','/register','/404']if(userStore.isLoggedIn){if(to.path==='/login'){next('/')// 已登录访问登录页,踢回首页}else{next()// 已登录,放行}}else{if(whiteList.includes(to.path)){next()// 未登录访问白名单,放行}else{next(`/login?redirect=${to.fullPath}`)// 未登录,跳登录并记住来源}}})死循环的坑:一开始我写的判断是"未登录且不是登录页 → next(‘/login’)",结果next('/login')又触发守卫,/login不在白名单里,再跳/login,无限循环,页面直接卡死。登录页必须进白名单,这是守卫的基本盘。
第三步:axios 拦截器,自动带 token + 401 统一处理
// request.jsservice.interceptors.request.use((config)=>{constuserStore=useUserStore()if(userStore.token){config.headers.Authorization=`Bearer${userStore.token}`}returnconfig})service.interceptors.response.use((response)=>{constres=response.dataif(res.code===401){// token 过期:清登录态,跳登录页constuserStore=useUserStore()userStore.logout()router.push('/login')returnPromise.reject(newError('登录已过期'))}returnres.code===200?res.data:Promise.reject(newError(res.message))},(error)=>Promise.reject(error))注意 Pinia 在拦截器里用必须在 store 实例化之后,useUserStore()要在函数体内调用,不能放模块顶层。
踩坑一:刷新后用户信息没了,权限菜单渲染错乱
现象:刷新页面不跳登录了,但侧边栏菜单变成"无权限",头像也空了。
排查过程:token 持久化了,用户信息没持久化——userInfo是内存对象,刷新后变 null,路由守卫只判断 token 不看用户信息,菜单组件拿不到角色权限。
定位思路:token 和用户信息要一起持久化,或者刷新后主动重新拉取。
最终解决:两个都做——state 初始化从 localStorage 读缓存,App.vue的onMounted里再fetchUserInfo()拉最新数据,保证权限实时且刷新不丢。
踩坑二:多标签页,一个页 401 全部踢下线
现象:开了三个标签页,一个接口 401,所有页面都被踢回登录页。
排查过程:401 处理里直接router.push('/login'),三个标签页的守卫同时触发,体验很糙。
定位思路:401 只清登录态 + 跳一次登录,别重复弹窗重复跳。
最终解决:加个标志位防重入:
letisRedirecting=falseif(res.code===401){userStore.logout()if(!isRedirecting){isRedirecting=truerouter.push('/login')}returnPromise.reject(newError('登录已过期'))}关于持久化的选型,别一上来就上 token 自动续期
写 token 持久化时顺手想过要不要做"自动续期":token 快过期时后台静默刷新。后来忍住了——后台管理系统用户量不大,静默刷新带来的复杂度(刷新失败、并发刷新、续期竞态)远超收益。简单方案就是 token 过期后 401 统一踢回登录页,用户重新登录一次,成本可接受。
存储位置我也纠结过:localStoragevssessionStorage。最后选了 localStorage——关浏览器重开要保持登录态,这是后台系统的默认预期;sessionStorage 关标签页就丢,反而天天要登录。安全性上 localStorage 有 XSS 读取风险,但项目里已经统一转义输出 + 内容安全策略,可接受。真要更严的场景,可以存 HttpOnly Cookie 由后端下发,前端不碰 token 只碰登录状态,那是另一套方案,小项目不必上。
可直接复用的要点
- token 和用户信息都持久化到 localStorage,state 初始化时读回来,刷新不掉线。
- 路由守卫:登录页必须进白名单,防死循环;已登录访问登录页踢回首页。
- 未登录跳登录页带上
redirect参数,登录成功后回跳原页面。 - axios 请求拦截器自动带 token,响应拦截器 401 统一清登录态跳登录。
- 401 处理加防重入标志,多标签页只跳一次。
- 刷新后
onMounted重新fetchUserInfo(),保证权限菜单和头像实时更新。