乐于分享
好东西不私藏

UniApp自定义全局API请求封装工具:从混乱到优雅的蜕变

UniApp自定义全局API请求封装工具:从混乱到优雅的蜕变

书接上篇,我们今天来聊聊UniApp开发中一个最基础也最重要的环节:网络请求封装。支持跨平台(H5/小程序/App),附带实现思路和所有源码示例,以及常见避坑指南和开发实践。

此系列文章将带领你从移动端跨平台开发入门到精通,如果你也喜欢关注APP、小程序、公众号、H5等等应用的开发,可以持续关注后续更新,避免错过宝贵的知识分享。

致开发者的忠告: AI编程盛行的今天,我们并不是不需要学习技术,而是更应该专研技术,拥有把控全局的架构设计思维才能在AI盛行的未来有立足之地。

你可能会说:“网络请求不就是uni.request吗?有什么好封装的?”但在真实项目中,你很快就会遇到一堆问题:

  • 每个接口都要手动加token、时间戳,烦不烦?

  • 接口返回的错误码,要在每个页面都处理一遍?

  • 想让接口请求时显示loading,但多个并发请求只显示一个,怎么办?

  • 需要对请求参数签名,防止篡改,怎么做?

  • 接口返回数据加密了,怎么统一解密?

如果你每个接口都单独处理这些问题,代码很快就会变成一坨屎山。今天我就带你从零构建一个功能完整、健壮可靠的全局请求封装器,统一解决这些问题。


一、为什么要封装?解决哪些痛点?

我们先想想,如果没有封装,一个典型的接口调用会写成什么样:

uni.request({  url'https://api.example.com/user/info',  method'GET',  header: {    'token': uni.getStorageSync('token'),    'timestamp'Date.now(),    'sign'generateSign(params) // 签名逻辑  },  success(res) => {    if (res.data.code === 200) {      // 成功处理    } else if (res.data.code === 401) {      // 跳转登录    } else {      uni.showToast({ title: res.data.message })    }  },  fail(err) => {    uni.showToast({ title'网络错误' })  },  complete() => {    uni.hideLoading()  }})

每次都要写这么一堆,而且每个接口都要处理错误码、loading、token……这简直是噩梦。

封装的目标

  • 统一处理公共参数:自动添加token、时间戳、设备信息等。

  • 统一加签/验签:保证请求不被篡改。

  • 统一加解密:敏感数据加密传输。

  • 统一错误处理:根据错误码自动提示、跳转登录。

  • 统一loading管理:并发请求只显示一个loading,自动关闭。

  • 代码复用:一处修改,处处生效。


二、设计思路:仿axios拦截器模式

我们要封装一个request函数,它内部使用uni.request,但提供请求拦截器和响应拦截器。拦截器可以让我们在请求发出前和收到响应后插入自定义逻辑。

流程图

发起请求 -> 请求拦截器(添加公共参数、加签、显示loading) -> uni.request -> 响应拦截器(处理错误码、解密、隐藏loading) -> 返回数据给调用者

我们还需要维护一个请求计数器,用于控制loading的显示与隐藏。


三、核心功能实现

3.1 基础结构

新建utils/request.js,先搭建骨架:

class Request {  constructor() {    this.interceptors = {      request: [],      response: []    }    this.config = {      baseURL: 'https://api.example.com',      timeout: 10000,      showLoading: true// 是否默认显示loading      loadingText: '加载中...',      // 其他默认配置    }    this.requestCount = 0 // 当前进行中的请求数  }  // 使用拦截器  use(interceptor) {    if (interceptor.request) {      this.interceptors.request.push(interceptor.request)    }    if (interceptor.response) {      this.interceptors.response.push(interceptor.response)    }  }  // 核心请求方法  async request(options) {    // 合并配置    const mergedOptions = { ...this.config, ...options }    // 请求拦截器链    let promise = Promise.resolve(mergedOptions)    this.interceptors.request.forEach(interceptor => {      promise = promise.then(interceptor)    })    // 执行请求    promise = promise.then(config => {      return this._http(config)    })    // 响应拦截器链    this.interceptors.response.forEach(interceptor => {      promise = promise.then(interceptor)    })    return promise  }  // 实际发起请求的方法  _http(options) {    return new Promise((resolve, reject) => {      // 处理loading显示      if (options.showLoading) {        this._showLoading()      }      uni.request({        url: options.baseURL + options.url,        method: options.method || 'GET',        data: options.data,        header: options.header || {},        timeout: options.timeout,        success: (res) => {          // 注意:这里不直接resolve,而是传递给响应拦截器          resolve(res)        },        fail: (err) => {          reject(err)        },        complete: () => {          // 隐藏loading(由计数器控制)          if (options.showLoading) {            this._hideLoading()          }        }      })    })  }  _showLoading() {    this.requestCount++    if (this.requestCount === 1) {      uni.showLoading({ title: this.config.loadingText, mask: true })    }  }  _hideLoading() {    if (this.requestCount > 0) {      this.requestCount--    }    if (this.requestCount === 0) {      uni.hideLoading()    }  }}export default new Request()

3.2 添加默认拦截器

request.js中,我们添加一些默认的拦截器实现公共功能。

请求拦截器:添加公共参数、签名

// 请求拦截器示例request.use({  request(config) => {    // 从storage获取token    const token = uni.getStorageSync('token')    const timestamp = Date.now()    // 添加公共header    config.header = {      ...config.header,      'token': token,      'timestamp': timestamp,      'app-version''1.0.0',      'platform': uni.getSystemInfoSync().platform    }    // 如果需要对参数签名,可以在这里处理    if (config.needSign !== false) {      const sign = generateSign(config.data, timestamp, token)      config.header['sign'] = sign    }    return config  }})

响应拦截器:统一处理错误码、解密

// 响应拦截器request.use({  response(res) => {    // res是uni.request的响应对象,包含statusCode和data    if (res.statusCode !== 200) {      // 网络错误(非2xx)      uni.showToast({ title'网络异常'icon'none' })      return Promise.reject(res)    }    const { code, message, data } = res.data    // 业务错误码处理    switch (code) {      case 200// 成功        // 如果需要解密数据,可以在这里处理        // const realData = decrypt(data)        return Promise.resolve(data)      case 401// 未登录或token过期        uni.showToast({ title'登录已过期'icon'none' })        // 跳转登录页        setTimeout(() => {          uni.navigateTo({ url'/pages/login/login' })        }, 1500)        return Promise.reject(new Error('登录过期'))      case 403// 无权限        uni.showToast({ title'暂无权限'icon'none' })        return Promise.reject(new Error('无权限'))      default:        // 其他错误码,显示后端返回的message        uni.showToast({ title: message || '系统错误'icon'none' })        return Promise.reject(new Error(message))    }  }})

3.3 签名与加密函数

签名算法通常由后端定义,比如将参数按字典序排序后拼接密钥,再MD5。这里我们写一个简单的示例:

// 签名函数(示例:按参数名排序后拼接timestamp和token)function generateSign(data, timestamp, token) {  if (!data) return ''  const keys = Object.keys(data).sort()  let str = ''  keys.forEach(key => {    if (data[key] !== undefined && data[key] !== null) {      str += `${key}=${data[key]}&`    }  })  str += `timestamp=${timestamp}&key=${token}`  // 使用crypto-js或内置的Crypto(但uni-app不支持,需引入库)  // 这里用简单拼接示意,实际应使用md5  return md5(str) // 需要引入md5库}

对于加解密,可以使用crypto-js库,但要注意小程序环境可能不支持某些API,建议使用后端配合的前端简单加密(如Base64)或使用成熟的库。


四、使用示例

4.1 在页面中调用

<scriptsetup>import request from '@/utils/request'const getUserInfo = async () => {  try {    const data = await request.request({      url'/user/info',      method'GET',      showLoadingtrue // 可选,默认true    })    console.log('用户信息', data)  } catch (err) {    console.error('请求失败', err)  }}</script>

4.2 修改全局配置

// 在某个地方修改baseURLrequest.config.baseURL='https://new-api.example.com'

4.3 针对某个请求禁用loading或签名

request.request({  url'/no-loading',  showLoadingfalse,  needSignfalse})

五、难点分析与解决方案

5.1 并发请求的loading管理

我们使用requestCount计数器来管理loading,当第一个请求开始时显示loading,最后一个请求结束时隐藏。这样可以避免多次显示/隐藏造成的闪烁。

但要注意:如果请求失败,complete也会执行,计数器会减,没问题。但如果某个请求长时间未返回,后面的请求结束后计数器归零,loading提前消失,但那个慢请求还在进行,最终回来时没有loading了。这通常是期望的行为(因为用户可能以为已经加载完了),但如果需要更精确的控制,可以设置一个定时器,在请求超时后强制关闭loading。

5.2 签名与加密的性能

签名和加密操作如果太复杂,会影响请求速度。建议在请求拦截器中异步处理?但签名通常同步,所以要注意不要在主线程做大量计算。如果加密数据较大,可以考虑只在必要时启用。

5.3 错误码处理的用户体验

不同的错误码可能需要不同的提示方式。比如401跳转登录,应该避免频繁跳转,可以在拦截器中加防抖。另外,某些错误码可能不需要弹窗(比如用户取消操作),可以在接口调用时传入skipErrorToast: true来跳过。

5.4 跨平台兼容

  • uni.request在小程序中支持良好,但需要注意并发请求数限制(微信小程序最多10个并发)。我们的计数器不受影响。

  • uni.showLoading在H5中不支持title太长,需要注意。

  • 某些小程序平台不支持uni.getSystemInfoSync?实测基本都支持。

5.5 token过期后无限循环跳转

如果请求返回401,拦截器中跳转登录页,但如果在登录页也调用了需要token的接口,又会触发401,导致死循环。解决方案:在登录页的接口调用中设置skipAuthCheck: true,或者在拦截器中判断当前页面是否是登录页,如果是则不放行。


六、常见错误及解决方案

❌ 错误1:忘记处理Promise.reject

现象:接口报错后,页面中的try/catch捕获不到错误。

原因:响应拦截器中直接return Promise.reject,但后面的代码没有继续抛出。

解决方案:确保拦截器链正确传递reject。我们的拦截器链是promise.then(interceptor),如果interceptor返回reject,后续的拦截器不会执行,但最终请求的catch能捕获到。

❌ 错误2:loading计数错误导致不消失

现象:某个请求失败,complete中调用_hideLoading,但计数可能变成负数。

原因:请求开始时计数器加1,结束时减1,但如果在请求开始前就调用了结束(比如异常),会导致计数错乱。

解决方案:在complete中加判断,只有计数器大于0时才减1,同时可以考虑在请求拦截器中增加防抖,防止重复打开。

❌ 错误3:签名算法与后端不一致

现象:接口返回签名错误。

原因:前后端签名规则未对齐,比如参数顺序、空值处理、编码方式等。

解决方案:与后端同学一起确定规则,写单元测试验证。建议将签名函数独立出来,方便测试。

❌ 错误4:响应拦截器直接修改了res.data,导致原始数据丢失

现象:在拦截器中直接res.data = decrypt(res.data),但后面可能还需要原始数据。

解决方案:返回新对象,不要修改原始res。我们上面是返回Promise.resolve(data),data已经是处理后的数据,符合预期。

❌ 错误5:小程序中请求头不能有下划线

现象:某些小程序平台对请求头字段名有限制,不能有下划线。

原因:微信小程序允许,但某些如支付宝小程序可能限制。

解决方案:统一使用驼峰或中划线,避免下划线。


七、完整代码

下面给出完整的utils/request.js,包含了上述所有功能,并增加了一些细节处理。

// utils/request.jsimport md5 from 'md5' // 需要安装:npm install md5class Request {  constructor() {    this.interceptors = {      request: [],      response: []    }    this.config = {      baseURL'https://api.example.com',      timeout10000,      showLoadingtrue,      loadingText'加载中...',      header: {        'Content-Type''application/json'      }    }    this.requestCount = 0    this._initDefaultInterceptors()  }  // 初始化默认拦截器  _initDefaultInterceptors() {    // 请求拦截器:添加公共参数、签名    this.use({      requestasync (config) => {        const token = uni.getStorageSync('token') || ''        const timestamp = Date.now()        // 公共header        config.header = {          ...config.header,          'token': token,          'timestamp': timestamp,          'platform': uni.getSystemInfoSync().platform        }        // 是否需要签名(默认需要)        if (config.needSign !== false) {          const sign = this._generateSign(config.data, timestamp, token)          config.header['sign'] = sign        }        // 是否需要loading        if (config.showLoading) {          this._showLoading(config.loadingText)        }        return config      }    })    // 响应拦截器:处理错误码    this.use({      responseasync (res) => {        // 隐藏loading        if (res.config?.showLoading) {          this._hideLoading()        }        if (res.statusCode !== 200) {          uni.showToast({ title'网络异常'icon'none' })          return Promise.reject(new Error('网络异常'))        }        const { code, message, data } = res.data        // 根据业务码处理        switch (code) {          case 200:            return data          case 401:            uni.showToast({ title'登录已过期'icon'none' })            // 避免无限跳转            const pages = getCurrentPages()            const currentPage = pages[pages.length - 1]?.route            if (currentPage !== 'pages/login/login') {              setTimeout(() => {                uni.navigateTo({ url'/pages/login/login' })              }, 1500)            }            return Promise.reject(new Error('登录过期'))          case 403:            uni.showToast({ title'暂无权限'icon'none' })            return Promise.reject(new Error('权限不足'))          default:            uni.showToast({ title: message || '系统错误'icon'none' })            return Promise.reject(new Error(message))        }      }    })  }  // 签名生成函数(示例)  _generateSign(data, timestamp, token) {    if (!data) return ''    // 将data按key排序,拼接成 key=value&... 格式    const keys = Object.keys(data).sort()    let str = ''    keys.forEach(key => {      if (data[key] !== undefined && data[key] !== null) {        str += `${key}=${data[key]}&`      }    })    str += `timestamp=${timestamp}&key=${token}`    return md5(str)  }  _showLoading(text) {    this.requestCount++    if (this.requestCount === 1) {      uni.showLoading({ title: text || this.config.loadingTextmasktrue })    }  }  _hideLoading() {    if (this.requestCount > 0) {      this.requestCount--    }    if (this.requestCount === 0) {      uni.hideLoading()    }  }  // 注册拦截器  use(interceptor) {    if (interceptor.request) {      this.interceptors.request.push(interceptor.request)    }    if (interceptor.response) {      this.interceptors.response.push(interceptor.response)    }  }  // 核心请求方法  async request(options) {    // 合并配置    const mergedOptions = { ...this.config, ...options }    // 请求拦截器链    let chain = Promise.resolve(mergedOptions)    this.interceptors.request.forEach(interceptor => {      chain = chain.then(interceptor)    })    // 发起请求    chain = chain.then(config => {      return new Promise((resolve, reject) => {        uni.request({          url: config.baseURL + config.url,          method: config.method || 'GET',          data: config.data,          header: config.header,          timeout: config.timeout,          success(res) => {            // 将config挂载到res上,方便响应拦截器使用            res.config = config            resolve(res)          },          fail(err) => {            // 网络错误也要隐藏loading            if (config.showLoading) {              this._hideLoading()            }            reject(err)          }        })      })    })    // 响应拦截器链    this.interceptors.response.forEach(interceptor => {      chain = chain.then(interceptor, (err) => {        // 如果前面的promise rejected,也传递给第一个响应拦截器的错误处理?这里简化,直接抛出        return Promise.reject(err)      })    })    return chain  }  // 便捷方法  get(url, data, options = {}) {    return this.request({ ...options, url, method'GET', data })  }  post(url, data, options = {}) {    return this.request({ ...options, url, method'POST', data })  }  put(url, data, options = {}) {    return this.request({ ...options, url, method'PUT', data })  }  delete(url, data, options = {}) {    return this.request({ ...options, url, method'DELETE', data })  }}export default new Request()

使用示例

// pages/index/index.vueimport request from '@/utils/request'const fetchData = async () => {  try {    const data = await request.get('/user/info', { userId123 }, {      showLoadingtrue,      needSigntrue    })    console.log(data)  } catch (err) {    console.log('请求失败', err)  }}

八、总结与进阶

今天我们实现了一个功能强大的全局请求封装器,它集成了:

  • 请求/响应拦截器

  • 公共参数自动添加

  • 签名与验签

  • 错误码统一处理

  • loading自动管理

  • 便捷的GET/POST方法

这只是一个起点。你还可以在此基础上扩展:

  • 请求重试机制:对特定错误码自动重试。

  • 请求缓存:对GET请求缓存结果。

  • 上传下载进度:封装uploadFile和downloadFile。

  • 离线请求队列:在网络恢复后自动重发。

最后送你一句话:好的封装不是把代码藏起来,而是把复杂度藏起来,让使用者专注于业务。希望你的项目从此告别混乱的请求代码,走向优雅。

如果在实践中遇到问题,欢迎带着你的代码来找我。

—— 一个喜欢折腾封装的老前辈 📦

加油,未来的全栈大佬!💪如果你也对移动端跨端开发感兴趣,关注我,后续还有更多优质文章分享!

往期相关文章推荐

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » UniApp自定义全局API请求封装工具:从混乱到优雅的蜕变

猜你喜欢

  • 暂无文章