文章目錄
- 前言
- 一、設計思路
- 二、執行流程
- 三、核心模塊
- 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過期后的無感刷新與請求重試。