uni-app 请求层封装实战:去重、缓存、重试与 Token 无感刷新一次到位

2026-10-11 0 213

去年接手一个 uni-app 项目,首页有 6 个卡片模块,每个卡片各自发自己的接口。上线后接到用户反馈:切到别的页面再切回来,流量跑得特别快。抓包一看,同一个 /home/summary 在 800ms 内被请求了 4 次——因为 4 个卡片各自在 onMounted 里调了一次。

更麻烦的是登录。后端 token 有效期 2 小时,refresh token 一次性。用户如果刚好在 token 过期那一秒打开首页,6 个并发请求同时返回 401,6 个都去调刷新接口,第一个刷新成功、后面 5 个拿着已经作废的 refresh token 全部失败,用户被踢回登录页。

这篇文章把这套请求层从零写一遍。代码基于 uni-app + Vue3 + TypeScript,微信小程序、App、H5 三端验证过。

一、为什么不直接在 uni.request 上加拦截器

很多人第一反应是用 uni.addInterceptor('request', {...})。这个 API 能用,但它只能改调用参数,拿不到返回值也改不了响应体,更没法在 401 的时候挂起请求、等刷新完再重放。所以请求层该自己写还是得自己写。

另一个常见做法是简单包一层 Promise:

export const request = (options) => new Promise((resolve, reject) => {
  uni.request({ ...options, success: resolve, fail: reject })
})

这层壳解决的是”回调转 Promise”,业务问题一个没解决。下面按问题一个个加能力。

二、先明确要解决什么

  • 并发去重:相同 method + url + 参数,在响应回来之前只发一次,其它调用方共享同一个 Promise。
  • TTL 缓存:GET 接口可以声明”N 秒内命中就直接返回”,减少切页面时的无谓请求。
  • 网络重试:超时、断网这类可恢复错误自动重试,业务错误绝不重试。
  • 取消请求:页面已销毁、用户快速切 tab 的场景,把飞行中的请求掐掉。
  • Token 无感刷新:并发 401 只触发一次刷新,其余请求排队等结果然后重放。

三、类型定义与取消令牌

先写基础类型。这里没有把 uni.request 的原始参数全部透传,只暴露业务真正需要的那几个,避免之后 API 面越滚越大。

// utils/http/types.ts
export interface RequestConfig {
  url: string
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
  data?: Record<string, any>
  header?: Record<string, string>
  timeout?: number
  auth?: boolean          // 是否携带 token,默认 true
  cache?: number          // 缓存毫秒数,仅 GET 生效,0 表示不缓存
  retry?: number          // 网络类错误的重试次数,默认 0
  cancelToken?: CancelToken
}

export class CancelToken {
  aborted = false
  private queue: Array<() => void> = []

  cancel() {
    if (this.aborted) return
    this.aborted = true
    // splice(0) 一次性取出并清空,防止重复执行
    this.queue.splice(0).forEach(fn => fn())
  }

  onCancel(fn: () => void) {
    if (this.aborted) fn()
    else this.queue.push(fn)
  }
}

为什么不用 AbortController?因为小程序端不保证有。自己写一个几十行的 CancelToken,三端行为一致,可控性反而更高。

四、去重与缓存

去重的关键是”怎么判断两个请求是同一个”。直接 JSON.stringify(data) 是不行的,因为 {a:1, b:2} 和 {b:2, a:1} 会得到不同字符串,实际上它们是同一个请求。

// utils/http/index.ts
function stableStringify(val: any): string {
  if (val === null || typeof val !== 'object') return JSON.stringify(val)
  if (Array.isArray(val)) return `[${val.map(stableStringify).join(',')}]`
  const keys = Object.keys(val).sort()
  return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(val[k])}`).join(',')}}`
}

const inflight = new Map<string, Promise<any>>()
const memoryCache = new Map<string, { data: any; expireAt: number }>()

