uni-app 请求层治理:token 无感刷新、并发排队、重复请求取消一次讲透

2026-09-17 0 564

去年冬天有个电商客户找过来,说用户投诉率突然涨了一波,反馈都是同一句话:加购物车加着加着就被踢到登录页。

复现的时候很容易看出来。用户点进商品详情,页面一上来同时发三个请求:商品信息、用户是否收藏、购物车角标数量。恰好这时候 token 过期了,三个请求几乎同时收到 401。当时的拦截器是这么写的——收到 401 就调刷新接口,刷新成功后重放请求。逻辑看着没什么问题,问题在于三个 401 是同时到的,于是刷新接口被调了三次。

后端的刷新接口有个约束:refresh_token 是一次性的,刷新成功后旧的立刻失效。第一次刷新成功,返回了新 token;第二次带着同一个 refresh_token 去刷,后端判定重放攻击,直接返回失败;应用层一看刷新失败,立刻执行”清除登录态、跳登录页”。第三次同理。用户根本不知道发生了什么,只看到页面自己跳走了。

这个问题不是 uni-app 独有的,但 uni-app 里格外容易踩,因为小程序页面初始化时并发发起多个请求几乎是默认操作。下面把这套请求层重建的过程拆开讲一遍,代码可以直接抄。

一、简单的拦截器为什么扛不住

先把问题代码还原一下。大概是这样的:

uni.addInterceptor('request', {
  success(res) {
    if (res.statusCode === 401) {
      refreshToken().then(() => {
        // 重放请求,但这里根本拿不到原始 url、参数
      })
    }
  }
})

这个写法有三处硬伤。

第一处是并发问题。拦截器是每个请求各自触发的,它没有一个全局视角。三个请求同时 401,就有三个拦截器实例同时决定去刷新 token。除非刷新操作本身是幂等的(大多数一次性的 refresh_token 都不是),否则必然出事。

第二处是上下文丢失。addInterceptorsuccess 回调只给你 res,原始请求的 url、method、data 全都不在里面。想重放,得自己维护一份请求快照表,非常别扭。

第三处是没有去重。用户快速切 tab,同一个用户信息接口可能在 300 毫秒内被发了五遍,五遍全部打到后端。小程序的并发请求上限只有 10 个,五遍用户信息就能把配额吃掉一半,后面的请求只能排队等。用户看到的界面就是卡顿。

所以与其修补拦截器,不如自己写一个薄薄的请求层,把 token 管理、并发控制、去重取消三件事收到一个函数里。uni-app 本来也推荐以 uni.request 为基础自行封装,这条路是官方认可的。

二、核心:刷新操作必须”单飞”

整个方案的支点就一个:同一时刻,全局最多只有一个刷新请求在飞。其他所有需要刷新的请求,都挂在一个等待队列上,等那个唯一的刷新请求成功之后,一起放行。

用一个 Promise 当锁就够了,不需要引入额外的锁库:

let refreshing = null      // 正在进行的刷新 Promise,null 表示没有
let waiting = []           // 等待刷新的请求

function refreshToken() {
  // 已经有人在刷了,排队等着
  if (refreshing) {
    return new Promise((resolve, reject) => {
      waiting.push({ resolve, reject })
    })
  }

  // 我是第一个,负责发起刷新
  refreshing = doRefresh()

  return refreshing
    .then((token) => {
      waiting.forEach((item) => item.resolve(token))
      waiting = []
      return token
    })
    .catch((err) => {
      waiting.forEach((item) => item.reject(err))
      waiting = []
      throw err
    })
    .finally(() => {
      refreshing = null
    })
}

关键点在 refreshing 这个变量。第一个到达的请求看到它是 null,就把它设成自己的 Promise,然后去调刷新接口。第二个、第三个到达的时候看到它不是 null,就只是往 waiting 数组里塞一个 resolve/reject 对,然后安静地等着。

等到唯一的刷新请求有结果了,.then 会把队列里所有等待者的 Promise 挨个 resolve,它们拿到的是同一个新 token。整个过程中后端只收到一次刷新请求,refresh_token 不会被重复消费。

