uni-app跨端登录模块实战:用Pinia把token管理做到优雅且持久

2026-08-06 0 253

做了一段时间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跨端开发最让人舒服的地方了。

如果你正被登录状态搞到头大,希望这篇文章能给你一点参考。代码可以直接复制到项目里跑,但强烈建议你先理解每个模块的职责,再根据自己后端的接口格式做调整。

uni-app跨端登录模块实战:用Pinia把token管理做到优雅且持久
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uni-app跨端登录模块实战:用Pinia把token管理做到优雅且持久 https://www.taomawang.com/web/uniapp/2494.html

常见问题

相关文章

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

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