uniapp 组合式 API 请求封装实战:拦截器、刷新 token 与统一错误处理

2026-08-13 0 619

说真的,在 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 做全局用户状态。咱们下次见。

uniapp 组合式 API 请求封装实战:拦截器、刷新 token 与统一错误处理
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uniapp 组合式 API 请求封装实战:拦截器、刷新 token 与统一错误处理 https://www.taomawang.com/web/uniapp/2536.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务