uniapp 实战:基于 Pinia 与拦截器实现 JWT 无感刷新与 API 统一封装

2026-07-30 0 1,000

做小程序或App时,用户登录后的Token管理一直是个琐碎又容易出错的环节。AccessToken过期了需要拿RefreshToken去换新的,换的过程中用户可能同时发起了好几个业务请求,如果处理不好就会同时触发多次刷新,后端压力大不说,前端还容易因为旧Token失效而跳登录页,体验很差。

另一个烦人的点是,每个页面都在用uni.request,请求前手动拼header带上token,请求后手动判断状态码弹错误提示,代码重复度很高,而且一旦要统一改处理逻辑(比如新增一个错误码映射),得翻遍整个项目。趁着项目切到Vue3+Pinia,索性用uni.addInterceptor配合Pinia把整个请求层重新封装了一遍,Token过期自动续、重复刷新自动排队、所有响应错误统一处理,页面里调用API时就跟用封装好的方法一样简单,不再需要关心这些底层细节。

这篇文章就把这个方案的完整实现分享出来,代码可以直接放到uniapp项目里跑。

整体思路

核心目标有三个:

  • 业务代码只用调用一个封装好的request函数,不直接碰uni.request
  • 请求拦截器自动在header里带上AccessToken,响应拦截器遇到401时自动用RefreshToken去刷新,刷新成功则重放原请求,业务代码无感知。
  • 多个请求同时遇到401时,只触发一次刷新操作,其他请求排队等待刷新结果。

技术栈基于uniapp的Vue3模式,状态管理用Pinia(相比Vuex更轻量,对TypeScript支持好),拦截器用uni.addInterceptor('request', { ... }),Token存储根据平台选择uni.setStorageSync或兼容的存储方式。

第一步:搭建用户状态Store

创建一个stores/user.js(或.ts),管理Token和用户信息。这里把AccessToken和RefreshToken存在本地存储里,同时用Pinia的state做响应式镜像,方便在拦截器里读取。

import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useUserStore = defineStore('user', () => {
    const accessToken = ref(uni.getStorageSync('access_token') || '');
    const refreshToken = ref(uni.getStorageSync('refresh_token') || '');
    const userInfo = ref(null);

    const isLoggedIn = computed(() => !!accessToken.value);

    function setTokens(access, refresh) {
        accessToken.value = access;
        refreshToken.value = refresh || refreshToken.value;
        uni.setStorageSync('access_token', access);
        if (refresh) {
            uni.setStorageSync('refresh_token', refresh);
        }
    }

    function clearLogin() {
        accessToken.value = '';
        refreshToken.value = '';
        userInfo.value = null;
        uni.removeStorageSync('access_token');
        uni.removeStorageSync('refresh_token');
        // 可跳转到登录页
        uni.reLaunch({ url: '/pages/login/login' });
    }

    function setUserInfo(info) {
        userInfo.value = info;
    }

    return {
        accessToken,
        refreshToken,
        userInfo,
        isLoggedIn,
        setTokens,
        clearLogin,
        setUserInfo
    };
});

Token持久化用了uni.getStorageSync,保证App冷启动时能恢复登录态。

第二步:封装刷新Token的队列逻辑

这是整个方案的核心。当响应拦截器捕获到401时,不能直接调用刷新接口,因为可能同时有多个请求都收到了401,导致刷新接口被并发调用多次。需要用一个“刷新锁”和一个等待队列来串行化这个过程。

创建utils/refreshQueue.js

let isRefreshing = false;
let pendingRequests = []; // 存储等待刷新完成的resolve函数

/**
 * 将请求加入等待队列,返回一个Promise
 * 当刷新完成后队列中的所有Promise都会resolve
 */
function waitForRefresh() {
    return new Promise((resolve) => {
        pendingRequests.push(resolve);
    });
}

/**
 * 刷新成功,用新的Token重放所有排队请求
 */
