尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Vue3登录功能全栈实战:从表单到路由守卫的完整解决方案

Vue3登录功能全栈实战:从表单到路由守卫的完整解决方案 1. 项目缘起为什么一个登录功能值得单独成篇做前端开发的朋友尤其是刚接触Vue生态的可能觉得登录功能不就是调个接口、存个token、跳个页面的事儿吗我最初也是这么想的直到在一个真实的中后台项目中因为登录模块的“小问题”导致整个项目延期上线我才彻底改变了看法。登录是用户进入你应用的第一道门也是整个前端应用安全、状态和数据流的基石。它远不止一个表单那么简单它涉及到用户状态管理、路由守卫、接口安全、错误处理、用户体验等一系列环环相扣的环节。这个“VueDemo-11.登录功能”项目就是我在踩过无数坑之后沉淀下来的一套相对完整、可复用的登录解决方案。它不是简单的axios.post(/login)就完事了而是从零开始构建一个健壮、安全、用户体验良好的登录模块。我会带你从最基础的登录表单开始一步步深入到Token管理、路由权限控制、用户信息持久化等核心细节并分享那些官方文档里不会写的“血泪教训”。无论你是Vue新手还是想优化现有登录逻辑的老手相信都能从中找到有价值的参考。2. 登录模块的核心架构与设计思路在动手写代码之前我们先要理清一个现代SPA单页应用登录模块到底需要哪些部分。如果把登录功能比作一个公司的前台接待系统那么它至少需要一个接待表单登录页、一个身份核验流程接口调用、一个临时通行证Token、一个记录员工信息的工牌用户状态、以及一套门禁规则路由守卫。2.1 技术栈选型与项目初始化本项目基于 Vue 3 Composition API TypeScript Vite 构建这是目前Vue生态中最主流、最前沿的技术组合。选择它们的原因如下Vue 3 Composition API提供了更灵活、逻辑更聚合的代码组织方式。对于登录这种涉及多个响应式状态如loading,form,errorMessage和副作用如API调用、路由跳转的功能使用script setup和Composition API会让代码更清晰。TypeScript为登录接口的请求/响应数据、用户信息对象、Token等提供明确的类型定义能极大减少因数据类型错误导致的bug尤其是在团队协作中。Vite极快的启动和热更新速度能提升开发体验。首先我们初始化项目并安装核心依赖# 使用Vite官方模板创建项目 npm create vuelatest vue-login-demo # 按照提示选择需要的特性TypeScript, Vue Router, Pinia cd vue-login-demo npm install # 安装UI库这里以Element Plus为例可根据喜好选择Ant Design Vue, Naive UI等 npm install element-plus element-plus/icons-vue # 安装HTTP客户端和状态管理库 npm install axios pinia2.2 目录结构规划一个清晰的目录结构是良好架构的开始。我们的src目录会这样组织src/ ├── api/ # 所有接口请求封装 │ ├── modules/ # 按模块划分的接口文件如auth.ts │ └── index.ts # 统一导出和axios实例配置 ├── stores/ # Pinia状态管理 │ └── user.ts # 用户状态存储 ├── router/ # 路由配置 │ ├── index.ts │ └── guards.ts # 路由守卫逻辑 ├── views/ # 页面组件 │ ├── Login.vue # 登录页 │ └── Home.vue # 主页登录后 ├── components/ # 可复用组件 ├── utils/ # 工具函数如token操作 └── types/ # TypeScript类型定义这个结构将登录相关的逻辑清晰地分散到了api数据、stores状态、router权限和views视图中符合“关注点分离”的原则。3. 从零构建登录页面与表单交互登录页是用户的第一印象我们需要一个简洁、友好且健壮的表单。3.1 基础表单组件搭建在views/Login.vue中我们使用Element Plus快速搭建一个表单。这里的关键不在于UI多炫酷而在于表单验证和用户体验。template div classlogin-container el-card classlogin-box h2系统登录/h2 el-form refloginFormRef :modelloginForm :rulesloginRules keyup.enterhandleLogin el-form-item propusername el-input v-modelloginForm.username placeholder请输入用户名 :prefix-iconUser sizelarge / /el-form-item el-form-item proppassword el-input v-modelloginForm.password typepassword placeholder请输入密码 :prefix-iconLock sizelarge show-password / /el-form-item el-form-item el-button :loadingloading typeprimary sizelarge clickhandleLogin classlogin-btn 登录 /el-button /el-form-item /el-form /el-card /div /template script setup langts import { ref, reactive } from vue import { User, Lock } from element-plus/icons-vue import type { FormInstance, FormRules } from element-plus import { useUserStore } from /stores/user import { useRouter } from vue-router const router useRouter() const userStore useUserStore() // 表单引用与数据 const loginFormRef refFormInstance() const loginForm reactive({ username: , password: }) const loading ref(false) // 表单验证规则 const loginRules: FormRules { username: [ { required: true, message: 用户名不能为空, trigger: blur }, { min: 3, max: 20, message: 长度在 3 到 20 个字符, trigger: blur } ], password: [ { required: true, message: 密码不能为空, trigger: blur }, { min: 6, max: 20, message: 长度在 6 到 20 个字符, trigger: blur } ] } // 登录处理函数 const handleLogin async () { // 1. 表单预验证 if (!loginFormRef.value) return const valid await loginFormRef.value.validate() if (!valid) return // 2. 开始加载 loading.value true try { // 3. 调用Pinia Action进行登录 await userStore.login(loginForm) // 4. 登录成功跳转到首页或重定向页 router.push(/) } catch (error) { // 错误处理已在userStore中统一进行这里可以补充一些UI提示 console.error(登录失败:, error) } finally { // 5. 无论成功失败都关闭加载状态 loading.value false } } /script注意这里将核心的登录逻辑userStore.login()抽离到了Pinia Store中这是非常关键的一步。它保证了业务逻辑与UI组件的分离使得登录逻辑可以被其他组件如导航栏的退出登录复用也更容易进行单元测试。3.2 表单验证的实战心得表单验证看似简单但细节决定体验即时验证 vs 提交时验证我们采用了blur失去焦点触发验证这是一种平衡。纯change输入变化验证会过于频繁打扰用户而只在submit时验证反馈又太慢。blur是一个不错的折中点。防重复提交通过loading状态和按钮的:loading属性有效防止了用户在请求未返回时连续点击导致的重复提交。键盘回车支持keyup.enterhandleLogin是一个提升用户体验的细节符合用户习惯。错误信息展示Element Plus的FormItem会自动在对应表单项下方显示message我们无需手动控制保持简洁。4. 状态管理Pinia与Token持久化方案这是登录功能最核心的部分之一。我们需要一个中心化的地方来管理用户的登录状态、Token和基本信息。4.1 创建用户状态存储在stores/user.ts中我们定义User Store// types/user.ts - 先定义类型 export interface ILoginForm { username: string password: string } export interface IUserInfo { id: number name: string avatar: string roles: string[] // 用于权限控制 } // stores/user.ts import { defineStore } from pinia import { ref, computed } from vue import { loginApi, getUserInfoApi, logoutApi } from /api/modules/auth import type { ILoginForm, IUserInfo } from /types/user import { setToken, getToken, removeToken } from /utils/auth export const useUserStore defineStore(user, () { // State const token refstring(getToken() || ) // 从本地存储初始化 const userInfo refIUserInfo | null(null) const roles refstring[]([]) // Getters const isLoggedIn computed(() !!token.value) const userName computed(() userInfo.value?.name || ) // Actions const login async (loginForm: ILoginForm) { try { // 调用登录接口 const { data } await loginApi(loginForm) // 假设接口返回 { code: 200, data: { token: xxx } } const accessToken data.token // 1. 存入state token.value accessToken // 2. 持久化到本地存储关键 setToken(accessToken) // 3. 获取用户详情信息 await getUserInfo() } catch (error) { // 清除可能已存储的token token.value removeToken() // 向上抛出错误由UI层处理如显示错误提示 throw error } } const getUserInfo async () { try { // 携带token调用获取用户信息接口 const { data } await getUserInfoApi() userInfo.value data roles.value data.roles || [] } catch (error) { // 获取用户信息失败可能是token失效执行登出 logout() throw error } } const logout async () { try { // 可选调用服务端登出接口让服务端使token失效 await logoutApi() } finally { // 无论服务端登出成功与否前端都必须清理 resetToken() } } const resetToken () { // 清空state token.value userInfo.value null roles.value [] // 清空本地存储 removeToken() } return { token, userInfo, roles, isLoggedIn, userName, login, getUserInfo, logout, resetToken } })4.2 Token持久化的安全考量与工具函数Token的存储位置是一个安全与便利的权衡。常见的方案有LocalStorage最方便但易受XSS攻击。SessionStorage页面关闭即消失体验不好。Cookie (HttpOnly)可防XSS但需注意CSRF防护且前端JS无法直接读写。对于大多数中后台管理系统采用LocalStorage是主流做法因为其便利性远大于风险后台系统通常XSS攻击面较小。我们可以在utils/auth.ts中封装相关操作// utils/auth.ts const TOKEN_KEY access_token export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY) } export function setToken(token: string): void { localStorage.setItem(TOKEN_KEY, token) } export function removeToken(): void { localStorage.removeItem(TOKEN_KEY) } // 可选设置token过期时间如果后端返回了expires_in export function setTokenWithExpiry(token: string, expiresIn: number): void { const now new Date() const expiryTime now.getTime() expiresIn * 1000 localStorage.setItem(TOKEN_KEY, token) localStorage.setItem(${TOKEN_KEY}_expiry, expiryTime.toString()) } export function getTokenWithExpiry(): string | null { const token localStorage.getItem(TOKEN_KEY) const expiryTime localStorage.getItem(${TOKEN_KEY}_expiry) if (!token || !expiryTime) { return null } if (Date.now() parseInt(expiryTime)) { // Token已过期清理 removeToken() localStorage.removeItem(${TOKEN_KEY}_expiry) return null } return token }重要经验永远不要只依赖前端来判断Token是否有效。前端存储的过期时间只是一个优化手段用于提前清除明显过期的token。真正的有效性验证必须在每次请求用户信息等敏感接口时由后端返回的HTTP状态码如401来决定。前端在收到401后应强制跳转回登录页。5. 路由守卫与权限控制实战路由守卫是守护应用内页面访问权限的“门卫”。没有它用户即使不登录也能直接通过URL访问内部页面。5.1 路由配置与元信息首先在router/index.ts中定义路由并使用meta字段标记哪些路由需要认证。import { createRouter, createWebHistory } from vue-router import type { RouteRecordRaw } from vue-router const routes: ArrayRouteRecordRaw [ { path: /login, name: Login, component: () import(/views/Login.vue), meta: { requiresAuth: false } // 明确标记登录页不需要认证 }, { path: /, name: Home, component: () import(/views/Home.vue), meta: { requiresAuth: true } // 需要认证才能访问 }, { path: /admin, name: Admin, component: () import(/views/Admin.vue), meta: { requiresAuth: true, roles: [admin] } // 需要认证且角色为admin }, // 404页面 { path: /:pathMatch(.*)*, name: NotFound, component: () import(/views/NotFound.vue) } ] const router createRouter({ history: createWebHistory(), routes }) export default router5.2 实现全局前置守卫在router/guards.ts中或在router/index.ts中直接编写我们实现核心的守卫逻辑// router/guards.ts import type { Router } from vue-router import { useUserStore } from /stores/user import { getToken } from /utils/auth export function setupRouterGuard(router: Router) { // 全局前置守卫 router.beforeEach(async (to, from, next) { const userStore useUserStore() const hasToken getToken() // 从本地存储检查token // 1. 判断目标路由是否需要认证 if (to.meta.requiresAuth false) { // 前往无需认证的页面如登录页直接放行 next() return } // 2. 有Token的情况 if (hasToken) { if (to.path /login) { // 已登录却想去登录页重定向到首页 next({ path: / }) } else { // 检查用户信息是否已获取 if (userStore.userInfo) { // 已获取用户信息检查角色权限 checkUserRole(to, userStore.roles, next) } else { try { // 未获取用户信息则发起请求获取 await userStore.getUserInfo() // 获取成功后再次检查角色权限 checkUserRole(to, userStore.roles, next) } catch (error) { // 获取用户信息失败如token失效清除token并跳转到登录页 await userStore.resetToken() next(/login?redirect${encodeURIComponent(to.fullPath)}) } } } } else { // 3. 没有Token的情况 if (to.meta.requiresAuth) { // 想去需要认证的页面重定向到登录页并记录目标地址以便登录后回跳 next(/login?redirect${encodeURIComponent(to.fullPath)}) } else { // 想去无需认证的页面放行 next() } } }) } // 辅助函数检查用户角色是否匹配路由要求的角色 function checkUserRole(to: any, userRoles: string[], next: Function) { const requiredRoles to.meta.roles as string[] | undefined if (requiredRoles requiredRoles.length 0) { // 路由要求特定角色 const hasRole userRoles.some(role requiredRoles.includes(role)) if (hasRole) { next() } else { // 角色不符跳转到无权限页面或首页 next({ path: /403 }) // 需要先定义403路由 } } else { // 路由不要求特定角色有权限即可访问 next() } }最后在main.ts中应用这个守卫// main.ts import { createApp } from vue import App from ./App.vue import router from ./router import { setupRouterGuard } from ./router/guards const app createApp(App) app.use(router) // 安装路由守卫 setupRouterGuard(router) app.mount(#app)5.3 路由守卫的常见“坑”与解决方案无限重定向循环这是最常见的坑。场景Token过期 - 守卫跳转/login-/login页的created钩子又去检查Token - 发现过期Token无效 - 又触发某个跳转... 解决方案在登录页组件中避免在初始化时进行任何可能触发路由跳转的认证检查。或者确保守卫逻辑中的条件判断互斥且周全。异步获取用户信息导致的白屏在beforeEach中await userStore.getUserInfo()时页面会等待请求完成才渲染。如果网络慢用户会看到白屏。优化方案方案A推荐在应用根组件如App.vue的onMounted中主动获取一次用户信息并存储起来。这样守卫中只需要判断userInfo是否存在无需等待请求。方案B对于非敏感路由如首页可以先放行让页面骨架屏先出来然后在页面组件内再去获取和渲染用户相关数据。动态路由的权限集成如果路由表是根据用户角色动态生成的情况会更复杂。通常需要在登录成功后根据角色权限通过router.addRoute()动态添加路由然后再进行跳转。这要求守卫逻辑能处理“路由尚未添加”的状态。6. 网络请求层Axios的精细化封装一个健壮的登录模块离不开强大的网络请求层。我们需要统一处理Token的携带、请求拦截、响应拦截和错误处理。6.1 创建Axios实例与请求拦截器在api/index.ts中// api/index.ts import axios from axios import type { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from axios import { getToken } from /utils/auth import { useUserStore } from /stores/user import router from /router // 创建axios实例 const service: AxiosInstance axios.create({ baseURL: import.meta.env.VITE_APP_BASE_API, // 从环境变量读取 timeout: 10000 // 10秒超时 }) // 请求拦截器 service.interceptors.request.use( (config: InternalAxiosRequestConfig) { // 在发送请求前做些什么 const token getToken() if (token) { // 如果token存在将其添加到请求头 config.headers.Authorization Bearer ${token} } return config }, (error) { // 对请求错误做些什么 return Promise.reject(error) } ) // 响应拦截器 service.interceptors.response.use( (response: AxiosResponse) { // 对响应数据做点什么 const res response.data // 假设后端统一返回格式为 { code: number, data: any, message: string } if (res.code 200) { return res.data // 直接返回业务数据 } else { // 业务逻辑错误如密码错误 // 可以在这里统一进行消息提示 console.error(请求错误 [${response.config.url}]:, res.message) return Promise.reject(new Error(res.message || Error)) } }, (error) { // 对响应错误做点什么 (HTTP状态码非2xx) console.error(请求失败:, error.response?.status, error.message) if (error.response) { switch (error.response.status) { case 401: // Token过期或无效 const userStore useUserStore() userStore.resetToken() // 跳转到登录页并携带当前页面路径 router.push(/login?redirect${encodeURIComponent(router.currentRoute.value.fullPath)}) break case 403: // 权限不足 router.push(/403) break case 404: // 资源不存在 router.push(/404) break case 500: // 服务器内部错误 console.error(服务器内部错误请联系管理员) break default: console.error(未知错误: ${error.response.status}) } } else if (error.request) { // 请求已发出但没有收到响应网络错误 console.error(网络错误请检查您的网络连接) } else { // 请求配置出错 console.error(请求配置错误:, error.message) } return Promise.reject(error) } ) export default service6.2 按模块封装API请求在api/modules/auth.ts中我们封装登录相关的具体请求// api/modules/auth.ts import request from /api/index import type { ILoginForm, IUserInfo } from /types/user // 登录接口 export function loginApi(data: ILoginForm) { // 注意这里返回的是Promise响应拦截器会帮我们提取出 res.data return request.post{ token: string }(/auth/login, data) } // 获取用户信息接口 export function getUserInfoApi() { return request.getIUserInfo(/auth/userinfo) } // 登出接口 export function logoutApi() { return request.post(/auth/logout) }6.3 拦截器中的关键细节Token的动态获取在请求拦截器中我们使用getToken()工具函数从localStorage读取而不是直接从Pinia Store读取。这是因为Store可能在页面刷新后重置而localStorage是持久化的能保证在应用初始化早期Store可能还未恢复水合拦截器也能拿到Token。错误处理的粒度在响应拦截器中我们将错误分为业务逻辑错误后端返回的特定code如1001表示密码错误和HTTP错误401 403等。业务错误通常由调用方在catch中处理并展示给用户如“用户名或密码错误”而HTTP错误如401则在拦截器中统一处理强制进行登出和跳转。避免在拦截器中直接使用UI组件拦截器是纯JS环境无法直接使用ElMessage等UI组件进行提示。一种做法是通过触发一个全局事件如EventBus或者在拦截器中reject错误由具体的页面调用处来捕获并展示UI提示。上面的示例采用了后者将错误信息reject出去。7. 登录功能的扩展与进阶优化一个基础的登录流程跑通后我们还可以从用户体验和安全角度进行诸多优化。7.1 自动登录与登录状态保持用户关闭浏览器再打开或者刷新页面后如何保持登录状态这需要一套完整的初始化逻辑。方案在应用入口处初始化用户状态在App.vue或一个专门的初始化函数中!-- App.vue -- script setup langts import { onMounted } from vue import { useUserStore } from /stores/user const userStore useUserStore() onMounted(async () { // 检查本地是否有token const token localStorage.getItem(access_token) if (token) { try { // 有token则尝试获取用户信息以验证token有效性 await userStore.getUserInfo() console.log(自动登录成功) } catch (error) { // token无效清除状态 console.log(token已失效请重新登录) userStore.resetToken() } } }) /script7.2 添加验证码与防暴力破解对于安全性要求更高的系统登录时需要图形验证码或短信验证码。前端实现要点获取验证码在登录页加载时或点击验证码图片时请求/auth/captcha接口后端返回一个图片Base64或唯一ID。携带验证码将用户输入的验证码或验证码ID作为参数与用户名密码一同提交到登录接口。错误处理当验证码错误时后端应返回明确错误码前端需刷新验证码图片。// 在Login.vue的data或store中增加 const captcha reactive({ image: , // base64图片 key: , // 后端返回的验证码唯一标识 code: // 用户输入的验证码 }) // 获取验证码的函数 const getCaptcha async () { const { data } await getCaptchaApi() captcha.image data:image/png;base64,${data.image} captcha.key data.key }7.3 多标签页同步登录状态当用户在A标签页退出登录B标签页应如何感知并同步状态可以使用localStorage的storage事件或BroadcastChannelAPI。使用storage事件的简单方案在App.vue或一个全局监听器中// 监听localStorage变化 window.addEventListener(storage, (event) { if (event.key access_token event.newValue null) { // 检测到token被清空其他标签页退出登录 const userStore useUserStore() userStore.resetToken() // 跳转到登录页 router.push(/login) } })在退出登录的logout函数中主动清除localStorage从而触发其他标签页的监听事件。7.4 请求重试与Token刷新双Token方案对于使用Access Token Refresh Token的无感刷新方案前端逻辑会复杂一些。基本流程登录成功后后端返回access_token短期如2小时和refresh_token长期如7天。前端将两者都安全存储。当access_token过期请求接口返回401时前端不是直接跳登录页而是尝试用refresh_token去调用刷新接口获取新的access_token。刷新成功后用新token重试原先失败的请求用户无感知。如果refresh_token也过期或无效则彻底跳转登录页。这需要在Axios响应拦截器中实现一个复杂的“请求队列”和“刷新锁”机制以避免并发请求时多次刷新Token。由于实现较为复杂此处不展开代码但它是构建企业级应用的重要考量。8. 完整的登录流程串联与调试现在让我们把所有的碎片串联起来看看一个完整的登录流程是如何工作的用户访问用户打开应用访问首页/。路由守卫拦截全局前置守卫beforeEach触发。检查localStorage无Token且目标路由meta.requiresAuth为true。守卫将用户重定向到/login?redirect/。登录页展示登录页组件Login.vue加载渲染表单。用户提交用户输入信息并提交触发handleLogin。调用Store ActionhandleLogin调用userStore.login(formData)。发起网络请求在Store的loginaction中通过封装好的loginApi发起POST请求。Axios请求拦截器自动将Token此时还没有或未来的Token加入请求头。处理响应请求成功后端返回Token。Store的loginaction将Token存入localStorage和Pinia state然后调用getUserInfo获取用户详情。状态更新与跳转用户信息获取成功更新Store。Login.vue中的try块执行完毕router.push(/)跳转回首页。再次触发守卫跳转到/再次触发beforeEach。此时守卫检测到有Token且已存在userInfo检查角色权限如有后放行。首页渲染首页组件Home.vue加载可以通过userStore获取并显示用户信息。调试技巧与常见问题排查问题登录成功但不跳转或跳转后用户信息消失。检查1Vue Devtools中查看Pinia Store的state确认token和userInfo是否已正确更新。检查2Application面板查看localStorage确认Token是否已持久化。检查3Network面板查看获取用户信息的请求是否成功响应数据是否符合预期类型。检查4检查路由守卫逻辑特别是hasToken和userInfo的判断条件以及next()的调用路径确保没有逻辑漏洞导致无限循环。问题页面刷新后登录状态丢失。检查确保在App.vue或Store的初始化中有从localStorage读取Token并尝试恢复用户状态的逻辑。问题接口请求报401但Token明明存在。检查1在Axios请求拦截器中打印config.headers.Authorization确认Token格式正确如Bearer xxx。检查2检查后端Token验证逻辑和过期时间。检查3可能是跨域问题导致请求头未携带。检查后端CORS配置确保允许Authorization头。构建一个完整的登录功能就像搭建一个精密的仪表盘每一个齿轮表单、验证、请求、状态、路由都必须严丝合缝。这个“VueDemo-11”项目从最直观的登录页面开始层层深入到状态管理、路由权限和网络封装的底层逻辑几乎涵盖了一个生产级登录模块所需的所有核心知识。在实际开发中你还需要根据具体的业务需求如SSO单点登录、第三方登录、动态路由等进行适配和扩展。希望这份详细的拆解和实战经验能帮助你少走弯路构建出更稳定、安全的前端应用入口。
返回列表