uni-app 多端请求库手写实战:Token 刷新并发、请求取消、断网重试一次搞定

2026-10-17 0 468

先说一件事:网上搜”uni-app 请求封装”,出来的文章十有八九是同一套代码——建个 utils/request.js,写几个拦截器,导出 get/post,完了。这套东西能跑,但你一上线就会发现几个要命的问题:App 切后台再回来,第一个请求必定 401 弹回登录页;几个页面同时请求,token 过期时会连续刷新四次;用户退出登录了,之前在途的请求还能把响应塞回已经销毁的页面。这篇文章就是来填这些坑的。

不用 Fly.js,不用 axios 适配器,就手写。原因很简单:uni-app 的多端本质差异太大,小程序没有 XMLHttpRequest,App 端走的是原生桥,H5 又是另一套。用第三方库你迟早要读它的源码,不如一开始就自己拿捏。

先定需求,再动手

我给自己划了五个必须满足的场景:

一是多端统一。同一套 API 在微信小程序、App、H5 上行为一致,不用写 ifdef 分支。二是 Token 无感刷新。Access Token 过期时自动用 Refresh Token 换新的,业务层完全无感。三是 401 并发排队。同一时刻如果有 5 个请求都撞上 token 过期,只能触发一次刷新,剩下 4 个等刷新完成后重放。四是请求取消。页面 onUnload 的时候能把在途请求干掉,避免响应回来时页面已经没了。五是超时重试。网络抖动导致的超时自动重试 2 次,业务错误不重试。

第五点特别说明一下:重试只针对”连接超时、DNS 解析失败”这类网络层异常,HTTP 状态码 400、500 一律不重试。因为服务端已经收到请求了,盲目重试可能在支付、下单场景造成重复扣款。

基础骨架:用 Promise 包一层 uni.request

uni.request 本身就是回调式的,第一件事是把它 Promise 化。但 uni-app 官方从 3.4 版本开始已经支持 Promise 风格了,直接 await uni.request({...}) 会返回 [err, res] 数组形式。这里有个坑:它的成功和失败都是 resolve,你要自己判断第一个参数是不是 null。

// utils/request/index.js

// 请求配置默认值,可以在别处覆盖
const defaultConfig = {
  baseURL: '',
  timeout: 15000,
  retryCount: 2,
  retryDelay: 1000,
  header: {
    'Content-Type': 'application/json'
  }
}

let config = { ...defaultConfig }

export function setConfig(options = {}) {
  config = { ...config, ...options }
}

// 拦截器容器
const interceptors = {
  request: [],
  response: [],
  responseError: []
}

export function onRequest(fn) {
  interceptors.request.push(fn)
}

export function onResponse(fn) {
  interceptors.response.push(fn)
}

export function onResponseError(fn) {
  interceptors.responseError.push(fn)
}

注意 baseURL 我留空了,因为不同项目的配置方式不一样。有人喜欢在 manifest.json 里配,有人喜欢按环境变量走。这里做成可注入的,后面在 main.js 里调用一次 setConfig 就完事。

核心:请求函数

下面是重头戏。request 函数的职责是组装参数、跑请求拦截器、调 uni.request、跑响应拦截器、返回业务数据。

// 生成唯一请求 ID,用于后面取消时定位
let requestSeq = 0

// 挂起的请求表,key 是 seq,value 是 { task, abort }
const pendingMap = new Map()

