做小程序或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.addInterceptor对request进行拦截,也可以直接封装一个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);
}
};
如果是需要上传文件,直接传filePath和name就好,request函数透传给uni.request,拦截器依然生效。
需要注意的几个问题
拦截器注册时机。 拦截器应该在App启动时尽早注册,可以在App.vue的onLaunch中引入@/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的方案嫁接过去,改造成本很低,换来的却是页面代码的大幅瘦身和登录体验的平滑提升。

