1. 为什么每个 Vue3 项目都得面对登录状态持久化
做过前端的同学应该都有过这种体验:辛辛苦苦把登录页做完,接口也通了,跳转也正常了,结果自己按了一下 F5,心里瞬间一凉——又回到登录页了。更尴尬的是,明明刚才还在操作的用户数据,刷新一下全没了,服务端那边明明也存着会话,但前端就是不知道“我是谁”了。
这个问题听起来很基础,但在真实项目里,登录状态持久化几乎决定了整个应用的用户体验下限。你想,一个管理系统,用户登进去之后慢慢看报表、填表单,切出去开个会,回来一刷新被踢回登录页,脾气好一点的骂两句,脾气不好的可能就再也不用你们的产品了。我曾经接手过一个内部后台项目,用户吐槽最多的就是“刷新一次就要重新登录一次”,技术负责人自己都受不了,最后全员排查发现不是后端的问题,就是前端压根没做状态恢复。
这篇东西我打算把这件事讲透。核心围绕三块:第一,Token 到底是个什么东西,为什么登录之后就一定要带上它;第二,Vue3 里怎么靠 Pinia 加 localStorage 这一套组合拳把登录状态稳稳地持久化;第三,实际操作中会遇到哪些坑,以及我实测下来比较稳的解决方案。适合谁看呢?刚入门 Vue3 没多久、正在做后台管理系统或中后台应用的人,还有那种“登录功能会写但刷新就丢”的初级前端。当然,如果你已经在项目里用 Pinia 了,想看看还有哪些细节容易踩,也可以直接跳到第 5 章和第 6 章。
2. Token 到底是什么:先把这个基础概念掰开揉碎
2.1 从“我是谁”的问题说起
HTTP 协议本身是“无状态”的,什么意思呢?就是每次请求之间,服务端默认不知道你是不是同一个人。你第一次请求输入了用户名密码,第二次请求带着数据过去,服务端如果什么都不记,它就会问一句:你是谁?
传统解决办法是 Session,也就是在服务端存储一份用户登录信息,然后给客户端发一个 Session ID 的 Cookie,浏览器每次自动带上它。服务端拿这个 ID 去查一下自己存的数据,就知道是谁了。这种方式有效,但有一个比较现实的问题:如果服务端要做集群、要扩容,每个服务器都得能查到这份 Session 数据,不然请求被负载均衡到另一台机器上就“不认识你”了。于是就有了集中式 Session 存储,比如 Redis。但这又增加了架构复杂度。
Token 的思路不一样。它不要求在服务端存一份“用户会话档案”,而是把用户身份信息做签名之后直接发给客户端。之后客户端每次请求都把它带回来,服务端验一下签名,验一下有效期,就完事了。这个过程等价于:你把东西存到保险柜里,但钥匙直接给了客户,钥匙本身有防伪标记,任何时候拿回来都能当场验证。
2.2 JWT 的三段式结构
现在市面上绝大多数项目提到的 Token,其实是 JWT(JSON Web Token)。它由三部分组成,中间用点号 . 分隔,格式大概长这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jythJq8fC9nCN7fRZNsTXfiN74gBWkx第一部分是 Header,说明用什么算法签名;第二部分是 Payload,放着用户 ID、过期时间这些基本信息;第三部分是 Signature,是用密钥对前两段内容做的签名。服务端收到请求后,先用同样的密钥算一下签名,对上了就说明内容没有被篡改过。然后看一眼过期时间,没过期就让请求继续往下走。
这里要特别注意:JWT 的 Payload 是明文 base64 编码的,谁都能解开看,只不过没有密钥的话改不了内容。所以不要把密码、手机号这种敏感信息直接塞进去。我曾经见过有同事把用户身份证号放在 Token 里,理由是“反正别人改不了”,后来被安全评审打了回来。内容能被看和内容能被改是两回事,能看就说明它不保密。
2.3 为什么前端要存 Token 而不是存登录名
有人可能会问:那我把用户 ID 存到 localStorage 里,请求的时候带上不也一样吗?还真不一样。Token 的核心价值是“防伪”,它带有服务端签发的签名,客户端自己造不出来。你向服务端证明“我是用户 1024”,总得拿出一样服务端能验证的可信凭证。光喊一句“我是 1024”谁信呢?但把带签名的 Token 拿出来,服务端验一下就知道这话不假。
还有一个现实原因:很多项目前后端是分离部署的,甚至跨域。Cookie 在跨域场景下配置麻烦,HttpOnly 属性加上之后脚本也读不到。而 Token 用 JS 存、用 HTTP Header 带,天然不受 Cookie 跨域策略的那么多限制。团队协作里,后端同事往往也更愿意直接处理 Authorization 头,而不是去调一堆 Cookie 的配置。
3. 登录状态持久化整体方案选型:存哪里才是关键
3.1 localStorage、sessionStorage、Cookie 三选一
做登录状态持久化,第一步是决定把 Token 放在哪里。前端常见的三个去处是 localStorage、sessionStorage 和 Cookie。我先把结论放这儿:大多数中后台项目,Token 默认放 localStorage,配合 Pinia 来做内存态管理。
为什么?因为 sessionStorage 有个致命的点——浏览器标签页之间不共享,而且关闭标签页就销毁。用户开了两个标签页访问你的系统,一个标签页登录了,另一个标签页还是未登录状态,一刷新整个状态还清零了,这哪里算“持久化”?本地存储方案的目的就是为了“刷新还在、关掉浏览器再打开还在”,sessionStorage 显然满足不了这个需求。
Cookie 也可以存 Token,而且浏览器会在每次请求时自动带上,省得自己写拦截器。但 Cookie 的问题在于:存储容量小,只有 4KB 左右,JWT 稍微长一点就可能塞不下;而且如果没设 HttpOnly,脚本一样能读,并没有比 localStorage 安全到哪去;再加上跨域环境下 Cookie 的 SameSite、Domain 配置很麻烦,一个配置不对,登录状态直接失效。
localStorage 的优势很直接:容量 5MB 左右,读写都是同步 API,刷新后数据还在,多个标签页共享一份。唯一的劣势是任何脚本都能访问它,一旦你的站点被注入脚本,Token 可能被偷走。这个风险后面“安全边界”那节我会细讲,但现在先明确一件事:常规内部系统、中后台管理端,localStorage 是足够用的。
3.2 状态存储与持久化存储要分开
有些同学的做法是把所有登录相关的响应数据直接扔到 localStorage 里,代码里到处读 localStorage 来拿用户信息。这样做能跑通,但时间一长就乱了。你想想:组件里到底是读 state 还是读 localStorage?改数据是改 Pinia 里的 state 还是再 setItem 一遍?多人协作的时候完全没有约束,最后就是一堆 localStorage 魔法字符串散落在代码各个角落。
我的建议是:Pinia 负责运行时状态,localStorage 负责掉电不丢。也就是说,页面执行过程里、组件渲染判断时,一律读 Pinia,不要直接读 localStorage。localStorage 只承担两个任务:初始化的时候恢复状态;每次状态变化的时候持久化。相当于一个“存档”的职责。这样做的好处是逻辑路径单一,组件只依赖响应式变量,不会出现“这两处读到的信息不一致”的诡异问题。
3.3 方案的多与少:为什么不用第三方库一劳永逸
Vue3 生态里有个现成的东西叫 pinia-plugin-persistedstate,配置一下就能自动把 store 同步到 localStorage,代码确实能省不少。我自己也用过一段时间,对于简单项目来说挺舒服。但真实项目用下来,我更倾向于“手写一个轻量的持久化 helper”,原因有三个:
第一,插件默认会把你 store 里所有的 state 都序列化进 localStorage,但有些状态其实是没必要持久化的,比如临时弹窗的状态、某个组件的内部缓存。全存进去不仅浪费空间,还可能把不该暴露的数据留在本地。
第二,登录状态这东西涉及到过期时间、用户信息、权限标识等多个字段,我希望持久化的数据结构是明确的、可版本化的。插件做得再好,它也是个“全量备份”,不够可控。
第三,手动封装并不复杂,也就一个函数的事。后面我会直接给出代码。如果团队里有新手,一份可读的、自己维护的工具代码,比一个“黑盒插件”更容易让人理解。
4. Vue3 登录状态持久化的核心实现步骤
4.1 登录成功之后:Token 和用户信息的保存逻辑
登录流程一般长这样:用户在登录页输入账号密码,前端把请求发到登录接口,服务端校验通过后返回一个数据结构,里面至少包含 token 和用户基本信息。当然,不同的后端返回字段可能不一样。有的是 token,有的是 accessToken,还有的带过期时间 expiresIn。
拿到数据后,我的做法是定义一个 auth store,集中管理这些数据。store 里大概这样:
import { defineStore } from 'pinia' export const useAuthStore = defineStore('auth', { state: () => ({ token: '', userInfo: {}, expiresAt: 0 }), getters: { isLoggedIn: (state) => !!state.token && Date.now() < state.expiresAt }, actions: { setAuth({ token, userInfo, expiresIn }) { this.token = token this.userInfo = userInfo this.expiresAt = Date.now() + expiresIn * 1000 }, clearAuth() { this.token = '' this.userInfo = {} this.expiresAt = 0 } } })这里我把 expiresAt 转换成了绝对时间戳。好多后端给的是有效秒数(比如 7200 表示两小时),如果你只存这个数字,刷新页面后怎么判断“还剩多久过期”?提前把绝对时间算出来存着,判断逻辑就简单了:当前时间点是否还在 expiresAt 之前。
4.2 Pinia 与 localStorage 的同步:手写一个轻量持久化方法
前面说了,页面运行期间只读 store,localStorage 负责存档。那具体怎么同步呢?我的做法是写一个通用的方法,在 store 初始化时读取,在登录和退出时写入:
const STORAGE_KEY = 'app_auth' export function loadAuthFromStorage() { try { const raw = localStorage.getItem(STORAGE_KEY) return raw ? JSON.parse(raw) : null } catch (e) { return null } } export function saveAuthToStorage(authState) { localStorage.setItem(STORAGE_KEY, JSON.stringify(authState)) } export function clearAuthStorage() { localStorage.removeItem(STORAGE_KEY) }你可能会觉得这个封装没什么技术含量,但它解决了几个实际问题。JSON.parse 如果遇到损坏数据会直接抛异常,这里用 try/catch 兜住,避免“本地存储坏了导致整个应用白屏”这种极端情况。加一个 try/catch 几乎零成本,但我见过太多因为一行 localStorage.getItem 崩溃导致所有页面都渲染不出来的事故了。
然后在登录 action 内部同步调用保存方法:
async function login(credentials) { const { data } = await api.post('/auth/login', credentials) authStore.setAuth(data) saveAuthToStorage({ token: data.token, userInfo: data.userInfo, expiresAt: Date.now() + data.expiresIn * 1000 }) }登出的操作也对称地写清楚:清除 store 里的状态,再清掉 localStorage,顺带把路由导航到登录页。
4.3 刷新页面后:如何恢复登录状态
刷新会清空内存中的 Pinia state,所以应用启动时得先从 localStorage 恢复。这个恢复动作放在哪?我推荐放在项目入口文件里,在应用创建后、挂载前进行一次同步。比如在 main.js 里:
const authStore = useAuthStore() const saved = loadAuthFromStorage() if (saved && saved.token && saved.expiresAt > Date.now()) { authStore.token = saved.token authStore.userInfo = saved.userInfo authStore.expiresAt = saved.expiresAt } else { clearAuthStorage() }注意这里做了一个过期判断:如果 Token 已经过期,直接把本地存档清掉,让用户重新走登录流程。这个判断很关键,否则可能出现“Token 明明已经过期了,应用却以为用户登录着”,等到某个请求发起时才被后端拒掉,用户体验特别突兀。
恢复状态之后,还有一个细节容易被忽略:用户在登录状态下刷新页面,有些系统会重新拉取一次用户信息接口,确认权限是否有变化。我的建议是:基础的用户信息可以从 localStorage 里直接恢复,让页面能立即渲染;然后在应用初始化阶段再异步调一次 /auth/me 之类的接口,把最新的用户资料刷新进去。这样既不会白屏等待,又能保证数据不过期。
4.4 axios 拦截器:让每个请求自动带上 Token
登录状态恢复之后,接下来就是“每个请求都要带上 Token”。如果每个接口都手动加 Header,那日子没法过了。正确做法是使用 axios 拦截器,统一在请求发出前把 Token 塞进去:
api.interceptors.request.use((config) => { const authStore = useAuthStore() if (authStore.token) { config.headers.Authorization = `Bearer ${authStore.token}` } return config })有人会问:这里能不能直接读 localStorage 而不是读 Pinia?可以,但不建议。因为读 Pinia 能保证拿到的是“当前内存中的最新值”,而不是“存储中的某个历史快照”。虽然大多数时候两者一样,但在并发场景下,读 store 更可靠。
响应拦截器也要做统一处理。最典型的是 401 状态码:后端返回 401 基本意味着 Token 失效或者没有权限,这时候前端应该清空登录状态,然后跳转到登录页:
api.interceptors.response.use( (response) => response, (error) => { if (error.response && error.response.status === 401) { authStore.clearAuth() clearAuthStorage() router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } }) } return Promise.reject(error) } )把跳转前的当前路由地址通过 query 传给登录页,登录成功后可以直接回跳用户刚才想访问的页面。这个小细节很影响体验,用户不用在登录之后重新找入口。
4.5 路由守卫:页面别让未登录的人进来
持久化解决了“状态在不在”,路由守卫解决的是“人该不该进”。在 vue-router 里配置一个全局前置守卫:
router.beforeEach((to) => { const authStore = useAuthStore() if (to.meta.public) { return true } if (!authStore.isLoggedIn) { return { path: '/login', query: { redirect: to.fullPath } } } return true })这里我给登录页、注册页这类不需要登录就能访问的页面加了 meta.public 标记。其它页面统一被收口:未登录就赶去登录页。注意一下,路由守卫的判断依赖的是 Pinia 中的状态,而不是直接读 localStorage。这样做好处是:刷新后先经过入口文件的恢复逻辑,再进入路由守卫,保证守卫判断时 store 里的状态已经是恢复过的。
如果你的项目有动态权限,比如不同角色能访问的路由不一样,那这一层还要再扩充。前端根据用户角色动态 addRoute,并把相应菜单渲染出来。这部分逻辑一般会和登录状态恢复放在同一个初始化流程里处理,时间先后要理清楚:先恢复用户信息,再根据用户权限生成可访问路由,最后放行页面。
5. 实操中的关键细节、安全边界与排查经验
5.1 401 响应和并发请求的“连环崩”
真实项目里,用户在一个页面上同时发出好几个请求,比如列表数据、字典数据、用户偏好,这三个请求可能同时间到达后端。如果 Token 恰好过期了,它们会接连收到 401。响应拦截器如果没做并发控制,那这段代码会被连续执行好几次:连续清 store,连续跳转登录页,最后还有可能弹出三个“登录过期”的提示框。虽然不是致命问题,但看起来非常业余。
我的解决方案是加一个标志位,只让第一次 401 触发跳转逻辑:
let isRedirecting = false if (error.response && error.response.status === 401) { if (!isRedirecting) { isRedirecting = true authStore.clearAuth() clearAuthStorage() router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } }) } return Promise.reject(error) }清完登录状态之后,等下次登录成功再把这个开关复位。
比这个更复杂一点的场景是刷新 Token。如果你的项目用的是双 Token 方案(accessToken + refreshToken),accessToken 过期后要用 refreshToken 去换新的,再重新播放失败的请求。这套逻辑要比简单的跳转复杂很多,核心坑点在于:并发失败的多个请求不能各自去刷新 Token,不然 refresh 接口会被调用好几次,而且后端的 refreshToken 一般是一次性的,第一次用完就失效了。正确做法是维护一个刷新队列,把并发情况下等待刷新完成的请求先存起来,等新的 Token 换回来之后逐个重发。网上有现成的封装思路,但实现的时候要手写一个 for 循环管理队列。如果你不想啃这块骨头,先用“过期就跳登录页”的方案也能满足大部分内部系统的需求。
5.2 安全边界:localStorage 被 XSS 偷走怎么办
localStorage 方案有一个没法回避的短板:存进去的 Token 任何 JS 脚本都能读。如果你的页面被人注入了一段恶意脚本,它执行一句 localStorage.getItem 就能把你的 Token 拿走。
那怎么办?首先确认你的应用对 XSS 做了基本防护:所有用户输入都有转义,富文本内容做白名单过滤,不随便使用 v-html。内部系统特别要留意“导出报表”这类功能,如果服务端返回的 HTML 片段被你直接绑进模板,风险就很大。其次,Token 的过期时间别设置得太长,常用的做法是 2 小时。时间短了,Token 就算被偷,攻击者能利用的窗口也小。再进一步,就得上 refreshToken + 短有效期 accessToken 的组合了,refreshToken 可以放在 Cookie 里并设置 HttpOnly,让脚本拿不到。但要记住,加了 HttpOnly 的 Cookie 会有跨域问题,需要服务端配合配置 SameSite 和 Domain。
我需要特别强调一点:不要把“存哪儿”当成安全问题的全部。就算你换成 Cookie,如果 XSS 能执行任意脚本,它照样能操控页面替你发起请求。安全的核心是防住 XSS 本身,Token 存哪里只是纵深防御中的一层。
5.3 多标签页同步问题
一个系统开两个标签页,在标签页 A 里退出登录,标签页 B 却还不知道,继续展示着用户信息。等用户点了一下操作,请求发出去,后端告诉我们令牌已失效,这时候才跳登录页。这种体验非常割裂。
浏览器有个 storage 事件,它监听的正是 localStorage 在不同标签页之间的变更。利用它可以在标签页 A 退出登录时,让标签页 B 也能感知到:
window.addEventListener('storage', (event) => { if (event.key === 'app_auth') { // 重新读取本地存储,同步到 Pinia const saved = loadAuthFromStorage() if (saved && saved.token) { authStore.setAuth({ ... }) } else { authStore.clearAuth() } } })注意 storage 事件只会在“其它标签页”触发,触发这个事件的标签页自己不会收到通知。所以你需要把 store 写入和 storage 事件监听配合起来使用。另外,还有一个需要留意的场景:如果你在登录页点了“记住我”,然后在另一个标签页冲掉 Token,当前标签页怎么处理?“记住我”是另一个话题,但多标签页同步本身就不复杂,难点是你要意识到它有坑。
5.4 白屏问题:启动顺序错导致的“已登录但页面打不开”
这是我自己踩过的一个坑。项目入口文件里用了一个异步初始化,在里面先调了用户信息接口,然后再去恢复路由。结果用户 Token 是有效的,接口却因为网络慢迟迟不返回,页面就一直卡在加载页。后来我把逻辑改成:先用本地存档同步恢复登录状态,让路由守卫放行,页面立刻渲染,用户信息再通过异步请求慢慢补充。这样启动速度明显更快,而且用户不会觉得系统“卡死”。
如果你用的是动态路由,还要注意“刷新之后先跳 404 再进路由”的问题。动态路由是在登录之后才 addRoute 的,刷新页面之后,内存中的动态路由丢失了。这时候你得在路由守卫里判断“已登录但路由表还没建好”,先把动态路由异步生成好,再放行到目标页面。网上很多“Vue3 动态路由刷新白屏”的提问,十有八九都是这个顺序没理顺。
5.5 退出登录时到底要清哪些东西
退出登录看似简单,但很多人会漏。清要清干净,以下清单可以根据项目情况取用:
- Pinia 中 auth store 的 state
- localStorage 中保存的登录存档
- 用户权限、菜单数据(如果存在 Vuex/Pinia 里,一并清掉)
- 一些全局缓存的请求数据(比如搜索条件、分页信息)
- 如果用了 refreshToken 存 Cookie,也要清 Cookie
为什么要强调“清干净”?因为如果你清了 Token 却没清用户信息,下次登录的时候旧用户的信息可能在界面上闪一下。还有,如果系统支持“记住账号名”这类功能,不要把密码放进去,明文密码出现在 localStorage 里属于严重的安全事故,这种锅没人想背。
6. 常见问题速查表与避坑清单
我把实际操作中最常遇到、也最容易被忽略问题的场景整理成了表格,方便你直接对照排查。
| 现象 | 可能的原因 | 排查思路与解决方案 |
|---|---|---|
| 刷新页面后回到登录页 | localStorage 里没存 Token,或 Pinia 没有在入口恢复 | 检查登录后是否调用了 saveAuthToStorage;检查入口是否有恢复逻辑 |
| 刷新后页面进去了,但所有接口都 401 | 恢复状态时没做过期判断,带着过期 Token 发请求 | 入口恢复时比较 expiresAt 和 Date.now(),过期就清掉 |
| 只在一个接口返回 401 时被多次跳转登录页 | 并发请求同时返回 401,每次响应都执行跳转逻辑 | 加 isRedirecting 标志位,只处理第一次 401 |
| 多标签页之间退出不同步 | 没有监听 storage 事件 | 在模块里加 storage 事件监听,变化时同步到 Pinia |
| 登录状态在,但动态路由刷新后 404 | 动态路由 addRoute 只在登录时执行,刷新后丢失 | 在路由守卫里判断路由表是否已初始化,异步恢复后再放行 |
| localStorage 里的数据被人工修改或损坏 | 没有容错处理 | loadAuthFromStorage 里用 try/catch,解析失败就返回 null |
| 页面每次刷新都短暂白屏 | 入口处的初始化逻辑等接口,阻塞了渲染 | 先同步恢复本地存档,异步的 userInfo 请求不要阻塞路由放行 |
| 打开系统直接看到了“未登录”提示,然后才跳到登录页 | 路由守卫判断了过期 Token,但没有清理失效状态 | 入口恢复时发现过期,立即 clearAuthStorage,并跳登录页 |
| 用户信息接口响应很慢 | 请求没做缓存,每刷新一次就重新拉一次 | 可以用本地快照先行渲染,接口响应后覆盖更新 |
| Token 超长,localStorage 存不下 | JWT 里塞了太多东西 | 后端精简 Payload,或选择 Cookie 方案(注意容量限制) |
这个表格看下来你会发现,大多数问题的根源就两条:一是恢复时机不对,二是没做边界容错。把入口恢复逻辑和路由守卫的顺序理顺,再给 localStorage 的读写加上 try/catch,理论上能干掉 80% 的刷新类 Bug。
最后再分享一个小技巧:给持久化数据的 key 加上一个版本号,比如 app_auth_v2。将来如果你改了存储的数据结构,旧版本存档在读取时会被自动忽略,让用户无感地重新登录一次,这比“打开系统直接报错”体面太多了。我在项目里吃过一次教训,当时改字段名忘加版本号,老用户机器上留着旧结构的存档,新代码一读就 JSON.parse 报错,整个应用白屏,排查了好久才锁定是本地缓存作怪。从那以后,凡是 localStorage 里跨版本的数据结构,我一律带版本号,成本极低,收益却很明确。
这套方案我用了挺长时间,经历过几个项目的打磨,确实把“登录状态持久化”这件事做得比较扎实了。如果你现在手头正好有一个 Vue3 项目要处理这个问题,建议先按第 4 章的步骤走通主流程,再对照第 5 章的坑把边界补上,基本就能交付一个让人放心的登录态体系了。