Pinia 状态管理实战 | 从 0 到 1 搭建 Vue3 项目状态层(附模块化 / 持久化)

12 阅读7分钟

Pinia 状态管理实战 | 从 0 到 1 搭建 Vue3 项目状态层(附模块化 / 持久化)

一、为什么是 Pinia?

还记得 Vuex 吗?那个陪伴我们多年的状态管理库,有着严格的 mutations、actions 分工,写起来像在写 Java——虽然严谨,但也繁琐。

// Vuex 时代的痛
mutations: {
  SET_USER(state, user) {
    state.user = user
  }
},
actions: {
  async fetchUser({ commit }) {
    const user = await api.getUser()
    commit('SET_USER', user) // 绕了一大圈
  }
}

而 Pinia 来了,它说:「简单点,写代码的方式简单点」

// Pinia 的快乐
export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  actions: {
    async fetchUser() {
      this.user = await api.getUser() // 直接赋值,爽!
    }
  }
})

1.1 Pinia 的核心优势

特性VuexPinia
mutations✅ 必须写❌ 没了
TypeScript 支持😖 痛苦😎 原生支持
代码量少 30%
学习曲线陡峭平缓
DevTools✅ 更好

二、项目初始化:从 0 开始搭建状态层

承接上一节的 Vite 项目,我们来深度拆解状态管理。

2.1 安装 Pinia

npm install pinia
npm install pinia-plugin-persistedstate # 持久化插件(后面会讲)

2.2 在 main.ts 中注册

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

// 创建 Pinia 实例
const pinia = createPinia()

const app = createApp(App)

// 注册插件(顺序很重要:先 Pinia,后路由)
app.use(pinia)
app.use(router)

app.mount('#app')

三、Store 的两种写法:你pick哪一种?

Pinia 支持两种 Store 定义方式,就像 Vue 有 Options API 和 Composition API 一样。

3.1 Options Store(类似 Vuex 风格)

// stores/counter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // state:数据源
  state: () => ({
    count: 0,
    name: '计数器'
  }),
  
  // getters:计算属性
  getters: {
    doubleCount: (state) => state.count * 2,
    // 使用 this 访问其他 getter
    displayText(): string {
      return `${this.name}: ${this.count} (翻倍后: ${this.doubleCount})`
    }
  },
  
  // actions:方法(支持同步异步)
  actions: {
    increment(amount = 1) {
      this.count += amount
    },
    async fetchAndSetCount() {
      // 模拟异步请求
      const res = await fetch('/api/count')
      const data = await res.json()
      this.count = data.count
    }
  }
})

3.2 Setup Store(Composition API 风格)⭐推荐

// stores/counter.ts (Setup Store)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // state:用 ref/reactive
  const count = ref(0)
  const name = ref('计数器')
  
  // getters:用 computed
  const doubleCount = computed(() => count.value * 2)
  const displayText = computed(() => {
    return `${name.value}: ${count.value} (翻倍后: ${doubleCount.value})`
  })
  
  // actions:普通函数
  function increment(amount = 1) {
    count.value += amount
  }
  
  async function fetchAndSetCount() {
    const res = await fetch('/api/count')
    const data = await res.json()
    count.value = data.count
  }
  
  // 必须返回所有暴露的内容
  return {
    count,
    name,
    doubleCount,
    displayText,
    increment,
    fetchAndSetCount
  }
})

为什么推荐 Setup Store?

  • 更灵活,可以组合复用逻辑
  • TypeScript 类型推导更好
  • 符合 Vue3 Composition API 的心智模型

四、模块化设计:把大象装进冰箱分几步?

企业级项目最忌讳「一个大 Store 管所有」。正确的姿势是:按业务模块拆分

4.1 推荐的项目结构

src/stores/
├── index.ts              # 统一导出
├── modules/
│   ├── user.ts           # 用户模块
│   ├── cart.ts           # 购物车模块
│   ├── product.ts        # 商品模块
│   └── app.ts            # 应用配置(主题/语言等)
├── composables/          # 可复用的组合逻辑
│   ├── useAuth.ts
│   └── useCache.ts
└── plugins/              # Pinia 插件
    └── logger.ts