function cacheKeyOf(cfg: RequestConfig, uid: string) {
  // 注意 uid 必须参与计算,否则 A 用户退出、B 用户登录后会读到 A 的缓存
  return `${uid}:${cfg.method || 'GET'}:${cfg.url}:${stableStringify(cfg.data || {})}`
}

缓存做了两级:内存 Map 做热点命中,uni.setStorageSync 做冷启动兜底。这里有个坑——微信小程序单个 key 上限 1MB,列表数据超过 200KB 就只留在内存里,别往 storage 塞,否则第一次能写进去,第二次直接抛异常。

function readCache(key: string) {
  const mem = memoryCache.get(key)
  if (mem) {
    if (mem.expireAt > Date.now()) return mem.data
    memoryCache.delete(key)
  }
  try {
    const raw = uni.getStorageSync('http_cache:' + key)
    if (!raw) return undefined
    if (raw.expireAt > Date.now()) {
      memoryCache.set(key, raw)
      return raw.data
    }
    uni.removeStorageSync('http_cache:' + key)
  } catch (e) { /* storage 异常不影响主流程 */ }
  return undefined
}

function writeCache(key: string, data: any, ttl: number) {
  const entry = { data, expireAt: Date.now() + ttl }
  memoryCache.set(key, entry)
  try {
    const size = JSON.stringify(data)?.length ?? 0
    if (size < 200 * 1024) uni.setStorageSync('http_cache:' + key, entry)
  } catch (e) { /* 忽略配额异常 */ }
}

入口函数 request 负责串联去重和缓存:

export function request<T = any>(cfg: RequestConfig): Promise<T> {
  const uid = getUserId() || 'anonymous'
  const key = cacheKeyOf(cfg, uid)
  const isGet = (cfg.method || 'GET').toUpperCase() === 'GET'

  if (isGet && (cfg.cache ?? 0) > 0) {
    const cached = readCache(key)
    if (cached !== undefined) return Promise.resolve(cached as T)
  }

  // 只有 GET 去重。POST 可能有副作用,合并掉会丢数据
  if (isGet && inflight.has(key)) {
    return inflight.get(key) as Promise<T>
  }

  const p = doRequest<T>(cfg, key).finally(() => {
    inflight.delete(key)
  })

  if (isGet) inflight.set(key, p)
  return p
}

五、重试、取消与真正的发送逻辑

接下来是核心。重试要区分错误类型:网络超时、DNS 失败、连接中断可以重试;4xx 业务错误重试一万次也没用,只会加重服务端负担。退避策略用指数 + 随机抖动,避免同一批客户端在同一毫秒集中重试。

export class HttpError extends Error {
  code: number
  detail?: any
  constructor(code: number, message: string, detail?: any) {
    super(message)
    this.code = code
    this.detail = detail
  }
}

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))

const BASE_URL = 'https://api.yourservice.com'

function send(cfg: RequestConfig, token: string, method: string) {
  return new Promise<any>((resolve, reject) => {
    const header: Record<string, string> = {
      'Content-Type': 'application/json',
      ...(cfg.header || {})
    }
    if (token) header.Authorization = `Bearer ${token}`

    const task = uni.request({
      url: /^https?:/.test(cfg.url) ? cfg.url : BASE_URL + cfg.url,
      method: method as any,
      data: cfg.data,
      header,
      timeout: cfg.timeout ?? 15000,
      success: resolve,
      fail: (err: any) => {
        // 小程序和 App 在 abort 之后都会走 fail,errMsg 里带 "abort"
        const msg = err?.errMsg || ''
        if (msg.includes('abort')) reject(new HttpError(-2, '请求已取消'))
        else reject(err)
      }
    })

    cfg.cancelToken?.onCancel(() => {
      task?.abort?.()
    })
  })
}

然后是 doRequest。这个函数参数里有一个 afterRefresh,是防止刷新完 token 重放请求后又 401 导致死循环的关键。

