uni-app 网络请求封装实战:拦截器、Token 无感刷新与并发控制

uni-app 网络请求封装实战:拦截器、Token 无感刷新与并发控制

admin
2026-07-25 / 0 评论 / 5 阅读

uni.request 是个原始 API:没有拦截器、不支持 Promise 链式调用(callback 风格)、错误处理要层层嵌套。任何像样的 uni-app 项目第一步都是封装请求层。本文从零实现一个生产级的 request 模块,覆盖拦截器、Token 无感刷新、并发请求控制与上传下载。

一、基础封装:Promise 化 + 统一错误处理

// utils/request.js
const BASE_URL = 'https://api.example.com'
const TIMEOUT = 10000

function request(options = {}) {
  return new Promise((resolve, reject) => {
    uni.request({
      url: BASE_URL + options.url,
      method: options.method || 'GET',
      data: options.data || {},
      header: options.header || {},
      timeout: TIMEOUT,
      success: (res) => {
        // HTTP 层成功
        if (res.statusCode >= 200 && res.statusCode < 300) {
          // 业务层判断
          const body = res.data
          if (body.code === 0) {
            resolve(body.data)
          } else {
            handleBizError(body)
            reject(body)
          }
        } else if (res.statusCode === 401) {
          // 交给 Token 刷新流程处理
          refreshTokenAndRetry(options).then(resolve).catch(reject)
        } else {
          uni.showToast({ title: `服务异常(${res.statusCode})`, icon: 'none' })
          reject(res)
        }
      },
      fail: (err) => {
        // 网络层失败:断网、DNS、超时
        uni.showToast({
          title: err.errMsg.includes('timeout') ? '请求超时' : '网络连接失败',
          icon: 'none'
        })
        reject(err)
      }
    })
  })
}

这里把三层错误分开处理:网络层(fail 回调)、HTTP 层(statusCode)、业务层(响应体 code),每层给用户不同的提示,排查问题时也能立刻定位层级。

二、请求拦截:自动携带 Token

// utils/auth.js
export function getToken() {
  return uni.getStorageSync('access_token') || ''
}

export function setTokens(access, refresh) {
  uni.setStorageSync('access_token', access)
  uni.setStorageSync('refresh_token', refresh)
}

export function clearTokens() {
  uni.removeStorageSync('access_token')
  uni.removeStorageSync('refresh_token')
}

在 request 内统一注入:

function request(options = {}) {
  // 白名单接口不携带 Token
  const whiteList = ['/auth/login', '/auth/captcha']
  const needAuth = !whiteList.includes(options.url)

  const header = {
    'Content-Type': 'application/json',
    ...(options.header || {})
  }

  if (needAuth && getToken()) {
    header.Authorization = `Bearer ${getToken()}`
  }

  return new Promise((resolve, reject) => {
    uni.request({
      url: BASE_URL + options.url,
      header,
      // ...同上
    })
  })
}

// 便捷方法
request.get = (url, data) => request({ url, method: 'GET', data })
request.post = (url, data) => request({ url, method: 'POST', data })
request.put = (url, data) => request({ url, method: 'PUT', data })
request.delete = (url, data) => request({ url, method: 'DELETE', data })

export default request

业务代码立刻清爽:

import request from '@/utils/request'

// 页面里
async loadOrders() {
  this.loading = true
  try {
    this.orders = await request.get('/orders', { page: this.page, size: 20 })
  } finally {
    this.loading = false
  }
}

三、Token 无感刷新(核心难点)

Access Token 过期时,理想体验是用户毫无感知地完成刷新并重放失败请求。难点在于:并发多个请求同时 401 时,只允许刷新一次,其余请求排队等待

// utils/request.js
let isRefreshing = false
let pendingQueue = [] // 等待刷新完成的请求

function refreshTokenAndRetry(options) {
  return new Promise((resolve, reject) => {
    pendingQueue.push({ options, resolve, reject })

    if (isRefreshing) return // 已有刷新任务在跑,只入队

    isRefreshing = true
    const refreshToken = uni.getStorageSync('refresh_token')

    // 用原始 uni.request 刷新,避免递归
    uni.request({
      url: BASE_URL + '/auth/refresh',
      method: 'POST',
      data: { refreshToken },
      success: (res) => {
        if (res.statusCode === 200 && res.data.code === 0) {
          const { accessToken, refreshToken: newRefresh } = res.data.data
          setTokens(accessToken, newRefresh)

          // 重放所有排队请求
          pendingQueue.forEach(({ options, resolve, reject }) => {
            request(options).then(resolve).catch(reject)
          })
        } else {
          // 刷新失败:登录态彻底失效
          clearTokens()
          pendingQueue.forEach(({ reject }) => reject(new Error('登录已过期')))
          uni.showToast({ title: '请重新登录', icon: 'none' })
          setTimeout(() => uni.reLaunch({ url: '/pages/login/login' }), 1500)
        }
      },
      fail: (err) => {
        pendingQueue.forEach(({ reject }) => reject(err))
      },
      complete: () => {
        isRefreshing = false
        pendingQueue = []
      }
    })
  })
}