.finally 里把 refreshing 置回 null 也很关键。否则第一次刷新结束后,后面所有过期的 token 都会发现 refreshing 不是 null,然后一直挂在队列里等一个再也不会到来的 resolve。

三、把队列变成真正能用的请求层

上面是理论基础,落到实际代码要处理的细节就多了。先看整体的骨架:

// utils/http.js
import { useUserStore } from '@/stores/user'

const BASE_URL = 'https://api.example.com'
const REFRESH_PATH = '/auth/refresh'

let refreshing = null
let waiting = []
let seq = 0
const pending = new Map()      // 去重用的请求表

// 换一个新 token,走的是裸 uni.request,不经过本层封装
async function doRefresh() {
  const userStore = useUserStore()
  const refreshToken = userStore.refreshToken
  if (!refreshToken) throw new Error('no refresh token')

  const res = await new Promise((resolve, reject) => {
    uni.request({
      url: BASE_URL + REFRESH_PATH,
      method: 'POST',
      data: { refreshToken },
      success: resolve,
      fail: reject
    })
  })

  if (res.statusCode !== 200 || !res.data?.data?.token) {
    throw new Error('refresh rejected')
  }

  const token = res.data.data.token
  userStore.setToken(token)
  return token
}

这里有个容易被忽略的点:doRefresh不能调用封装好的 request 函数。因为那个函数本身会遇到 401 然后触发刷新,就会形成死循环。它必须直接调 uni.request,用最原始的方式发出去。

紧接着是 request 主体:

export function request(options) {
  const {
    url,
    method = 'GET',
    data,
    header = {},
    needAuth = true,
    dedupe = true,
    timeout = 15000,
    ...rest
  } = options

  return new Promise((resolve, reject) => {
    const userStore = useUserStore()
    const myId = ++seq
    const key = dedupe ? makeKey({ method, url, data }) : null

    // 去重:同一个请求正在飞行中,直接把它掐掉
    if (key && pending.has(key)) {
      const prev = pending.get(key)
      if (prev && prev.id !== myId) {
        try { prev.task.abort() } catch (e) {}
      }
    }

    const finalHeader = { ...header }
    if (needAuth && userStore.token) {
      finalHeader.Authorization = `Bearer ${userStore.token}`
    }

    const task = uni.request({
      url: /^https?:///.test(url) ? url : BASE_URL + url,
      method,
      data,
      header: finalHeader,
      timeout,
      ...rest,

      success: async (res) => {
        // 401 且没重试过,走刷新再重放
        if (res.statusCode === 401 && needAuth && !rest._retried) {
          try {
            const token = await refreshToken()
            const retried = await request({
              ...options,
              header: { ...header, Authorization: `Bearer ${token}` },
              _retried: true,
              dedupe: false
            })
            resolve(retried)
          } catch (e) {
            reject(e)
          }
          return
        }

        if (res.statusCode >= 200 && res.statusCode < 300) {
          // 这里按你的后端约定调整
          if (res.data && res.data.code !== undefined && res.data.code !== 0) {
            showError(res.data.message)
            reject(res.data)
            return
          }
          resolve(res.data?.data !== undefined ? res.data.data : res.data)
        } else {
          showError(describeStatus(res.statusCode))
          reject(res)
        }
      },

      fail: (err) => {
        reject(err)
      },

      complete: () => {
        if (key) {
          const cur = pending.get(key)
          if (cur && cur.id === myId) pending.delete(key)
        }
      }
    })

    if (key) {
      pending.set(key, { id: myId, task })
    }
  })
}

去重这里绕了一个弯。pending 里存的不只是 task,还带了一个自增的 id。这是因为被取消的旧请求也会触发 complete,如果 complete 里不带判断地 pending.delete(key),会误删掉新请求在表里的记录。加上 id 比对之后,只有当前这条记录的主人才能删它。

四、请求去重:分三种情况处理

不是所有重复请求都该被掐掉。实际项目里得分三种情况:

第一种是”读”接口的重复。比如商品列表、用户信息、配置项。同一个 URL、同样的参数,在很短时间间隔内再次发起,把前一个 abort 掉是最合理的。这也是上面代码默认去重的场景。