async function doRequest<T>(
  cfg: RequestConfig,
  key: string,
  attempt = 0,
  afterRefresh = false
): Promise<T> {
  const method = (cfg.method || 'GET').toUpperCase()
  const useAuth = cfg.auth !== false
  const tokenAtSend = useAuth ? getToken() : ''
  const retryLimit = cfg.retry ?? 0

  if (cfg.cancelToken?.aborted) throw new HttpError(-2, '请求已取消')

  try {
    const res = await send(cfg, tokenAtSend, method)
    const { statusCode, data } = res

    if (statusCode >= 200 && statusCode < 300) {
      const body: any = data
      // 按后端约定判断业务码,这里假设 { code, message, data }
      if (body && typeof body.code === 'number' && body.code !== 0) {
        throw new HttpError(body.code, body.message || '业务异常', body)
      }
      const payload = body && 'data' in body ? body.data : body
      if (method === 'GET' && (cfg.cache ?? 0) > 0) {
        writeCache(key, payload, cfg.cache!)
      }
      return payload as T
    }

    if (statusCode === 401 && useAuth) {
      if (afterRefresh) {
        clearAuth()
        uni.reLaunch({ url: '/pages/login/index' })
        throw new HttpError(401, '登录已过期')
      }
      // 并发 401 时,先到的那个请求可能已经刷新完 token 了
      if (getToken() && getToken() !== tokenAtSend) {
        return doRequest<T>(cfg, key, attempt, false)
      }
      await refreshTokenOnce()
      return doRequest<T>(cfg, key, attempt, true)
    }

    if (statusCode >= 500 && attempt < retryLimit) {
      return retryLater<T>(cfg, key, attempt, afterRefresh)
    }

    throw new HttpError(statusCode, `HTTP ${statusCode}`, data)
  } catch (err: any) {
    if (err instanceof HttpError) throw err
    if (attempt < retryLimit) {
      return retryLater<T>(cfg, key, attempt, afterRefresh)
    }
    throw new HttpError(-1, err?.errMsg || '网络异常,请稍后重试')
  }
}

async function retryLater<T>(
  cfg: RequestConfig,
  key: string,
  attempt: number,
  afterRefresh: boolean
): Promise<T> {
  const delay = Math.min(300 * 2 ** attempt, 3000) + Math.floor(Math.random() * 200)
  await sleep(delay)
  return doRequest<T>(cfg, key, attempt + 1, afterRefresh)
}

六、Token 无感刷新

网上很多实现是给刷新接口加个 isRefreshing 标志,其他请求轮询等待。轮询有两个毛病:等待时长不好定,而且容易漏掉刷新失败的情况。用一个共享 Promise 更干净——所有并发请求 await 同一个 Promise,成功就都往下走,失败就都一起抛。

let refreshPromise: Promise<void> | null = null

export function refreshTokenOnce(): Promise<void> {
  if (!refreshPromise) {
    refreshPromise = doRefresh().finally(() => {
      refreshPromise = null
    })
  }
  return refreshPromise
}

// 注意:这里用裸的 uni.request,不能走封装,
// 否则刷新失败会再次触发 401 处理,形成递归
async function doRefresh(): Promise<void> {
  const refreshToken = getRefreshToken()
  if (!refreshToken) throw new HttpError(401, '缺少 refresh token')

  try {
    const res: any = await new Promise((resolve, reject) => {
      uni.request({
        url: BASE_URL + '/auth/refresh',
        method: 'POST',
        data: { refreshToken },
        header: { 'Content-Type': 'application/json' },
        timeout: 10000,
        success: resolve,
        fail: reject
      })
    })

    if (res.statusCode !== 200 || res.data?.code !== 0) {
      throw new Error('refresh rejected')
    }
    setToken(res.data.data.accessToken, res.data.data.refreshToken)
  } catch (e) {
    clearAuth()
    uni.reLaunch({ url: '/pages/login/index' })
    throw new HttpError(401, '登录已过期,请重新登录')
  }
}