function onRefreshSuccess(newAccessToken, newRefreshToken) {
    const store = useUserStore();
    store.setTokens(newAccessToken, newRefreshToken);
    // 通知所有排队请求可以继续了
    pendingRequests.forEach(callback => callback(newAccessToken));
    pendingRequests = [];
    isRefreshing = false;
}

/**
 * 刷新失败,清空队列并抛出错误
 */
function onRefreshFailure(error) {
    pendingRequests.forEach(callback => callback(null));
    pendingRequests = [];
    isRefreshing = false;
    // 清除登录态
    const store = useUserStore();
    store.clearLogin();
    return Promise.reject(error);
}

/**
 * 执行刷新操作(只会被真正调用一次)
 */
async function doRefreshToken() {
    const store = useUserStore();
    try {
        const res = await uni.request({
            url: 'https://your-api.com/auth/refresh',
            method: 'POST',
            data: {
                refreshToken: store.refreshToken
            }
        });
        if (res.statusCode === 200 && res.data.code === 0) {
            const { accessToken, refreshToken } = res.data.data;
            onRefreshSuccess(accessToken, refreshToken);
            return accessToken;
        } else {
            return onRefreshFailure(new Error('刷新Token失败'));
        }
    } catch (e) {
        return onRefreshFailure(e);
    }
}

/**
 * 外部调用的入口:如果正在刷新,加入等待队列;否则发起刷新
 */
export function getValidAccessToken() {
    if (isRefreshing) {
        return waitForRefresh();
    }
    isRefreshing = true;
    return doRefreshToken();
}

这里getValidAccessToken被拦截器调用时,如果已有刷新在进行,就把当前请求的继续执行权压入pendingRequests,等刷新完成后再统一放行。这样就保证了无论多少个401同时来,都只有一次网络刷新请求。

第三步:用拦截器组装请求和响应逻辑

新建utils/request.js,完成请求封装、拦截器注册和错误处理。这里采用uni.addInterceptorrequest进行拦截,也可以直接封装一个request函数而不依赖拦截器,但拦截器方式对项目中可能遗留的直接uni.request也有约束力。

import { useUserStore } from '@/stores/user';
import { getValidAccessToken } from './refreshQueue';

// 基础请求地址
const BASE_URL = 'https://your-api.com';

// 注册请求拦截器
uni.addInterceptor('request', {
    invoke(args) {
        // 请求前自动拼接完整URL
        if (!args.url.startsWith('http')) {
            args.url = BASE_URL + args.url;
        }
        // 自动附加header
        const store = useUserStore();
        if (store.accessToken) {
            args.header = {
                ...args.header,
                'Authorization': `Bearer ${store.accessToken}`
            };
        }
    },
    fail(err) {
        console.error('请求发送失败', err);
        return false;
    }
});

// 注册响应拦截器
uni.addInterceptor('request', {
    async success(res) {
        // 若响应状态码不是401,正常返回
        if (res.statusCode !== 401) {
            return res;
        }
        // 如果是刷新接口自身返回401,说明refreshToken也失效,直接登出
        if (res.config.url.includes('/auth/refresh')) {
            const store = useUserStore();
            store.clearLogin();
            return Promise.reject(new Error('登录已过期,请重新登录'));
        }
        // 尝试刷新Token
        try {
            const newToken = await getValidAccessToken();
            if (!newToken) {
                // 刷新失败,已登出
                return Promise.reject(new Error('刷新Token失败'));
            }
            // 用新Token重新发起原请求
            const config = res.config;
            config.header['Authorization'] = `Bearer ${newToken}`;
            return new Promise((resolve, reject) => {
                uni.request({
                    ...config,
                    success: (retryRes) => resolve(retryRes),
                    fail: (err) => reject(err)
                });
            });
        } catch (e) {
            return Promise.reject(e);
        }
    },
    fail(err) {
        console.error('响应失败', err);
        // 可以统一处理网络错误提示
        uni.showToast({ title: '网络异常', icon: 'none' });
        return Promise.reject(err);
    }
});

