Skip to content

Pinia 状态管理

1. Pinia 简介

1.1 为什么用 Pinia

Pinia vs Vuex:
├── API 更简单 (没有 mutations)
├── TypeScript 支持更好
├── 体积更小 (~1KB)
├── 模块化更灵活 (不需要嵌套 modules)
├── 异步 actions 写法简单
└── 组合式风格原生支持

1.2 安装配置

bash
npm install pinia
javascript
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.mount('#app')

2. Store 定义

2.1 选项式 Store

javascript
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed, actions } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // 状态
  state: () => ({
    count: 0,
    history: []
  }),
  
  // 计算属性
  getters: {
    doubleCount: (state) => state.count * 2,
    // 访问其他 store
    otherStoreCount: (state) => {
      const otherStore = useOtherStore()
      return otherStore.count
    }
  },
  
  // 方法 (同步/异步)
  actions: {
    increment() {
      this.count++
      this.history.push(this.count)
    },
    async fetchData() {
      const res = await fetch('/api/data')
      this.data = await res.json()
    }
  }
})

2.2 组合式 Store (推荐)

javascript
// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed, markRaw } from 'vue'
import { usePermissionStore } from './permission'

export const useUserStore = defineStore('user', () => {
  // 状态
  const profile = ref(null)
  const token = ref(localStorage.getItem('token') || '')
  const isLoading = ref(false)
  
  // 计算属性
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => profile.value?.name || 'Guest')
  const permissions = computed(() => profile.value?.permissions || [])
  
  // 方法
  async function login(credentials) {
    isLoading.value = true
    try {
      const res = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials)
      })
      const data = await res.json()
      
      token.value = data.token
      profile.value = data.user
      
      localStorage.setItem('token', data.token)
      
      return true
    } catch (error) {
      console.error('登录失败:', error)
      return false
    } finally {
      isLoading.value = false
    }
  }
  
  function logout() {
    token.value = ''
    profile.value = null
    localStorage.removeItem('token')
  }
  
  // 检查权限
  function hasPermission(permission) {
    return permissions.value.includes(permission)
  }
  
  // 返回
  return {
    profile,
    token,
    isLoading,
    isLoggedIn,
    userName,
    permissions,
    login,
    logout,
    hasPermission
  }
})

3. Store 使用

3.1 组件中使用

vue
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import { useCounterStore } from '@/stores/counter'

// 获取 store
const userStore = useUserStore()
const counterStore = useCounterStore()

// 解构状态 (需要用 storeToRefs 保持响应式)
const { profile, isLoggedIn } = storeToRefs(userStore)

// 解构 actions (不需要 storeToRefs,直接保持响应式)
const { login, logout } = userStore

// 直接访问
console.log(userStore.profile)
console.log(userStore.token)

// 调用 actions
async function handleLogin() {
  await userStore.login({ username: 'admin', password: '123' })
}
</script>

<template>
  <div>
    <p v-if="isLoggedIn">欢迎, {{ userStore.userName }}</p>
    <button @click="handleLogin">登录</button>
  </div>
</template>

3.2 storeToRefs 使用

javascript
import { storeToRefs } from 'pinia'

const store = useStore()

// 错误: 解构丢失响应式
const { name, age } = store  // name, age 失去响应式

// 正确: storeToRefs
const { name, age } = storeToRefs(store)  // 保持响应式
name.value = '新名字'

// 对于 actions,不需要 storeToRefs
const { updateName } = store  // actions 保持响应式

4. Getters

4.1 基础 getters

javascript
// options style
export const useStore = defineStore('shop', {
  state: () => ({
    products: [
      { id: 1, name: '苹果', price: 5, category: '水果' },
      { id: 2, name: '白菜', price: 3, category: '蔬菜' },
      { id: 3, name: '香蕉', price: 6, category: '水果' }
    ],
    cart: []
  }),
  
  getters: {
    // 基本 getter
    fruitProducts: (state) => {
      return state.products.filter(p => p.category === '水果')
    },
    
    // 依赖其他 getter
    totalPrice: (state) => {
      return state.cart.reduce((sum, item) => {
        const product = state.products.find(p => p.id === item.id)
        return sum + product.price * item.quantity
      }, 0)
    },
    
    // 使用 this
    expensiveItems: (state) => {
      return state.products.filter(p => p.price > 5)
    },
    
    // 传参的 getter
    getProductById: (state) => (id) => {
      return state.products.find(p => p.id === id)
    }
  }
})