4.2 用户模块(完整示例)

// stores/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo, LoginParams } from '@/types/user'
import { loginApi, getUserInfoApi } from '@/api/user'
import { ElMessage } from 'element-plus'

export const useUserStore = defineStore('user', () => {
  // --- State ---
  const token = ref<string | null>(localStorage.getItem('token'))
  const userInfo = ref<UserInfo | null>(null)
  const permissions = ref<string[]>([])
  
  // --- Getters ---
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => userInfo.value?.name || '游客')
  const userRole = computed(() => userInfo.value?.role || 'guest')
  const hasPermission = computed(() => (perm: string) => {
    return permissions.value.includes(perm) || userRole.value === 'admin'
  })
  
  // --- Actions ---
  // 登录
  async function login(params: LoginParams) {
    try {
      const res = await loginApi(params)
      token.value = res.token
      userInfo.value = res.userInfo
      permissions.value = res.permissions || []
      
      // 同步到 localStorage
      localStorage.setItem('token', res.token)
      
      ElMessage.success('登录成功')
      return true
    } catch (error) {
      ElMessage.error('登录失败:' + (error as Error).message)
      return false
    }
  }
  
  // 登出
  function logout() {
    token.value = null
    userInfo.value = null
    permissions.value = []
    localStorage.removeItem('token')
    ElMessage.success('已退出登录')
  }
  
  // 获取用户信息
  async function fetchUserInfo() {
    if (!token.value) return
    
    try {
      const res = await getUserInfoApi()
      userInfo.value = res.userInfo
      permissions.value = res.permissions
    } catch (error) {
      console.error('获取用户信息失败:', error)
      // token 无效,自动登出
      if ((error as any).response?.status === 401) {
        logout()
      }
    }
  }
  
  // 更新用户信息
  function updateUserInfo(data: Partial<UserInfo>) {
    if (userInfo.value) {
      userInfo.value = { ...userInfo.value, ...data }
    }
  }
  
  return {
    // state
    token,
    userInfo,
    permissions,
    // getters
    isLoggedIn,
    userName,
    userRole,
    hasPermission,
    // actions
    login,
    logout,
    fetchUserInfo,
    updateUserInfo
  }
})

4.3 应用配置模块(主题/语言)

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

type Theme = 'light' | 'dark'
type Language = 'zh' | 'en'

export const useAppStore = defineStore('app', () => {
  // 从 localStorage 读取初始值
  const getInitialTheme = (): Theme => {
    const saved = localStorage.getItem('theme') as Theme
    return saved || 'light'
  }
  
  const getInitialLanguage = (): Language => {
    const saved = localStorage.getItem('language') as Language
    return saved || 'zh'
  }
  
  // State
  const theme = ref<Theme>(getInitialTheme())
  const language = ref<Language>(getInitialLanguage())
  const sidebarCollapsed = ref(false)
  
  // Getters
  const isDark = computed(() => theme.value === 'dark')
  const currentLanguage = computed(() => language.value)
  
  // Actions
  function setTheme(newTheme: Theme) {
    theme.value = newTheme
    localStorage.setItem('theme', newTheme)
    
    // 更新 HTML 的 data-theme 属性(用于 CSS 变量)
    document.documentElement.setAttribute('data-theme', newTheme)
  }
  
  function toggleTheme() {
    setTheme(theme.value === 'light' ? 'dark' : 'light')
  }
  
  function setLanguage(lang: Language) {
    language.value = lang
    localStorage.setItem('language', lang)
  }
  
  function toggleSidebar() {
    sidebarCollapsed.value = !sidebarCollapsed.value
  }
  
  return {
    theme,
    language,
    sidebarCollapsed,
    isDark,
    currentLanguage,
    setTheme,
    toggleTheme,
    setLanguage,
    toggleSidebar
  }
})