这套机制的关键点:

  1. isRefreshing 互斥锁:第一个 401 触发刷新,后续 401 只入队
  2. 排队重放:刷新成功后遍历队列,用新 Token 重发原请求
  3. 彻底失败兜底:refresh token 也过期时,清空凭证踢回登录页
  4. 用原生 uni.request 刷新:避免刷新接口自身 401 造成无限递归

四、并发控制与取消请求

取消请求

uni.request 返回 RequestTask,可以 abort:

// 页面搜索场景:新请求发出前取消上一个
export function requestWithAbort(options) {
  if (requestWithAbort._task) {
    requestWithAbort._task.abort()
  }

  const taskHolder = {}
  const p = new Promise((resolve, reject) => {
    taskHolder.task = uni.request({
      ...options,
      success: resolve,
      fail: reject
    })
  })
  requestWithAbort._task = taskHolder.task
  return p
}

搜索防抖 + 取消请求组合,才能彻底解决"旧结果覆盖新结果"的竞态问题。

并发限制

小程序对同时进行的 wx.request 有 10 个上限(微信端),批量任务要做池化控制:

async function requestPool(tasks, limit = 8) {
  const results = []
  const executing = []

  for (const task of tasks) {
    const p = task().then(r => ({ status: 'ok', data: r }))
      .catch(e => ({ status: 'fail', error: e }))
    results.push(p)

    if (limit <= tasks.length) {
      executing.push(p)
      p.finally(() => executing.splice(executing.indexOf(p), 1))
      if (executing.length >= limit) {
        await Promise.race(executing)
      }
    }
  }

  return Promise.all(results)
}

// 批量拉取 50 个商品详情,并发 8
const details = await requestPool(
  goodsIds.map(id => () => request.get(`/goods/${id}`)),
  8
)

五、上传与下载

文件上传

function upload(filePath, formData = {}) {
  return new Promise((resolve, reject) => {
    uni.uploadFile({
      url: BASE_URL + '/upload',
      filePath,
      name: 'file',
      formData, // 额外表单字段
      header: { Authorization: `Bearer ${getToken()}` },
      success: (res) => {
        // 注意:uploadFile 返回的是字符串,要手动 parse
        const body = JSON.parse(res.data)
        body.code === 0 ? resolve(body.data) : reject(body)
      },
      fail: reject
    })
  })
}

uploadFile 响应体是字符串,不 像 request 自动按 content-type 解析,JSON.parse 是必经步骤,这是极高频的坑。

分片上传大文件

uni-app 端实现分片需要先读文件(uni.getFileSystemManager,仅小程序端可用),将 Blob 切片后逐片上传,最后通知后端合并:

// 伪代码框架
const chunkSize = 1024 * 512 // 512KB
const totalChunks = Math.ceil(fileSize / chunkSize)

for (let i = 0; i < totalChunks; i++) {
  await request.post('/upload/chunk', {
    uploadId,
    index: i,
    total: totalChunks,
    chunk: chunkData
  })
}
// 全部上传完成后合并
await request.post('/upload/merge', { uploadId, filename })

六、环境切换与接口配置

// config/env.js
let baseUrl = ''

// #ifdef MP-WEIXIN
baseUrl = 'https://mp-api.example.com' // 小程序正式环境
// #endif

// #ifdef H5
// H5 开发环境走代理
baseUrl = process.env.NODE_ENV === 'development'
  ? '/api'
  : 'https://h5-api.example.com'
// #endif

// #ifdef APP-PLUS
baseUrl = 'https://app-api.example.com'
// #endif

export { baseUrl }

利用条件编译按平台切换域名,比运行时 process.env 判断更干净。

总结

  • 三层错误模型:网络层 fail、HTTP 层 statusCode、业务层 code 分开处理
  • Token 无感刷新 = 互斥锁 + 等待队列 + 重放,缺一不可
  • 竞态问题 = 防抖 + abort 双保险
  • uploadFile 响应是字符串,必须手动 JSON.parse
  • 微信端并发请求上限 10,批量任务用请求池

一个健壮的请求层是项目的地基,值得花一个下午把它写好。下一篇讲条件编译与多端适配——uni-app "一次开发多端运行"的光鲜与代价。

0

评论 (0)

取消
0:00