1. 登录功能到底在做什么:先梳理清楚再动手
做登录功能,很多新手的第一反应是"写个表单、调个接口、存个 token",然后就开始敲代码。这个思路不能说错,但大概率会在项目后期被各种细节反复折磨。我在实际项目里见过太多因为登录模块设计草率而引发的连锁问题:页面刷新后登录态丢失、接口突然全部 401、路由跳转出现死循环、token 过期后用户一脸懵……
登录功能表面上是一个表单提交,本质上是一整套认证链路的起点。它至少要回答这几个问题:
- 用户身份怎么验证?——用户名密码、验证码、第三方授权等
- 登录成功后凭证存哪里?——localStorage、sessionStorage、cookie,各有各的坑
- 后续请求怎么带上凭证?——这就轮到 axios 出场了
- 凭证失效了怎么办?——自动跳登录页还是静默刷新?
- 没有登录能访问哪些页面?——路由守卫要管的事
这个链路里,axios 封装承担的是"所有请求自动携带凭证 + 统一处理返回结果 + 集中响应异常"这个核心职责。把这层做好,后续每个业务模块的接口调用代码都能干净一大截。
所以我这篇文章不打算只给你一段能用就行的代码,而是把登录从表单到请求再到路由守卫的完整链路拆开讲,每一步都说明白"为什么这么做",同时把我实际踩过的坑一并交代清楚。适合刚接触 Vue 没多久、准备独立做完整项目的朋友,也适合已经写了一些功能但总觉得登录模块"哪里不对劲"的人对照自查。
环境我默认是 Vue 3 + Vite + Vue Router 4 + Pinia,后面所有代码都基于这个组合。如果你还在用 Vue 2,思路完全一致,只是部分 API 写法不同。
2. axios 封装的第一步:先想清楚 token 存在哪
很多人封装 axios 上来就写拦截器,但 token 的存储方案没定下来,拦截器里的代码就是空中楼阁。我习惯先把存储层定好,因为这是后续所有逻辑的地基。
2.1 localStorage、sessionStorage、cookie 三选一
这三种方案的差异我先用表格列清楚:
| 存储方式 | 生命周期 | 刷新页面 | 关闭浏览器 | 跨标签页 | 安全风险 |
|---|---|---|---|---|---|
| localStorage | 永久 | 保留 | 保留 | 共享 | XSS 可读取 |
| sessionStorage | 标签页级 | 保留 | 销毁 | 不共享 | XSS 可读取 |
| cookie(HttpOnly) | 可设置过期时间 | 保留 | 按配置 | 共享 | XSS 读不到,但有 CSRF 风险 |
如果是纯前端项目、后端接口不做特殊处理,绝大多数情况我用 localStorage。原因很直接:刷新页面和关闭浏览器后登录态都能保留,用户不用频繁重新登录,跨标签页同步也不需要额外处理。它的缺点是 XSS 攻击能直接读走 token,但这个风险在常规业务项目里通过前端框架的插值转义已经能控制住大多数场景。
如果项目安全等级比较高,或者后端有精力配合,更稳妥的组合是HttpOnly cookie 存 token + CSRF Token 防护。这种方案的前端代码反而更简单——axios 默认的withCredentials配置打开后,cookie 会自动带上,完全不需要在代码里手动读 token、写 token。但是前提是后端必须配合设置 CORS 和 cookie 属性,前后端如果有一端没到位,排查起来非常痛苦。
sessionStorage 我基本不推荐用于登录态,它连刷新页面都保留不了。等等——刷新页面的行为在所有浏览器里其实细节不太一样:普通刷新会保留,但如果用户新开标签页重新输入地址,sessionStorage 就空了。这样用户会觉得"我明明登录过,怎么又让我登录?",体验很糟糕。
2.2 我实际用的 token 管理模块
定好 localStorage 之后,我会单独抽一个auth.js模块来管理 token 的读写,不直接散落在各个文件里:
// src/utils/auth.js const TOKEN_KEY = 'app_admin_token' export function getToken() { return localStorage.getItem(TOKEN_KEY) } export function setToken(token) { localStorage.setItem(TOKEN_KEY, token) } export function removeToken() { localStorage.removeItem(TOKEN_KEY) }别小看这个模块,它带来三个实际好处:第一,如果哪一天你想把存储方案从 localStorage 换成 cookie,只需要改这一个文件;第二,key 命名统一,避免多个项目或者多个环境共用域名时 token 互相覆盖;第三,后续如果 token 过期要清理或者要同时存用户信息,在封装好的函数里扩展就行。
还有一个细节容易被忽略:key 的名称前面加个项目前缀。比如app_admin_token而不是简单的token。因为 localStorage 是按域名隔离的,同一个开发域名下的不同项目如果都存token,会互相覆盖。我吃过这个亏——本地同时跑两个项目,都是 localhost 的不同端口,但其实 localStorage 在某些浏览器里是共享的,结果 A 项目的登录态把 B 项目的顶掉了。
2.3 token 里要不要同时存用户信息
有人会把用户信息也存到 localStorage 里,追求"打开页面就能展示用户名头像"的即时体验。这个做法可以做,但要注意两个问题。
第一,localStorage 里的用户信息是不可信的。用户可以手动改了再刷新页面,如果你的代码直接信任这个数据去渲染权限相关的 UI,会出安全隐患。所以用户信息只能用于展示性的内容,真正的权限判断必须以接口返回的实时数据为准。
第二,用户信息更新同步是个坑。用户改了头像之后,localStorage 里的旧数据什么时候更新?如果不同步,就会出现"资料页显示新头像,导航栏显示旧头像"这种尴尬情况。我自己更推荐的做法是:登录接口返回的 userInfo 只存必要的字段(比如 id、昵称、头像链接),其余数据在进入首页后通过单独的用户信息接口拉取,并且每次刷新页面时都重新拉取一次,保证数据新鲜。
3. axios 实例与拦截器:所有请求的"安检通道"
封装 axios 的核心目标就一句话:让业务代码里不需要关心 token 怎么传、错误怎么处理,只管拿到数据或用例。实现这个目标靠的就是 axios 实例 + 请求拦截器 + 响应拦截器这三个东西的配合。
3.1 创建拥有独立配置的 axios 实例
不要直接改axios的全局默认配置,更不要裸用axios.get调用接口。原因有两点:一是你没法针对当前项目设置统一的 baseURL 和超时时间;二是整个应用里可能有多个服务端(比如业务接口和文件服务),它们的请求配置本来就不一样,各建各的实例才能互不干扰。
// src/utils/request.js import axios from 'axios' import { getToken } from './auth' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000 })这里单独说一下baseURL的取值。用环境变量VITE_API_BASE_URL是 Vite 项目的标准做法,这样开发环境可以指向代理地址,生产环境可以指向真实域名,不需要改代码。如果你用 Vue CLI 创建的项目,对应的环境变量名是VUE_APP_API_BASE_URL。没有配置环境变量的时候,我建议先给一个默认值/api,配合 Vite 的代理配置使用,这样能完美绕过开发时的跨域问题。
3.2 请求拦截器:给每个请求穿上"登录马甲"
请求拦截器干的事情非常固定,就是在请求发出去之前,把 token 塞进请求头里。
service.interceptors.request.use( config => { const token = getToken() if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }, error => { return Promise.reject(error) } )这里有几个细节说明一下。
Header 的命名,Authorization是 HTTP 标准字段,后端通常也是按这个字段来解析的。但有些后端团队自定义了X-Token之类的名字,务必跟后端确认。因为一个字母写错,表现就是所有请求 401,而且控制台翻不到任何有用的报错——请求头里的东西普通报错信息里看不到,排查起来非常恼火。
Bearer 前缀,这是 JWT 体系的通行写法,表示这是一个 Bearer Token,格式是Bearer eyJhbGciOi...。有些后端不要求前缀,有些要求小写bearer,以接口文档为准。我的建议是统一按 JWT 标准写法加Bearer,即使你现在的后端没做校验,后续如果换后端或者做 token 缓存刷新,这个格式也不会出错。
请求拦截器的错误分支(第二个函数)平时基本用不到,因为请求还没发出去,常见的错误就是配置写错了,比如 URL 不合法。但保留它没有坏处,代码结构完整,而且万一在某些情况下请求被取消,这里能捕获到。
3.3 响应拦截器:把"数据剥壳"和"错误上报"集中处理
响应拦截器是整个封装里最花心思的部分。先看一段完整的代码:
service.interceptors.response.use( response => { // 根据后端约定的结构解包 const res = response.data // 二进制数据直接返回,比如文件流 if (response.request.responseType === 'blob' || response.request.responseType === 'arraybuffer') { return response } // 业务码判断,不同项目的约定差异很大 if (res.code === 0 || res.code === 200) { return res } // 业务逻辑上的失败,比如参数错误、无权限 ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message || 'Error')) }, error => { // HTTP 层错误,这里是重点 if (error.response) { const status = error.response.status switch (status) { case 401: handleUnauthorized() break case 403: ElMessage.error('没有权限执行此操作') break case 404: ElMessage.error('请求的资源不存在') break case 500: ElMessage.error('服务器内部错误') break default: ElMessage.error(`请求失败(${status})`) } } else if (error.code === 'ECONNABORTED') { ElMessage.error('请求超时,请稍后重试') } else { ElMessage.error('网络异常,请检查网络连接') } return Promise.reject(error) } )这段代码里最重要的思想是区分两层错误:HTTP 层错误(状态码 4xx、5xx)和业务层错误(HTTP 200 但业务 code 不是成功)。很多后端设计喜欢 HTTP 永远返回 200,只在 body 里用 code 区分成功失败,这时候响应拦截器就完全靠业务码判断,上面代码里的res.code判断逻辑就是干这个的。
还要注意一个问题:不要忘了把响应拦截器的返回值剥到合适的一层。我在例子中返回的是res,也就是整个响应体的 data 部分(后端包了一层{ code, message, data }的壳)。如果你把res.data直接返回,业务层代码就不用写res.data.data这种丑代码。但是反过来,有些团队喜欢在拦截器里就剥到最里层的业务数据,即return res.data,这样业务代码拿到的直接是"有用数据",遇到错误在拦截器里就拦截了,拿不到 null。两种风格各有取舍,我倾向于返回res而不是res.data,因为有些接口确实需要同时用到 code 和 data,特别是分页接口偶尔会把分页元信息放在 data 外层,剥太深反而损失灵活性。
3.4 401 处理和 token 刷新
任何登录功能都逃不过 token 过期这个话题。大多数项目采用的是"过期就重新登录"策略,这是最简单可靠的做法,但用户体验确实一般——用户正填着表单,点了提交,突然跳登录页,输入的东西全没了。
稍微好一点的方案是静默刷新 token:在响应拦截器里捕获 401 后,不急着跳登录页,而是拿着 refresh_token 去换一个新的 access_token,然后重新把刚才失败的请求发一遍。
let isRefreshing = false let pendingQueue = [] function handleUnauthorized() { const refreshToken = getRefreshToken() if (!refreshToken) { resetToLogin() return } if (isRefreshing) { // 已经有请求在刷新 token,把后续请求挂起 return new Promise((resolve, reject) => { pendingQueue.push({ resolve, reject }) }) } isRefreshing = true return service.post('/auth/refresh', { refreshToken }) .then(res => { setToken(res.data.token) setRefreshToken(res.data.refreshToken) // 把排队中的请求重新发一遍 pendingQueue.forEach(item => item.resolve()) pendingQueue = [] return res.data.token }) .catch(err => { pendingQueue.forEach(item => item.reject(err)) pendingQueue = [] resetToLogin() return Promise.reject(err) }) .finally(() => { isRefreshing = false }) }这个方案里有一个值得注意的坑:多个请求同时 401 时,绝对不能每个请求都去刷一次 token,否则后端可能认为是异常刷新,直接把 refresh_token 也作废了。所以要用一个isRefreshing标志位 + 一个待重发队列,保证只有第一个 401 会触发刷新,其余请求排队等新的 token 返回后再重放。
不过这个方案的复杂度不只在前端,后端必须提供 refresh_token 接口,并且设置合理的过期机制和刷新策略。如果你们后端暂时不做这个,那就老老实实走"401 清空登录态跳登录页"的简单策略,别硬上复杂度。
4. 路由守卫:登录校验的正确打开方式
axios 封装好了,token 也有了,如果用户没登录就直接访问一个受保护的页面,浏览器地址栏输入 URL 就能绕开登录流程,所以必须做路由层面的拦截。Vue Router 4 提供的beforeEach全局前置守卫就是干这个的。
4.1 简单版的登录校验
// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' import { getToken } from '@/utils/auth' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', name: 'Login', component: () => import('@/views/login/index.vue'), meta: { title: '登录' } }, { path: '/', component: () => import('@/layout/index.vue'), redirect: '/dashboard', meta: { requiresAuth: true }, children: [ { path: 'dashboard', name: 'Dashboard', component: () => import('@/views/dashboard/index.vue'), meta: { title: '首页' } } ] } ] }) router.beforeEach((to, from, next) => { document.title = to.meta.title ? `${to.meta.title} - 管理系统` : '管理系统' if (to.meta.requiresAuth) { if (getToken()) { next() } else { next({ path: '/login', query: { redirect: to.fullPath } }) } } else { next() } })这段代码逻辑很直白:进入需要登录的页面时检查 token,没 token 就踢到登录页,并且通过query.redirect记录用户原本想去的地址,登录成功后方便跳回去。
我要强调redirect这个参数,它是个容易被忽略的体验细节。没有它,用户本来想打开订单详情页,被踢到登录页并登录成功后,系统却把他丢到首页,他还得自己重新找入口。有redirect的话,登录成功可以直接跳回原来的目标页:
// 登录页面中 const redirect = route.query.redirect || '/' router.replace(redirect)注意用replace而不是push,这样登录页不会残留到浏览历史里,用户按后退键不会"退回"到登录页。
4.2 已登录用户访问登录页的拦截
还有一个常见需求:已经登录的用户,在地址栏输入/login,应该自动跳回首页,别再让他看到登录表单。这个逻辑要加在守卫里:
if (to.path === '/login' && getToken()) { next({ path: '/' }) return }注意这里有个细节展示了一种常见的死循环模式:如果跳转目标仍然是需要登录的页面,并且这个页面还会被守卫拦截,就可能造成无限重定向。比如上面的代码里,已登录用户访问/login跳到/,然后/的守卫发现已有 token,放行。如果没有 token,访问/login被允许进入登录页,不会出现死循环。但假如有人把逻辑写成了"没有 token 时访问/login跳转到/,而/又需要 token",就会来回跳。我的建议是每次写完守卫都手动测一遍这几个场景:未登录访问登录页、未登录访问受保护页、已登录访问登录页,确保都不会出现重定向死循环。
4.3 用户信息的加载时机
token 存在只代表"曾经登录过",不代表"现在还是有效用户"。有些项目在路由守卫里发现 token 存在就直接放行,然后进入页面后接口全部报 401,用户看到一堆错误弹窗,体验很差。
更稳妥的做法是在守卫里增加一个用户信息懒加载的逻辑:进入系统时判断 Pinia 里有没有用户信息,没有就调用接口拉取,拉取失败(说明 token 已经失效)就清空登录态跳登录页。
import { useUserStore } from '@/stores/user' router.beforeEach(async (to, from, next) => { const userStore = useUserStore() if (document.title !== to.meta.title) { document.title = to.meta.title ? `${to.meta.title} - 管理系统` : '管理系统' } if (to.meta.requiresAuth) { if (!getToken()) { next({ path: '/login', query: { redirect: to.fullPath } }) return } if (!userStore.userInfo) { try { await userStore.fetchUserInfo() next() } catch (error) { await userStore.resetAuth() next({ path: '/login', query: { redirect: to.fullPath } }) } } else { next() } } else { next() } })把用户信息加载放在路由守卫里的好处是所有受保护页面都共享这一道关卡,不用每个页面自己判断用户信息有没有加载。缺陷是守卫函数变成了异步的,所有跳转都要等接口返回,页面切换会有短暂的阻塞感。实际用下来这个延迟通常在几十到几百毫秒之间,用户可以接受。如果觉得慢,可以做缓存和本地持久化,把接口请求延迟到进入页面后再做。
5. 登录页表单:细节决定成败
登录页是整个系统里被用户看到次数最多的一个页面,表单交互的细节直接影响第一次使用产品的好感度。我见过太多登录页只有一个输入框一个按钮就算完事,实际上好用的登录页要考虑的事不少。
5.1 表单校验:不只是"不空就行"
登录表单一般有两个字段:用户名和密码。看起来简单,但校验逻辑还是有一些讲究:
<script setup> import { reactive, ref } from 'vue' import { useRouter, useRoute } from 'vue-router' import { ElMessage } from 'element-plus' import { login } from '@/api/auth' import { setToken, setRefreshToken } from '@/utils/auth' import { useUserStore } from '@/stores/user' const router = useRouter() const route = useRoute() const userStore = useUserStore() const formRef = ref(null) const form = reactive({ username: '', password: '', remember: false }) const rules = { username: [ { required: true, message: '请输入用户名', trigger: 'blur' }, { min: 3, max: 20, message: '用户名长度应在3到20个字符之间', trigger: 'blur' } ], password: [ { required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, max: 32, message: '密码长度应在6到32个字符之间', trigger: 'blur' } ] } const loading = ref(false) async function handleLogin() { if (!formRef.value) return try { await formRef.value.validate() } catch { return } loading.value = true try { const res = await login({ username: form.username, password: form.password }) if (form.remember) { localStorage.setItem('remember_username', form.username) } else { localStorage.removeItem('remember_username') } setToken(res.data.token) if (res.data.refreshToken) { setRefreshToken(res.data.refreshToken) } userStore.setUserInfo(res.data.userInfo || {}) ElMessage.success('登录成功') router.replace(route.query.redirect || '/') } catch (error) { // 错误提示已经在响应拦截器里统一处理了 } finally { loading.value = false } } const rules computed... </script>这里我做了几件额外的事:
密码长度下限 6。有些项目只做required校验,密码输入一个字符也能提交,然后被后端拒绝。前端先做一个基础的长度校验,能少浪费一次网络请求。
按钮 loading 状态。点击登录后按钮必须变成 loading,防止用户重复点击。我曾经见过没做 loading 处理的系统,用户手一抖连点三下"登录",后端收到三笔一模一样的登录请求,即使第二次第三次都会因为 token 已刷新而失败,但过程中的体验和日志都很混乱。
记住用户名。用 localStorage 存一个remember_username,下次进登录页时自动填充。这是很小的功能,但对于经常要退出重登的用户来说体验提升明显。
5.2 密码传输要不要加密
关于密码传明文还是加密,很多前端同学问过。我的结论是:密码加密不能完全依赖前端。HTTPS 已经是现代 Web 应用的基础前提,在 HTTPS 下传输密码就是安全的。如果你们项目跑在 HTTP 上,前端再怎么加密也只是把简单的位移或者对称加密,反而给攻击者提供了逆向分析的机会。
如果出于合规要求或者后端明确要求,前端可以做一层摘要处理,比如用 SHA-256 对密码加盐哈希后再传输。但这层保护能防的是"后端日志不小心把密码打出来"这种场景,防不了中间人攻击。真正该做的是确保整个环境走 HTTPS,以及后端存储时加盐哈希。
5.3 验证码要不要做
登录页加验证码是个老话题。我的建议是分情况:
- 纯内部系统、使用人数少、有网络边界防护:暂时不做图形验证码,开发效率和体验优先
- 面向公众的产品,特别是涉及资金交易:必须做,而且推荐滑动验证码或行为式验证码
- 观察到有暴力破解、撞库迹象:立刻加上
图形验证码的前端集成比较直接,后端生成图片,前端显示并提交用户输入的验证码。它的核心作用是增加自动化攻击的成本,但会降低部分真实用户的体验。业务需求没有明确提出之前,不要自作主张加上,这个功能做起来简单,但无形中让每个用户的登录操作多了一步。
5.4 登录成功后跳转的正确姿势
登录成功后跳转,有人用router.push,有人用router.replace,这里我明确说一下区别:用 replace。
如果你的操作流是:用户访问受保护页 → 被踢到登录页 → 登录成功 → 跳回原目标页。此时浏览器历史栈里是"目标页 → 登录页",如果用 push,历史栈会变成"目标页 → 登录页 → 目标页",用户按后退会回到登录页,然后又可能被守卫踢回目标页,形成一种奇怪的"循环感"。用 replace 则把登录页替换成目标页,历史栈是"目标页 → 目标页",后退就退出系统了,符合直觉。
还有一个细节:router.replace(route.query.redirect || '/')里的redirect一定要做校验,防止开放重定向漏洞。虽然多数后台系统不涉及,但如果redirect被用户手动改成外部网址,登录后可能会跳出当前系统。稳妥做法是只允许站内地址:
const redirect = route.query.redirect const target = typeof redirect === 'string' && redirect.startsWith('/') ? redirect : '/' router.replace(target)6. 登录接口调用:写好第一层 API 封装
API 层的封装看似无关紧要,但它决定了后面业务代码写起来干不干净。我在公司里经常发现一个项目里同时存在request.post('/login')、axios.post('/auth/login')、service.post('/user/login')三种风格,这就是没做统一 API 层管理的后果。
6.1 把登录接口单独抽出来
// src/api/auth.js import service from '@/utils/request' export function login(data) { return service.post('/auth/login', data) } export function logout() { return service.post('/auth/logout') } export function getCaptcha() { return service.get('/auth/captcha') } export function refreshToken(refreshToken) { return service.post('/auth/refresh', { refreshToken }) } export function getUserInfo(params) { return service.get('/user/info', { params }) }每个接口函数只负责定义 URL 和参数,具体发送逻辑全部交给封装好的 service 实例。这样做的价值体现在三个地方:
第一,URL 集中管理,接口路径变更只改一个文件。第二,接口参数结构清晰,调用方不需要关心请求头等细节。第三,方便统一处理接口层面的逻辑,比如某些接口需要单独设置超时时间或者下载文件流,在这个文件里单独用service.post('...', data, { timeout: 30000 })覆盖默认配置即可。
6.2 登录成功后的数据流
登录成功之后,前端要做的动作有一串,这个顺序我建议固定下来:
- 校验表单(前端基础校验)
- 调登录接口
- 存储 token(和 refreshToken,如果后端返回了的话)
- 初始化用户信息(Pinia store)
- 跳转目标页面
- 如果用户信息里有权限相关数据,触发权限菜单的动态生成
为什么顺序不能乱?因为第 3 步是后续一切的前提,token 没存进去就跳转,路由守卫里getToken()会拿到空值,又把你踢回登录页。第 4 步放在跳转前做,是为了避免跳转后页面已经开始渲染了,但用户信息还是空的,导致导航栏头像闪一下缺省图。
这里顺便提一个我踩过的坑:有些后端登录接口不返回 userInfo,只返回 token,你需要额外调一个/user/info接口来拿用户资料。这时候第 4 步就是一个异步操作,必须先等它完成再跳转。如果这个接口失败,即使 token 存在,也要考虑是否把已登录的状态回滚,不然会出现"看似登录成功,实际什么数据都拉不到"的假死状态。我的处理方式是把这个接口放到路由守卫的"用户信息懒加载"逻辑里(前面第 4 节提到过),登录成功只存 token,然后跳转,守卫发现没有 userInfo 就去拉取,拉到再进页面,拉不到就踢回登录页。
6.3 退出登录:别只清空本地 token
退出登录的常见误区是只调用localStorage.removeItem('token')就算完事。这样做虽然用户能退出,但后端 session 层没有任何变化,如果 token 设计为长时间有效,别人捡到这个 token 还能继续用。所以退出登录的正确姿势是:
// src/stores/user.js async function logout() { try { await apiLogout() } catch (error) { // 即使后端登出接口失败,也要继续本地清理 } finally { resetAuth() router.push('/login') } } function resetAuth() { removeToken() removeRefreshToken() userInfo = null // 清空其他敏感数据 }关键点是后端登出接口即使失败,本地清理也必须执行。很多网络环境里用户点退出时正好网络断了,这时候不能让用户卡在"退出失败"的提示上,他的诉求就是离开当前账号。正确逻辑是 notify 一下后端,但不等结果,本地立即清理。
另外还要考虑多标签页同步清除的问题。用户在一个标签页退出登录,另一个标签页还停留在系统里。如果不处理,另一个标签页的请求会继续带 token 发出去,直到 401 才被发现。解决方案是监听storage事件:
window.addEventListener('storage', (event) => { if (event.key === TOKEN_KEY && !event.newValue) { // 其他标签页退出了登录,当前页也清理 resetAuth() router.push('/login') } })这个监听非常适合放在入口文件或应用的初始化逻辑里,不要忘记移除监听,不过单页应用一般整个生命周期都在运行,问题不大。
7. 动态权限:登录后"看到什么页面"的控制
登录功能做到这里已经能正常工作了,但真实后台项目几乎都会面临权限控制的进阶需求:不同角色的用户登录后,菜单和页面不一样。这个功能跟登录链路是紧紧绑在一起的,所以我也展开说一下。
7.1 前端控制权限 vs 后端控制权限
前端路由权限控制在中小型项目里很常见。后端登录接口返回用户拥有的菜单/路由权限列表,前端根据这个列表动态注册路由。好处是灵活,权限一变就能体现在菜单上;风险是前端路由守卫拦不住真正的资源访问,如果用户知道某个页面 URL,直接输入照样能打开渲染(除非加细粒度的组件级权限判断)。
后端控制权限更安全,页面是否可访问由后端接口决定,用户每次请求资源都会校验。但前端依然需要知道当前用户能渲染哪些菜单,所以通常的做法还是两者结合。我这里说的动态路由方案,属于「后端返回权限数据、前端动态注册路由」,适合大多数管理系统。
7.2 动态添加路由的代码组织
Vue Router 4 里动态添加路由要依赖router.addRoute,并且必须在路由守卫里决定添加哪些路由。
// 登录后拿到用户权限标识 const permissionCodes = ['dashboard', 'order', 'user'] // 动态路由表按模块定义 const asyncRoutes = [ { path: '/order', component: Layout, name: 'Order', meta: { title: '订单管理', permission: 'order' }, children: [ // ... ] }, // 更多模块... ] // 根据权限过滤路由 function filterRoutes(routes, permissionCodes) { return routes.filter(route => { if (route.meta && route.meta.permission) { return permissionCodes.includes(route.meta.permission) } return true }) } // 在路由守卫里动态添加 if (userStore.permissionCodes) { const accessibleRoutes = filterRoutes(asyncRoutes, userStore.permissionCodes) accessibleRoutes.forEach(route => router.addRoute(route)) // 注意:addRoute 是异步的,需要 next({ ...to, replace: true }) 重新进入 next({ ...to, replace: true }) } else { next() }这里的"坑"比较有代表性:addRoute添加完路由之后,原本导航的目标路由还不存在,直接next()会失败。所以需要在添加完成后用next({ ...to, replace: true })重新触发一次导航。如果处理不好,很容易出现"刷新页面后路由跳转 404"或"路由守卫死循环"的问题。
7.3 动态路由与刷新页面的兼容问题
动态路由最大的敌人是页面刷新。刷新后整个应用重新加载,路由表恢复成初始状态,之前动态添加的路由全没了。如果用户当前在/order/list页面刷新,路由匹配不到,就会掉到 404 页面。
解决方案就是上面代码的思路:在路由守卫里,每次进入页面之前都检查"动态路由是否已添加"。用一个标志位dynamicRoutesAdded记录添加状态,刷新后它是 false,守卫里重新执行过滤和添加流程,然后把导航重新发起一次。这个方案要做对,要点在于把"动态路由添加逻辑"设计成幂等的——重复执行不能导致路由重复注册。router.addRoute重复添加同名的路由不会直接报错,但会导致路由匹配混乱,所以通常在添加之前先router.removeRoute(name)或者判断一下当前是否已添加。
说白了,动态路由的复杂度比登录本身高一个量级。如果你的项目角色类型比较少(比如就管理员和普通用户),用静态路由 + 菜单项 visible 控制就能解决,没必要动动态路由的大杀器。杀鸡用牛刀,维护成本反而高。
8. 我踩过的坑和最终的排查思路
文章的最后一个部分,我想把做登录功能过程中遇到过的典型问题汇总一下,这些几乎都是所有新人会踩的。如果你项目里也出现了类似的诡异现象,可以对照这个表快速定位方向。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 登录成功后跳转,又立刻被踢回登录页 | token 没存成功/存的 key 不一致 | 检查setToken的 key 和getToken的 key 是否一致,有没有被环境变量覆盖 |
| 所有接口都报 401 | 请求拦截器没生效/Header 拼错 | 在 Network 面板看请求头里有没有 Authorization,检查 Bearer 是否有空格 |
| 刷新页面后路由 404 | 动态路由没重新添加 | 在路由守卫里检查动态路由添加逻辑和标志位 |
| 登录按钮快速连点,产生多条请求 | 没有 loading 状态 | 按钮 disabled + loading |
| 接口报 200 但页面拿不到数据 | 响应拦截器剥壳层数不对 | 打印响应拦截器返回结果,确认返回的是res.data还是res |
| 跨域请求带不上 cookie | withCredentials没打开 | 前端设置withCredentials: true,后端需要配置对应的 CORS 头 |
| 登录接口报错但没有提示 | 响应拦截器没有处理非 HTTP 错误 | 在error分支里加上error.message提示 |
排查登录相关问题的通用套路,也是我反复使用的:先看 Network 面板,登录请求有没有发出去,请求头和响应体是什么。这一步能区分是前端问题、网络问题还是后端问题。然后看控制台有没有 JS 报错,再进 Vue DevTools 看 Pinia 里的状态和 localStorage 里的 token。按照"请求层 → 存储层 → 路由层 → 视图层"的顺序排查,基本不会走偏。
还有一个很隐蔽但值得单独说的坑:本地开发时接口通了,打包部署到服务器后所有接口都 401。这种十有八九是环境变量的坑——VITE_API_BASE_URL在开发环境有值,但在生产环境的.env.production文件里没配,打包后 baseURL 变成了默认值/api,而服务器上的 Nginx 没有把/api代理到后端服务,请求全都发到前端静态资源服务器上去了,自然什么也拿不到。部署后一定要打开控制台看接口真实请求的 URL 是什么,别想当然。
9. 最后给你一套可以直接用的上线 Checklist
写到这里,登录功能的前端部分从存储、请求、守卫、表单、权限到踩坑排查都过了一遍。我最后把一个项目上线前需要检查的清单列出来,按这个顺序过一遍,能少收到一堆用户反馈。
- HTTPS 已启用,页面和接口都走加密通道,浏览器地址栏没有"不安全"提示
- token 过期时间确认过,短期 token + 长期 refreshToken 的刷新逻辑测试过,包括多接口同时过期的场景
- 修改密码后旧 token 能作废,后端要有对应的逻辑,前端配合触发
- 退出登录后本地一切凭证清除干净,包括用户信息、权限数据、remember_username 以外的敏感字段
- 路由守卫覆盖所有受保护页面,包括嵌套路由和动态路由的场景
- 登录页在自动填充密码时不会意外提交,很多浏览器的自动填充会触发 form submit,要监听并处理
- 接口异常提示友好,用户遇到网络问题时能看到人话提示,而不是白屏或者控制台错误
- 多次刷新页面后登录态稳定,不受动态路由重定向、用户信息懒加载缓慢等问题的影响
我在实际做项目时最大的感受是:登录功能的技术难度并不高,但它是一个横跨多个层级的串联型功能,任何一个环节配合不到位,整个链路就断了。所以这篇文章刻意没有只讲 axios 封装那一小块,而是把存储选择、拦截器分工、路由守卫、表单细节、动态权限、退出清理全部串起来讲。你按这个顺序去实现,应该能少走很多弯路。
最后多一句嘴:代码写完之后,一定自己手动把"未登录访问深链→登录→跳回原页面→刷新→退出→再访问"这条完整路径走一遍。这些场景里暴露出的问题,比十个单元测试发现的都多。