说真的,在 uniapp 项目里写请求,以前最烦的就是每个页面都去重复写 loading、错误提示、token 加头。刚开始还好,项目一大就乱了套,尤其是有些接口需要登录才能访问,一不小心token过期了,还可能引发连环报错。
后来我借着 Vue 3 的组合式 API 思路,在 uniapp 里封装了一个 useRequest 方法。不光能把网络请求集中管理,还能在拦截器里做 token 刷新和队列等待,页面里用起来异常清爽。今天就把这个完整思路和核心代码拆给大家。
1. 我的目录结构
先看一眼项目的核心目录,比较常规:
src /
api /
request.js // 组合式请求函数
auth.js // 登录相关接口
config /
index.js // 接口地址,超时时间等
pages /
login / ...
home / ...
store /
user.js // 用户状态管理,用简单的 reactive 就可以
我不用第三方网络库,直接基于 uni.request 包一层,这样依赖最少,改起来也最快。
2. 全局配置
先在 config/index.js 里放一些公共参数:
export const BASE_URL = 'https://api.example.com'
export const TIMEOUT = 15000
export const TOKEN_KEY = 'access_token'
export const REFRESH_TOKEN_KEY = 'refresh_token'
这些配置看着简单,但能防止后面把 baseURL 到处硬编码。
3. 组合式 useRequest 的核心实现
重点来了。我封装的原则是:
- 统一 get、post、put、delete 方法;
- 请求前自动带上 token;
- 响应统一处理业务码和 HTTP 状态,遇到 401 自动刷新 token;
- 刷新 token 期间,其他请求要排队等,不重复刷新。
直接看 api/request.js 里的代码,我尽量把注释写明白了。
import { BASE_URL, TIMEOUT, TOKEN_KEY, REFRESH_TOKEN_KEY } from '@/config'
// 全局请求配置,比如是否显示 loading
const requestConfig = {
showLoading: false,
loadingText: '加载中...',
}
// 缓存刷新 token 的 promise,防止并发重复刷新
let refreshTokenPromise = null
/** 刷新 token */
function refreshTokenRequest() {
return new Promise((resolve, reject) => {
const refreshToken = uni.getStorageSync(REFRESH_TOKEN_KEY)
if (!refreshToken) {
reject(new Error('没有refreshToken'))
return
}
uni.request({
url: `${BASE_URL}/auth/refresh`,
method: 'POST',
data: { refresh_token: refreshToken },
success: (res) => {
if (res.statusCode === 200 && res.data.code === 0) {
const { access_token, refresh_token } = res.data.data
uni.setStorageSync(TOKEN_KEY, access_token)
uni.setStorageSync(REFRESH_TOKEN_KEY, refresh_token)
resolve(res.data.data)
} else {
// 刷新失败,清空登录态,跳转登录页
uni.removeStorageSync(TOKEN_KEY)
uni.removeStorageSync(REFRESH_TOKEN_KEY)
uni.reLaunch({ url: '/pages/login/index' })
reject(new Error('刷新失败'))
}
},
fail: reject
})
})
}
/** 调用登录接口后,存用户信息 */
export function setLoginInfo({ token, refreshToken }) {
uni.setStorageSync(TOKEN_KEY, token)
uni.setStorageSync(REFRESH_TOKEN_KEY, refreshToken)
}
export function useRequest(options = {}) {
// 如果传入新的 showLoading,就覆盖,不然用全局的
const loading = options.showLoading ?? requestConfig.showLoading
/** 核心请求方法 */
function request({ url, method = 'GET', data = {} }) {
return new Promise((resolve, reject) => {
if (loading) {
uni.showLoading({ title: requestConfig.loadingText, mask: true })
}
const header = { 'Content-Type': 'application/json' }
const accessToken = uni.getStorageSync(TOKEN_KEY)
if (accessToken) {
header['Authorization'] = 'Bearer ' + accessToken
}
uni.request({
url: url.startsWith('http') ? url : BASE_URL + url,
method,
data,
header,
timeout: TIMEOUT,
success: async (res) => {
if (res.statusCode === 401) {
// token 失效,走刷新逻辑
try {
// 如果已经有刷新任务,就复用,避免并发刷新
if (!refreshTokenPromise) {
refreshTokenPromise = refreshTokenRequest().finally(() => {
refreshTokenPromise = null
})
}
await refreshTokenPromise
// 刷新成功,重新请求当前接口
const newAccessToken = uni.getStorageSync(TOKEN_KEY)
const retryHeader = { ...header, Authorization: 'Bearer ' + newAccessToken }
uni.request({
url,
method,
data,
header: retryHeader,
timeout: TIMEOUT,
success: (retryRes) => {
if (retryRes.statusCode === 200) {
resolve(retryRes.data)
} else {
reject(retryRes)
}
},
fail: reject
})
} catch (err) {
reject(err)
}
return
}
// 非 401 正常返回
if (res.statusCode === 200) {
// 这里假设你的后端返回 { code: 0, data: ..., message: '' }
if (res.data && res.data.code === 0) {
resolve(res.data.data)
} else {
// 业务错误
uni.showToast({ title: res.data.message || '业务错误', icon: 'none' })
reject(res.data)
}
} else {
// http 错误
uni.showToast({ title: `请求错误 ${res.statusCode}`, icon: 'none' })
reject(res)
}
},
fail: (err) => {
uni.showToast({ title: '网络异常', icon: 'none' })
reject(err)
},
complete: () => {
if (loading) {
uni.hideLoading()
}
}
})
})
}
// 返回常用的方法
return {
request,
get: (url, data) => request({ url, method: 'GET', data }),
post: (url, data) => request({ url, method: 'POST', data }),
put: (url, data) => request({ url, method: 'PUT', data }),
delete: (url, data) => request({ url, method: 'DELETE', data })
}
}
这里边有两个值得多说的点:
3.1 关于刷新 token 的并发等待
之前用的时候发现,有时候一个页面放了好几个接口,几乎同时返回 401。如果每个接口都去刷新一次 refreshToken,后端就被刷爆了。所以我搞了一个全局的 refreshTokenPromise,第一个 401 进来触发刷新,后续的 401 就只等同一个 promise 完成,然后自动用新 token 重放原请求。
3.2 业务码和 HTTP 状态分开处理
很多后端喜欢 HTTP 200,然后业务 code 里再分情况。所以我这边先判断 HTTP 状态,再判断业务 code,这样后端怎么定义都方便,前端只要接口返回“code=0”就算成功。具体业务状态码可以根据自己后端来改。
4. 在业务接口中使用
现在看看怎么在具体的 API 模块里用。比方说 api/auth.js 处理登录和用户信息:
import { useRequest, setLoginInfo } from './request'
const { request } = useRequest()
// 登录
export function login(username, password) {
return request({
url: '/auth/login',
method: 'POST',
data: { username, password }
}).then(data => {
// 登录成功后保存 token
setLoginInfo({
token: data.access_token,
refreshToken: data.refresh_token
})
return data
})
}
// 获取用户资料
export function getProfile() {
return request({
url: '/user/profile',
method: 'GET'
})
}
然后在页面里,尤其是用了 setup 语法之后,调接口简直不要太顺。拿一个典型的登录页举例。
5. 实战:登录页与自动附带 token
页面代码简化到核心部分:
<script setup>
import { ref } from 'vue'
import { login } from '@/api/auth'
const username = ref('')
const password = ref('')
const loading = ref(false)
async function handleLogin() {
if (!username.value || !password.value) {
uni.showToast({ title: '请输入账号密码', icon: 'none' })
return
}
loading.value = true
try {
const data = await login(username.value, password.value)
uni.showToast({ title: '登录成功' })
setTimeout(() => uni.reLaunch({ url: '/pages/home/index' }), 500)
} catch (e) {
// 错误已经在拦截器里统一提示了,这里只需要处理逻辑
console.log('登录失败', e)
} finally {
loading.value = false
}
}
</script>
登录成功后,token 已经被 setLoginInfo 存好了。之后在另一个页面直接调用 getProfile(),请求头会自动加上 Authorization。如果 token 过期,会走刷新流程,整个过程页面里完全无感知。
6. 顺带聊聊放 loading 的小技巧
有的人喜欢每个接口单独搞 Loading,有的人只想要全局一个。我每次调用 useRequest() 的时候可以传 { showLoading: true },比如获取列表:
const { get } = useRequest({ showLoading: true })
get('/order/list').then((data) => {
orderList.value = data
})
但是如果你没传,就用默认的 false。简单又直接,不想在页面里每次写 uni.showLoading 的时候就特别方便。
7. 以后新增项目,我直接复制这套
封装完以后,我最大的感受就是新开一个页面再也不用“重新造轮子”了。写一个接口就在 api 文件夹里加个函数,页面里只管调函数,然后拿到数据,其他乱七八糟的路由拦截、状态码判断都不归页面管。
这套东西也不是我拍脑袋想的,是实打实被坑出来的。之前有个老项目,每个页面都有一段长得差不多的 uni.request,后来后端改了一个业务 code,前端硬是花了一晚上把所有页面手动改了一遍。从那以后,我就决定再也不在业务里放飞自我的写网络请求。
8. 但是要注意,不是所有场景都适合这样套
如果你的项目特别小,就一两个页面,那确实没必要搞这么复杂。但这种搭配「请求封装 + 组合式 API」的写法在中等以上项目里,后期维护是真的爽。
另外,如果你用了 TypeScript,那更得搞一搞。给请求函数加上泛型,返回 Promise,编译期就能跑出很多问题。我这篇就不展开 TS 了,毕竟 main 文件那一坨已经够长。
最后
我也不知道这个封装算不算最优雅,但在我目前做过的 uniapp 项目里,已经足够稳定。如果你也在折腾 uniapp 接口层,希望这篇能给你个参考。尤其是那个并发刷新 token 的思路,换个项目语言也是一样的。
以后有机会再写一篇 TypeScript 版本的,或者配上 pinia 做全局用户状态。咱们下次见。

