【微信小程序】微信小程序基于双token的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存入本地

二、执行流程

完整流程如下:

  1. 发起请求:前端调用request方法,封装函数请求头携带accessToken
  2. 401 拦截:接口返回401,排除登录接口后,检查到存在refreshToken
  3. 状态判断:isRefreshing为false,设置为true,将刷新流程锁定,调用refreshToken函数。
  4. 刷新 Token:发起/Login/RefreshToken请求,成功后获取新accessToken,更新缓存与请求头
  5. 重试原始请求:用新accessToken重新发起之前的触发执行refreshToken逻辑的请求,成功后返回结果给前端。
  6. 队列重试:遍历requestQueue,期间可能有其他请求因401加入队列,调用每个请求的retryRequest,用新accessToken重试。
  7. 状态重置:清空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过期后的无感刷新与请求重试。

相关推荐
@大迁世界4 分钟前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路12 分钟前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug16 分钟前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu1213818 分钟前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中39 分钟前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路43 分钟前
GDAL 实现矢量合并
前端
hxjhnct1 小时前
React useContext的缺陷
前端·react.js·前端框架
前端 贾公子1 小时前
从入门到实践:前端 Monorepo 工程化实战(4)
前端
菩提小狗1 小时前
Sqlmap双击运行脚本,双击直接打开。
前端·笔记·安全·web安全
前端工作日常1 小时前
我学习到的AG-UI的概念
前端