uni-app 请求层封装:重复请求合并、主动取消与 401 续期竞态

2026-10-15 0 875

去年冬天一个晚上,后端同事在群里发了一张监控截图:同一秒内有六个 /auth/refresh 请求打进来。我看了眼时间,正好是 token 过期的那一分钟。

原因不复杂。首页 onLoad 里同时发了商品列表、banner、用户信息三个请求,详情页预加载又发了两个,消息红点还有一个。token 过期后这六个请求全部返回 401,各自触发一次刷新逻辑,六个刷新请求并发出去,后五个拿到的 refreshToken 已经被第一个失效掉了,全部刷新失败,用户被踢回登录页。

修的时候才发现,项目里的”请求封装”其实只是给 uni.request 套了层 Promise,加了个统一错误提示。真正麻烦的事——去重、取消、续期——一件都没做。这篇文章就把这层补上。

uni.request 缺的是什么

直接看原生用法:

uni.request({
  url: '/api/goods',
  method: 'GET',
  success: res => {},
  fail: err => {},
  complete: () => {}
})

问题有三个。

第一,它返回的不是什么都不给。很多人以为没有返回值,其实它会返回一个 requestTask 对象。这个对象上挂着 abort(),也就是取消能力。资料里经常一笔带过,但取消这件事全靠它。

第二,它没有拦截器。每个页面都要手写 header.token,处理一次 401,处理一次 500。写十次之后必然有不一致的地方。

第三,它不知道”这个请求和上一个请求是不是一回事”。这一点是造成那次事故的根源。

第一件事:把相同请求合并掉

先区分清楚两种”重复”。

一种是同一时刻发出的完全相同的请求。首页三个组件都在 onLoad 里调 getUserInfo(),它们想要的其实是同一份数据。这种情况不该取消防任何一个,而是让它们共享同一个网络请求。

另一种是先后发出、后一个要让前一个失效。搜索联想框是典型:用户输入”a”,请求发出;再输入”ab”,请求又发出;如果”a”的响应晚于”ab”回来,列表就会闪一下旧数据。这种情况必须取消前一个。

先处理第一种,用一个 Map 缓存正在飞行中的 Promise。

const inflight = new Map()

function requestKey(config) {
  const method = (config.method || 'GET').toUpperCase()
  let body = ''
  if (config.data) {
    try {
      body = JSON.stringify(config.data)
    } catch (e) {
      body = String(config.data)
    }
  }
  return `${method}::${config.url}::${body}`
}

function request(config) {
  const key = requestKey(config)

  if (inflight.has(key)) {
    // 别的调用方已经在请求同一个东西,直接复用
    return inflight.get(key)
  }

  const p = doRequest(config).finally(() => {
    inflight.delete(key)
  })

  inflight.set(key, p)
  return p
}

有几个细节值得留意。

JSON.stringify 的顺序问题。{a:1, b:2} 和 {b:2, a:1} 字符串化之后不一样,会被当成两个不同的请求。项目里如果没人乱写 key 顺序,通常不用管;真在意的话可以写个按 key 排序的序列化,但那属于过度设计。

为什么不用 finally 之外的写法。finally 在成功和失败都会执行,能保证 Map 里的条目一定会被清掉。如果放在 then 里,请求失败时就会残留,后续相同请求会拿到一个已 reject 的 Promise,永远失败。这个坑我踩过一次,排查了很久。

只对幂等请求合并。POST 创建订单、提交表单这类请求,重复调用本身就不该发生,但万一发生了,合并掉会让用户以为”提交了两次只算一次”。这类请求建议排除在合并之外。做法很简单,在 config 里加个 allowMerge: false,判断一下就行。

第二件事:主动取消,用 requestTask.abort()

搜索联想、页面离开时中止请求、切换 tab 时放弃上一个 tab 的请求——这些都需要真取消。uni.request 在小程序端和 App 端返回的 requestTask 上都有 abort(),H5 端底层是 XMLHttpRequest,同样支持。