4.4 统一导出(方便使用)

// stores/index.ts
export { useUserStore } from './modules/user'
export { useAppStore } from './modules/app'
export { useCartStore } from './modules/cart'
export { useProductStore } from './modules/product'

// 如果需要,可以创建一个组合多个 store 的 hook
import { useUserStore } from './modules/user'
import { useAppStore } from './modules/app'

export const useStore = () => ({
  user: useUserStore(),
  app: useAppStore()
})

五、持久化:让状态「记住」自己

5.1 问题场景

用户登录后刷新页面,状态丢了——这是初学者最常见的困惑。

// 刷新后,token 没了,又要重新登录
// 用户体验:???

5.2 解决方案:pinia-plugin-persistedstate

npm install pinia-plugin-persistedstate
// src/main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // 注册插件

5.3 基本用法

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null,
    userInfo: null
  }),
  persist: true // 一键开启持久化
})

就这么简单!默认会:

  • 使用 localStorage
  • key 为 store名(这里是 'user')
  • 自动同步整个 state

5.4 高级配置:按需持久化

有时候我们不想存所有东西(比如敏感信息、临时数据):

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null,
    userInfo: null,
    tempSearchKeyword: '', // 这个不想持久化
    loginTime: null
  }),
  persist: {
    key: 'user-storage', // 自定义存储 key
    storage: localStorage, // 可选 sessionStorage
    paths: ['token', 'userInfo'], // 只持久化这两个字段
    beforeRestore: (context) => {
      console.log('即将恢复状态', context)
    },
    afterRestore: (context) => {
      console.log('状态恢复完成', context)
    }
  }
})

5.5 Setup Store 的持久化写法

// stores/modules/app.ts
export const useAppStore = defineStore('app', () => {
  const theme = ref('light')
  const language = ref('zh')
  
  // ... 其他逻辑
  
  return {
    theme,
    language
  }
}, {
  persist: {
    key: 'app-settings',
    paths: ['theme', 'language'] // 只持久化主题和语言
  }
})

5.6 多标签页同步

如果你想让多个标签页的状态保持同步,可以这样配置:

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null
  }),
  persist: {
    storage: localStorage,
    // 监听 storage 事件,实现多标签页同步
    beforeRestore: (context) => {
      window.addEventListener('storage', (e) => {
        if (e.key === 'user-storage') {
          // 重新恢复状态
          context.store.$hydrate()
        }
      })
    }
  }
})

六、Store 组合与复用(类似 Composables)

这是 Pinia 最强大的特性之一:Store 可以像组合式函数一样复用-5

6.1 场景:多个模块需要认证逻辑

假设你的应用有多个模块都需要用到用户认证状态,不想在每个 Store 里重复写一遍登录/登出逻辑。

// stores/composables/useAuth.ts
import { ref, computed } from 'vue'

export function useAuth() {
  const isLoggedIn = ref(false)
  const username = ref('')
  
  function login(name: string) {
    isLoggedIn.value = true
    username.value = name
  }
  
  function logout() {
    isLoggedIn.value = false
    username.value = ''
  }
  
  return {
    isLoggedIn,
    username,
    login,
    logout
  }
}

6.2 在 Store 中复用

// stores/modules/user.ts
import { defineStore } from 'pinia'
import { useAuth } from '../composables/useAuth'

export const useUserStore = defineStore('user', () => {
  // 复用认证逻辑
  const { isLoggedIn, username, login, logout } = useAuth()
  
  // 扩展用户专属状态
  const userId = ref<number | null>(null)
  const avatar = ref('')
  
  // 扩展登录方法
  const loginWithId = (name: string, id: number) => {
    login(name) // 调用复用的 login
    userId.value = id
  }
  
  return {
    isLoggedIn,
    username,
    userId,
    avatar,
    login: loginWithId,
    logout
  }
})

// stores/modules/admin.ts
import { defineStore } from 'pinia'
import { useAuth } from '../composables/useAuth'

