Vue登录全流程:验证码解锁+JWT认证+Vuex状态管理实战
1. 项目概述与核心需求解析1.1 从一道八股文题目说起最近在整理前端面试资料时遇到了一个很有意思的题目如何实现一个带验证码解锁的登录页面并且用JWT完成身份认证整个登录状态用Vuex来管理。乍一看这就像个标准八股文题但真动手写一遍才发现里面的坑比面试题本身要深得多。先说结论这道题考察的是前端工程中最常见的三个模块——验证码、JWT令牌和全局状态管理。它们组合在一起就是一个完整的前后端分离登录链路。如果你能把这套逻辑理清楚面试时基本能覆盖登录相关的所有问题更重要的是你在实际项目中真的能直接复用这套方案。我这次实现的目标很明确做一个纯前端可运行的演示项目后端用Node.js模拟前端用Vue 2 Vuex 3重点落在“验证码前端生成与校验”“JWT签发与鉴权流程”“Vuex持久化登录态”三条主线上。注意这个项目里的JWT密钥、token过期策略、验证码生成逻辑全部属于演示性质。真实项目中JWT密钥必须存放在服务端环境变量中且验证码校验一定要在服务端完成纯前端校验只适用于教学场景。1.2 整体技术选型与适用场景这个项目适合三种人看准备前端面试的候选人、刚接手公司后台管理系统登录模块的新人、以及想搞清楚“Vuex到底怎么管理登录态”的初中级开发者。技术栈选型如下模块方案选型理由前端框架Vue 2.6 Vue Router 3存量项目占比高面试题多基于此版本状态管理Vuex 3登录态天然适合全局共享避免props层层传递验证码Canvas动态绘制不依赖第三方库能讲清楚生成原理JWT处理jsrsasign浏览器端模拟 后端jsonwebtoken思想演示完整签发与验签流程数据请求axios 请求拦截器统一注入token处理401场景为什么选这套组合而不是 Vue 3 Pinia说实话Vue 3 Pinia 现在也很主流但八股文面试里 Vuex 的 mutation/action 概念仍然是高频考点而且很多公司存量项目还是 Vue 2。我这次用的是兼容性最强的方案代码思路迁移到 Vue 3 只需要改少量 API 名整体设计完全一致。在实际项目中这个方案覆盖的远不止“登录”这一个场景。权限控制、用户信息缓存、多标签页同步登录状态、token续签全都是依赖这套基础架构的。2. 验证码解锁的前端实现细节2.1 Canvas验证码生成的核心原理验证码的本质是一次性的“人机校验凭证”。我在项目里选择了Canvas绘制方案而不是后端返回图片流。原因很实际纯前端方案能完整展示生成逻辑对理解“前端验证码为什么可以被绕过”这件事非常有帮助。生成验证码的核心代码分为三块随机字符生成、Canvas绘制、干扰元素处理。// utils/captcha.js const CODE_LENGTH 4; const CHARACTERS ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789; function randomChar() { const index Math.floor(Math.random() * CHARACTERS.length); return CHARACTERS[index]; } function generateCaptcha(canvas) { const ctx canvas.getContext(2d); const width canvas.width; const height canvas.height; // 清空画布 ctx.fillStyle #f0f2f5; ctx.fillRect(0, 0, width, height); // 生成随机字符 let code ; for (let i 0; i CODE_LENGTH; i) { code randomChar(); } // 逐个绘制字符带随机旋转和位移 const chars code.split(); chars.forEach((char, index) { const fontSize 28 Math.floor(Math.random() * 8); ctx.font bold ${fontSize}px Arial; ctx.fillStyle rgb(${Math.floor(Math.random() * 120)}, ${Math.floor(Math.random() * 120)}, ${Math.floor(Math.random() * 120)}); const x 10 index * 22 Math.floor(Math.random() * 6); const y 30 Math.floor(Math.random() * 10); const angle (Math.random() - 0.5) * 0.6; ctx.save(); ctx.translate(x, y); ctx.rotate(angle); ctx.fillText(char, 0, 0); ctx.restore(); }); // 绘制干扰线和噪点 for (let i 0; i 5; i) { ctx.strokeStyle rgba(${Math.floor(Math.random() * 200)}, ${Math.floor(Math.random() * 200)}, ${Math.floor(Math.random() * 200)}, 0.4); ctx.beginPath(); ctx.moveTo(Math.floor(Math.random() * width), Math.floor(Math.random() * height)); ctx.lineTo(Math.floor(Math.random() * width), Math.floor(Math.random() * height)); ctx.stroke(); } for (let i 0; i 30; i) { ctx.fillStyle rgba(${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, 0.5); ctx.beginPath(); ctx.arc(Math.floor(Math.random() * width), Math.floor(Math.random() * height), 1, 0, Math.PI * 2); ctx.fill(); } return code.toLowerCase(); }这里有个细节值得注意验证码字符主动去掉了容易混淆的字母和数字比如大写的I和小写的l、数字0和字母O。这是实际项目中一个很容易被忽略但又很重要的设计。如果验证码里同时出现这些类似字符用户会反复输错体验极差。2.2 前端验证码校验的局限与改进思路纯前端验证码最大的问题在于代码是暴露给用户的任何人都能通过浏览器控制台手动修改验证码的状态。所以我在项目里做了这样一层设计——前端生成验证码后把验证码的hash值使用简单混淆函数存储到内存变量中提交登录时必须带上前端计算出的hash与用户输入值后端或模拟后端会重新从hash中解出验证码比对。严格来说这种“安全”在产品环境是远远不够的。真实项目的验证码应该由后端生成并存储前端只负责展示后端下发的图片Base64或图片URL提交时由后端校验。我之所以在项目里用纯前端模拟是想先把“生成逻辑”讲清楚再让读者理解“为什么必须挪到服务端”。如果是在真实项目里我会这样做后端生成验证码后在服务端Session或Redis中保存验证码文本再生成一张带干扰的图片返回给前端。前端提交表单时把验证码文本一起提交后端比对正确后立即删除该验证码防止重复使用。这个方案虽然多一次网络请求但安全性完全不是一个等级。2.3 验证码刷新与倒计时的交互设计验证码解锁页面上还有一个容易被面试官追问的细节点击验证码图片刷新、错误后自动刷新、发送验证码按钮倒计时。!-- components/CaptchaInput.vue -- template div classcaptcha-wrapper input v-modelcaptchaText placeholder请输入验证码 maxlength4 / canvas refcaptchaCanvas width120 height44 clickrefreshCaptcha title点击刷新验证码 /canvas /div /template script export default { data() { return { captchaText: , currentCaptchaHash: , }; }, mounted() { this.refreshCaptcha(); }, methods: { refreshCaptcha() { const canvas this.$refs.captchaCanvas; const code generateCaptcha(canvas); this.currentCaptchaHash simpleHash(code); this.captchaText ; }, validateCaptcha() { const inputHash simpleHash(this.captchaText.trim().toLowerCase()); return inputHash this.currentCaptchaHash; }, }, }; /script这里面有个实际体验问题用户输了半天验证码一刷新验证码图片输入框内容必须同步清空。很多入门项目会把这两个状态割裂开导致用户以为验证码错了一直点刷新一直输最后恼火。细节决定体验这个点写进简历的“注重细节”是站得住脚的。simpleHash函数我用了简单的字符串混淆思路是遍历每个字符将字符编码乘一个固定质数后取模再转为十六进制。当然这只是用来演示状态关联的不是真正的加密。真正的安全校验永远依赖服务端。3. JWT登录机制的深度拆解3.1 JWT的结构与签名原理JWTJSON Web Token本质上是一个经过签名的JSON字符串。它由三部分组成Header头部、Payload载荷、Signature签名用点号分隔形如xxxxx.yyyyy.zzzzz。Header部分通常长这样{ alg: HS256, typ: JWT }它声明了使用的签名算法和令牌类型。Payload部分是核心数据区存放用户标识、过期时间、签发时间等{ sub: user_001, username: admin, iat: 1735800000, exp: 1735886400 }这里最关键的字段是exp过期时间戳和iat签发时间戳。过期时间是JWT安全性的命门如果设置过长token泄露后风险极大设置过短用户频繁重新登录体验又差。实际项目中常用2小时作为访问令牌的有效期。Signature签名是JWT防止被篡改的核心机制。以HS256算法为例签名的计算过程是HMACSHA256( base64UrlEncode(Header) . base64UrlEncode(Payload), secretKey )也就是把前两段的Base64Url编码结果用点号拼接再用密钥进行HMAC-SHA256运算最后把运算结果再做Base64Url编码。整个过程可以类比成“私章盖印”服务端用自己手里的印章密钥在内容上盖个章任何拿到token的人改了内容印章就失效了接收方一验就知道文件被动过。我写了一个辅助函数来演示这个解码和验签的过程// utils/jwt.js const BASE64_URL_CHARS ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_; function base64UrlEncode(str) { const utf8Str encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) String.fromCharCode(parseInt(p1, 16)) ); const base64 btoa(utf8Str); return base64.replace(/\/g, -).replace(/\//g, _).replace(/$/, ); } function base64UrlDecode(str) { const base64 str.replace(/-/g, ).replace(/_/g, /); const padded base64.padEnd(Math.ceil(base64.length / 4) * 4, ); const utf8Str atob(padded); return decodeURIComponent(utf8Str.replace(/%([0-9A-F]{2})/g, (_, p1) String.fromCharCode(parseInt(p1, 16)) )); } function createToken(payload, secret) { const header { alg: HS256, typ: JWT }; const encodedHeader base64UrlEncode(JSON.stringify(header)); const encodedPayload base64UrlEncode(JSON.stringify(payload)); const signature simpleHmacSha256(${encodedHeader}.${encodedPayload}, secret); return ${encodedHeader}.${encodedPayload}.${signature}; }3.2 JWT登录流程的完整时序登录流程不是“拿到token就完事”它包含签发、存储、携带、校验、续签五个环节。我把整个流程串一下前端提交用户名、密码、验证码到后端后端校验验证码再校验用户名密码校验通过后服务端生成JWTpayload中包含userId、username、exp等字段前端收到token后存入localStorage同时调用Vuex的action更新state后续每次请求axios请求拦截器自动在Authorization头里带上Bearer token后端拿到token后验签、检查exp通过后放行请求若token过期接口返回401前端捕获后用refreshToken尝试续签续签失败则跳转登录页第7步的token续签是目前面试里问得最多的一块。主流方案有两种双token机制accessToken短期有效refreshToken长期有效和静默续期。我在项目里用的是双token机制刷新令牌的接口单独设计// api/auth.js export function refreshToken(refreshToken) { return request({ url: /api/auth/refresh, method: post, data: { refreshToken }, }); }刷新接口返回新的accessToken和新的refreshToken前端拿到后更新Vuex和localStorage然后用原请求的配置重发一次。这里的关键点在于如果有多个请求同时401不能每个请求都去调刷新接口否则会打爆后端。正确做法是维护一个“是否正在刷新”的标记刷新期间其他401请求先进入队列等待刷新完成后统一重放。3.3 前端如何安全地存储JWTtoken存哪里这是前端面试的经典送命题。localStorage、sessionStorage、cookie三种方案各有优劣但最核心的准则是凡是能通过脚本读取的存储都存在XSS窃取风险。存储方式优点缺点适用场景localStorage持久化、跨标签页共享易被XSS窃取非敏感系统的登录tokensessionStorage关闭标签页即失效多标签页不共享临时性操作凭证cookieHttpOnlyJS不可读、天然防XSS需处理CSRF高安全要求系统我在项目里选择localStorage原因很实在演示项目没有高安全要求而且localStorage跨标签页共享这个特性让“用户在一个标签页登录另一个标签页同步登录状态”实现起来非常自然这对后台管理系统特别有用。如果做高安全要求的系统我建议用HttpOnly Cookie存token后端用SameSite和CSRF Token双重防护。但那种方案的实现复杂度高很多而且前后端联调时对跨域配置有严格要求不适合用来回答“如何实现登录”的基础问题。3.4 JWT的过期策略与自动退出现在很多前端项目存在一个特别常见的体验问题token过期后用户没有任何提示点一个按钮才发现接口全报401然后被强制踢回登录页刚才填的表单内容全丢了。这跟没做“过期预判”有很大关系。我在项目里做了这样两件事第一在axios响应拦截器里统一捕获401service.interceptors.response.use( (response) response.data, (error) { if (error.response error.response.status 401) { const config error.config; if (!config._retry) { config._retry true; const refresh store.state.user.refreshToken; if (refresh) { return refreshToken(refresh) .then((res) { store.commit(user/SET_TOKEN, res.accessToken); config.headers[Authorization] Bearer ${res.accessToken}; return service(config); }) .catch(() { store.dispatch(user/logout); router.push(/login); return Promise.reject(error); }); } } store.dispatch(user/logout); router.push(/login); } return Promise.reject(error); } );第二在路由守卫里做“token存在性预判”router.beforeEach((to, from, next) { const token store.state.user.token; if (to.path ! /login !token) { next(/login); } else if (to.path /login token) { next(/); } else { next(); } });注意这里的_retry标记非常重要。如果不加这个标记一旦刷新token接口本身返回401就会陷入“刷新-失败-又刷新”的死循环。标记的作用是保证同一个请求最多只刷新一次token。4. Vuex中的登录状态设计4.1 状态模块的划分与设计思路Vuex管理登录状态最忌讳的是把所有状态塞进一个store里。我习惯按功能域拆成modules这里拆出user模块和app模块。user管登录态app管全局UI状态。刻意保持这种拆分的习惯到了项目后期收益会非常大。user模块的核心设计// store/modules/user.js const state { token: localStorage.getItem(token) || , refreshToken: localStorage.getItem(refreshToken) || , userInfo: JSON.parse(localStorage.getItem(userInfo) || null), isLoggedIn: !!localStorage.getItem(token), }; const mutations { SET_TOKEN(state, token) { state.token token; state.isLoggedIn !!token; localStorage.setItem(token, token); }, SET_REFRESH_TOKEN(state, refreshToken) { state.refreshToken refreshToken; localStorage.setItem(refreshToken, refreshToken); }, SET_USER_INFO(state, userInfo) { state.userInfo userInfo; localStorage.setItem(userInfo, JSON.stringify(userInfo)); }, RESET_STATE(state) { state.token ; state.refreshToken ; state.userInfo null; state.isLoggedIn false; localStorage.removeItem(token); localStorage.removeItem(refreshToken); localStorage.removeItem(userInfo); }, }; const actions { async login({ commit }, payload) { const loginRes await loginApi(payload); commit(SET_TOKEN, loginRes.token); commit(SET_REFRESH_TOKEN, loginRes.refreshToken); const infoRes await getUserInfoApi(loginRes.token); commit(SET_USER_INFO, infoRes); return infoRes; }, async logout({ commit }) { await logoutApi(); commit(RESET_STATE); window.location.href /login; }, };把token写入localStorage的逻辑放在mutation里而不是在组件里遵循了“状态变更必须有迹可循”的原则。任何地方改了token一定经过mutation排查问题的时候只需要搜mutation就够了。4.2 getters与Vuex持久化的配合技巧Vuex的state是存储在内存中的刷新页面就全部丢失。所以需要在初始化state时从localStorage恢复数据或者在每次mutation提交时同步写入localStorage。我在上面的代码里采用了后者逻辑更直接。我在getters里做了一个“便捷登录状态”导出避免在模板里写很长的表达式const getters { isLoggedIn: (state) state.isLoggedIn, displayName: (state) { if (!state.userInfo) return 未登录; return state.userInfo.nickname || state.userInfo.username; }, avatar: (state) state.userInfo?.avatar || 默认头像URL, }; export default { namespaced: true, state, mutations, actions, getters, };这里namespaced: true必须加上。如果不加不同模块之间的mutation可能重名在大型项目里会引发非常隐蔽的bug。另外提一个实际项目里很有用的场景让两个标签页同步登录状态。利用storage事件在一个标签页logout时另一个标签页也能自动清除登录态。window.addEventListener(storage, (event) { if (event.key token !event.newValue) { store.commit(user/RESET_STATE); } });这个效果如果不用storage事件要实现跨标签页同步就得用BroadcastChannel或WebSocket成本高得多。而localStorage天生就是跨tab共享的监听storage事件是性价比最高的方案。4.3 登录页的完整实现与状态流转登录页是整个模块的入口。我的页面做成了“验证码解锁 账号密码登录”两段式交互这样设计的好处是用户先完成验证码校验再进入账号密码输入逻辑分离得更清楚而且天然防止了暴力破解的批量请求。!-- views/Login.vue关键结构 -- template div classlogin-page div classlogin-card h2后台管理系统/h2 !-- 第一步验证码解锁 -- div v-if!unlocked classcaptcha-lock p完成验证码校验后解锁登录表单/p CaptchaInput refcaptchaInput / el-button typeprimary clickhandleUnlock解 锁/el-button /div !-- 第二步账号密码登录 -- div v-else classlogin-form el-form :modelloginForm :rulesloginRules refloginFormRef el-form-item propusername el-input v-modelloginForm.username placeholder请输入用户名 / /el-form-item el-form-item proppassword el-input v-modelloginForm.password typepassword placeholder请输入密码 / /el-form-item el-button typeprimary :loadingloading clickhandleLogin 登 录 /el-button /el-form /div /div /div /template登录按钮回调里的状态流转async handleLogin() { this.loading true; try { await this.$store.dispatch(user/login, { username: this.loginForm.username, password: this.loginForm.password, }); this.$message.success(登录成功); this.$router.push(/dashboard); } catch (error) { // 登录失败重置验证码重新锁定 this.unlocked false; this.$nextTick(() this.$refs.captchaInput.refreshCaptcha()); } finally { this.loading false; } }5. 常见问题与排查技巧实录5.1 JWT解码遇坑记录我在调试过程中遇到过几个经典问题挑三个最典型的分享。第一个是Base64Url解码时中文乱码。JWT payload里如果存了中文字段比如nickname浏览器端的atob函数解码出来是乱码。这是因为atob直接处理的是二进制字符串中文UTF-8编码后需要先转成字节数组再解码。修复方案就是先decodeURIComponent再atob或者用TextDecoder我上面的base64UrlDecode函数已经处理了这个情况。第二个是jwt.io在线解析时显示签名无效。这个坑我印象很深不是前后端密钥不一致而是JWT库在生成签名时使用的Base64Url编码对等号的处理不同。JWT规范的Base64Url要去掉padding的等号但有些库实现没有遵循规范生成的token里带等号。前端解析时没有兼容这种情况就会验签失败。处理方式是对签名部分做正则替换去掉末尾的等号const normalizedSignature signature.replace(/$/, );第三是token过期时间用Date.now()误写成了毫秒。JWT规范里exp是秒级时间戳。如果你用了毫秒级时间戳token会瞬间“过期”因为当前秒级时间永远小于毫秒级时间戳。这是我见过最容易犯的错误没有之一。5.2 Cookie跨域导致验证码刷新不生效的排查在真实项目中如果验证码是后端生成并存在Cookie里的前端会遇到“验证码图片刷新了但服务端保存的验证码没变”的问题。这通常是跨域请求没有携带Cookie导致的。排查思路非常固定先看浏览器Network面板里验证码请求的Request Headers中有没有Cookie字段再跨域场景下需要后端设置Access-Control-Allow-Credentials: true并且前端axios必须配置withCredentials: true。两个条件缺一个Cookie都不会带上。这个问题我强调一下验证码校验失败的排查顺序永远是“先查验证码有没有传到后端再查后端存的是不是同一个验证码最后查Cookie/Header有没有正常携带”不要一上来就怀疑验证码算法本身。5.3 Vuex状态与本地存储不同步的焦虑时刻有次做项目时我发现用户退出登录后localStorage里的token已经清除了但Vuex的state里token还是旧值。原因是退出操作直接调用了localStorage.removeItem绕过了mutation。组件里读取的是Vuex state不是localStorage所以视图上显示的还是登录状态。这种问题一旦出现排查起来特别费劲因为“看起来数据清了实际没清干净”。后来总结出的规矩是所有涉及localStorage的读写一律收敛到mutation里组件不允许直接操作storage。这个规矩看着简单但真做起来能省掉很多诡异bug。如果用的是Vuex的持久化插件比如vuex-persistedstate还要注意插件默认是自动同步state到storage的如果又在mutation里手动写一次storage可能会造成写两次、互相覆盖的情况。插件方案选一个就好不要混用。5.4 排查工具推荐jwt.io与浏览器插件调试JWT最常用的工具是jwt.io。它可以在线解析JWT的Header和Payload也能验证签名。不过要注意一点敏感数据的系统不要直接把正式环境的token贴到jwt.io里因为这个token会在浏览器端被JavaScript读取存在泄露风险。本地开发环境或者测试环境问题不大生产环境的token我建议用本地的解码脚本处理或者用VSCode插件JWT Decoder离线解析。浏览器端推荐在DevTools里写Snippets存一段解析token的小脚本function parseJwt(token) { const base64Url token.split(.)[1]; const base64 base64Url.replace(/-/g, ).replace(/_/g, /); const jsonPayload decodeURIComponent( atob(base64).split().map((c) % (00 c.charCodeAt(0).toString(16)).slice(-2)).join() ); return JSON.parse(jsonPayload); }遇到token解析相关的问题直接在DevTools里调用它比打开第三方网站快得多。6. 实操心得与扩展方向这次从题目到完整体验的实践我最大的感受是八股文题目完全可以当做一个微型项目的起点关键在于能否把题干里的每个名词展开成可运行的代码。验证码解锁、JWT登录、Vuex管理这三者单独拿出来都能写一篇长文但串联到一起才是一个完整的“前端系统设计”能力。再分享一个实操心得整个登录模块完成后我建议做一个“攻击模拟”练习——在DevTools里修改JWT的payload比如把exp改大然后用篡改后的token去请求接口观察后端是否验签失败。这样能直观理解JWT“防篡改”到底是什么意思比死记硬背书上的概念有效得多。后续想进一步扩展的话可以沿着三条线走一是做单点登录SSO让同一套登录态在多个子系统之间共享二是引入权限控制把Vuex中的userInfo扩展为角色和权限列表配合路由守卫做动态路由三是在登录流程中加入多因素认证短信验证码或扫码登录把验证码模块升级为真实的生产级方案。代码相关的内容我已经打包整理好了核心思路都在上面。有问题可以随时交流我在实际开发中踩过的坑基本都有记录。