【微信小程序】微信小程序基于双token的API请求封装与无感刷新实现方案
文章目录
- 前言
- 一、设计思路
- 二、执行流程
- 三、核心模块
- 3.1 全局配置
- 3.2 request封装
- 3.2.1 request方法配置参数
- 3.2.2 请求预处理
- 3.2.3 核心请求流程
- 3.3 刷新accessToken
- 3.4 辅助方法
- 四、api封装示例
- 总结
前言
现代前后端分离的模式中,一般都是采用token的方式实现API的鉴权,而不是传统Web应用中依赖服务器端的Session存储和客户端Cookie的自动传递匹配机制。前端发起的请求时,在其请求头内传入“Authorization:token”,后端解析请求头中的token, 获取载荷信息过期时间等状态信息,验证Token是否有效,实现鉴权。
但是token本身是具有有效性限制的,本文将实现一种微信小程序客户端在发起请求后,服务器发现token过期,客户端能自动向服务器发起请求获取最新的token,再重试上一个因为过期token而未执行的请求的流程。
一、设计思路
本文所讨论的无感刷新token的实现是基于微信小程序原生wx.request封装,采用双token的方式(accessToken + refreshToken)。accessToken生命周期短,作为请求头写入请求传给后端用于鉴权,refreshToken生命周期长,用于刷新accessToken。本方案核心目标是解决accessToken过期后,用户无感知刷新accessToken并重试请求,避免频繁跳转登录页影响体验。
并且将完善实现并发控制下的请求管理,实现单例刷新。同一时间多个请求同时出现accessToken失效,仅运行第一个请求触发刷新accessToken,最后在统一执行阻塞的请求。
这里提到的accessToken和refreshToken应当在首次成功登录之后通过setStorageSync存入本地
二、执行流程
完整流程如下:
- 发起请求:前端调用request方法,封装函数请求头携带accessToken
- 401 拦截:接口返回401,排除登录接口后,检查到存在refreshToken
- 状态判断:isRefreshing为false,设置为true,将刷新流程锁定,调用refreshToken函数。
- 刷新 Token:发起/Login/RefreshToken请求,成功后获取新accessToken,更新缓存与请求头
- 重试原始请求:用新accessToken重新发起之前的触发执行refreshToken逻辑的请求,成功后返回结果给前端。
- 队列重试:遍历requestQueue,期间可能有其他请求因401加入队列,调用每个请求的retryRequest,用新accessToken重试。
- 状态重置:清空requestQueue,设置isRefreshing为false,解锁刷新机制,无感刷新完成
三、核心模块
3.1 全局配置
const baseURL = 'http://localhost:806'
//请求超时时间
const timeout = 10000;
/*** 是否正在刷新token* 判断无刷新 → 锁定刷新流程 → 发起请求*/
let isRefreshing = false; // 是否正在刷新token
/*** 等待刷新token的请求队列* 刷新成功:队列中的请求需重试,重试后清空队列;* 刷新失败:队列中的请求已无意义(无有效 token 可用),直接清空队列;* 刷新过程中:队列不能重置(需保留等待的请求)。*/
let requestQueue = [];
isRefreshing和requestQueue是两个关键全局变量来实现并发控制与请求管理
- isRefreshing(bool):标记是否正在发起 Token 刷新请求,防止同一时间多个请求触发重复刷新
- requestQueue(array):存储Token刷新期间发起的请求,刷新成功后统一重试,保证请求完整性与用户无感知。
3.2 request封装
封装一个基于原生wx.request的函数,作为所有接口请求的入口,负责请求参数处理、Token 携带、401 拦截、队列管理。
3.2.1 request方法配置参数
通过一个默认的配置项实现构造函数的职能,优先使用具体的api请求方法里配置项。
export function request(options) {const {url, //接口路径(相对路径)method = 'GET', //请求方法(GET/POST 等)data = null, //请求参数header = {}, //自定义请求头isShowLoading = true, //是否显示加载中弹窗isNeedToken = true, //是否需要携带Access TokenretryCount = 0, //当前重试次数maxRetry = 1, //最大重试次数} = options/*** 省略*/
}
3.2.2 请求预处理
let requestUrl = url;let requestData = data;const requestHeader = {'Content-Type': 'application/json', // 默认JSON格式...header // 允许用户覆盖默认头}// 处理GET请求的参数if (method === 'get' && data) {// 将参数序列化为查询字符串const queryString = Object.keys(data).map(key => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`).join('&');requestUrl += `?${queryString}`;requestData = null; // 清空data字段,因为已经将参数拼接到url中了}if (isShowLoading) {wx.showLoading({title: "加载中",mask: true //开启蒙版遮罩});}if (isNeedToken) {const token = wx.getStorageSync('accessToken');if (token) { // 仅当token存在时添加requestHeader['Authorization'] = `Bearer ${token }`;}}
3.2.3 核心请求流程
解析服务器的响应,通过是否是非登录请求的401,来判断上一个请求无访问权限,需要获取新的token。
- 步骤1:无refreshToken标志彻底过期,跳转登录
- 步骤2:封装当前请求的重试逻辑,在获取到新的Token后重新发起当前请求
- 步骤3:根据刷新状态,决定是立刻发起刷新token逻辑还是加入到待执行请求的队列里
- 步骤4:执行刷新accessToken的逻辑
进入刷新accessToken的逻辑时,需要锁定刷新入口,保证仅有一个请求能进入刷新流程。并且在执行刷新accessToken的逻辑后需要回调重试队列中的所有请求,重试完成后清空队列
//返回Promise对象return new Promise((resolve, reject) => {wx.request({url: baseURL + requestUrl,timeout: timeout,method: method,data: requestData,header: requestHeader,success: (res) => {//非登录请求,并且响应状态码是401,说明无访问权限,需要获取新的tokenif (res.statusCode == 401 && url != "loginEncrypt") {const _refreshToken = wx.getStorageSync('refreshToken');//步骤1:无refreshToken标志彻底过期,跳转登录if (!_refreshToken) {if (getCurrentPage() !== 'pages/login/login') {wx.navigateTo({ url: '/pages/login/login' });}return;}//步骤2:封装当前请求的重试逻辑,在获取到新的Token后重新发起当前请求const retryRequest = () => {//如果新token仍无效,额外再触发if (retryCount >= maxRetry) {reject(new Error('超过最大重试次数'));return;}//用新token重新发起当前请求request({...options,isShowLoading: false, // 避免重复显示loadingretryCount: retryCount + 1}).then(resolve).catch(reject);};//步骤3:根据刷新状态,决定是立刻发起刷新token逻辑还是加入到待执行请求的队列里if (isRefreshing) {//正在刷新token,将当前请求加入队列等待requestQueue.push(retryRequest);}else {//锁定刷新,保证仅有一个请求能进入刷新流程isRefreshing = true;//刷新tokenlet requestParms = {url: url,data: requestData,method: method,header: requestHeader,};//步骤4:执行刷新accessToken的逻辑refreshToken(requestParms, (result) => {resolve(result);//刷新成功后,重试队列中的所有请求requestQueue.forEach(async (retry) => {try { await retry(); } catch (err) { console.error('队列请求重试失败:', err); }});//重试完成后清空队列requestQueue = [];}, reject);}}//说明是正常请求else {resolve(res.data);}},fail: (res) => {wx.showToast({title: '请求数据失败,请稍后重试。',icon: 'error',duration: 2000});reject(res);},complete: () => {wx.hideLoading();}})})
3.3 刷新accessToken
accessToken刷新函数是实现无感刷新的一个重要组成。它主要是用来发起刷新accessToken请求、更新accessToken缓存、并且重试队列请求。
- 步骤1:refreshToken标志登录信息的彻底失效,需要重新执行登录验证,清空队列,释放accessToken的刷新
- 步骤2:重试本次因accessToken失效无法正常响应的请求
- 步骤3:刷新成功后,重试队列中的所有请求【执行刷新Token中进入队列的请求】
执行刷新token的时候,把accessToken和refreshToken同时传入,用于比较二者是否匹配,防止出现refreshToken泄漏导致的刷新漏洞。
function refreshToken(requestParms, outResolve, outReject) {const _refreshToken = wx.getStorageSync('refreshToken');// 发起刷新Token的请求wx.request({url: baseURL + '/Login/RefreshToken',timeout: timeout,method: 'POST',header: requestParms.header,data: {refreshToken: _refreshToken},success: (res) => {//步骤1:refreshToken标志登录信息的彻底失效,需要重新执行登录验证,清空队列,释放accessToken的刷新if (res.statusCode != 200) {wx.showToast({title: res.data.msg,icon: 'none'});//刷新失败:清空队列requestQueue = [];//解锁刷新isRefreshing = false;//跳转登录setTimeout(() => {// 跳转登录if (getCurrentPage() !== 'pages/login/login') {wx.navigateTo({ url: '/pages/login/login' });}}, 2000);return;}//步骤2:重试本次因accessToken失效无法正常响应的请求wx.setStorageSync('accessToken', res.data.data);requestParms.header['Authorization'] = 'Bearer ' + res.data.data;wx.request({url: baseURL + requestParms.url,timeout: timeout,method: requestParms.method,data: requestParms.data,header: { ...requestParms.header },success: (res) => {outResolve(res.data);},fail: (res) => {wx.showToast({title: res.data.msg ? res.data.msg : '请求数据失败,请稍后重试',icon: 'error',duration: 2000});outReject(res); // 通知外层失败},complete: () => {// 刷新完成:重置状态(无论成功失败)isRefreshing = false;}})},fail: () => {// 刷新失败:清空队列,重置状态requestQueue = [];isRefreshing = false;// 请求失败,需要重新登录if (getCurrentPage() !== 'pages/login/login') {wx.navigateTo({ url: '/pages/login/login' });}}});
}
3.4 辅助方法
用于获取当前页面的路径。
/*** 获取当前页面路径*/
function getCurrentPage() {const pages = getCurrentPages();return pages[pages.length - 1]?.route || '';
}
四、api封装示例
目录结构
miniprogram/
├── api/
│ ├── modules/
│ │ ├── auth/
│ │ └── index.js
│ ├── index.js
│ └── request.js
└── pages/└── login/└── login.js
api -> auth -> index.js示例
import { request } from "../../../api/request";// 加密登录
export function login(params) {return request({url: '/Auth/Login',method: 'post',data: params})
}
api -> index.js示例
export * as authApi from './modules/auth/index';
login.js示例
import { authApi } from '../../api/index';
authApi.login({encryptStr: _encryptStr}).then(res => {})
完整request.js代码
// 全局请求封装
//接口基础地址
const baseURL = 'http://localhost:806'
//请求超时时间
const timeout = 10000;
/*** 是否正在刷新token* 判断无刷新 → 锁定刷新流程 → 发起请求*/
let isRefreshing = false; // 是否正在刷新token
/*** 等待刷新token的请求队列* 刷新成功:队列中的请求需重试,重试后清空队列;* 刷新失败:队列中的请求已无意义(无有效 token 可用),直接清空队列;* 刷新过程中:队列不能重置(需保留等待的请求)。*/
let requestQueue = [];/*** 请求封装* @param {*} options */
export function request(options) {const {url, //接口路径(相对路径)method = 'GET', //请求方法(GET/POST 等)data = null, //请求参数header = {}, //自定义请求头isShowLoading = true, //是否显示加载中弹窗isNeedToken = true, //是否需要携带Access TokenretryCount = 0, //当前重试次数maxRetry = 1, //最大重试次数} = optionslet requestUrl = url;let requestData = data;const requestHeader = {'Content-Type': 'application/json', // 默认JSON格式...header // 允许用户覆盖默认头}// 处理GET请求的参数if (method === 'get' && data) {// 将参数序列化为查询字符串const queryString = Object.keys(data).map(key => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`).join('&');requestUrl += `?${queryString}`;requestData = null; // 清空data字段,因为已经将参数拼接到url中了}if (isShowLoading) {wx.showLoading({title: "加载中",mask: true //开启蒙版遮罩});}if (isNeedToken) {const token = wx.getStorageSync('accessToken');if (token) { // 仅当token存在时添加requestHeader['Authorization'] = `Bearer ${token}`;}}//返回Promise对象return new Promise((resolve, reject) => {wx.request({url: baseURL + requestUrl,timeout: timeout,method: method,data: requestData,header: requestHeader,success: (res) => {//非登录请求,并且响应状态码是401,说明无访问权限,需要获取新的tokenif (res.statusCode == 401 && url != "loginEncrypt") {const _refreshToken = wx.getStorageSync('refreshToken');//步骤1:无refreshToken标志彻底过期,跳转登录if (!_refreshToken) {if (getCurrentPage() !== 'pages/login/login') {wx.navigateTo({ url: '/pages/login/login' });}return;}//步骤2:封装当前请求的重试逻辑,在获取到新的Token后重新发起当前请求const retryRequest = () => {//如果新token仍无效,额外再触发if (retryCount >= maxRetry) {reject(new Error('超过最大重试次数'));return;}//用新token重新发起当前请求request({...options,isShowLoading: false, // 避免重复显示loadingretryCount: retryCount + 1}).then(resolve).catch(reject);};//步骤3:根据刷新状态,决定是立刻发起刷新token逻辑还是加入到待执行请求的队列里if (isRefreshing) {//正在刷新token,将当前请求加入队列等待requestQueue.push(retryRequest);}else {//锁定刷新,保证仅有一个请求能进入刷新流程isRefreshing = true;//刷新tokenlet requestParms = {url: url,data: requestData,method: method,header: requestHeader,};//步骤4:执行刷新accessToken的逻辑refreshToken(requestParms, (result) => {resolve(result);//刷新成功后,重试队列中的所有请求requestQueue.forEach(async (retry) => {try { await retry(); } catch (err) { console.error('队列请求重试失败:', err); }});//重试完成后清空队列requestQueue = [];}, reject);}}//说明是正常请求else {resolve(res.data);}},fail: (res) => {wx.showToast({title: '请求数据失败,请稍后重试。',icon: 'error',duration: 2000});reject(res);},complete: () => {wx.hideLoading();}})})
}/*** 刷新token* @param {*} requestParms * @param {*} outResolve */
function refreshToken(requestParms, outResolve, outReject) {const _refreshToken = wx.getStorageSync('refreshToken');// 发起刷新Token的请求wx.request({url: baseURL + '/Login/RefreshToken',timeout: timeout,method: 'POST',header: requestParms.header,data: {refreshToken: _refreshToken},success: (res) => {//步骤1:refreshToken标志登录信息的彻底失效,需要重新执行登录验证,清空队列,释放accessToken的刷新if (res.statusCode != 200) {wx.showToast({title: res.data.msg,icon: 'none'});//刷新失败:清空队列requestQueue = [];//解锁刷新isRefreshing = false;//跳转登录setTimeout(() => {// 跳转登录if (getCurrentPage() !== 'pages/login/login') {wx.navigateTo({ url: '/pages/login/login' });}}, 2000);return;}//步骤2:重试本次因accessToken失效无法正常响应的请求wx.setStorageSync('accessToken', res.data.data);requestParms.header['Authorization'] = 'Bearer ' + res.data.data;wx.request({url: baseURL + requestParms.url,timeout: timeout,method: requestParms.method,data: requestParms.data,header: { ...requestParms.header },success: (res) => {outResolve(res.data);},fail: (res) => {wx.showToast({title: res.data.msg ? res.data.msg : '请求数据失败,请稍后重试',icon: 'error',duration: 2000});outReject(res); // 通知外层失败},complete: () => {// 刷新完成:重置状态(无论成功失败)isRefreshing = false;}})},fail: () => {// 刷新失败:清空队列,重置状态requestQueue = [];isRefreshing = false;// 请求失败,需要重新登录if (getCurrentPage() !== 'pages/login/login') {wx.navigateTo({ url: '/pages/login/login' });}}});
}/*** 获取当前页面路径*/
function getCurrentPage() {const pages = getCurrentPages();return pages[pages.length - 1]?.route || '';
}
总结
该方案通过封装微信小程序wx.request,结合双token机制与并发请求队列管理,实现了token过期后的无感刷新与请求重试。