文章目录
- 前言
- 一、设计思路
- 二、执行流程
- 三、核心模块
-
- [3.1 全局配置](#3.1 全局配置)
- [3.2 request封装](#3.2 request封装)
-
- [3.2.1 request方法配置参数](#3.2.1 request方法配置参数)
- [3.2.2 请求预处理](#3.2.2 请求预处理)
- [3.2.3 核心请求流程](#3.2.3 核心请求流程)
- [3.3 刷新accessToken](#3.3 刷新accessToken)
- [3.4 辅助方法](#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 全局配置
javascript
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请求方法里配置项。
csharp
export function request(options) {
const {
url, //接口路径(相对路径)
method = 'GET', //请求方法(GET/POST 等)
data = null, //请求参数
header = {}, //自定义请求头
isShowLoading = true, //是否显示加载中弹窗
isNeedToken = true, //是否需要携带Access Token
retryCount = 0, //当前重试次数
maxRetry = 1, //最大重试次数
} = options
/**
* 省略
*/
}
3.2.2 请求预处理
javascript
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的逻辑后需要回调重试队列中的所有请求,重试完成后清空队列
javascript
//返回Promise对象
return new Promise((resolve, reject) => {
wx.request({
url: baseURL + requestUrl,
timeout: timeout,
method: method,
data: requestData,
header: requestHeader,
success: (res) => {
//非登录请求,并且响应状态码是401,说明无访问权限,需要获取新的token
if (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, // 避免重复显示loading
retryCount: retryCount + 1
}).then(resolve).catch(reject);
};
//步骤3:根据刷新状态,决定是立刻发起刷新token逻辑还是加入到待执行请求的队列里
if (isRefreshing) {
//正在刷新token,将当前请求加入队列等待
requestQueue.push(retryRequest);
}
else {
//锁定刷新,保证仅有一个请求能进入刷新流程
isRefreshing = true;
//刷新token
let 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泄漏导致的刷新漏洞。
javascript
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 辅助方法
用于获取当前页面的路径。
javascript
/**
* 获取当前页面路径
*/
function getCurrentPage() {
const pages = getCurrentPages();
return pages[pages.length - 1]?.route || '';
}
四、api封装示例
目录结构
bash
miniprogram/
├── api/
│ ├── modules/
│ │ ├── auth/
│ │ └── index.js
│ ├── index.js
│ └── request.js
└── pages/
└── login/
└── login.js
api -> auth -> index.js示例
javascript
import { request } from "../../../api/request";
// 加密登录
export function login(params) {
return request({
url: '/Auth/Login',
method: 'post',
data: params
})
}
api -> index.js示例
javascript
export * as authApi from './modules/auth/index';
login.js示例
javascript
import { authApi } from '../../api/index';
authApi.login({
encryptStr: _encryptStr
}).then(res => {
})
完整request.js代码
javascript
// 全局请求封装
//接口基础地址
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 Token
retryCount = 0, //当前重试次数
maxRetry = 1, //最大重试次数
} = options
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}`;
}
}
//返回Promise对象
return new Promise((resolve, reject) => {
wx.request({
url: baseURL + requestUrl,
timeout: timeout,
method: method,
data: requestData,
header: requestHeader,
success: (res) => {
//非登录请求,并且响应状态码是401,说明无访问权限,需要获取新的token
if (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, // 避免重复显示loading
retryCount: retryCount + 1
}).then(resolve).catch(reject);
};
//步骤3:根据刷新状态,决定是立刻发起刷新token逻辑还是加入到待执行请求的队列里
if (isRefreshing) {
//正在刷新token,将当前请求加入队列等待
requestQueue.push(retryRequest);
}
else {
//锁定刷新,保证仅有一个请求能进入刷新流程
isRefreshing = true;
//刷新token
let 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过期后的无感刷新与请求重试。