第二种是”写”接口的重复。比如提交订单、发布评论。这种不应该 abort——你 abort 的是客户端的请求,服务器可能已经收到了。用户点两次提交按钮,你可能发出去了两单。正确做法是在 UI 层做按钮防抖,而不是靠请求层拦截。

第三种是同一路径不同参数的请求。比如搜索接口,用户边打字边搜,每次参数都不同。这种绝对不能去重,每个请求都得发出去。但你可以利用”取消上一次”的机制只保留最新一次:

function makeKey({ method, url, data }) {
  const m = (method || 'GET').toUpperCase()
  const d = typeof data === 'string' ? data : JSON.stringify(data || {})
  return `${m}:${url}:${d}`
}

// 只按 URL 去重(不管参数),用于搜索、tab 切换这类场景
function makeKeyByPath({ method, url }) {
  const m = (method || 'GET').toUpperCase()
  return `${m}:${url}:*`
}

搜索场景可以在调用的时候显式指定用哪种 key:

// 用户每输入一个字就调一次
request({
  url: '/search',
  data: { q: keyword },
  dedupe: true,
  dedupeBy: 'path'    // 相同 URL 只保留最新一次
}).then(result => {
  // 只有最后一次的结果会真的 resolve
})

被 abort 掉的请求会走 fail 回调,里面会拿到一个 errMsg: "request:fail abort" 的错误。所以在 fail 里得区分”用户主动取消”和”网络真挂了”,前者不应该弹 toast:

fail: (err) => {
  const msg = err?.errMsg || ''
  if (msg.indexOf('abort') !== -1) {
    // 被去重机制取消的,静默处理
    reject({ __aborted: true })
    return
  }
  showError('网络异常,请稍后重试')
  reject(err)
}

五、多端登录统一封装

token 从哪来?三个平台拿登录凭证的方式完全不同,写散在各个页面里迟早会乱。集中到一个文件里:

// utils/auth.js
import { request } from './http'

function wxMiniLogin() {
  return new Promise((resolve, reject) => {
    uni.login({
      provider: 'weixin',
      success: (res) => resolve({ type: 'mp', code: res.code }),
      fail: reject
    })
  })
}

function appWxLogin() {
  return new Promise((resolve, reject) => {
    uni.getProvider({
      service: 'oauth',
      success: (prov) => {
        if (!prov.provider.includes('weixin')) {
          reject(new Error('当前环境未安装微信'))
          return
        }
        uni.login({
          provider: 'weixin',
          success: (res) => {
            const auth = res.authResult || {}
            resolve({
              type: 'app',
              openid: auth.openid,
              accessToken: auth.access_token,
              unionid: auth.unionid
            })
          },
          fail: reject
        })
      },
      fail: reject
    })
  })
}

async function h5WxLogin() {
  // 从授权回调 URL 里取 code
  const url = new URL(window.location.href)
  const code = url.searchParams.get('code')
  if (!code) {
    // 没有 code 就跳去授权页
    const redirect = encodeURIComponent(window.location.href)
    const target = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${APPID}&redirect_uri=${redirect}&response_type=code&scope=snsapi_userinfo&state=1#wechat_redirect`
    window.location.replace(target)
    return new Promise(() => {})   // 页面即将跳走,返回一个永不 resolve 的 Promise
  }
  return { type: 'h5', code }
}

export async function login() {
  let credentials = null

  // #ifdef MP-WEIXIN
  credentials = await wxMiniLogin()
  // #endif

  // #ifdef APP-PLUS
  credentials = await appWxLogin()
  // #endif

  // #ifdef H5
  credentials = await h5WxLogin()
  // #endif

  if (!credentials) {
    throw new Error('当前平台不支持登录')
  }

  // 三个端把凭证上报给同一个接口,由后端去换 openid
  const result = await request({
    url: '/auth/login',
    method: 'POST',
    data: credentials,
    needAuth: false,
    dedupe: false
  })

  const userStore = useUserStore()
  userStore.setSession(result)
  return result
}

