uni-app 请求封装实战:并发 401 下的 token 无感刷新

2026-10-01 0 864

先说个场景,估计做过小程序的人都不陌生。

首页 onLoad 里并发发了五个请求:用户信息、订单列表、优惠券、消息数、推荐位。用户手机放着没动,过了半小时再切回来,五个请求几乎同时到达服务端,全部返回 401。

如果没做任何处理,结果就是五次刷新 token 的请求一起打过去。而后端的 refresh token 是做轮换的——用一次就作废、发一个新的。于是五个请求里只有第一个能成功,剩下四个拿着已经失效的旧 refresh token 去换,全部被拒。前端拿到四个刷新失败,直接把人踢回登录页。

用户什么也没干,就看着页面闪了一下,重新登录。

这篇文章就把这个坑从头填一遍。代码基于 uni-app 的 Vue 3 项目,但思路放到任何前端框架里都通用。

先把 uni.request 的脾气摸一摸

有件事得先确认清楚:uni.request 调用之后返回的是一个 RequestTask 对象,它的主要用途是让你在需要的时候调 .abort() 把请求掐掉。至于 Promise 风格,不同平台、不同版本的表现并不完全一致,有些场景下能链式写,有些场景下会因为没传回调而行为诡异。

所以结论很直接:别指望原生的返回值,自己包一层 Promise,把所有分支都捏在自己手里。

第一步:一个能被复用的最小封装

先建两个文件,一个管 token 存取,一个管请求。

// utils/auth.js
const ACCESS_KEY = 'access_token'
const REFRESH_KEY = 'refresh_token'

export function getAccessToken() {
  return uni.getStorageSync(ACCESS_KEY) || ''
}

export function getRefreshToken() {
  return uni.getStorageSync(REFRESH_KEY) || ''
}

export function saveTokens(data) {
  uni.setStorageSync(ACCESS_KEY, data.access_token)
  if (data.refresh_token) {
    uni.setStorageSync(REFRESH_KEY, data.refresh_token)
  }
}

export function clearTokens() {
  uni.removeStorageSync(ACCESS_KEY)
  uni.removeStorageSync(REFRESH_KEY)
}

然后是请求主体。这一步先把非 401 的路径走通:正常返回、业务错误码、HTTP 错误、网络失败,四种情况分别落到 resolve 还是 reject。

// utils/request.js
import { getAccessToken, getRefreshToken, saveTokens, clearTokens } from './auth'

const BASE_URL = 'https://api.example.com'
const TIMEOUT = 15000

export function request(options) {
  return send(options, false)
}

function send(options, isRetry) {
  const token = getAccessToken()

  return new Promise((resolve, reject) => {
    uni.request({
      url: options.url.startsWith('http') ? options.url : BASE_URL + options.url,
      method: options.method || 'GET',
      data: options.data || {},
      timeout: options.timeout || TIMEOUT,
      header: {
        'Content-Type': 'application/json',
        ...(token ? { Authorization: 'Bearer ' + token } : {}),
        ...options.header
      },
      success(res) {
        const statusCode = res.statusCode
        const data = res.data

        if (statusCode === 401 || (data && data.code === 401)) {
          if (isRetry) {
            // 换成新 token 之后还是 401,说明这个身份已经不可信了
            reject(makeError('UNAUTHORIZED', data))
            logout()
            return
          }
          enqueue(() => send(options, true), resolve, reject)
          return
        }

        if (statusCode >= 200 && statusCode < 300) {
          if (data && typeof data === 'object' && 'code' in data && data.code !== 0) {
            reject(makeError(data.message || '业务异常', data))
            return
          }
          resolve(data)
          return
        }

        reject(makeError('HTTP ' + statusCode, data))
      },
      fail(err) {
        reject(makeError(err.errMsg || '网络异常', err))
      }
    })
  })
}

function makeError(message, raw) {
  const err = new Error(message)
  err.raw = raw
  return err
}

这里 isRetry 是个关键参数。它标记「这一发是不是已经换过 token 重发的」。没有它,代码在刷新成功后仍返回 401 的情况下会无限重试,最后把浏览器页面卡死。

第二步:队列 + 单例刷新

这是整篇文章的核心,也是那个事故的解法。