export const useAdminStore = defineStore('admin', () => {
  // 同样复用认证逻辑
  const { isLoggedIn, username, login, logout } = useAuth()
  
  // 管理员特有的状态
  const adminLevel = ref(1)
  
  return {
    isLoggedIn,
    username,
    adminLevel,
    login,
    logout
  }
})

6.3 场景:数据缓存逻辑复用

多个模块都需要缓存数据(比如商品列表、订单列表),可以封装一个通用的缓存逻辑-5

// stores/composables/useCache.ts
import { ref } from 'vue'

export function useCache<T>(key: string, fetchFn: () => Promise<T>, expireTime = 5 * 60 * 1000) {
  const cachedData = ref<T | null>(null)
  const lastFetchTime = ref<number | null>(null)
  
  const getData = async () => {
    const now = Date.now()
    
    // 如果有缓存且未过期,直接返回缓存
    if (cachedData.value && lastFetchTime.value && (now - lastFetchTime.value) < expireTime) {
      console.log(`[缓存命中] ${key}`)
      return cachedData.value
    }
    
    // 否则重新获取
    console.log(`[缓存失效] ${key},重新获取`)
    const freshData = await fetchFn()
    cachedData.value = freshData
    lastFetchTime.value = now
    return freshData
  }
  
  const clearCache = () => {
    cachedData.value = null
    lastFetchTime.value = null
  }
  
  return {
    getData,
    clearCache,
    cachedData
  }
}
// stores/modules/product.ts
import { defineStore } from 'pinia'
import { useCache } from '../composables/useCache'
import { fetchProductList } from '@/api/product'

export const useProductStore = defineStore('product', () => {
  const { getData, clearCache, cachedData } = useCache(
    'products',
    fetchProductList,
    10 * 60 * 1000 // 10分钟缓存
  )
  
  const loadProducts = async () => {
    return await getData()
  }
  
  return {
    products: cachedData,
    loadProducts,
    clearCache
  }
})

七、在组件中使用:三种姿势

7.1 基础用法(最常用)

<!-- views/Profile.vue -->
<template>
  <div class="profile">
    <h2>个人中心</h2>
    
    <div v-if="userStore.isLoggedIn">
      <el-avatar :src="userStore.userInfo?.avatar" />
      <p>用户名:{{ userStore.userName }}</p>
      <p>角色:{{ userStore.userRole }}</p>
      
      <el-button @click="handleLogout">退出登录</el-button>
    </div>
    
    <div v-else>
      <p>请先登录</p>
      <el-button @click="goToLogin">去登录</el-button>
    </div>
    
    <!-- 测试权限指令 -->
    <button v-if="userStore.hasPermission('product:edit')">
      编辑商品
    </button>
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/stores/modules/user'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'

const userStore = useUserStore()
const router = useRouter()

const handleLogout = () => {
  ElMessageBox.confirm('确认退出登录吗?', '提示', {
    type: 'info'
  }).then(() => {
    userStore.logout()
    router.push('/login')
  })
}

const goToLogin = () => {
  router.push('/login')
}
</script>

7.2 解构赋值(小心丢失响应性)

<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia' // 重要!

const userStore = useUserStore()

// ❌ 错误:直接解构会丢失响应性
const { userName, isLoggedIn } = userStore

// ✅ 正确:使用 storeToRefs
const { userName, isLoggedIn, userInfo } = storeToRefs(userStore)

// actions 可以直接解构(不会丢失)
const { login, logout } = userStore
</script>

7.3 在路由守卫中使用

// src/router/index.ts
import { useUserStore } from '@/stores/modules/user'

router.beforeEach((to, from, next) => {
  // 需要手动获取 store 实例
  const userStore = useUserStore()
  
  if (to.meta.requiresAuth && !userStore.isLoggedIn) {
    next({ path: '/login', query: { redirect: to.fullPath } })
  } else {
    next()
  }
})

7.4 在 axios 拦截器中使用

