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, // 是否默认显示loadingloadingText: '加载中...',// 其他默认配置}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获取tokenconst token = uni.getStorageSync('token')const timestamp = Date.now()// 添加公共headerconfig.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和dataif (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:// 其他错误码,显示后端返回的messageuni.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不支持,需引入库)// 这里用简单拼接示意,实际应使用md5return 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',showLoading: true // 可选,默认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',showLoading: false,needSign: false})
五、难点分析与解决方案
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',timeout: 10000,showLoading: true,loadingText: '加载中...',header: {'Content-Type': 'application/json'}}this.requestCount = 0this._initDefaultInterceptors()}// 初始化默认拦截器_initDefaultInterceptors() {// 请求拦截器:添加公共参数、签名this.use({request: async (config) => {const token = uni.getStorageSync('token') || ''const timestamp = Date.now()// 公共headerconfig.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}// 是否需要loadingif (config.showLoading) {this._showLoading(config.loadingText)}return config}})// 响应拦截器:处理错误码this.use({response: async (res) => {// 隐藏loadingif (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 datacase 401:uni.showToast({ title: '登录已过期', icon: 'none' })// 避免无限跳转const pages = getCurrentPages()const currentPage = pages[pages.length - 1]?.routeif (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.loadingText, mask: true })}}_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 = configresolve(res)},fail: (err) => {// 网络错误也要隐藏loadingif (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', { userId: 123 }, {showLoading: true,needSign: true})console.log(data)} catch (err) {console.log('请求失败', err)}}
八、总结与进阶
今天我们实现了一个功能强大的全局请求封装器,它集成了:
-
请求/响应拦截器
-
公共参数自动添加
-
签名与验签
-
错误码统一处理
-
loading自动管理
-
便捷的GET/POST方法
这只是一个起点。你还可以在此基础上扩展:
-
请求重试机制:对特定错误码自动重试。
-
请求缓存:对GET请求缓存结果。
-
上传下载进度:封装uploadFile和downloadFile。
-
离线请求队列:在网络恢复后自动重发。
最后送你一句话:好的封装不是把代码藏起来,而是把复杂度藏起来,让使用者专注于业务。希望你的项目从此告别混乱的请求代码,走向优雅。
如果在实践中遇到问题,欢迎带着你的代码来找我。
—— 一个喜欢折腾封装的老前辈 📦
加油,未来的全栈大佬!💪如果你也对移动端跨端开发感兴趣,关注我,后续还有更多优质文章分享!


往期相关文章推荐
夜雨聆风