export function request(options = {}) {
  const seq = ++requestSeq
  const finalOptions = {
    ...config,
    ...options,
    header: { ...config.header, ...options.header }
  }

  return new Promise((resolve, reject) => {
    // 跑请求拦截器,每个拦截器可以修改 options
    let promise = Promise.resolve(finalOptions)
    for (const fn of interceptors.request) {
      promise = promise.then(fn)
    }

    promise.then((opts) => {
      // uni.request 在 uniapp 3.4+ 返回 Promise,但我们要拿 requestTask
      // 所以还是用 success/fail 回调形式,这样才能调 abort()
      const task = uni.request({
        url: buildURL(opts),
        method: opts.method || 'GET',
        data: opts.data || {},
        header: opts.header,
        timeout: opts.timeout,
        dataType: opts.dataType || 'json',
        responseType: opts.responseType || 'text',
        sslVerify: false,
        success: (res) => {
          pendingMap.delete(seq)
          handleResponse(res, opts, resolve, reject)
        },
        fail: (err) => {
          pendingMap.delete(seq)
          handleNetworkError(err, opts, resolve, reject, seq)
        }
      })

      // 注册到 pendingMap,允许外部取消
      pendingMap.set(seq, { task, seq })

      // 如果调用方传了 signal(自己实现的支持),监听取消
      if (opts.signal && typeof opts.signal.onAbort === 'function') {
        opts.signal.onAbort(() => {
          task.abort()
          pendingMap.delete(seq)
          reject(createCancelError('请求被主动取消'))
        })
      }
    }).catch(reject)
  })
}