// src/utils/request.ts
import { useUserStore } from '@/stores/modules/user'

request.interceptors.request.use((config) => {
  const userStore = useUserStore()
  
  if (userStore.token) {
    config.headers.Authorization = `Bearer ${userStore.token}`
  }
  
  return config
})

request.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      const userStore = useUserStore()
      userStore.logout() // 自动清除状态
      router.push('/login')
    }
    return Promise.reject(error)
  }
)

八、Pinia 插件开发:定制你的专属功能

8.1 日志插件:记录所有状态变化

// stores/plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export function loggerPlugin({ store, options }: PiniaPluginContext) {
  // 订阅 state 变化
  store.$subscribe((mutation, state) => {
    console.group(`📝 [${store.$id}] 状态变化`)
    console.log('类型:', mutation.type)
    console.log('载荷:', mutation.payload)
    console.log('新状态:', state)
    console.groupEnd()
  })
  
  // 订阅 action 调用
  store.$onAction(({
    name,       // action 名称
    store,      // store 实例
    args,       // 参数
    after,      // 成功后回调
    onError     // 失败后回调
  }) => {
    console.log(`🚀 [${store.$id}] 调用 action: ${name}`, args)
    
    after(result => {
      console.log(`✅ [${store.$id}] action 成功: ${name}`, result)
    })
    
    onError(error => {
      console.error(`❌ [${store.$id}] action 失败: ${name}`, error)
    })
  })
}

8.2 注册插件

// src/main.ts
import { loggerPlugin } from './stores/plugins/logger'

const pinia = createPinia()
pinia.use(loggerPlugin) // 全局生效

8.3 自定义持久化插件

// stores/plugins/customPersist.ts
export function customPersist({ store }: PiniaPluginContext) {
  // 从 localStorage 恢复状态
  const savedState = localStorage.getItem(`pinia:${store.$id}`)
  if (savedState) {
    store.$patch(JSON.parse(savedState))
  }
  
  // 订阅变化并保存
  store.$subscribe((mutation, state) => {
    localStorage.setItem(`pinia:${store.$id}`, JSON.stringify(state))
  })
}

九、性能优化与最佳实践

9.1 避免在 getter 中返回新对象

// ❌ 错误:每次访问都返回新对象,破坏缓存
getters: {
  filteredList: (state) => {
    return state.list.filter(item => item.active) // 每次都是新数组
  }
}

// ✅ 正确:getter 本身会缓存计算结果
getters: {
  activeCount: (state) => state.list.filter(item => item.active).length
}

9.2 按需加载 Store

// 在组件中动态导入(适用于大型应用)
const useUserStore = () => import('@/stores/user').then(m => m.useUserStore)

// 或者在路由懒加载时使用
const UserModule = () => import('@/views/User.vue')

9.3 使用 shallowRef 优化大对象

import { shallowRef } from 'vue'

// 对于大型对象,不需要深度响应式
const bigData = shallowRef(null)

// 只有整体替换时才触发更新
bigData.value = await fetchLargeDataset()

9.4 重置 Store 状态

// 添加重置方法
export const useUserStore = defineStore('user', () => {
  const initialState = {
    token: null,
    userInfo: null,
    permissions: []
  }
  
  const token = ref(initialState.token)
  const userInfo = ref(initialState.userInfo)
  const permissions = ref(initialState.permissions)
  
  function $reset() {
    token.value = initialState.token
    userInfo.value = initialState.userInfo
    permissions.value = initialState.permissions
    localStorage.removeItem('token')
  }
  
  return {
    token,
    userInfo,
    permissions,
    $reset,
    // ... 其他 actions
  }
})

十、TypeScript 类型增强

10.1 为 store 添加类型

// stores/modules/user.ts
import type { UserInfo } from '@/types/user'

export interface UserState {
  token: string | null
  userInfo: UserInfo | null
  permissions: string[]
}

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

10.2 扩展 Pinia 类型(为所有 store 添加通用方法)