这里最容易被忽略的是 tokenAtSend 这个快照。没有它,6 个并发 401 会依次进入刷新分支,虽然共享了 Promise 不会重复请求刷新接口,但每个请求都会多绕一圈。有了它,后到的请求发现 token 已经变了,直接重放,省一次 await。

七、页面里怎么用

<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { request, CancelToken, HttpError } from '@/utils/http'

const list = ref<any[]>([])
const loading = ref(false)
const cancelToken = new CancelToken()

async function loadFeed() {
  loading.value = true
  try {
    const data = await request<{ list: any[] }>({
      url: '/home/feed',
      data: { page: 1, size: 20 },
      cache: 30 * 1000,
      retry: 2,
      cancelToken
    })
    list.value = data.list
  } catch (e: any) {
    if (e instanceof HttpError && e.code === -2) return   // 主动取消,不弹提示
    uni.showToast({ title: e.message || '加载失败', icon: 'none' })
  } finally {
    loading.value = false
  }
}

// 下拉刷新时要用新的 token,旧的那个已经 cancel 了
function onPullDownRefresh() {
  cancelToken.cancel()
  loadFeed().finally(() => uni.stopPullDownRefresh())
}

onUnmounted(() => cancelToken.cancel())
</script>

页面 A 和页面 B 同时请求 /user/profile,只会真实发出一次网络请求。第二个页面拿到的是同一个 Promise 的结果,耗时基本为零。首页那几个卡片模块的问题就此消失。

八、几个真正翻过车的地方

1. 缓存必须隔离用户。这是我最开始偷懒埋的雷:cacheKey 没带 userId。测试同学切换账号后,发现新账号看到了上个账号的订单数据。后来在 key 里拼了 userId,并在退出登录时调用一次 clearAllCache() 清掉 storage 前缀为 http_cache: 的 key。

2. POST 不要参与去重。曾经为了”优化”,给 POST 也加了去重。结果用户连续提交两次表单,第二次被合并成了第一个请求的返回结果,”提交成功”的 toast 弹了两次但只有一条数据入库。涉及写操作的接口,宁可多发也不要去重。

3. abort 之后 fail 回调照常触发。小程序端调用 requestTask.abort() 之后,fail 依然会被调用,errMsg 是 request:fail abort。如果不识别这个字符串,用户快速切页时就会看到莫名其妙的”网络异常”提示。上面的实现里统一转成了 code === -2,业务层直接忽略即可。

4. 重试要设置上限。指数退避如果只写 2 ** attempt 不封顶,第 6 次重试就要等 32 秒,用户早就以为 App 卡死了。所以 Math.min(base * 2 ** attempt, 3000) 这个封顶值是必须的。

5. H5 端注意跨域与预检。加 Authorization 头会让简单请求变成非简单请求,浏览器会先发 OPTIONS 预检。后端如果没放行 OPTIONS,H5 端会一片红。小程序和 App 没有这个问题,容易在联调阶段漏掉。

九、还可以继续加的东西

目前这套大概 200 行,覆盖了日常项目里 90% 的场景。如果想继续往下做,有几个方向:

  • SWR 模式:缓存命中时不直接返回,而是先返回旧数据、同时后台发起请求,拿到新数据后通过回调通知页面刷新。适合资讯流这种对时效性不那么敏感的场景。
  • 请求埋点:在 send 里记录耗时、状态码、重试次数,统一上报。线上排查接口慢的时候特别有用。
  • 按环境切换 baseURL:开发、测试、预发、生产四套地址,配合构建变量注入。
  • 请求优先级:用户主动触发的请求优先于后台静默刷新,可以做一个简单的队列调度。

请求层这种东西,前期多花半天写好,后面能省掉几十次”为什么这个接口又请求了两遍”的排查。别等到线上出问题了再回头补。

uni-app 请求层封装实战:去重、缓存、重试与 Token 无感刷新一次到位
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uni-app 请求层封装实战:去重、缓存、重试与 Token 无感刷新一次到位 https://www.taomawang.com/web/uniapp/2919.html

常见问题

相关文章

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

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