function doRequest(config) {
  return new Promise((resolve, reject) => {
    const task = uni.request({
      url: BASE_URL + config.url,
      method: config.method || 'GET',
      data: config.data || {},
      header: buildHeader(config),
      timeout: config.timeout || 15000,
      success: res => resolve(handleResponse(res, config)),
      fail: err => reject(normalizeError(err)),
      complete: () => {}
    })

    // 把 task 挂到外部,供 abort 使用
    if (config.cancelKey && task) {
      registerCancelable(config.cancelKey, task)
    }
  })
}

然后维护一张”取消键到 task”的表。同一个 cancelKey 来了新请求,旧的就 abort 掉。

const cancelable = new Map()

function registerCancelable(key, task) {
  const old = cancelable.get(key)
  if (old && typeof old.abort === 'function') {
    old.abort()
  }
  cancelable.set(key, task)
}

// 请求结束时清理,避免内存泄漏
function clearCancelable(key, task) {
  if (cancelable.get(key) === task) {
    cancelable.delete(key)
  }
}

使用侧:

let searchSeq = 0

function onKeywordChange(kw) {
  if (!kw.trim()) return

  request({
    url: '/api/search/suggest',
    data: { kw },
    // 所有联想请求共用一个取消键,
    // 新的一次进来就会把上一次干掉
    cancelKey: 'search-suggest'
  })
    .then(list => {
      suggestList.value = list
    })
    .catch(err => {
      // 被 abort 的请求会走 fail,这里要判断一下
      if (err && err.aborted) return
      suggestList.value = []
    })
}

这里有个很容易翻车的点:被 abort 的请求,fail 回调照样会触发,errMsg 通常是 "request:fail abort" 或者包含 “abort” 字样。如果不做区分,用户每输入一个字就会弹一次”网络异常”的提示。

所以在归一化错误的时候,要把这种情况单独标出来:

function normalizeError(err) {
  const msg = (err && err.errMsg) || ''
  const aborted = /abort/i.test(msg)

  return {
    code: aborted ? 'ABORTED' : 'NETWORK',
    aborted,
    message: aborted ? '请求已取消' : '网络连接失败',
    raw: err
  }
}

然后在统一错误处理里,凡是 aborted 为 true 的,一律静默跳过,不弹提示、不打日志、不重试。

还有一个细节:cancelKey 是共享的,如果两个不同页面都用了 'search-suggest',会互相取消。命名带上页面或组件前缀,比如 'home-search-suggest',能避免这类交叉污染。

第三件事:401 续期,只能有一个人去刷新

这才是那次事故真正的起因。核心思路是”单飞”——同一时刻只允许一个刷新流程在跑,其他所有等着它,拿到新 token 后再重放自己原来的请求。

let refreshing = null

function refreshToken() {
  // 已经在刷新了,挂到队列上等结果
  if (refreshing) return refreshing

  refreshing = rawRefresh()
    .then(newToken => {
      setToken(newToken)
      return newToken
    })
    .finally(() => {
      // 无论成功失败,都要释放锁,
      // 否则后续所有请求会永远卡在等待里
      refreshing = null
    })

  return refreshing
}

// 独立的一支请求,不能走业务请求函数,否则递归
function rawRefresh() {
  return new Promise((resolve, reject) => {
    uni.request({
      url: BASE_URL + '/auth/refresh',
      method: 'POST',
      data: { refreshToken: getRefreshToken() },
      success: res => {
        const data = res.data || {}
        if (data.code === 0 && data.token) {
          resolve(data.token)
        } else {
          reject(new Error('refresh failed'))
        }
      },
      fail: reject
    })
  })
}

关键在于 refreshing 这个变量存的是 Promise 而不是布尔值。这样后来者不需要自己排队,直接 return refreshing 就搭上了同一班车,拿到的是同一个结果。用布尔值加队列也能做,但代码量翻倍,还得自己处理成功和失败两条回执路径。

然后是 401 的处理逻辑,嵌在响应处理里:

async function handleResponse(res, config) {
  const body = res.data || {}

  if (res.statusCode === 401 || body.code === 401) {
    // 已经重试过一次,不再重试,直接踢出
    if (config._retried) {
      clearLoginState()
      redirectToLogin()
      throw { code: 'UNAUTHORIZED', message: '登录已过期' }
    }

    try {
      await refreshToken()
    } catch (e) {
      clearLoginState()
      redirectToLogin()
      throw { code: 'UNAUTHORIZED', message: '登录已过期' }
    }

    // 刷新成功,拿新 token 重放原请求
    return doRequest(Object.assign({}, config, { _retried: true }))
  }

  if (body.code !== 0) {
    uni.showToast({ title: body.message || '请求失败', icon: 'none' })
    throw { code: body.code, message: body.message }
  }

  return body.data
}

_retried 这个标记非常重要。新 token 本身也可能是过期的(比如服务端时间不同步,或者用户手动改了本地存储)。没有这个标记,重放请求又拿到 401,又去刷新,又重放,直接死循环,把服务端刷爆。加了这个标记,第二次 401 直接走登出,干净利落。

重放时要用 doRequest 而不是 request。因为 request 会走去重逻辑,如果原请求还在 inflight 里,重放会直接被当成重复请求返回旧的 Promise,那就白搭了。doRequest 是最底层的请求函数,绕开所有缓存层。

还有一个细节:config._retried 不能污染原对象。上面用了 Object.assign 生成新对象,如果直接 config._retried = true 再重放,原对象被改了,后续如果同一个 config 被复用会出问题。

登出的并发问题

六个请求同时 401,其中一个把用户踢到登录页,另外五个也在跑登出逻辑,可能弹五次提示、跳五次页面。做法是加一个标记:

let loggingOut = false

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

  uni.removeStorageSync('token')
  uni.removeStorageSync('refreshToken')

  uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })

  setTimeout(() => {
    uni.reLaunch({ url: '/pages/login/index' })
    loggingOut = false
  }, 800)
}

延时跳转是为了让 toast 有机会显示出来。reLaunch 会清掉页面栈,立刻跳的话 toast 会被一起销毁。

第四件事:图片上传时的并发限流

这个需求跟前面三个不太一样,但也经常和它们一起出现。用户一次选十张图,如果同时发十个上传请求,小程序的并发数限制会直接让部分请求失败,H5 端则可能把浏览器连接池占满,导致页面上其他请求卡住。

做一个简单的并发调度器,代码不到二十行。

function createLimiter(max) {
  let running = 0
  const queue = []

  function next() {
    if (running >= max || !queue.length) return
    running++
    const task = queue.shift()

    task.fn()
      .then(task.resolve, task.reject)
      .finally(() => {
        running--
        next()
      })
  }

  return function schedule(fn) {
    return new Promise((resolve, reject) => {
      queue.push({ fn, resolve, reject })
      next()
    })
  }
}

用起来:

const uploadLimiter = createLimiter(3)

function uploadOne(file) {
  return uploadLimiter(() => new Promise((resolve, reject) => {
    uni.uploadFile({
      url: BASE_URL + '/upload',
      filePath: file.path,
      name: 'file',
      header: buildHeader(),
      success: res => {
        try {
          const data = JSON.parse(res.data)
          data.code === 0 ? resolve(data.data) : reject(data)
        } catch (e) {
          reject(new Error('返回格式异常'))
        }
      },
      fail: reject
    })
  }))
}

// 十张图,任何时候只有三个在跑
async function uploadAll(files) {
  const results = await Promise.allSettled(files.map(uploadOne))
  // 汇总成功和失败,交给 UI 展示
  return results
}

Promise.allSettled 在这里比 Promise.all 合适。十张图错了一张,用户希望的是”九张成功、一张失败可以重试”,而不是整个批次作废。用 allSettled 拿到全部结果,UI 上把失败的那张标红让用户重传就行。

顺带说一句,uni.uploadFile 返回的 uploadTask 上有 onProgressUpdate 回调,可以做单张图片的上传进度。如果整个批次要显示总进度,就得自己在进度回调里汇总,按已上传字节数除以总字节数来算。

多端差异清单

这套封装写完,三端跑一遍,还是会遇到一些行为不一致的地方。列几个印象最深的。

数组参数的序列化