/**
 * 封装后的请求方法,业务层统一调用
 */
export function request(options) {
    return new Promise((resolve, reject) => {
        uni.request({
            ...options,
            success: (res) => {
                // 业务层状态码判断(例如后端返回的code字段)
                if (res.data.code === 0 || res.data.code === 200) {
                    resolve(res.data);
                } else {
                    const msg = res.data.msg || '请求失败';
                    uni.showToast({ title: msg, icon: 'none' });
                    reject(new Error(msg));
                }
            },
            fail: (err) => {
                uni.showToast({ title: '网络错误', icon: 'none' });
                reject(err);
            }
        });
    });
}

这里有两个拦截器:第一个负责请求前拼URL和加Token;第二个负责响应时处理401并触发刷新,刷新后用原配置重新发起请求。注意在重试时用的是uni.request而不是递归调用request函数,避免死循环。

同时,在响应拦截器外又封装了一层request函数,处理业务逻辑层的code判断和统一Toast,这样实际业务代码里只需要关心成功数据,不用每次都写if (res.data.code === 0)

第四步:在页面或组件中使用

现在业务代码变得非常干净。假设有一个获取用户订单列表的API:

import { request } from '@/utils/request.js';

// 页面setup里
const loadOrders = async () => {
    try {
        const res = await request({
            url: '/api/orders',
            method: 'GET',
            data: { page: 1, pageSize: 10 }
        });
        orderList.value = res.data.list;
    } catch (e) {
        // 错误已由request内部处理了Toast,这里可以做额外处理
        console.log('加载订单失败', e);
    }
};

如果是需要上传文件,直接传filePathname就好,request函数透传给uni.request,拦截器依然生效。

需要注意的几个问题

拦截器注册时机。 拦截器应该在App启动时尽早注册,可以在App.vueonLaunch中引入@/utils/request.js确保执行。如果项目使用了uni插件或第三方库,也要保证拦截器在它们之前生效。

Token存储安全。 使用uni.setStorageSync在小程序端是明文存储,对于高敏感应用可以考虑加密后存储,或只在内存中保留。RefreshToken的安全性尤为重要,因为它能用来换取新Token。

登出后的清理。 clearLogin中使用了uni.reLaunch跳转登录页,适合一进入就强制登录的场景。如果你的应用允许游客浏览,可以改成重置状态但不跳转,由页面自行判断。

TypeScript支持。 上面的代码用JS写的,但在TS项目中可以给request和响应类型加泛型,进一步约束数据结构。拦截器的类型声明在@dcloudio/types中也有体现。

方案对比

在没有这套封装之前,每个接口调用都得写类似下面的模板:

uni.request({
    url: BASE_URL + '/api/profile',
    header: { Authorization: 'Bearer ' + uni.getStorageSync('access_token') },
    success: (res) => {
        if (res.statusCode === 200 && res.data.code === 0) {
            // 处理数据
        } else if (res.statusCode === 401) {
            // 刷新逻辑散落各处
        }
    }
});

现在只需要request({ url: '/api/profile' }),零落的逻辑全部收敛到两个文件里,项目越复杂收益越大。

总结

这套方案把uniapp中Token管理和请求封装中最麻烦的几点集中解决了:请求自动带Token、401自动刷新、并发刷新控制、业务错误统一处理。用到的工具都是uniapp内置或Vue生态的标准成员,没有引入第三方库,代码加起来也就一百多行,但可以覆盖绝大多数前后端分离的小程序或App项目。

如果你正在维护一个接口越来越多的uniapp项目,不妨把这套拦截器+Pinia的方案嫁接过去,改造成本很低,换来的却是页面代码的大幅瘦身和登录体验的平滑提升。

uniapp 实战:基于 Pinia 与拦截器实现 JWT 无感刷新与 API 统一封装
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uniapp 实战:基于 Pinia 与拦截器实现 JWT 无感刷新与 API 统一封装 https://www.taomawang.com/web/uniapp/2457.html

常见问题

相关文章

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

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