H5 这里有个坑。h5WxLogin 在没拿到 code 的时候会执行页面跳转,跳转之后原来的 JS 上下文就没了,所以它返回的 Promise 永远不会有结果。这不是 bug,是设计——微信授权码的获取流程本来就是”跳走、回来、从 URL 里读”。所以调用 login() 的地方不要在 await 之后写任何重要逻辑,得靠页面重新加载后从 URL 里读到 code 再走一遍流程。

六、五个容易翻车的细节

第一,刷新失败后必须清理干净。刷新失败的原因很多:refresh_token 过期、网络抖动、后端 500。但无论是哪种,都必须把等待队列里的请求全部 reject 掉,并且清空本地登录态。挂在队列里的请求如果直接丢弃不 reject,上层 await 就会永远卡住,页面 loading 一直在转。我手上这套里刷新失败的处理是这样:

.catch((err) => {
  waiting.forEach((item) => item.reject(err))
  waiting = []

  const userStore = useUserStore()
  userStore.clearSession()

  // 已经在登录页了就别再跳了,否则死循环
  const pages = getCurrentPages()
  const current = pages[pages.length - 1]
  if (!current || !current.route.includes('login')) {
    uni.reLaunch({ url: '/pages/login/index' })
  }

  throw err
})

那个”已经在登录页”的判断非常必要。之前有个版本没做这个,因为登录页本身也会调用接口(拿验证码配置),一旦后端刷新接口出问题,就会不停地 reLaunch,栈溢出直接闪退。

第二,重放请求要带 _retried 标记,且不能再去重。_retried 防止无限循环:如果重放的请求又收到 401,不应该再触发一次刷新,直接失败就好。至于去重,重放时如果还开着去重,可能会和别的请求撞车被 abort 掉,这个场景下宁可多打一次也不要出错。

第三,小程序的并发上限是 10。这是微信的硬限制,超过之后请求会排队。如果你在页面 onLoad 里一口气发十几二十个请求,超出的部分会等到前面的完成才发出去。你以为的”并发”其实是串行的。解决办法是延迟加载——把不着急的请求放到 onReady 之后、或者用户交互触发时再发。

第四,uploadFile 和 downloadFile 走的是另一条路。这两个 API 用的是 uni.uploadFileuni.downloadFile,和 uni.request 不共享拦截器。如果项目里用它们上传头像、同步日志,得单独处理 token 注入。最常见的翻车是:用户改完头像上传,结果返回 401 因为没带 token。uploadFile 的 header 里是要手动塞 Authorization 的。

第五,App 端的 abort() 支持情况。requestTask.abort() 在微信小程序、H5 上早就有了,App 端是 3.6+ 版本才补上。如果你的 App 端跑在更老的基座上,task.abort 可能是 undefined,调它会直接报错。所以取消那一步要先判断:

if (prev && prev.task && typeof prev.task.abort === 'function') {
  try { prev.task.abort() } catch (e) {}
}

包一层 try-catch 不是为了掩盖错误,而是因为 abort 在有些边缘情况下会抛异常,比如请求已经完成了正准备触发回调,这时候 abort 会失败。这不影响业务,报出来反而污染日志。

七、收尾

整套东西加起来不到 200 行,但把请求层里最容易出事的三个问题都收进去了:token 刷新不再并发、账号被踢的场景少了一多半;同接口重复请求被自动取消,低端机上滑动更顺;多端登录统一到一个入口,加新平台的时候只改一个文件。

上线之后有个意外收获。以前排查线上问题,看请求日志经常看到同一个接口在一秒内被打了七八遍,还以为是用户手快。改成去重之后,日志干净多了,真正的慢接口一眼就能看出来——因为日志里不再有冗余的重复项来干扰视线。

如果你手上也有类似的需求,建议从 refreshToken 那个单飞函数开始改。先把并发问题解决,其他两件事(去重、多端登录)可以分批推进。上来就大改容易出乱子,分步骤走更稳。

uni-app 请求层治理:token 无感刷新、并发排队、重复请求取消一次讲透
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uni-app 请求层治理:token 无感刷新、并发排队、重复请求取消一次讲透 https://www.taomawang.com/web/uniapp/2765.html

常见问题

相关文章

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

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