前端Token无感刷新:让用户像在游乐园畅玩一样流畅

❤ 写在前面

如果觉得对你有帮助的话,点个小❤❤ 吧,你的支持是对我最大的鼓励~

个人独立开发wx小程序,感谢支持!


🎪 从游乐园门票说起

想象一下,你去游乐园玩,门票(Token)有一定有效期。传统方式中,门票过期时:

  • 保安拦下你:"票过期了,去售票处重新买!"
  • 你不得不离开项目,排队重新买票,再回来继续玩

无感刷新就像有个贴心助手:

  • 门票快过期时,助手悄悄帮你续期
  • 你完全感知不到,继续畅玩各个项目

这就是我们今天要实现的用户体验!

🔍 为什么需要Token刷新?

Token的生命周期

markdown 复制代码
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│  登录获取   │────▶│  使用Token  │────▶│  Token过期  │
│ AccessToken │     │  访问接口   │     │   401错误   │
└─────────────┘     └─────────────┘     └─────────────┘
                                              │
                        ┌─────────────────────┘
                        ▼
                 ┌─────────────┐     ┌─────────────┐
                 │ 传统方式:   │────▶│ 用户需重新  │
                 │ 跳转登录页  │     │    登录     │
                 └─────────────┘     └─────────────┘

问题来了:每次Token过期都让用户重新登录,体验极差!

🎯 无感刷新的核心思路

graph TD A[用户发起请求] --> B{Token是否有效?} B -- 有效 --> C[正常请求] B -- 已过期 --> D[拦截请求] D --> E{是否正在刷新?} E -- 否 --> F[发起刷新请求] F --> G[获取新Token] G --> H[重试原请求] E -- 是 --> I[加入等待队列] I --> J[刷新完成后重试] C --> K[返回数据] H --> K J --> K

💻 实战代码实现(基于axios)

第一步:基础配置

javascript 复制代码
// tokenManager.js
class TokenManager {
  constructor() {
    this.accessToken = localStorage.getItem('access_token');
    this.refreshToken = localStorage.getItem('refresh_token');
    this.isRefreshing = false; // 是否正在刷新
    this.requestsQueue = []; // 请求等待队列
  }
  
  // 保存token
  setTokens(accessToken, refreshToken) {
    this.accessToken = accessToken;
    this.refreshToken = refreshToken;
    localStorage.setItem('access_token', accessToken);
    localStorage.setItem('refresh_token', refreshToken);
  }
  
  // 清除token
  clearTokens() {
    this.accessToken = null;
    this.refreshToken = null;
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
  }
}

第二步:axios拦截器设置

javascript 复制代码
// http.js
import axios from 'axios';
import TokenManager from './tokenManager';

const tokenManager = new TokenManager();
const http = axios.create({
  baseURL: process.env.VUE_APP_BASE_API,
  timeout: 10000
});

