uni-app 状态管理实战:Pinia 集成、持久化适配与登录态设计

uni-app 状态管理实战:Pinia 集成、持久化适配与登录态设计

admin
2026-08-04 / 0 评论 / 5 阅读

页面通信篇里简单提过 Pinia,但真实项目的状态管理远不止"装个库"。多端 storage 差异、持久化插件适配、登录态设计、模块划分,每一步都有坑。本文把 uni-app 状态管理的完整方案一次性讲透。

一、为什么 uni-app 项目需要状态管理

先看反例——不用状态管理时的典型代码:

// 每个页面都要重复拉用户信息
onShow() {
  const userId = uni.getStorageSync('user_id')
  const res = await request.get(`/users/${userId}`)
  this.user = res
}

问题:重复请求、数据不同步(A 页改了昵称 B 页还是旧的、storage 读写散落各处。状态管理的本质是把跨页面共享的响应式数据收口到一个地方

哪些数据该进全局 store:

数据类型示例方案
登录态token、用户信息store + 持久化
业务共享状态购物车、选中的收货地址store
页面间临时传值列表页→详情页参数URL / EventChannel
单页面私有状态表单数据data / ref

判断标准:两个以上无父子关系的页面需要读同一份数据 → 上 store;只在跳转链上传递 → 用页面通信方案。不要为了"显得规范"把所有东西塞进 store。

二、Pinia 集成

// main.js
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

export function createApp() {
  const app = createSSRApp(App)
  app.use(createPinia())
  return { app }
}
// stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: null
  }),

  getters: {
    isLoggedIn: (state) => !!state.token,
    displayName: (state) => state.userInfo?.nickname || '未登录'
  },

  actions: {
    async login(code) {
      const res = await request.post('/auth/wx-login', { code })
      this.token = res.accessToken
      this.userInfo = res.user
    },

    logout() {
      this.token = ''
      this.userInfo = null
      uni.reLaunch({ url: '/pages/login/login' })
    }
  }
})
// 任意页面使用
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

userStore.isLoggedIn // getter,响应式
await userStore.login(code) // action

三、持久化:storage 适配是关键

直接持久化的问题

小程序端没有 localStorage,Pinia 官方的 pinia-plugin-persistedstate 默认走 localStorage,在小程序端直接报错。两个解决路径:

方案一:手写持久化(推荐,无依赖)

// utils/persist.js
import { watch, toRaw } from 'vue'

/**
 * 将 store state 持久化到 uni storage
 * @param {Store} store pinia store 实例
 * @param {string} key 存储键名
 */
export function persistStore(store, key) {
  // 启动时恢复
  const saved = uni.getStorageSync(key)
  if (saved) {
    store.$patch(JSON.parse(saved))
  }

  // 变更时保存(deep watch)
  store.$subscribe((mutation, state) => {
    try {
      uni.setStorageSync(key, JSON.stringify(toRaw(state)))
    } catch (e) {
      console.warn('持久化失败', e)
    }
  })
}
// stores/index.js —— 统一初始化
import { createPinia } from 'pinia'
import { persistStore } from '@/utils/persist'
import { useUserStore } from './user'
import { useCartStore } from './cart'

const pinia = createPinia()

// 在 App.vue onLaunch 里调用,确保 uni storage 可用
export function initStores() {
  persistStore(useUserStore(), 'app:user')
  persistStore(useCartStore(), 'app:cart')
}

export default pinia
// App.vue
export default {
  onLaunch() {
    initStores()
  }
}

$subscribe 是 Pinia 内置的订阅机制,任何 mutation 都会触发,比手动 watch 更可靠。

方案二:persist 插件 + 自定义 storage

如果坚持用 pinia-plugin-persistedstate,给它传入适配 uni storage 的 driver:

// main.js
import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persistedstate'

const pinia = createPinia()

pinia.use(createPersistedState({
  storage: {
    getItem: (key) => uni.getStorageSync(key) || null,
    setItem: (key, value) => uni.setStorageSync(key, value),
    // uni storage 没有.removeItem 语义?有:uni.removeStorageSync
  }
}))

export function createApp() {
  const app = createSSRApp(App)
  app.use(pinia)
  return { app }
}

持久化的粒度控制

不是所有 state 都值得持久化。用户偏好(主题、字体大小)全量持久化;购物车持久化但要注意登录后与服务器合并;临时 UI 状态(弹窗开关)绝不持久化。用插件的话按 store 配置 paths 字段挑选。

