Chapter 06

状态管理

Pinia 跨端状态管理、事件总线通信、持久化存储——构建可维护的应用状态体系

6.1 状态管理的必要性

随着应用规模增大,以下场景会变得棘手:

这些场景都需要全局共享状态,而不是在每个组件中维护独立的局部状态。

6.2 Pinia 在 uni-app 中的使用

uni-app(Vue 3 + Vite)原生支持 Pinia,使用方式与普通 Vue 3 项目完全一致。

安装与初始化

# 安装 Pinia
npm install pinia

# 如果需要持久化插件
npm install pinia-plugin-persistedstate
// main.ts
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

export function createApp() {
  const app = createSSRApp(App)  // 注意:uni-app 用 createSSRApp
  const pinia = createPinia()
  app.use(pinia)
  return { app, pinia }
}
createSSRApp vs createApp

uni-app 的入口使用 createSSRApp 而不是 createApp,这是为了支持 SSR 和小程序的组件初始化机制。即使你不需要 SSR,也必须使用这个函数。

定义用户 Store(Setup 写法)

// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface UserInfo {
  id: number
  name: string
  avatar: string
  role: 'user' | 'admin'
}

export const useUserStore = defineStore('user', () => {
  // state
  const token = ref('')
  const userInfo = ref<UserInfo | null>(null)

  // getters
  const isLoggedIn = computed(() => !!token.value)
  const isAdmin = computed(() => userInfo.value?.role === 'admin')

  // actions
  async function login(credentials: { phone: string; code: string }) {
    const res = await uni.request({
      url: '/api/auth/login',
      method: 'POST',
      data: credentials
    })
    const data = res.data as { token: string; user: UserInfo }
    token.value = data.token
    userInfo.value = data.user
    // 持久化存储
    uni.setStorageSync('token', data.token)
    uni.setStorageSync('userInfo', JSON.stringify(data.user))
  }

  function logout() {
    token.value = ''
    userInfo.value = null
    uni.removeStorageSync('token')
    uni.removeStorageSync('userInfo')
    uni.reLaunch({ url: '/pages/login/login' })
  }

  function initFromStorage() {
    // 应用启动时从本地存储恢复状态
    const savedToken = uni.getStorageSync('token')
    const savedUser = uni.getStorageSync('userInfo')
    if (savedToken) token.value = savedToken
    if (savedUser) {
      try { userInfo.value = JSON.parse(savedUser) } catch {}
    }
  }

  return { token, userInfo, isLoggedIn, isAdmin, login, logout, initFromStorage }
})

购物车 Store(含持久化)

// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface CartItem {
  productId: number
  name: string
  price: number
  quantity: number
  selected: boolean
}

const CART_STORAGE_KEY = 'cart_items'

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>(loadFromStorage())

  const totalCount = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  )

  const selectedTotal = computed(() =>
    items.value
      .filter(i => i.selected)
      .reduce((sum, i) => sum + i.price * i.quantity, 0)
  )

  function addItem(product: Omit<CartItem, 'quantity' | 'selected'>) {
    const existing = items.value.find(i => i.productId === product.productId)
    if (existing) {
      existing.quantity++
    } else {
      items.value.push({ ...product, quantity: 1, selected: true })
    }
    saveToStorage()
  }

  function saveToStorage() {
    uni.setStorageSync(CART_STORAGE_KEY, JSON.stringify(items.value))
  }

  function loadFromStorage(): CartItem[] {
    try {
      const data = uni.getStorageSync(CART_STORAGE_KEY)
      return data ? JSON.parse(data) : []
    } catch { return [] }
  }

  return { items, totalCount, selectedTotal, addItem }
})

在页面中使用 Store

<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()
const cartStore = useCartStore()

// storeToRefs 解构后保持响应性
const { userInfo, isLoggedIn } = storeToRefs(userStore)
const { totalCount } = storeToRefs(cartStore)
</script>

<template>
  <view>
    <text>欢迎,{{ userInfo?.name }}</text>
    <text>购物车共 {{ totalCount }} 件</text>
  </view>
</template>

6.3 uni.$emit 与 uni.$on — 事件总线

uni-app 内置了一个简单的全局事件总线,适合跨页面、跨组件的轻量级通信,无需引入额外的库:

uni.$emit(eventName, data)
触发全局事件,携带数据。任何注册了该事件监听的组件/页面都会收到。
uni.$on(eventName, handler)
监听全局事件。通常在 onLoadonShow 中注册。
uni.$once(eventName, handler)
监听一次性事件,触发后自动移除监听器。
uni.$off(eventName, handler)
移除事件监听器。必须onUnload 中调用,否则会造成内存泄漏(监听器在页面销毁后仍存在)。
// 消息页面:发送新消息通知
function sendMessage(msg: string) {
  uni.$emit('newMessage', { content: msg, time: new Date() })
}

// 首页:监听新消息更新未读数
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { ref } from 'vue'

const unreadCount = ref(0)

function onNewMessage() {
  unreadCount.value++
}

onLoad(() => {
  uni.$on('newMessage', onNewMessage)
})

onUnload(() => {
  // 关键:页面卸载时移除监听,防止内存泄漏
  uni.$off('newMessage', onNewMessage)
})
事件总线的适用边界

uni.$emit / $on 适合临时的、轻量的跨页面通信。如果一个状态需要在 3 个以上的页面共享,或者数据结构较复杂,应该迁移到 Pinia 管理。过度依赖事件总线会让代码难以追踪和调试。

6.4 本地存储封装

uni-app 的 uni.setStorageSync 等 API 只支持存储字符串(非字符串会被序列化),封装一层通用工具可以减少重复的 JSON 处理:

// utils/storage.ts — 类型安全的本地存储工具
export const storage = {
  set<T>(key: string, value: T): void {
    try {
      uni.setStorageSync(key, JSON.stringify(value))
    } catch (e) {
      console.error(`[Storage] set ${key} failed:`, e)
    }
  },

  get<T>(key: string, defaultValue?: T): T | null {
    try {
      const raw = uni.getStorageSync(key)
      return raw ? (JSON.parse(raw) as T) : (defaultValue ?? null)
    } catch { return defaultValue ?? null }
  },

  remove(key: string): void {
    uni.removeStorageSync(key)
  },

  clear(): void {
    uni.clearStorageSync()
  }
}

// 使用示例
storage.set('settings', { theme: 'dark', language: 'zh' })
const settings = storage.get<{ theme: string; language: string }>('settings')

6.5 全局主题状态

// stores/theme.ts
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { storage } from '@/utils/storage'

type Theme = 'light' | 'dark' | 'system'

export const useThemeStore = defineStore('theme', () => {
  const theme = ref<Theme>(storage.get('theme') ?? 'light')

  // 自动持久化
  watch(theme, (val) => storage.set('theme', val))

  function toggle() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }

  return { theme, toggle }
})

6.6 小结