【微信小程序】微信小程序基于双token的API请求封装与无感刷新实现方案

发布于:2025-08-30 ⋅ 阅读:(13) ⋅ 点赞:(0)


前言

现代前后端分离的模式中,一般都是采用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 全局配置

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 Token
    retryCount = 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,说明无访问权限,需要获取新的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泄漏导致的刷新漏洞。

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 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过期后的无感刷新与请求重试。