四、登录态设计:完整方案

登录态是小程序里最重要的全局状态,完整链路:静默登录 → 检查有效期 → 请求拦截 → 失效处理

// stores/user.js 完整版
import { defineStore } from 'pinia'
import request from '@/utils/request'

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    refreshToken: '',
    userInfo: null,
    wxSessionKey: ''
  }),

  getters: {
    isLoggedIn: (state) => !!state.token
  },

  actions: {
    /**
     * 静默登录:App 启动即调用,用户无感知
     * 小程序 wx.login 换 code → 后端换 openid → 绑定/注册用户 → 发 token
     */
    async silentLogin() {
      if (this.token) return

      try {
        const [err, res] = await uni.login({ provider: 'weixin' })
        if (err) return

        const data = await request.post('/auth/silent-login', { code: res.code })
        this.token = data.accessToken
        this.refreshToken = data.refreshToken
      } catch (e) {
        // 静默失败不弹窗,需要用户信息时再引导
        console.warn('静默登录失败', e)
      }
    },

    /** 强制登录:访问需要身份的页面时调用 */
    async ensureLogin() {
      if (this.isLoggedIn) return true

      await this.silentLogin()
      if (this.isLoggedIn) return true

      // 静默失败(未注册),跳登录页
      const pages = getCurrentPages()
      const current = pages[pages.length - 1]
      uni.navigateTo({
        url: `/pages/login/login?redirect=/${current.route}`
      })
      return false
    },

    async fetchProfile() {
      this.userInfo = await request.get('/me')
    },

    logout() {
      this.$reset() // 重置到初始 state
      uni.reLaunch({ url: '/pages/login/login' })
    }
  }
})
// App.vue
export default {
  onLaunch() {
    initStores()
    useUserStore().silentLogin()
  }
}

五、store 模块划分

中大型项目的目录建议:

stores/
├── index.js        # pinia 实例 + initStores
├── user.js         # 登录态、用户信息
├── cart.js         # 购物车
├── app.js          # 全局配置:主题、系统信息、定位城市
└── address.js      # 收货地址列表

划分原则:

  1. 按业务域拆,不按页面拆(订单相关的状态跟订单 store,即使被 5 个页面用)
  2. store 之间可以互相引用useCartStore 内部调用 useUserStore 检查登录态,Pinia 支持这种组合
  3. action 里做业务编排:加入购物车 = 检查登录 → 调接口 → 更新 state → toast 提示,这一串逻辑写进 action 而不是页面里,页面只管调用
// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import request from '@/utils/request'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: []
  }),

  actions: {
    async add(goods, count = 1) {
      const userStore = useUserStore() // store 组合

      const ok = await userStore.ensureLogin()
      if (!ok) return

      // 本地乐观更新 + 服务端同步
      const local = this.items.find(i => i.goodsId === goods.id)
      if (local) {
        local.count += count
      } else {
        this.items.push({ ...goods, goodsId: goods.id, count })
      }

      try {
        await request.post('/cart/add', { goodsId: goods.id, count })
      } catch (e) {
        uni.showToast({ title: '同步失败', icon: 'none' })
      }
    }
  }
})

六、多端差异注意点

  1. storage 大小限制:单 key 上限 1MB,总上限 10MB(微信)。购物车这类大对象要控制规模,必要时只存 ID 列表,详情进 store 后从接口拉
  2. App 端可以直用 localStorage:但为了代码统一,全端都用 uni.setStorageSync
  3. storage 是同步 API:批量写入会阻塞逻辑层,高频写入(如实时输入内容)做节流
// 写入节流
let saveTimer = null
store.$subscribe(() => {
  clearTimeout(saveTimer)
  saveTimer = setTimeout(() => {
    uni.setStorageSync(key, JSON.stringify(toRaw(store.$state)))
  }, 300)
})

总结

  • 跨页面共享的响应式数据进 store,跳转传参用页面通信,不要过度集中
  • 小程序端持久化必须适配 uni storage:手写 $subscribe 方案零依赖最稳
  • 登录态标准链路:onLaunch 静默登录 → ensureLogin 强制拦截 → action 内编排业务
  • store 按业务域划分,action 做业务编排,store 之间可组合
  • storage 有 1MB 单 key 限制,大对象持久化要做节流和规模控制

下一篇讲登录授权的"最后一公里"——手机号验证码、头像昵称填写这些微信持续收紧的接口怎么优雅应对。

0

评论 (0)

取消
0:00