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 class="captcha-wrapper"> <input v-model="captchaText" placeholder="请输入验证码" maxlength="4" /> <canvas ref="captchaCanvas" width="120" height="44" @click="refreshCaptcha" 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的结构与签名原理
JWT(JSON 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就完事”,它包含签发、存储、携带、校验、续签五个环节。我把整个流程串一下:
- 前端提交用户名、密码、验证码到后端
- 后端校验验证码,再校验用户名密码
- 校验通过后,服务端生成JWT,payload中包含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 前端如何安全地存储JWT
token存哪里,这是前端面试的经典送命题。localStorage、sessionStorage、cookie三种方案各有优劣,但最核心的准则是:凡是能通过脚本读取的存储,都存在XSS窃取风险。
| 存储方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| localStorage | 持久化、跨标签页共享 | 易被XSS窃取 | 非敏感系统的登录token |
| sessionStorage | 关闭标签页即失效 | 多标签页不共享 | 临时性操作凭证 |
| cookie(HttpOnly) | JS不可读、天然防XSS | 需处理CSRF | 高安全要求系统 |
我在项目里选择localStorage,原因很实在:演示项目没有高安全要求,而且localStorage跨标签页共享这个特性,让“用户在一个标签页登录,另一个标签页同步登录状态”实现起来非常自然,这对后台管理系统特别有用。
如果做高安全要求的系统,我建议用HttpOnly Cookie存token,后端用SameSite和CSRF Token双重防护。但那种方案的实现复杂度高很多,而且前后端联调时对跨域配置有严格要求,不适合用来回答“如何实现登录”的基础问题。
3.4 JWT的过期策略与自动退出
现在很多前端项目存在一个特别常见的体验问题:token过期后用户没有任何提示,点一个按钮才发现接口全报401,然后被强制踢回登录页,刚才填的表单内容全丢了。这跟没做“过期预判”有很大关系。
我在项目里做了这样两件事:
第一,在axios响应拦截器里统一捕获401:
service.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 class="login-page"> <div class="login-card"> <h2>后台管理系统</h2> <!-- 第一步:验证码解锁 --> <div v-if="!unlocked" class="captcha-lock"> <p>完成验证码校验后解锁登录表单</p> <CaptchaInput ref="captchaInput" /> <el-button type="primary" @click="handleUnlock">解 锁</el-button> </div> <!-- 第二步:账号密码登录 --> <div v-else class="login-form"> <el-form :model="loginForm" :rules="loginRules" ref="loginFormRef"> <el-form-item prop="username"> <el-input v-model="loginForm.username" placeholder="请输入用户名" /> </el-form-item> <el-form-item prop="password"> <el-input v-model="loginForm.password" type="password" placeholder="请输入密码" /> </el-form-item> <el-button type="primary" :loading="loading" @click="handleLogin"> 登 录 </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扩展为角色和权限列表,配合路由守卫做动态路由;三是在登录流程中加入多因素认证,短信验证码或扫码登录,把验证码模块升级为真实的生产级方案。
代码相关的内容我已经打包整理好了,核心思路都在上面。有问题可以随时交流,我在实际开发中踩过的坑,基本都有记录。