做了一段时间uni-app跨端项目,最让我头疼的不是样式适配,而是登录状态管理。H5、微信小程序、App,每个平台对本地存储的API不一样,token过期时间也不一样,还得考虑登录态自动续期。用传统Vuex写起来繁琐,用起来也笨重。
后来我把项目迁移到了Vue3 + Pinia,配合uni-app的拦截器,才算把这一摊事理顺了。今天分享一套我目前在用的完整方案,里面包含了我踩过的坑和优化后的代码。
一、项目准备:先装Pinia,再改造main.js
uni-app官方已经默认支持Vue3,所以只需要安装Pinia依赖。
npm install pinia
然后在main.js里挂载Pinia,以及引入一个初始化登录状态的函数。这里我选择把登录相关逻辑独立成模块,避免main.js里堆太多东西。
import { createSSRApp } from 'vue'
import * as Pinia from 'pinia'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
app.use(Pinia.createPinia())
return {
app,
Pinia
}
}
还有一个很重要的点:如果不做SSR,你可以直接用uni.$pinia来获取Pinia实例。但在App.vue的onLaunch里获取Pinia实例需要一点小技巧,后面会讲到。
二、设计userStore:用Pinia管理用户信息与token
我习惯把用户相关的状态全部塞进一个store里,包括用户信息、token、登录状态、过期时间。这样在页面中调用方便,而且Pinia的响应式特性可以让你实时感知登录状态变化。
先建一个store/user.js:
import { defineStore } from 'pinia'
import { loginApi, getUserInfoApi } from '@/api/user'
import { refreshTokenApi } from '@/api/auth'
export const useUserStore = defineStore('user', {
state: () => ({
token: uni.getStorageSync('token') || '',
refreshToken: uni.getStorageSync('refreshToken') || '',
userInfo: uni.getStorageSync('userInfo') || null,
expiresAt: Number(uni.getStorageSync('expiresAt') || 0)
}),
getters: {
isLoggedIn: (state) => !!state.token,
isTokenExpired: (state) => {
if (!state.expiresAt) return true
// 提前30秒判断过期,避免请求刚好在临界点挂掉
return state.expiresAt - Date.now() < 30000
}
},
actions: {
// 登录成功后调用
setAuth({ token, refreshToken, expiresIn }) {
this.token = token
this.refreshToken = refreshToken
this.expiresAt = Date.now() + expiresIn * 1000
uni.setStorageSync('token', token)
uni.setStorageSync('refreshToken', refreshToken)
uni.setStorageSync('expiresAt', this.expiresAt)
},
setUserInfo(info) {
this.userInfo = info
uni.setStorageSync('userInfo', info)
},
async login(credentials) {
const { data } = await loginApi(credentials)
this.setAuth(data)
await this.fetchUserInfo()
},
async fetchUserInfo() {
const { data } = await getUserInfoApi()
this.setUserInfo(data)
},
async refreshAuthToken() {
if (!this.refreshToken) {
this.logout()
return
}
try {
const { data } = await refreshTokenApi(this.refreshToken)
this.setAuth(data)
} catch (e) {
// 刷新失败,强制重新登录
this.logout()
uni.navigateTo({
url: '/pages/login/index'
})
}
},
logout() {
this.token = ''
this.refreshToken = ''
this.userInfo = null
this.expiresAt = 0
try {
uni.removeStorageSync('token')
uni.removeStorageSync('refreshToken')
uni.removeStorageSync('userInfo')
uni.removeStorageSync('expiresAt')
} catch (e) {
console.log('清除缓存失败', e)
}
}
}
})
有几个细节我解释一下:
- expiresAt用来保存token的绝对过期时间戳,而不是只用expiresIn,因为服务端返回的expiresIn是从当前时刻起算的秒数,我们要把它换算成具体时间。
- getter里isTokenExpired加了一个30秒的提前量,防止网络慢的时候请求发出去token刚好过期。
- logout里不跳转页面,由调用方决定是否跳转,这样解耦更干净。
三、封装请求拦截器:自动带上token,响应401就刷新token
uni-app自带的uni.request不太适合直接用,因为每次都要写success、fail,而且没法统一处理状态码。我习惯封装一个request.js,核心逻辑是:请求前带上token,响应后判断业务码,遇到token过期就尝试刷新。
import { useUserStore } from '@/store/user'
import { BASE_URL } from '@/config'
let isRefreshing = false
let pendingQueue = []
function refreshTokenAndRetry(options) {
const userStore = useUserStore()
return new Promise((resolve, reject) => {
// 如果已经在刷新token了,就把后续请求排队
if (isRefreshing) {
pendingQueue.push({ options, resolve, reject })
return
}
isRefreshing = true
userStore.refreshAuthToken().then(() => {
// 刷新成功后,重新执行队列里所有请求
pendingQueue.forEach(item => {
item.resolve(handleRequest(item.options))
})
pendingQueue = []
}).catch(err => {
pendingQueue.forEach(item => item.reject(err))
pendingQueue = []
}).finally(() => {
isRefreshing = false
})
// 当前这次请求也走同样的重试逻辑
resolve(handleRequest(options))
})
}
function handleRequest(options) {
const userStore = useUserStore()
return new Promise((resolve, reject) => {
const token = userStore.token
const header = { ...options.header }
if (token) {
header['Authorization'] = `Bearer ${token}`
}
uni.request({
url: BASE_URL + options.url,
method: options.method || 'GET',
data: options.data || {},
header,
success: async (res) => {
const { code, data, message } = res.data
if (code === 0) {
resolve(data)
return
}
if (code === 401) {
// token失效,尝试刷新
try {
const result = await refreshTokenAndRetry(options)
resolve(result)
} catch (error) {
const userStore = useUserStore()
userStore.logout()
uni.navigateTo({
url: '/pages/login/index'
})
reject(error)
}
return
}
uni.showToast({
title: message || '请求出错',
icon: 'none'
})
reject(res.data)
},
fail: (err) => {
uni.showToast({
title: '网络异常',
icon: 'none'
})
reject(err)
}
})
})
}
export default function request(options) {
return handleRequest(options)
}
这段代码里最关键的是维护了一个pendingQueue。当多个请求同时遇到401时,如果每个请求都各自去刷新token,就会造成重复刷新。有了队列,只需要刷新一次,然后让所有等待的请求带着新的token重新发起。
还要注意一点,在App.vue的onLaunch里调用store方法之前,需要先获取Pinia实例。因为Pinia在应用初始化时还没完全挂载好,直接import store再调用可能会报错。
// App.vue
import { onLaunch } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user'
onLaunch(() => {
const userStore = useUserStore()
// 假设你有本地缓存的登录态,可以在这里做一次静默登录
if (userStore.token && userStore.isTokenExpired) {
userStore.refreshAuthToken().catch(() => {
userStore.logout()
})
}
})
实际上我测试发现,在App.vue的setup里通过useUserStore()是可以直接用的,因为onLaunch执行时整个应用已经初始化完成。
四、登录页与微信小程序的一键登录
H5、App都用普通的账号密码登录,咱就不细说了。但微信小程序里比较特别,必须用uni.login获取code,然后发给后端换token。
下面是一个微信小程序专属的登录方法:
// api/user.js
import request from '@/utils/request'
export function loginApi(data) {
return request({
url: '/user/login',
method: 'POST',
data
})
}
export function wxLoginApi(code) {
return request({
url: '/user/wxlogin',
method: 'POST',
data: { code }
})
}
export function getUserInfoApi() {
return request({
url: '/user/info',
method: 'GET'
})
}
登录页里,点击微信登录按钮时这样调用:
async function handleWxLogin() {
// #ifdef MP-WEIXIN
const { code } = await uni.login({ provider: 'weixin' })
const userStore = useUserStore()
await userStore.loginWithWx(code)
uni.switchTab({
url: '/pages/index/index'
})
// #endif
}
这个loginWithWx方法需要单独写在store里:
async loginWithWx(code) {
const { data } = await wxLoginApi(code)
this.setAuth(data)
await this.fetchUserInfo()
}
五、自动续期:定时器还是请求时判断?
自动续期无外乎两种方案:
- 每隔一段时间主动刷新token。比如每30分钟调一次refresh接口。
- 每次请求前检查isTokenExpired,如果快过期就先去刷新。
第一种方案很干净,但需要管理定时器,而且如果App长时间挂在后台,定时器可能被系统挂起。第二种方案结合请求拦截器,几乎无感知,我更喜欢这种方式。
在request.js的handleRequest里,request正式发起之前可以先做一次过期检查:
function handleRequest(options) {
return new Promise((resolve, reject) => {
const userStore = useUserStore()
const token = userStore.token
// 如果token存在且已过期,先刷新
if (token && userStore.isTokenExpired) {
userStore.refreshAuthToken().catch(() => {
userStore.logout()
uni.navigateTo({
url: '/pages/login/index'
})
reject(new Error('登录已过期'))
})
}
// 继续发起正式请求
// 这里省略后续代码...
})
}
这样一来,用户操作时如果token刚好过期了,第一个请求会触发刷新,后面的请求直接使用刷新后的新token,用户完全无感知。唯一的问题是,如果refreshToken也过期了,那用户会被强制登出。这种情况我一般会引导用户重新登录,同时保留当前页面数据。
六、实际遇到的坑和解决办法
坑1:H5刷新页面后Pinia状态丢失
我一开始天真地以为Pinia和Vuex一样刷新后状态就没了,但H5刷新页面整个JS重新加载,store是空的。这时候需要靠本地缓存恢复状态。
我的解决方法是:在store定义时,state的初始值直接使用uni.getStorageSync读取。例如token,直接从storage里同步读取,这样只要本地有缓存,刷新后store就能立刻恢复。注意必须用同步API,否则onLaunch时可能还没读到。
坑2:微信小程序的storage和H5不一样吗
uni.getStorageSync 是跨端统一的,所以这点没问题。但如果你在H5上测试微信小程序登录,uni.login会直接报错,所以我用条件编译把特定平台代码包裹起来。这是uni-app跨端开发的基本素养,不能偷懒。
坑3:refreshToken本身过期了怎么办
我让后端返回两个token:accessToken(短期,比如2小时)和refreshToken(长期,比如14天)。当accessToken过期后,用refreshToken去换新的token。如果refreshToken也过期了,那说明用户长期未登录,只能重新登录。
在refreshAuthToken方法里,如果接口返回401,我就把状态清空并跳转到登录页。
坑4:多个请求同时401导致重复刷新
上面已经用队列解决了。但还要注意一个事情:刷新token的接口本身不能走带token的拦截器,否则会死循环。我通常把refreshTokenApi放到一个独立文件里,绕过请求封装。
// api/auth.js
import { BASE_URL } from '@/config'
export function refreshTokenApi(refreshToken) {
return new Promise((resolve, reject) => {
uni.request({
url: BASE_URL + '/auth/refresh',
method: 'POST',
data: { refreshToken },
success: (res) => {
if (res.data.code === 0) {
resolve(res.data)
} else {
reject(res.data)
}
},
fail: reject
})
})
}
这样刷新逻辑和普通业务请求完全分离,代码也更清晰。
七、从页面调用Store的体验
有了这套基础,页面上使用起来就非常顺手。比如在个人中心页面:
import { useUserStore } from '@/store/user'
import { computed } from 'vue'
const userStore = useUserStore()
const nickname = computed(() => userStore.userInfo?.nickname || '点击登录')
const isLoggedIn = computed(() => userStore.isLoggedIn)
function handleLogout() {
uni.showModal({
title: '确认退出',
content: '退出后部分功能无法使用',
success: (res) => {
if (res.confirm) {
userStore.logout()
uni.navigateTo({
url: '/pages/login/index'
})
}
}
})
}
只需要在profile页面中判断一下isLoggedIn,如果是false就展示登录按钮。登录成功后store里状态自动更新,页面UI也响应式地改变。
八、这种方案的适用范围
我这种写法适合中小型项目,没有引入复杂的RBAC权限体系,也没有多角色切换。如果你需要做更细致的路由守卫或者角色权限,可以在拦截器里增加额外的判断逻辑,但核心的token管理思路是通用的。
我自己把这套方案用在了两个跨端项目上,从微信小程序到支付宝小程序再到H5,除了少量条件编译,几乎没有改过登录逻辑。这大概是uni-app跨端开发最让人舒服的地方了。
如果你正被登录状态搞到头大,希望这篇文章能给你一点参考。代码可以直接复制到项目里跑,但强烈建议你先理解每个模块的职责,再根据自己后端的接口格式做调整。