GET 请求传 data: { ids: [1, 2, 3] },小程序端默认会序列化成 ids[0]=1&ids[1]=2&ids[2]=3,而 H5 端在某些情况下会变成 ids=1&ids=2&ids=3。后端如果只认其中一种格式,就会拿不到参数。

稳妥做法是自己处理,把它转成逗号分隔的字符串:

function normalizeQuery(data) {
  const out = {}
  Object.keys(data || {}).forEach(k => {
    const v = data[k]
    if (Array.isArray(v)) {
      out[k] = v.join(',')
    } else if (v !== undefined && v !== null) {
      out[k] = v
    }
  })
  return out
}

后端拿到 "1,2,3" 自己 split 一下,三端行为就统一了。

响应头的读取

小程序端的 requestTask 上有 onHeadersReceived 方法,可以在响应头到达时立刻拿到,不用等 body。App 端部分版本没有,H5 端也没有。如果有”根据响应头里的分页信息做无限滚动”这类需求,得做条件编译,或者在 H5 端改用别的方案。

大部分项目其实用不上,写在这里是提醒一句:看到别人代码里用 onHeadersReceived,先确认下自己的目标端支不支持。

超时的表现

H5 端超时后 fail 里的 errMsg 是 "request:fail timeout",小程序端可能是 "request:fail timeout" 也可能是具体的网络错误。这两种在归一化的时候都归到 NETWORK 就行,不用细分。

Content-Type 的自动处理

小程序端如果 header 里不写 Content-Type,默认会带 application/json,POST 的 data 对象会被自动序列化成 JSON 字符串。H5 端如果显式写成 application/x-www-form-urlencoded,uni-app 会帮你把对象序列化成表单格式。这两种行为最好在封装里统一掉,别让业务代码去关心。

function buildHeader(config) {
  const header = Object.assign({}, config.header)

  if (!header['Content-Type']) {
    header['Content-Type'] = config.form
      ? 'application/x-www-form-urlencoded'
      : 'application/json'
  }

  const token = getToken()
  if (token && !header.Authorization) {
    header.Authorization = 'Bearer ' + token
  }

  return header
}

把上面这些拼起来

完整的调用顺序大概是这样:

function request(config) {
  // 1. 参数归一
  const finalConfig = Object.assign({}, config, {
    data: normalizeQuery(config.data),
    header: buildHeader(config)
  })

  // 2. 合并相同请求
  const key = requestKey(finalConfig)
  if (finalConfig.allowMerge !== false && inflight.has(key)) {
    return inflight.get(key)
  }

  // 3. 发请求,内部处理 401 续期和重放
  const p = doRequestWithRetry(finalConfig).finally(() => {
    inflight.delete(key)
  })

  if (finalConfig.allowMerge !== false) {
    inflight.set(key, p)
  }

  return p
}

async function doRequestWithRetry(config) {
  const res = await doRequest(config)
  return handleResponse(res, config)
}

看起来层次有点多,但每一层的职责是清晰的:request 管去重和整体流程,doRequest 只管把请求发出去、把 task 管好,handleResponse 只管判断业务码和 401。

拆分带来的好处,是在加新需求时不用动老代码。比如最近要加”请求耗时埋点”,只需要在 doRequest 里加两个时间戳,别的层完全不用碰。

最后

回到开头那次事故,最后改动的代码量其实不大,核心就是那个存 Promise 而不是布尔值的 refreshing 变量。但它解决的问题是系统性的:无论页面并发多少个请求,无论 token 在什么时刻过期,都只会有一个刷新流程,用户的登录态不会因为并发而崩掉。

如果项目里的请求封装现在只是”套了层 Promise”,建议按这个顺序补:先去重,再取消,最后处理 401。前两个改动小、收益立竿见影,第三个改动稍大,但那是唯一会引发线上事故的一环,值得花半天时间把它做对。

uni-app 请求层封装:重复请求合并、主动取消与 401 续期竞态
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uni-app 请求层封装:重复请求合并、主动取消与 401 续期竞态 https://www.taomawang.com/web/uniapp/2941.html

常见问题

相关文章

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

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