思路只有两条:

  1. 同一时刻只允许存在一个刷新请求,其余的一律排队等着。
  2. 刷新成功之后,把排队的请求挨个用新 token 重发一遍。

代码如下:

// 刷新中的 Promise,非空表示正在刷新
let refreshTask = null

// 等待刷新的请求,每一项是 { retry, resolve, reject }
const waiting = []

// 防止并发跳登录页
let loggingOut = false

function enqueue(retry, resolve, reject) {
  waiting.push({ retry, resolve, reject })
  startRefresh()
}

function startRefresh() {
  if (refreshTask) return

  refreshTask = doRefresh().then(
    newToken => {
      refreshTask = null
      flush(null, newToken)
    },
    err => {
      refreshTask = null
      flush(err, null)
      logout()
    }
  )
}

function flush(err, token) {
  const queue = waiting.splice(0)
  queue.forEach(item => {
    if (err) {
      item.reject(err)
      return
    }
    item.retry().then(item.resolve).catch(item.reject)
  })
}

注意队列里存的是 retry 函数,不是请求参数。这个设计后面讲上传接口的时候会显出价值——因为上传没法简单地「用同样的参数重发一次」,它需要重新执行整个 uploadFile 调用。

refreshTask = null 的位置也值得说一下。我特意把它放在了 flush 之前,而不是塞到 finally 里。因为 flush 会触发一批新请求,万一其中某个又碰到 401,那时 refreshTask 已经是 null 了,能立刻开启下一轮刷新,不会卡住。

第三步:刷新接口本身必须另起炉灶

这一段如果不特别提醒,几乎人人都会踩。

刷新 token 的那个接口,绝对不能走上面那个 send 封装。否则一旦刷新接口自己返回 401(比如 refresh token 过期了),它会被再次送进队列,触发新的刷新,而新的刷新又走这条路……调用栈直接爆掉。

function doRefresh() {
  const refreshToken = getRefreshToken()

  if (!refreshToken) {
    return Promise.reject(new Error('NO_REFRESH_TOKEN'))
  }

  // 这里用最原始的 uni.request,不走 send
  return new Promise((resolve, reject) => {
    uni.request({
      url: BASE_URL + '/auth/refresh',
      method: 'POST',
      data: { refresh_token: refreshToken },
      header: { 'Content-Type': 'application/json' },
      success(res) {
        const body = res.data
        if (res.statusCode === 200 && body && body.data && body.data.access_token) {
          saveTokens(body.data)
          resolve(body.data.access_token)
        } else {
          reject(new Error('REFRESH_FAILED'))
        }
      },
      fail: reject
    })
  })
}

第四步:登出只做一次

五个请求同时失败,每个都会走到登出逻辑,如果不加锁,页面会跳五次。虽然 reLaunch 最终结果一样,但中间的过程会有明显闪烁,还可能因为页面栈变动触发一些奇怪的 onShow。

function logout() {
  if (loggingOut) return
  loggingOut = true

  clearTokens()

  uni.reLaunch({
    url: '/pages/login/index',
    complete() {
      // 留个缓冲,等页面切换完再解锁
      setTimeout(() => {
        loggingOut = false
      }, 1000)
    }
  })
}

用 reLaunch 而不是 navigateTo,是因为要清空页面栈。用户重新登录之后按返回键,不该还能退回到刚才那个已经失效的页面。

第五步:上传接口也得接进这套体系

这是个容易被忽略的地方。uni.uploadFile 和 uni.downloadFile 都不走 uni.request,所以前面写的封装对它们完全无效。

更麻烦的是,上传大文件动辄几十秒,token 过期几乎是必然发生的事。用户好不容易传到 80%,突然报个 401,体验非常差。

export function upload(filePath, name) {
  const token = getAccessToken()

  return new Promise((resolve, reject) => {
    uni.uploadFile({
      url: BASE_URL + '/api/v1/upload',
      filePath,
      name: name || 'file',
      header: token ? { Authorization: 'Bearer ' + token } : {},
      success(res) {
        let body
        try {
          // 注意:uploadFile 的 res.data 是字符串,得自己 parse
          body = JSON.parse(res.data)
        } catch (e) {
          reject(new Error('返回内容不是合法 JSON'))
          return
        }

        if (res.statusCode === 401 || body.code === 401) {
          // 把整个 upload 调用本身塞进队列,刷新完重新传一次
          enqueue(() => upload(filePath, name), resolve, reject)
          return
        }

        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve(body)
          return
        }

        reject(makeError('HTTP ' + res.statusCode, body))
      },
      fail(err) {
        reject(makeError(err.errMsg || '上传失败', err))
      }
    })
  })
}

