在前后端分离架构中,Vue 前端配合 Axios 发起请求,Node.js(Express)搭建后端服务时,可实现 Token 无感刷新以提升用户体验。具体而言,前端 Vue 项目通过 Axios 拦截器,在每次请求前检查 Token 状态。若 Token 即将过期,先向服务端发起静默刷新请求,Express 后端验证旧 Token 后颁发新 Token。前端拦截器收到新 Token 后,将其更新到本地存储,并重新发起原请求,整个过程对用户透明,无需手动重新登录。


页面基本流程
- 登录成功后,后端返回 Access Token 和 Refresh Token,前端存储两者及各自有效期。
- 每次发起业务请求前,前端判断 Access Token 是否即将过期。
- 若即将过期,先调用 “刷新 Token 接口”,用有效的 Refresh Token 换取新的 Access Token。
- 用新的 Access Token 发起原业务请求,用户全程无感知。
- 若 Refresh Token 也过期,才会引导用户重新登录。
一、技术栈与核心约定
- 前端:Vue 3(适配 Vue 2,只需微调语法)+ Axios(统一请求拦截)
- 后端:Node.js + Express + JWT(生成 Token)+ Redis(存储 Refresh Token,可选但推荐)
- Token 规则:
- Access Token:短期有效(1 小时),用于业务请求身份验证
- Refresh Token:长期有效(7 天),仅用于刷新 Access Token
- 状态码:401 = Access Token 过期 / 无效;403 = Refresh Token 过期 / 无效
二、前端实现(核心代码)
1. 初始化 Axios 实例(api/index.js)
封装请求 / 响应拦截器,处理 Token 携带、刷新和重试逻辑:、
1import axios from 'axios'; 2import { ElMessage } from 'element-plus'; // 按需引入 UI 组件库提示(可选) 3 4// 1. 创建 Axios 实例 5const service = axios.create({ 6 baseURL: import.meta.env.VITE_API_BASE_URL, // 环境变量配置后端地址 7 timeout: 5000, // 请求超时时间 8}); 9 10// 2. Token 存取工具函数(安全存储建议用 HttpOnly Cookie,此处用 localStorage 演示) 11const TokenKey = { 12 ACCESS: 'access_token', 13 REFRESH: 'refresh_token', 14}; 15 16// 获取 Token 17const getAccessToken = () => localStorage.getItem(TokenKey.ACCESS); 18const getRefreshToken = () => localStorage.getItem(TokenKey.REFRESH); 19// 存储新 Token 20const setTokens = (accessToken, refreshToken) => { 21 localStorage.setItem(TokenKey.ACCESS, accessToken); 22 localStorage.setItem(TokenKey.REFRESH, refreshToken); 23}; 24// 清除 Token(退出登录用) 25const removeTokens = () => { 26 localStorage.removeItem(TokenKey.ACCESS); 27 localStorage.removeItem(TokenKey.REFRESH); 28}; 29 30// 3. 刷新状态管理(防止并发请求重复刷新 Token) 31let isRefreshing = false; // 是否正在刷新 Token 32let requestQueue = []; // 等待刷新完成的请求队列 33 34// 4. 请求拦截器:自动给所有请求添加 Access Token 35service.interceptors.request.use( 36 (config) => { 37 const token = getAccessToken(); 38 if (token) { 39 // 规范格式:Bearer + 空格 + Token(后端需对应解析) 40 config.headers.Authorization = `Bearer ${token}`; 41 } 42 return config; 43 }, 44 (error) => Promise.reject(error) 45); 46 47// 5. 响应拦截器:处理 Token 过期逻辑 48service.interceptors.response.use( 49 (response) => response.data, // 直接返回响应体,简化业务层调用 50 async (error) => { 51 const { response, config } = error; 52 const originalRequest = config; // 原始失败请求 53 54 // 仅处理 401 状态码(Access Token 过期/无效),且排除刷新 Token 本身的请求 55 if (response?.status === 401 && originalRequest.url !== '/auth/refresh') { 56 // 避免重复刷新:正在刷新时,将请求加入队列 57 if (isRefreshing) { 58 return new Promise((resolve) => { 59 requestQueue.push(() => { 60 // 刷新成功后,用新 Token 重试原始请求 61 originalRequest.headers.Authorization = `Bearer ${getAccessToken()}`; 62 resolve(service(originalRequest)); 63 }); 64 }); 65 } 66 67 originalRequest._retry = true; // 标记该请求已进入重试流程 68 isRefreshing = true; // 开启刷新状态 69 70 try { 71 // 调用后端刷新接口,用 Refresh Token 换取新 Token 72 const refreshToken = getRefreshToken(); 73 if (!refreshToken) { 74 throw new Error('Refresh Token 不存在'); 75 } 76 77 const refreshRes = await service.post('/auth/refresh', { 78 refreshToken, // 传给后端的 Refresh Token 79 }); 80 81 // 存储新 Token 82 const { accessToken, refreshToken: newRefreshToken } = refreshRes; 83 setTokens(accessToken, newRefreshToken); 84 85 // 重试队列中所有等待的请求 86 requestQueue.forEach((callback) => callback()); 87 requestQueue = []; // 清空队列 88 89 // 重试当前失败的请求 90 originalRequest.headers.Authorization = `Bearer ${accessToken}`; 91 return service(originalRequest); 92 } catch (refreshError) { 93 // 刷新失败(Refresh Token 过期/无效),强制跳转登录页 94 removeTokens(); // 清除本地无效 Token 95 ElMessage.error('登录已过期,请重新登录'); 96 window.location.href = '/login'; // 跳转到登录页 97 return Promise.reject(refreshError); 98 } finally { 99 isRefreshing = false; // 关闭刷新状态 100 } 101 } 102 103 // 非 401 错误(如网络错误、业务错误),直接抛出 104 ElMessage.error(error.message || '请求失败'); 105 return Promise.reject(error); 106 } 107); 108 109export default service;
2. 登录与业务请求示例(api/user.js)
1import service from './index'; 2 3// 登录:获取初始双 Token 4export const login = (username, password) => { 5 return service.post('/auth/login', { username, password }); 6}; 7 8// 业务请求示例(无需手动处理 Token) 9export const getUserInfo = () => { 10 return service.get('/user/info'); 11}; 12 13// 退出登录:清除 Token 14export const logout = () => { 15 localStorage.removeItem('access_token'); 16 localStorage.removeItem('refresh_token'); 17 window.location.href = '/login'; 18};
3. 登录页面使用示例(Login.vue)
1<template> 2 <div> 3 <input v-model="username" placeholder="用户名" /> 4 <input v-model="password" type="password" placeholder="密码" /> 5 <button @click="handleLogin">登录</button> 6 </div> 7</template> 8 9<script setup> 10import { ref } from 'vue'; 11import { login } from '@/api/user'; 12import { ElMessage } from 'element-plus'; 13 14const username = ref(''); 15const password = ref(''); 16 17const handleLogin = async () => { 18 try { 19 // 调用登录接口,后端返回 accessToken 和 refreshToken 20 const res = await login(username.value, password.value); 21 // 存储 Token(实际已在 api 拦截器中处理,此处简化) 22 localStorage.setItem('access_token', res.accessToken); 23 localStorage.setItem('refresh_token', res.refreshToken); 24 ElMessage.success('登录成功'); 25 window.location.href = '/home'; // 跳转到首页 26 } catch (error) { 27 ElMessage.error('登录失败,请检查账号密码'); 28 } 29}; 30</script>
三、后端实现(Node.js + Express)
1. 依赖安装
npm install express jsonwebtoken redis cors dotenv // 核心依赖
2. 核心配置(config.js)
1require('dotenv').config(); 2 3module.exports = { 4 // JWT 密钥(生产环境需用环境变量,避免硬编码) 5 JWT_SECRET: process.env.JWT_SECRET || 'your-secret-key-321', 6 // Token 有效期 7 ACCESS_TOKEN_EXPIRES: '1h', // 1 小时 8 REFRESH_TOKEN_EXPIRES: '7d', // 7 天 9 // Redis 配置(存储 Refresh Token,防止重复使用) 10 REDIS: { 11 host: 'localhost', 12 port: 6379, 13 db: 0, 14 }, 15};
3. JWT 工具函数(utils/jwt.js)
1const jwt = require('jsonwebtoken'); 2const config = require('../config'); 3 4// 生成 Token 5const generateToken = (payload, expiresIn) => { 6 return jwt.sign(payload, config.JWT_SECRET, { expiresIn }); 7}; 8 9// 验证 Token 10const verifyToken = (token) => { 11 try { 12 return jwt.verify(token, config.JWT_SECRET); 13 } catch (error) { 14 throw new Error('Token 无效或已过期'); 15 } 16}; 17 18module.exports = { generateToken, verifyToken };
4. Redis 工具函数(utils/redis.js)
1const redis = require('redis'); 2const config = require('../config'); 3 4// 创建 Redis 客户端 5const client = redis.createClient({ 6 host: config.REDIS.host, 7 port: config.REDIS.port, 8 db: config.REDIS.db, 9}); 10 11// 连接 Redis 12client.connect().catch((err) => console.error('Redis 连接失败:', err)); 13 14// 存储 Refresh Token(key: userId, value: refreshToken) 15const setRefreshToken = async (userId, refreshToken) => { 16 // 有效期与 Refresh Token 一致(7 天) 17 await client.setEx(`refresh_token:${userId}`, 60 * 60 * 24 * 7, refreshToken); 18}; 19 20// 获取 Refresh Token 21const getRefreshToken = async (userId) => { 22 return await client.get(`refresh_token:${userId}`); 23}; 24 25// 删除 Refresh Token(退出登录时) 26const deleteRefreshToken = async (userId) => { 27 await client.del(`refresh_token:${userId}`); 28}; 29 30module.exports = { setRefreshToken, getRefreshToken, deleteRefreshToken };
5. 核心接口实现(routes/auth.js)
1const express = require('express'); 2const router = express.Router(); 3const { generateToken, verifyToken } = require('../utils/jwt'); 4const { setRefreshToken, getRefreshToken, deleteRefreshToken } = require('../utils/redis'); 5const config = require('../config'); 6 7// 模拟用户数据库(实际替换为 MySQL/MongoDB) 8const mockUsers = [ 9 { id: 1, username: 'admin', password: '123456' }, 10]; 11 12// 1. 登录接口:生成双 Token 13router.post('/login', (req, res) => { 14 const { username, password } = req.body; 15 // 验证账号密码 16 const user = mockUsers.find( 17 (u) => u.username === username && u.password === password 18 ); 19 20 if (!user) { 21 return res.status(400).json({ message: '账号或密码错误' }); 22 } 23 24 // 生成双 Token(payload 中存储用户唯一标识,避免敏感信息) 25 const accessToken = generateToken({ userId: user.id }, config.ACCESS_TOKEN_EXPIRES); 26 const refreshToken = generateToken({ userId: user.id }, config.REFRESH_TOKEN_EXPIRES); 27 28 // 存储 Refresh Token 到 Redis(用于后续验证) 29 setRefreshToken(user.id, refreshToken); 30 31 // 返回双 Token 给前端 32 res.json({ 33 code: 200, 34 message: '登录成功', 35 data: { accessToken, refreshToken }, 36 }); 37}); 38 39// 2. 刷新 Token 接口:用有效 Refresh Token 换取新双 Token 40router.post('/refresh', async (req, res) => { 41 const { refreshToken } = req.body; 42 if (!refreshToken) { 43 return res.status(403).json({ message: 'Refresh Token 不能为空' }); 44 } 45 46 try { 47 // 1. 验证 Refresh Token 有效性 48 const payload = verifyToken(refreshToken); 49 const { userId } = payload; 50 51 // 2. 验证 Redis 中存储的 Refresh Token 是否一致(防止伪造) 52 const storedRefreshToken = await getRefreshToken(userId); 53 if (storedRefreshToken !== refreshToken) { 54 return res.status(403).json({ message: 'Refresh Token 无效' }); 55 } 56 57 // 3. 生成新的双 Token 58 const newAccessToken = generateToken({ userId }, config.ACCESS_TOKEN_EXPIRES); 59 const newRefreshToken = generateToken({ userId }, config.REFRESH_TOKEN_EXPIRES); 60 61 // 4. 更新 Redis 中的 Refresh Token(滑动过期,增强安全性) 62 await setRefreshToken(userId, newRefreshToken); 63 64 // 5. 返回新 Token 65 res.json({ 66 code: 200, 67 data: { accessToken: newAccessToken, refreshToken: newRefreshToken }, 68 }); 69 } catch (error) { 70 return res.status(403).json({ message: 'Refresh Token 已过期,请重新登录' }); 71 } 72}); 73 74// 3. 退出登录接口:删除 Redis 中的 Refresh Token 75router.post('/logout', async (req, res) => { 76 const token = req.headers.authorization?.split(' ')[1]; 77 if (!token) { 78 return res.status(400).json({ message: 'Token 不能为空' }); 79 } 80 81 try { 82 const payload = verifyToken(token); 83 await deleteRefreshToken(payload.userId); 84 res.json({ code: 200, message: '退出登录成功' }); 85 } catch (error) { 86 res.status(400).json({ message: '退出登录失败' }); 87 } 88}); 89 90module.exports = router;
6. 后端入口文件(app.js)
1const express = require('express'); 2const cors = require('cors'); 3const authRouter = require('./routes/auth'); 4 5const app = express(); 6const port = 3001; 7 8// 跨域配置(生产环境需限制 origin) 9app.use(cors()); 10// 解析 JSON 请求体 11app.use(express.json()); 12 13// 挂载路由 14app.use('/api/auth', authRouter); 15 16// 启动服务 17app.listen(port, () => { 18 console.log(`后端服务启动成功:http://localhost:${port}`); 19});
https://mybj123.com/27766.html
四、关键注意事项(生产环境必看)
- 安全存储 Token
- 不推荐用 localStorage 存储(易受 XSS 攻击),优先用 HttpOnly Cookie 存储 Refresh Token,前端无法读取,避免窃取。
- Access Token 可存在内存(如 Vuex/Pinia),页面刷新后通过 Cookie 获取 Refresh Token 重新刷新。
- 防止重复刷新
- 用
isRefreshing状态和requestQueue队列,避免多个并发请求同时触发刷新接口,导致 Token 冲突。
- 用
- Redis 的必要性
- 存储 Refresh Token 到 Redis,支持 “强制登出”“单点登录” 功能(如修改密码后,删除 Redis 中的旧 Refresh Token,强制用户重新登录)。
- HTTPS 协议
- 生产环境必须启用 HTTPS,防止 Token 在传输过程中被中间人窃取。
- Token 有效期合理设置
- Access Token:15 分钟~2 小时(越短越安全)。
- Refresh Token:7~30 天(平衡安全性和用户体验)。
五、完整流程梳理
- 用户登录 → 后端验证账号密码 → 返回 Access Token 和 Refresh Token → 前端存储。
- 前端发起业务请求 → 拦截器自动携带 Access Token → 后端验证有效 → 返回业务数据。
- 若 Access Token 过期 → 后端返回 401 → 前端拦截器调用刷新接口。
- 刷新接口验证 Refresh Token 有效 → 返回新双 Token → 前端更新存储,重试原始请求。
- 若 Refresh Token 过期 → 前端清除 Token,跳转登录页。
《Vue + Axios + Node.js(Express)如何实现无感刷新Token?》 是转载文章,点击查看原文。