4.2 组合式 getters

javascript
export const useStore = defineStore('shop', () => {
  const products = ref([
    { id: 1, name: '苹果', price: 5 },
    { id: 2, name: '白菜', price: 3 }
  ])
  
  // getter
  const expensiveProducts = computed(() => {
    return products.value.filter(p => p.price > 4)
  })
  
  // 带参数的 getter
  const getProductById = computed(() => (id) => {
    return products.value.find(p => p.id === id)
  })
  
  return { products, expensiveProducts, getProductById }
})

5. Actions

5.1 同步/异步 Actions

javascript
export const useUserStore = defineStore('user', () => {
  const users = ref([])
  const isLoading = ref(false)
  const error = ref(null)
  
  // 同步 action
  function setUser(user) {
    this.currentUser = user
  }
  
  // 异步 action
  async function fetchUsers() {
    isLoading.value = true
    error.value = null
    
    try {
      const res = await fetch('/api/users')
      if (!res.ok) throw new Error('获取用户失败')
      users.value = await res.json()
    } catch (e) {
      error.value = e.message
    } finally {
      isLoading.value = false
    }
  }
  
  // 多个异步操作
  async function initialize() {
    isLoading.value = true
    
    await Promise.all([
      fetchUsers(),
      fetchPermissions(),
      fetchSettings()
    ])
    
    isLoading.value = false
  }
  
  // 错误处理
  async function riskyAction() {
    try {
      await doSomethingRisky()
    } catch (error) {
      // 处理错误,可能回滚状态
      console.error(error)
    }
  }
  
  return { users, isLoading, error, fetchUsers, initialize }
})

5.2 访问其他 Store

javascript
// stores/cart.js
export const useCartStore = defineStore('cart', () => {
  const items = ref([])
  
  // 访问其他 store
  const userStore = useUserStore()
  
  const total = computed(() => {
    return items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  })
  
  async function checkout() {
    // 检查用户状态
    if (!userStore.isLoggedIn) {
      throw new Error('请先登录')
    }
    // ...
  }
  
  return { items, total, checkout }
})

5.3 Action 返回 Promise

javascript
export const useApiStore = defineStore('api', () => {
  async function fetchData() {
    const response = await fetch('/api/data')
    if (!response.ok) {
      throw new Error('请求失败')
    }
    return await response.json()
  }
  
  return { fetchData }
})

// 组件中使用
const api = useApiStore()

// 方式1: await
const data = await api.fetchData()

// 方式2: Promise
api.fetchData()
  .then(data => console.log(data))
  .catch(err => console.error(err))

6. 持久化

6.1 手动持久化

javascript
// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    token: localStorage.getItem('token') || '',
    refreshToken: localStorage.getItem('refreshToken') || '',
    profile: null
  }),
  
  actions: {
    setToken(token, refreshToken) {
      this.token = token
      this.refreshToken = refreshToken
      localStorage.setItem('token', token)
      localStorage.setItem('refreshToken', refreshToken)
    },
    
    clearToken() {
      this.token = ''
      this.refreshToken = ''
      localStorage.removeItem('token')
      localStorage.removeItem('refreshToken')
    }
  }
})

6.2 插件方式

javascript
// plugins/pinia-plugin-persist.js
export function piniaPluginPersist({ store }) {
  // 初始化时恢复状态
  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))
  })
}

// main.js
import { createPinia } from 'pinia'
import piniaPluginPersist from '@/plugins/pinia-plugin-persist'

const pinia = createPinia()
pinia.use(piniaPluginPersist)

7. Store 进阶

7.1 订阅 store 变化

javascript
const store = useStore()

// 订阅 mutation
store.$subscribe((mutation, state) => {
  console.log('store 变化:', mutation)
  console.log('新状态:', state)
}, { detached: true })  // 组件卸载后继续监听

// 订阅 action
store.$onAction((action) => {
  console.log('调用 action:', action.name)
  action.after((result) => {
    console.log('action 完成,结果:', result)
  })
  action.onError((error) => {
    console.error('action 失败:', error)
  })
})

7.2 重置 store

javascript
const store = useStore()

// 重置到初始状态
store.$reset()

// 手动重置
function resetStore() {
  store.$state = {
    count: 0,
    name: '',
    items: []
  }
}

7.3 替换 store 状态

javascript
const store = useStore()

// 完全替换
store.$state = {
  count: 100,
  name: '新名字'
}

