用 uniapp 开发跨端应用,最头疼的不是页面样式,而是网络请求层。官方提供的 uni.request 虽然能用,但每个页面都写一堆重复代码,token 快过期时还要到处处理 401。后来我参考 axios 的思路,自己封装了一套带拦截器的请求模块,现在把所有页面的请求都收敛到一个文件里管理,舒服太多了。
今天不聊怎么用第三方库,直接基于 uni.request 手写一套支持请求/响应拦截器、并发防重、取消请求的封装。全程代码无任何第三方依赖,复制即用。
先定义我们要解决的问题
项目里有几个痛点是强行逼我做封装的:
- token 注入:每个请求都要在 header 里带 Authorization,不能每个接口写一遍。
- 统一错误处理:接口报 500 时统一弹 toast,报 401 时自动跳登录页,不能每处都 try-catch。
- 并发控制:某些页面同时发几个请求时,如果 token 失效,不能每个请求都弹一次登录框。
- 取消请求:用户切换页面或重复点击时,需要能主动终止上一个请求。
这些需求如果用 uni.request 原生做,那代码会散成一地。下面直接看代码。
基础封装:一个能用的 request 函数
先写一个最核心的 request 函数,它接收参数,返回一个 Promise,内部调用 uni.request。这个阶段不做任何拦截,只处理基础逻辑。
// http.js
const BASE_URL = 'https://api.example.com'
export 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: options.timeout || 15000,
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data)
} else {
const err = new Error(`HTTP ${res.statusCode}`)
err.statusCode = res.statusCode
err.data = res.data
reject(err)
}
},
fail: (err) => {
reject(err)
}
})
})
}
这个函数大家都会写,问题是怎么把拦截器加进去。如果直接在函数里写死逻辑,那如果以后想加多个拦截器或者动态禁用拦截器,就很麻烦。
所以我要用“拦截器数组”的方式来管理,而不是简单把逻辑写死在 request 内部。
拦截器设计:用数组保存,按顺序执行
我们先定义两个数组:requestInterceptors 用于存放请求拦截器,responseInterceptors 用于存放响应拦截器。每个拦截器都是一个函数,接收参数并返回新的参数。
const requestInterceptors = []
const responseInterceptors = []
export const http = {
// 请求拦截器:在发送前执行,可以用来改 header 或 data
useRequestInterceptor(fn) {
requestInterceptors.push(fn)
},
// 响应拦截器:在拿到响应后执行,可以用来处理错误码
useResponseInterceptor(fn) {
responseInterceptors.push(fn)
}
}
接下来我们要把这些拦截器串起来。核心思路是:发起请求前,先对 options 执行所有请求拦截器,得到一个最终配置;然后去调 uni.request;拿到响应后,再对结果执行响应拦截器。如果中间有拦截器抛错,就直接走 reject。
function executeInterceptors(interceptors, params) {
return interceptors.reduce((prevPromise, fn) => {
return prevPromise.then(fn)
}, Promise.resolve(params))
}
async function requestWithInterceptors(options) {
try {
const finalOptions = await executeInterceptors(requestInterceptors, options)
const response = await new Promise((resolve, reject) => {
uni.request({
...finalOptions,
success: resolve,
fail: reject
})
})
let result = response
// 执行响应拦截器,每个拦截器可以修改结果或者抛出错误
for (let i = 0; i < responseInterceptors.length; i++) {
result = await responseInterceptors[i](result)
}
return result
} catch (error) {
throw error
}
}
注意这里响应拦截器是用 for 循环而不是 reduce,因为响应拦截器可能需要额外处理原始响应,而且如果中途遇到 401 需要特殊跳转,用循环更好控制。
加上 Token 自动注入
现在往里写第一个请求拦截器:从存储里取出 token,塞进 header。这里我用的是 uni.getStorageSync,如果你项目里用了其他状态管理,自己改一下即可。
http.useRequestInterceptor((options) => {
const token = uni.getStorageSync('token')
if (token) {
options.header = options.header || {}
options.header['Authorization'] = `Bearer ${token}`
}
return options
})
仅仅这样,每个请求都会自动带上 token。但还有一个很常见的问题:如果 token 过期,后端返回 401,此时我们需要统一处理。直接写一个响应拦截器。
401 统一处理与并发防重
当后端返回 401 时,我们要做的不是每个请求都跳登录,而是只处理一次。这里需要一个标志位 isRedirectingToLogin 来防止多个请求同时触发跳转。
let isRedirectingToLogin = false
http.useResponseInterceptor((response) => {
const { statusCode, data } = response
// 业务错误码 401 或者 HTTP 401
if (statusCode === 401 || (data && data.code === 401)) {
if (!isRedirectingToLogin) {
isRedirectingToLogin = true
// 清除本地 token
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
// 跳转登录页
uni.reLaunch({ url: '/pages/login/index' })
setTimeout(() => {
isRedirectingToLogin = false
}, 2000) // 加个保护,避免极端情况下一直卡在 true
}
throw new Error('登录已过期,请重新登录')
}
// 如果 HTTP 状态码不是 2xx,直接当错误抛
if (statusCode < 200 || statusCode >= 300) {
const err = new Error(data?.message || '请求失败')
err.statusCode = statusCode
err.data = data
throw err
}
// 如果业务 code 不为 0,也视为错误
if (data && typeof data.code !== 'undefined' && data.code !== 0) {
const err = new Error(data.message || '业务错误')
err.code = data.code
err.data = data
throw err
}
return data // 返回真正有用的数据,而不是整个 response
})
有了这个响应拦截器,我们在页面里写的代码就会非常简洁:不用处理错误码,只需要关心成功后的数据。
但是这里还有个坑:如果请求发了两个,第一个 401 触发跳转,第二个 401 还在处理中。由于 isRedirectingToLogin 是 true,第二个请求不会跳转,但它依然会抛错。这是合理的,因为这两个请求的调用方需要捕获 error。但如果希望在页面中统一静默处理,可以在拦截器中判断是否正在跳转,如果是则抛一个不弹提示的错误。我一般配合 toast 统一处理,而不是每个页面自己弹。
最终导出:给页面使用的请求方法
有了上面这些,我再封装 get、post、put、delete 等方法,页面里只需要调用这些方法即可。
export const http = {
get(url, data, options = {}) {
return requestWithInterceptors({
url,
method: 'GET',
data,
...options
})
},
post(url, data, options = {}) {
return requestWithInterceptors({
url,
method: 'POST',
data,
...options
})
},
put(url, data, options = {}) {
return requestWithInterceptors({
url,
method: 'PUT',
data,
...options
})
},
delete(url, data, options = {}) {
return requestWithInterceptors({
url,
method: 'DELETE',
data,
...options
})
}
}
然后在 main.js 里引入并挂载到全局,这样页面里直接 this.$http.get 就能用。
// main.js
import { http } from '@/utils/http'
App.mpType = 'app'
const app = new Vue({
// ...
http // 挂到实例上,页面里通过 this.$http 访问
})
如果你用的是 Vue3 + setup 语法,就直接 import 使用就行,不用挂载。
进阶:取消请求与 AbortController
H5 端可以用 AbortController,小程序里目前不支持,但 uniapp 的 uni.request 在 App 和小程序端有 requestTask.abort() 方法。为了统一处理,我封装了一个支持取消的扩展。
思路是:在 options 中传入一个 signal 对象,内部如果存在 signal,则创建 requestTask,监听 signal.abort 事件来调用 abort()。
function createRequestPromise(finalOptions) {
return new Promise((resolve, reject) => {
let requestTask
// 如果是 H5 或支持 AbortController 的环境,用 abort 事件取消
const signal = finalOptions.signal
const abortHandler = () => {
if (requestTask) {
requestTask.abort()
}
reject(new Error('请求已取消'))
}
if (signal) {
signal.addEventListener('abort', abortHandler, { once: true })
}
requestTask = uni.request({
...finalOptions,
success: (res) => {
signal?.removeEventListener('abort', abortHandler)
resolve(res)
},
fail: (err) => {
signal?.removeEventListener('abort', abortHandler)
reject(err)
}
})
})
}
然后在 requestWithInterceptors 中把这个 promise 替换原来那个 new Promise。
页面里怎么用?用一个简易的 AbortController 模拟对象,由于小程序没有原生的,我们只需要一个事件注册机制:
// 手动为微信小程序等环境模拟一个 signal
function createAbortSignal() {
const listeners = []
return {
aborted: false,
addEventListener(type, fn) {
if (type === 'abort') {
listeners.push(fn)
}
},
removeEventListener(type, fn) {
const idx = listeners.indexOf(fn)
if (idx > -1) listeners.splice(idx, 1)
},
abort() {
this.aborted = true
listeners.forEach(fn => fn())
}
}
}
这样一个请求就可以这么写:
const signal = createAbortSignal()
this.$http.get('/api/long-list', {}, { signal })
// 用户点击取消时
signal.abort()
当然在 H5 端可以直接用浏览器原生 AbortController,不过我们封装的这个模拟对象也能跑。为了方便,我在封装内部根据环境判断,但为了让代码统一,这里全部走自定义 signal,避开不同端差异。
并发请求:与拦截器完全兼容
页面里有时需要同时请求两个接口,用 Promise.all 就好。但注意如果其中一个 401,另一个也会被 reject,这是正常行为。如果你希望一个请求失败不影响另一个,那就用 Promise.allSettled,但它目前在小程序基础库较低版本可能不支持,自己做个 polyfill 即可。
// 并行请求
const [userInfo, config] = await Promise.all([
this.$http.get('/user/info'),
this.$http.get('/app/config')
])
如果担心并发重复请求,可以在请求拦截器里加一个 map 存请求标识,相同标识且未完成时直接返回上一次的 Promise。这个实现也不难,但今天主题是拦截器,就不展开讲,留个思考题给大家。
完整代码示例:把上面所有拼起来
为了便于你直接复制,我把一个最小可用的完整 http.js 贴在下面。去掉了一些业务相关的错误提示,保留核心逻辑。
// utils/http.js
const BASE_URL = 'https://api.example.com'
const requestInterceptors = []
const responseInterceptors = []
function executeRequestInterceptors(options) {
return requestInterceptors.reduce((promise, fn) => promise.then(fn), Promise.resolve(options))
}
async function executeResponseInterceptors(response) {
let result = response
for (const interceptor of responseInterceptors) {
result = await interceptor(result)
}
return result
}
function createRequestTask(finalOptions) {
return new Promise((resolve, reject) => {
let requestTask
let aborted = false
const signal = finalOptions.signal
const abortHandler = () => {
if (requestTask) {
requestTask.abort()
}
aborted = true
reject(new Error('Request aborted'))
}
if (signal) {
signal.addEventListener('abort', abortHandler, { once: true })
}
requestTask = uni.request({
...finalOptions,
success: (res) => {
signal?.removeEventListener('abort', abortHandler)
if (!aborted) resolve(res)
},
fail: (err) => {
signal?.removeEventListener('abort', abortHandler)
if (!aborted) reject(err)
}
})
})
}
async function requestWithInterceptors(options) {
const finalOptions = await executeRequestInterceptors(options)
const response = await createRequestTask(finalOptions)
return executeResponseInterceptors(response)
}
export const http = {
useRequestInterceptor(fn) {
requestInterceptors.push(fn)
},
useResponseInterceptor(fn) {
responseInterceptors.push(fn)
},
get(url, data, options = {}) {
return requestWithInterceptors({ url, method: 'GET', data, ...options })
},
post(url, data, options = {}) {
return requestWithInterceptors({ url, method: 'POST', data, ...options })
},
put(url, data, options = {}) {
return requestWithInterceptors({ url, method: 'PUT', data, ...options })
},
delete(url, data, options = {}) {
return requestWithInterceptors({ url, method: 'DELETE', data, ...options })
}
}
// ---------- 默认注入 token 和错误处理 ----------
http.useRequestInterceptor((options) => {
const token = uni.getStorageSync('token')
if (token) {
options.header = options.header || {}
options.header.Authorization = `Bearer ${token}`
}
return options
})
http.useResponseInterceptor((response) => {
const { statusCode, data } = response
if (statusCode === 401 || (data && data.code === 401)) {
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
// 防止重复跳转
if (!uni.getStorageSync('_isLoggingOut')) {
uni.setStorageSync('_isLoggingOut', true)
uni.reLaunch({ url: '/pages/login/index' })
setTimeout(() => uni.removeStorageSync('_isLoggingOut'), 1000)
}
return Promise.reject(new Error('登录态失效'))
}
if (statusCode < 200 || statusCode >= 300) {
return Promise.reject(new Error(data?.message || '请求失败'))
}
if (data && typeof data.code !== 'undefined' && data.code !== 0) {
return Promise.reject(new Error(data.message || '业务错误'))
}
return data
})
这里把401防重跳转写得更粗粒度一些,用 storage 作为锁。实际项目里可以做得更优雅,但目的是演示拦截器能力。
总结:这套封装怎么帮你减负
现在页面里请求代码长这样:
try {
const data = await this.$http.post('/order/create', { goodsId: 123 })
console.log('下单成功', data)
} catch (error) {
// 错误已经由拦截器弹 toast 或跳登录,这里只需要兜底
console.error(error)
}
你不需要在每个页面里写 token 注入,也不需要到处处理 401。如果需要修改默认错误提示,只需要在拦截器里改一行。
这套代码我是在 uniapp 项目里实际跑过的,微信小程序、H5、App 三端都没问题。如果你项目中遇到某些端不支持 Promise.prototype.finally,注意加 polyfill。还有,uni.request 的 timeout 在 App 端可能表现不一样,建议在拦截器里做统一超时逻辑,而不只是依赖 uni 参数。
希望这篇能给你启发,有更好的想法欢迎在评论区交流。