// 请求拦截器
http.interceptors.request.use(
  (config) => {
    if (tokenManager.accessToken) {
      config.headers.Authorization = `Bearer ${tokenManager.accessToken}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// 响应拦截器 - 核心逻辑在这里!
http.interceptors.response.use(
  (response) => {
    // 正常响应直接返回
    return response;
  },
  async (error) => {
    const originalRequest = error.config;
    
    // 如果不是401错误,直接返回
    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error);
    }
    
    // 标记这个请求已经重试过,避免无限循环
    originalRequest._retry = true;
    
    // 如果没有refreshToken,跳转到登录页
    if (!tokenManager.refreshToken) {
      tokenManager.clearTokens();
      window.location.href = '/login';
      return Promise.reject(error);
    }
    
    // 如果正在刷新token,将请求加入队列
    if (tokenManager.isRefreshing) {
      return new Promise((resolve) => {
        tokenManager.requestsQueue.push(() => {
          originalRequest.headers.Authorization = `Bearer ${tokenManager.accessToken}`;
          resolve(http(originalRequest));
        });
      });
    }
    
    // 开始刷新token
    tokenManager.isRefreshing = true;
    
    try {
      // 调用刷新接口
      const { data } = await axios.post('/api/auth/refresh', {
        refresh_token: tokenManager.refreshToken
      });
      
      // 保存新token
      tokenManager.setTokens(data.access_token, data.refresh_token);
      
      // 执行等待队列中的所有请求
      tokenManager.requestsQueue.forEach(callback => callback());
      tokenManager.requestsQueue = [];
      
      // 重试原始请求
      originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
      return http(originalRequest);
      
    } catch (refreshError) {
      // 刷新失败,清除token并跳转登录
      tokenManager.clearTokens();
      tokenManager.requestsQueue = [];
      window.location.href = '/login';
      return Promise.reject(refreshError);
    } finally {
      tokenManager.isRefreshing = false;
    }
  }
);

export default http;

第三步:使用示例

javascript 复制代码
// userService.js
import http from './http';

export const getUserInfo = async () => {
  try {
    const response = await http.get('/api/user/info');
    return response.data;
  } catch (error) {
    console.error('获取用户信息失败:', error);
    throw error;
  }
};

export const updateProfile = async (data) => {
  try {
    const response = await http.post('/api/user/profile', data);
    return response.data;
  } catch (error) {
    console.error('更新资料失败:', error);
    throw error;
  }
};

🎨 增强体验:添加视觉提示

虽然说是"无感",但适当的提示能让体验更好:

javascript 复制代码
// 在刷新token时显示加载提示
let refreshLoading = null;

// 修改响应拦截器中的刷新部分
try {
  // 显示轻量级提示
  refreshLoading = showLoading('正在更新登录状态...');
  
  const { data } = await axios.post('/api/auth/refresh', {
    refresh_token: tokenManager.refreshToken
  });
  
  // 隐藏提示
  refreshLoading?.hide();
  showToast('登录状态已更新', 'success', 2000);
  
  // ... 其余逻辑
} catch (error) {
  refreshLoading?.hide();
  showToast('登录已过期,请重新登录', 'error');
  // ... 其余错误处理
}

🛡️ 安全注意事项

  1. Refresh Token有效期:通常比Access Token长,但也不是永久的

  2. 单次使用:每次使用Refresh Token后,服务端应该颁发新的Refresh Token

  3. 安全存储

    javascript 复制代码
    // 使用更安全的方式存储
    const secureStorage = {
      setItem: (key, value) => {
        if (window.crypto && window.crypto.subtle) {
          // 考虑使用加密存储
          localStorage.setItem(key, value);
        } else {
          // 降级方案
          localStorage.setItem(key, value);
        }
      },
      getItem: (key) => localStorage.getItem(key)
    };

🎪 回到游乐园比喻

现在我们的系统就像这样工作:

markdown 复制代码
游乐园项目(API请求) → 检票口(拦截器)
    │
    ├── 票有效 → 直接进入
    │
    ├── 票过期,有续票资格 → 助手悄悄续票 → 继续游玩
    │
    └── 票过期,无续票资格 → 引导重新购票(登录)

📊 性能优化小贴士

javascript 复制代码
// 1. 预刷新:在token即将过期时提前刷新
const shouldRefreshToken = () => {
  const tokenExpiry = getTokenExpiry(tokenManager.accessToken);
  const now = Date.now();
  // 在过期前5分钟开始刷新
  return tokenExpiry - now < 5 * 60 * 1000;
};

// 2. 定时检查
setInterval(() => {
  if (shouldRefreshToken() && !tokenManager.isRefreshing) {
    refreshTokenSilently();
  }
}, 60000); // 每分钟检查一次

// 3. 并发控制优化
const MAX_QUEUE_SIZE = 50;
if (tokenManager.requestsQueue.length > MAX_QUEUE_SIZE) {
  // 队列过长,可能是异常情况
  tokenManager.requestsQueue = [];
  window.location.reload(); // 或采取其他恢复措施
}

🎉 总结

实现Token无感刷新的关键在于:

  1. 拦截401错误:在axios响应拦截器中捕获
  2. 避免并发刷新:用标志位和队列控制
  3. 优雅降级:刷新失败时友好引导重新登录
  4. 用户体验:适当的提示(但不是打断)

现在你的应用就像那个贴心的游乐园助手,让用户在不知不觉中保持登录状态,享受流畅的体验!

试试实现它,让你的应用告别烦人的"登录已过期"提示吧!🚀


小作业:你能想到在哪些场景下,即使实现了无感刷新,仍然需要主动提示用户重新登录吗?欢迎在评论区分享你的想法!💭

相关推荐
用户8168694747252 小时前
Context API 的订阅机制与性能优化
前端·react.js
用户49394095229352 小时前
Function.prototype.bind实现
前端
AAA阿giao2 小时前
Vue3 调用 Coze 工作流:从上传宠物照到生成冰球明星的完整技术解析
前端·vue.js·coze
异界蜉蝣2 小时前
React Fiber架构:Diff算法的演进
前端·react.js·前端框架
追梦_life2 小时前
localStorage使用不止于getItem、setItem、removeItem
前端·javascript
全栈陈序员2 小时前
请描述下你对 Vue 生命周期的理解?在 `created` 和 `mounted` 中请求数据有什么区别?
前端·javascript·vue.js·学习·前端框架
无限大62 小时前
用三行代码实现圣诞树?别逗了!让我们来真的
前端·javascript
init_23612 小时前
label-route-capability
服务器·前端·网络
拉姆哥的小屋2 小时前
深度剖析SentiWordNet情感词典:155,287单词的情感世界
前端·javascript·easyui