队列里存 retry 函数的好处在这里体现得很清楚:() => upload(filePath, name) 会重新走一遍完整的上传流程,而不是试图去复用某个已经废弃的 RequestTask。

下载同理,把 downloadFile 包一遍,401 分支里 enqueue(() => download(url), resolve, reject) 就完事了。

第六步:业务层怎么用

封装完之后,业务代码应该是这个样子,跟没封装一样简单:

// api/order.js
import { request } from '@/utils/request'

export function fetchOrders(params) {
  return request({ url: '/api/v1/orders', method: 'GET', data: params })
}

export function fetchProfile() {
  return request({ url: '/api/v1/me', method: 'GET' })
}

export function fetchCoupons() {
  return request({ url: '/api/v1/coupons', method: 'GET' })
}
// pages/index/index.vue
import { fetchOrders, fetchProfile, fetchCoupons } from '@/api/order'

async function loadHome() {
  try {
    const [orders, profile, coupons] = await Promise.all([
      fetchOrders({ page: 1 }),
      fetchProfile(),
      fetchCoupons()
    ])
    // 这里拿到的三个结果都是正常的
  } catch (e) {
    // 只有真的失败(网络断了、或者刷新也没救回来)才会走到这
    uni.showToast({ title: e.message, icon: 'none' })
  }
}

三个请求并发,如果 token 都过期了,只会发出一次刷新请求。刷新成功后三个请求自动重发,业务代码感知不到任何东西。这就是「无感」两个字的含义。

踩坑清单

refresh token 轮换的后端要留宽限期。 就算前端已经做了单例刷新,网络抖动的情况下仍然可能出现两个刷新请求同时到达服务端。如果后端是「用一次立刻作废旧的」,第二个请求就会失败。稳妥做法是让旧 refresh token 在签发后的几秒内仍然可用。

别把业务错误码和 HTTP 状态码混为一谈。 有些后端 401 是放在响应体里的 code 字段,HTTP 状态码给的是 200。上面代码里两种都判断了,但你的实际约定是什么,得回去翻一遍接口文档。

H5 端的 CORS 会导致 401 变成网络错误。 如果服务端在 401 响应上没有正确设置 Access-Control-Allow-Origin,浏览器会在 XHR 层面就把这个响应拦掉,前端 fail 回调收到的是一句「请求失败」,压根看不到 401。这种情况下你只能从服务端解决。

微信小程序要提前配好域名白名单。 request、uploadFile、downloadFile 的域名是分开配的,少配一个上传就会失败。开发阶段可以在开发者工具里勾上「不校验合法域名」,但上线前一定要一个个检查。

队列长度没有上限。 如果某一刻有大量请求同时 401,队列会被撑得很大。一般来说问题不大,但如果你的应用有轮询逻辑,最好给队列加个长度保护,超过阈值就直接全部 reject 并登出。

onShow 里重复发请求要留个心眼。 小程序从后台切回前台会触发 onShow,如果里面无条件重新拉数据,加上还没处理完的旧请求,很容易造出一堆并发的 401。加个简单的节流或者状态标记。

收尾

这套方案的核心其实就两张纸都写得下:一个刷新中的 Promise 当锁,一个等待数组当队列。但在实际项目里,它能把「切回前台被踢下线」这类投诉直接抹掉。

落地的时候建议分两步走:先把基础封装和错误分类接好,跑通正常流程;再把 401 分支和刷新逻辑接上。别一次性改完,不然出问题的时候你分不清是封装本身有 bug,还是刷新逻辑有问题。

uni-app 请求封装实战:并发 401 下的 token 无感刷新
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app 请求封装实战:并发 401 下的 token 无感刷新 https://www.taomawang.com/web/uniapp/2846.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务