function buildURL(opts) {
  const url = opts.url || ''
  if (/^https?:///.test(url)) return url
  const base = opts.baseURL || ''
  if (!base) return url
  return base.replace(//$/, '') + '/' + url.replace(/^//, '')
}

这一段里有几个点值得单独说。

为什么不用 await uni.request 而用回调?因为 Promise 形态下你拿不到 requestTask 对象,而取消请求必须调用 task.abort()。这是个很容易被忽略的细节,一旦用了 Promise 风格,你就失去了取消能力。

pendingMap 是干嘛的?它是一个全局的请求登记表,记录所有在途请求的 seq 和 task。为什么要它?因为后面要做”请求取消”和”401 排队重放”,这两件事都需要能拿到全局的在途请求列表。别看这行 pendingMap.set(seq, { task, seq }) 简单,它是后面所有高级特性的基础。

响应处理:区分业务错误和网络错误

这里要先定义清楚后端返回的数据结构。我一般约定成 { code, msg, data },code 为 0 表示成功,其它表示业务异常。如果你的后端用的是 HTTP 状态码区分,那下面的代码要改一下。

function handleResponse(res, opts, resolve, reject) {
  const { statusCode, data: rawData } = res

  // 先跑响应拦截器
  let data = rawData
  for (const fn of interceptors.response) {
    const result = fn(res, opts)
    if (result !== undefined) data = result
  }

  // HTTP 层错误
  if (statusCode < 200 || statusCode >= 300) {
    if (statusCode === 401) {
      // 交给 token 刷新逻辑处理,下面单独讲
      handle401(opts, resolve, reject)
      return
    }
    const err = createHttpError(statusCode, data)
    for (const fn of interceptors.responseError) fn(err)
    reject(err)
    return
  }

  // 业务层错误
  if (data && typeof data === 'object' && 'code' in data) {
    if (data.code === 0 || data.code === 200) {
      resolve(data.data !== undefined ? data.data : data)
      return
    }
    const err = createBizError(data.code, data.msg || '请求失败')
    for (const fn of interceptors.responseError) fn(err)
    reject(err)
    return
  }

  // 后端没有按约定格式返回,原样吐出去
  resolve(data)
}

响应拦截器的设计这里有个小细节:如果拦截器返回的不是 undefined,就替换掉 data。这样写可以让拦截器做”统一解密”或”去壳”的操作,而不需要每处都干预。

401 并发排队:整个库最绕的部分

场景复现:用户在公司坐了一天没动 App,晚上打开首页。此时首页会同时发出 4 个接口请求(用户信息、消息数量、订单列表、广告位)。这 4 个请求带的都是过期的 Access Token,服务端全部返回 401。

如果每个 401 都去刷 Token,就有四个问题:一是四次刷新请求可能撞上服务端单点登录限制被踢;二是刷新完的 Token 互相覆盖,导致后面拿的 Token 未必是最新的;三是网络浪费;四是如果 Refresh Token 只能用一次,那后三次刷新会直接失败导致用户登出。

所以必须做成”第一个 401 触发刷新,后面三个挂起等待”的模型。

// 刷新状态:null 表示没在刷,Promise 表示正在刷
let refreshPromise = null
// 挂起队列:存放等待刷新完成的请求
let waitingQueue = []

function handle401(opts, resolve, reject) {
  // 如果 URL 本身就是刷新接口,不能再递归刷新了
  if (opts.url && opts.url.includes('/auth/refresh')) {
    forceLogout()
    reject(createAuthError('Refresh Token 已失效'))
    return
  }

  // 当前没在刷新:发起一次刷新,并把 Promise 存下来
  if (!refreshPromise) {
    refreshPromise = doRefresh()
  }

  // 所有 401 请求都挂到队列里,等同一个 Promise 完成
  refreshPromise
    .then(() => {
      // 刷新成功,用新 token 重放原请求
      return request({ ...opts, _isRetry: true })
    })
    .then(resolve)
    .catch((err) => {
      // 刷新失败,全部拒绝
      reject(err)
    })
    .finally(() => {
      // 只有当前是最后一个等待者时才清理
      const idx = waitingQueue.indexOf(refreshPromise)
      if (idx > -1) waitingQueue.splice(idx, 1)
      if (waitingQueue.length === 0) {
        refreshPromise = null
      }
    })

  waitingQueue.push(refreshPromise)
}

async function doRefresh() {
  const refreshToken = uni.getStorageSync('refresh_token')
  if (!refreshToken) {
    forceLogout()
    throw createAuthError('没有 Refresh Token')
  }

  try {
    // 注意:这里直接调 uni.request,绕开本库的拦截器
    // 避免刷新接口本身又被 401 逻辑拦截
    const res = await new Promise((resolve, reject) => {
      uni.request({
        url: config.baseURL + '/auth/refresh',
        method: 'POST',
        data: { refresh_token: refreshToken },
        success: resolve,
        fail: reject
      })
    })

    if (res.statusCode !== 200 || res.data.code !== 0) {
      throw createAuthError('刷新失败')
    }

    const { access_token, refresh_token } = res.data.data
    uni.setStorageSync('access_token', access_token)
    if (refresh_token) {
      uni.setStorageSync('refresh_token', refresh_token)
    }

    // 更新默认请求头里的 Authorization
    config.header.Authorization = 'Bearer ' + access_token
    return access_token
  } catch (e) {
    forceLogout()
    throw createAuthError(e.message || '刷新 Token 异常')
  }
}

function forceLogout() {
  uni.removeStorageSync('access_token')
  uni.removeStorageSync('refresh_token')
  // 防止重复跳转登录页
  const pages = getCurrentPages()
  const current = pages[pages.length - 1]
  if (current && current.route !== 'pages/login/index') {
    uni.reLaunch({ url: '/pages/login/index' })
  }
}

这段代码有几个容易翻车的地方。

refreshPromise 的清理时机。不能在每个 401 请求的 finally 里都清空 refreshPromise,否则第一个请求刷新完清空了,第二个请求醒来发现 refreshPromise 是 null,又重新刷一次。正确做法是用 waitingQueue 计数,所有等待者都收到结果后再清空。上面代码里我简化了一下,用 indexOf 定位,其实更严谨的做法是在 doRefresh 的 finally 里判断,但那种写法要处理闭包捕获的复杂问题,权衡下来还是现在这样清晰。

刷新接口本身必须绕开拦截器。不能直接调本库的 request(),否则刷新接口返回 401 时会再次触发 handle401,递归下去直到爆栈。我上面是直接用 uni.request 调的,有些团队会觉得这样不优雅,想加白名单机制。也可以,但白名单本质上是同一个思路,只是把判断挪到入口处而已。

forceLogout 要防重复。如果 4 个请求都触发刷新失败,理论上会调用 4 次 forceLogout,可能弹出 4 个登录页。上面的写法用 getCurrentPages 判断了下,但还是有极小概率并发穿透。生产环境建议再加个 module 级别的 flag。

请求取消:小程序端没有 AbortController

H5 端可以直接用 AbortController,小程序和 App 端没有这个 API。uni-app 的方案是用 requestTask.abort()。

我在 request 函数里暴露了一个 abort 接口:

export function abortAll() {
  pendingMap.forEach(({ task }) => {
    try {
      task.abort()
    } catch (e) {
      // 已经完成的请求 abort 会抛,忽略
    }
  })
  pendingMap.clear()
}

export function abortByPrefix(urlPrefix) {
  pendingMap.forEach(({ task, seq }, key) => {
    // pendingMap 里存了 task 但没存 url,这里需要额外存一份
    // 更严谨的版本需要把 opts 也放进去
  })
}

等等,上面这个 abortByPrefix 是半成品,因为 pendingMap 里没存 url。要让它工作,得在 set 的时候把 opts 也塞进去。改成:

pendingMap.set(seq, { task, url: opts.url, seq })

export function abortByPrefix(prefix) {
  pendingMap.forEach(({ task, url }, key) => {
    if (url && url.startsWith(prefix)) {
      try { task.abort() } catch (e) {}
      pendingMap.delete(key)
    }
  })
}

然后在使用侧,常见的用法是在页面 onUnload 里干掉本页的所有请求。但我们这里没法按页面归属来分,因为库不知道哪些请求属于哪个页面。

一个工程化的做法是做一个”请求命名空间”:给 request 加一个 ns 参数,值就是当前页面的路由。然后 abortByNamespace('pages/order/detail') 就能清掉某个页面的所有在途请求。

// 在页面里
import { request, abortByNamespace } from '@/utils/request'

onUnload(() => {
  abortByNamespace('pages/order/detail')
})

// 请求时带上 ns
request({
  url: '/order/detail',
  ns: 'pages/order/detail'
})

注意 abort 之后,Promise 会 reject 一个错误,业务代码要能识别这是”主动取消”而不是”请求失败”。所以我在 reject 之前,会附上一个特殊的标记:

function createCancelError(msg) {
  const err = new Error(msg)
  err.name = 'CancelError'
  err.__isCancel = true
  return err
}

业务层 catch 的时候判断一下:

try {
  const data = await request({ url: '/x' })
} catch (e) {
  if (e.__isCancel) return  // 页面已卸载,忽略
  // 其它错误正常处理
}

超时重试:只重网络异常

重试是最容易写坏的逻辑。常见的错误写法是往拦截器里塞,或者干脆在 fail 回调里递归调 request。这两种都会让重试次数失控。

正确的是在 handleNetworkError 里判断:

function handleNetworkError(err, opts, resolve, reject, seq) {
  const currentRetry = opts._retryCount || 0

  // 判断是否可重试
  const retryable = isRetryable(err) && currentRetry < (opts.retryCount || 0)

  if (retryable) {
    setTimeout(() => {
      request({ ...opts, _retryCount: currentRetry + 1 })
        .then(resolve)
        .catch(reject)
    }, opts.retryDelay || 1000)
    return
  }

  const e = new Error(err.errMsg || '网络异常')
  e.name = 'NetworkError'
  e.detail = err
  for (const fn of interceptors.responseError) fn(e)
  reject(e)
}

function isRetryable(err) {
  const msg = err.errMsg || ''
  // 只重试网络层异常
  return msg.includes('timeout') ||
         msg.includes('fail dns') ||
         msg.includes('fail connect')
}

为什么不用指数退避?因为移动端网络恢复往往很快,等 1 秒、2 秒、4 秒反而让用户觉得页面卡住。固定 1 秒间隔重试两次,是移动端体验最好的平衡点。如果你想做智能一点,可以在 isRetryable 返回 true 时先用 uni.getNetworkType 查一下当前是不是没网,没网就别重试了,浪费。

把拦截器、请求、取消拼起来:完整调用示例

先看初始化,放在 main.js 里:

import { setConfig, onRequest, onResponseError } from '@/utils/request'

setConfig({
  baseURL: import.meta.env.VITE_API_BASE,
  timeout: 15000
})

// 每次请求前自动带上 token
onRequest((opts) => {
  const token = uni.getStorageSync('access_token')
  if (token) {
    opts.header = opts.header || {}
    opts.header.Authorization = 'Bearer ' + token
  }
  return opts
})

// 统一错误提示
onResponseError((err) => {
  if (err.__isCancel) return
  if (err.name === 'AuthError') return  // 已经跳登录页了,不弹
  uni.showToast({
    title: err.message || '网络异常',
    icon: 'none'
  })
})

业务调用就非常干净了:

import { request } from '@/utils/request'

async function fetchOrderList() {
  try {
    const list = await request({
      url: '/order/list',
      method: 'GET',
      data: { page: 1, size: 20 },
      ns: 'pages/order/list'
    })
    return list
  } catch (e) {
    if (e.__isCancel) return []
    throw e
  }
}

真实踩坑记录

坑一:Token 刷新后旧请求重放,header 里的 token 没更新。我最初的重放代码是 request({ ...opts }),以为拦截器会重新跑一遍就带上新 token 了。但实际上 opts.header 是在最外层合并时做的一次浅拷贝,里面可能已经存了旧的 Authorization,重放时如果拦截器没有再覆盖它,就会带着旧 token 撞回 401。修复方式是在 handle401 的重放里显式清掉旧 header:request({ ...opts, header: { ...opts.header, Authorization: undefined }, _isRetry: true })。

坑二:App 端切后台再切回,第一个请求超时。这个现象我查了三天,最后发现是 uni-app 在 App 端切后台后会暂停 JS 执行,但底层网络请求的计时器还在跑。切回来时 uni.request 的超时时间已经用完了,于是立刻报 timeout。解决方案是在 onShow 时主动检测并刷新一下 token(如果不快过期就直接用),同时把 timeout 从 15 秒放宽到 20 秒。根本解决方案是 App 端用原生插件处理网络层,但那成本太高,不是所有项目都值得。

坑三:微信小程序的 requestTask.abort() 会触发 fail 回调。而且是带着 errMsg: "request:fail abort" 触发的。如果你的 fail 处理里没做识别,就会当成网络错误弹提示。所以我在 handleNetworkError 开头就判断 if (err.errMsg && err.errMsg.includes('abort')),直接 reject 一个 CancelError,不重试也不弹提示。

坑四:H5 端本地调试时 CORS 失效。这一条其实跟库本身没关系,但如果你的项目是通过 vite.config.js 配代理的,要注意 baseURL 不能写成完整的后端域名,否则会绕过代理触发跨域。我的做法是开发环境 baseURL 留空,全走相对路径,由代理转发;生产环境才拼上完整域名。

最后聊一下要不要用 TypeScript

写到这里已经八百多行了,讲真,这套东西用 TS 重写会舒服很多,特别是错误类型和拦截器函数的签名,有类型约束之后 IDE 能帮你抓出很多低级错误。我现在的做法是保留 JS 主体,但在类型定义文件里导出接口:

// types/request.d.ts
export interface RequestOptions {
  url: string
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
  data?: Record<string, any>
  header?: Record<string, string>
  timeout?: number
  retryCount?: number
  retryDelay?: number
  ns?: string
  _isRetry?: boolean
  _retryCount?: number
}

这样业务代码有提示,库本身还是纯 JS,不用担心编译链问题。等到某天整个项目迁移到 TS 了,再把库本身改掉也来得及。

这套请求库在生产环境跑了大概半年,中间只改过两次:一次是给 forceLogout 加并发去重,一次是让 refreshPromise 的清理更严谨。其余部分一直很稳。如果你也在给 uni-app 项目做网络层,建议把这套当起点,但一定要结合自己的后端协议做调整,别照抄。

uni-app 多端请求库手写实战:Token 刷新并发、请求取消、断网重试一次搞定
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uni-app 多端请求库手写实战:Token 刷新并发、请求取消、断网重试一次搞定 https://www.taomawang.com/web/uniapp/2951.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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