// 部分替换
store.$patch({
  count: 50,
  name: '部分更新'
})

8. Pinia 实战

8.1 用户认证模块

javascript
// stores/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useAuthStore = defineStore('auth', () => {
  const user = ref(null)
  const token = ref(localStorage.getItem('token'))
  const permissions = ref([])
  
  const isAuthenticated = computed(() => !!token.value)
  const isAdmin = computed(() => permissions.value.includes('admin'))
  
  async function login(credentials) {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(credentials)
    })
    
    if (!res.ok) {
      const error = await res.json()
      throw new Error(error.message || '登录失败')
    }
    
    const data = await res.json()
    token.value = data.token
    user.value = data.user
    permissions.value = data.permissions || []
    
    localStorage.setItem('token', data.token)
    
    return data
  }
  
  async function logout() {
    try {
      await fetch('/api/auth/logout', { method: 'POST' })
    } finally {
      token.value = null
      user.value = null
      permissions.value = []
      localStorage.removeItem('token')
    }
  }
  
  async function refreshToken() {
    const res = await fetch('/api/auth/refresh', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token.value}`
      }
    })
    
    if (!res.ok) {
      logout()
      throw new Error('Token 刷新失败')
    }
    
    const data = await res.json()
    token.value = data.token
    localStorage.setItem('token', data.token)
  }
  
  function hasPermission(permission) {
    return permissions.value.includes(permission)
  }
  
  return {
    user,
    token,
    permissions,
    isAuthenticated,
    isAdmin,
    login,
    logout,
    refreshToken,
    hasPermission
  }
})

8.2 全局请求拦截

javascript
// stores/request.js
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'

export const useRequestStore = defineStore('request', () => {
  const auth = useAuthStore()
  
  async function request(url, options = {}) {
    const headers = {
      'Content-Type': 'application/json',
      ...options.headers
    }
    
    if (auth.token) {
      headers['Authorization'] = `Bearer ${auth.token}`
    }
    
    try {
      const res = await fetch(url, {
        ...options,
        headers
      })
      
      // 处理 401
      if (res.status === 401) {
        // 尝试刷新 token
        try {
          await auth.refreshToken()
          // 重试请求
          headers['Authorization'] = `Bearer ${auth.token}`
          return fetch(url, { ...options, headers })
        } catch {
          await auth.logout()
          window.location.href = '/login'
          throw new Error('登录已过期')
        }
      }
      
      if (!res.ok) {
        const error = await res.json().catch(() => ({}))
        throw new Error(error.message || '请求失败')
      }
      
      return await res.json()
    } catch (error) {
      throw error
    }
  }
  
  function get(url, options = {}) {
    return request(url, { ...options, method: 'GET' })
  }
  
  function post(url, data, options = {}) {
    return request(url, {
      ...options,
      method: 'POST',
      body: JSON.stringify(data)
    })
  }
  
  return { request, get, post }
})

9. 面试要点

9.1 Pinia vs Vuex

Pinia 优势:
- API 更简洁 (去掉了 mutations)
- TypeScript 支持更好
- actions 可以是异步,不用 commit
- 模块化更灵活
- 体积更小
- DevTools 支持好

Vuex 4 特点:
- 兼容 Vue 3
- 保留了 mutations (同步)
- 需要 commit 调用
- 相对更规范但更繁琐

9.2 setup store vs options store

setup store (组合式):
- 更接近普通 composition API 代码
- 灵活性高
- TypeScript 支持好
- 可以直接测试
- 推荐使用

options store (选项式):
- 风格统一 (state/getters/actions)
- 对 Vue 2 用户友好
- 但 getters 访问其他 store 不直观

9.3 storeToRefs 原理

javascript
// 原理
function storeToRefs(store) {
  // 遍历 state 和 getters,转为 ref
  const refs = {}
  for (const key in store.$state) {
    refs[key] = toRef(store.$state, key)
  }
  // actions 不需要,保持原样
  return refs
}

// 注意: actions 解构后仍有响应式,因为 actions 是函数
const { increment } = store  // 没问题
const { name } = storeToRefs(store)  // name 是 ref,需要 .value

9.4 持久化方案

1. 手动持久化
   - 在 action 中手动读/写 localStorage
   - 控制粒度细

2. 插件方式
   - 封装成 pinia 插件
   - 自动持久化
   - 可配置白名单/黑名单

3. 注意
   - 不要持久化敏感信息
   - 考虑加密
   - 大数据量要优化

基于 MIT 许可发布