// types/pinia.d.ts
import 'pinia'

declare module 'pinia' {
  export interface PiniaCustomProperties {
    // 给所有 store 添加 $reset 方法
    $reset(): void
    
    // 添加自定义属性
    readonly $id: string
  }
  
  export interface PiniaCustomStateProperties<S> {
    // 给所有 state 添加 toJSON 方法
    toJSON(): S
  }
}

10.3 为插件添加类型

// stores/plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export interface LoggerPluginOptions {
  enabled?: boolean
  filter?: (storeId: string) => boolean
}

export function loggerPlugin(options: LoggerPluginOptions = {}) {
  return (context: PiniaPluginContext) => {
    // 插件逻辑
  }
}

十一、实战演练:完整的购物车模块

让我们把学到的知识串起来,实现一个完整的购物车模块。

11.1 购物车 Store

// stores/modules/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { CartItem, Product } from '@/types'
import { ElMessage } from 'element-plus'

export const useCartStore = defineStore('cart', () => {
  // --- State ---
  const items = ref<CartItem[]>([])
  const loading = ref(false)
  const lastUpdated = ref<Date | null>(null)
  
  // --- Getters ---
  const totalCount = computed(() => {
    return items.value.reduce((sum, item) => sum + item.quantity, 0)
  })
  
  const totalPrice = computed(() => {
    return items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  })
  
  const isEmpty = computed(() => items.value.length === 0)
  
  const formattedTotal = computed(() => {
    return ${totalPrice.value.toFixed(2)}`
  })
  
  // --- Actions ---
  function addItem(product: Product, quantity = 1) {
    const existing = items.value.find(item => item.id === product.id)
    
    if (existing) {
      existing.quantity += quantity
    } else {
      items.value.push({
        id: product.id,
        name: product.name,
        price: product.price,
        image: product.image,
        quantity
      })
    }
    
    lastUpdated.value = new Date()
    ElMessage.success(`已添加 ${product.name} 到购物车`)
  }
  
  function removeItem(productId: number) {
    const index = items.value.findIndex(item => item.id === productId)
    if (index > -1) {
      const removed = items.value[index]
      items.value.splice(index, 1)
      ElMessage.success(`已移除 ${removed.name}`)
    }
  }
  
  function updateQuantity(productId: number, quantity: number) {
    const item = items.value.find(item => item.id === productId)
    if (item) {
      if (quantity <= 0) {
        removeItem(productId)
      } else {
        item.quantity = quantity
      }
    }
  }
  
  function clearCart() {
    items.value = []
    ElMessage.success('购物车已清空')
  }
  
  async function checkout() {
    if (isEmpty.value) {
      ElMessage.warning('购物车是空的')
      return false
    }
    
    loading.value = true
    try {
      // 模拟提交订单
      await new Promise(resolve => setTimeout(resolve, 1500))
      
      // 提交成功后清空购物车
      clearCart()
      ElMessage.success('下单成功!')
      return true
    } catch (error) {
      ElMessage.error('下单失败,请重试')
      return false
    } finally {
      loading.value = false
    }
  }
  
  return {
    // state
    items,
    loading,
    lastUpdated,
    // getters
    totalCount,
    totalPrice,
    isEmpty,
    formattedTotal,
    // actions
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    checkout
  }
}, {
  persist: {
    key: 'shopping-cart',
    paths: ['items'], // 只持久化商品列表
    storage: localStorage
  }
})

11.2 在组件中使用

<!-- components/CartIcon.vue -->
<template>
  <el-badge :value="cartStore.totalCount" :hidden="cartStore.isEmpty">
    <el-button :icon="ShoppingCart" @click="showCartDrawer = true">
      购物车
    </el-button>
  </el-badge>
  
  <el-drawer v-model="showCartDrawer" title="购物车" size="400px">
    <div v-loading="cartStore.loading" class="cart-content">
      <template v-if="!cartStore.isEmpty">
        <div v-for="item in cartStore.items" :key="item.id" class="cart-item">
          <img :src="item.image" :alt="item.name" class="item-image">
          <div class="item-info">
            <h4>{{ item.name }}</h4>
            <p class="item-price">¥{{ item.price }}</p>
          </div>
          <div class="item-actions">
            <el-input-number
              v-model="item.quantity"
              :min="1"
              :max="99"
              size="small"
              @change="handleQuantityChange(item.id, $event)"
            />
            <el-button
              type="danger"
              :icon="Delete"
              link
              @click="cartStore.removeItem(item.id)"
            />
          </div>
        </div>
        
        <div class="cart-footer">
          <div class="total">
            <span>总计:</span>
            <span class="total-price">{{ cartStore.formattedTotal }}</span>
          </div>
          <el-button
            type="primary"
            :loading="cartStore.loading"
            @click="handleCheckout"
          >
            结算
          </el-button>
        </div>
      </template>
      
      <el-empty v-else description="购物车空空如也" />
    </div>
  </el-drawer>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ShoppingCart, Delete } from '@element-plus/icons-vue'
import { useCartStore } from '@/stores/modules/cart'
import { ElMessageBox } from 'element-plus'

const cartStore = useCartStore()
const showCartDrawer = ref(false)

const handleQuantityChange = (productId: number, quantity: number) => {
  cartStore.updateQuantity(productId, quantity)
}

const handleCheckout = async () => {
  ElMessageBox.confirm('确认提交订单吗?', '提示', {
    type: 'info'
  }).then(async () => {
    const success = await cartStore.checkout()
    if (success) {
      showCartDrawer.value = false
    }
  })
}
</script>

<style scoped lang="scss">
.cart-content {
  padding: 20px;
  height: 100%;
  display: flex;
  flex-direction: column;
}

.cart-item {
  display: flex;
  align-items: center;
  padding: 12px 0;
  border-bottom: 1px solid #eee;
  
  .item-image {
    width: 60px;
    height: 60px;
    object-fit: cover;
    border-radius: 4px;
    margin-right: 12px;
  }
  
  .item-info {
    flex: 1;
    
    h4 {
      margin: 0 0 4px;
      font-size: 14px;
    }
    
    .item-price {
      margin: 0;
      color: #f56c6c;
      font-weight: bold;
    }
  }
  
  .item-actions {
    display: flex;
    align-items: center;
    gap: 8px;
  }
}

.cart-footer {
  margin-top: auto;
  padding-top: 20px;
  border-top: 2px solid #eee;
  
  .total {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
    font-size: 16px;
    
    .total-price {
      color: #f56c6c;
      font-size: 20px;
      font-weight: bold;
    }
  }
}
</style>

十二、总结与进阶

12.1 Pinia 核心要点回顾

概念作用类比
State存储数据组件的 data
Getter计算派生状态组件的 computed
Action修改状态的方法组件的 methods
Plugin扩展功能全局混入
Store上述内容的容器一个模块

12.2 什么时候用 Pinia?

  • ✅ 多个组件共享同一份数据
  • ✅ 数据需要跨路由持久化
  • ✅ 有复杂的业务逻辑需要复用
  • ✅ 需要 DevTools 调试状态变化
  • ❌ 简单的父子组件通信(用 props/emit 就够了)

12.3 下一步学习方向

  1. Pinia + Vue Query:服务端状态管理
  2. Pinia + WebSocket:实时数据同步
  3. Pinia 源码阅读:理解响应式原理
  4. 自定义插件开发:根据项目需求定制

12.4 写在最后

从 Vuex 到 Pinia,不仅仅是 API 的简化,更是对「状态管理应该简单」这一理念的回归。就像 Evan You 说的:

"Pinia 成功地在保持清晰的设计分离的同时,提供了简单、小巧且易于上手的 API。"

掌握 Pinia,不是为了炫技,而是为了让代码更清晰、维护更简单。现在,去重构你项目里的状态管理吧!🚀