Vue Pinia 狀態管理

Pinia 是 Vue 官方推薦的狀態管理函式庫,取代了 Vuex 成為 Vue 3 的首選。Pinia 更輕量、更簡單,同時提供完整的 TypeScript 支援。

為什麼需要狀態管理?

當應用程式變大時,多個元件可能需要共用相同的狀態。雖然可以用 props 和 events 或 provide/inject 來傳遞,但對於複雜的應用程式,集中管理狀態會更有效率。

安裝 Pinia

npm install pinia

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')

定義 Store

Store 是用來存放共用狀態的地方。有兩種定義方式:

Option Store(類似 Options API)

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

export const useCounterStore = defineStore('counter', {
  // state - 狀態
  state: () => ({
    count: 0,
    name: 'Counter'
  }),

  // getters - 計算屬性
  getters: {
    doubleCount: (state) => state.count * 2,
    // 使用其他 getter
    doubleCountPlusOne() {
      return this.doubleCount + 1
    }
  },

  // actions - 方法(可以是同步或非同步)
  actions: {
    increment() {
      this.count++
    },
    async fetchData() {
      const response = await fetch('/api/data')
      this.data = await response.json()
    }
  }
})

Setup Store(類似 Composition API,推薦)

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

export const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0)
  const name = ref('Counter')

  // getters
  const doubleCount = computed(() => count.value * 2)

  // actions
  function increment() {
    count.value++
  }

  async function fetchData() {
    const response = await fetch('/api/data')
    // ...
  }

  return {
    count,
    name,
    doubleCount,
    increment,
    fetchData
  }
})

使用 Store

在元件中使用

<script setup>
import { useCounterStore } from '@/stores/counter'

// 取得 store 實例
const counter = useCounterStore()
</script>

<template>
  <!-- 直接存取 state -->
  <p>計數:{{ counter.count }}</p>
  <p>名稱:{{ counter.name }}</p>

  <!-- 使用 getter -->
  <p>雙倍:{{ counter.doubleCount }}</p>

  <!-- 呼叫 action -->
  <button @click="counter.increment()">+1</button>
</template>

解構 Store(注意響應式)

直接解構會失去響應式:

// ❌ 失去響應式
const { count, doubleCount } = useCounterStore()

// ✅ 使用 storeToRefs 保持響應式
import { storeToRefs } from 'pinia'

const counter = useCounterStore()
const { count, doubleCount } = storeToRefs(counter)

// actions 可以直接解構
const { increment } = counter

修改 State

直接修改

const counter = useCounterStore()
counter.count++
counter.name = 'New Name'

使用 $patch(批量修改)

// 物件語法
counter.$patch({
  count: counter.count + 1,
  name: 'New Name'
})

// 函式語法(適合陣列操作)
counter.$patch((state) => {
  state.items.push({ name: 'new item' })
  state.count++
})

替換整個 state

counter.$state = { count: 0, name: 'Reset' }

重置 state

counter.$reset()  // 重置為初始值
$reset() 只在 Option Store 中可用。Setup Store 需要自己實作重置邏輯。

訂閱 State 變化

const counter = useCounterStore()

// 訂閱變化
const unsubscribe = counter.$subscribe((mutation, state) => {
  console.log('mutation type:', mutation.type)
  console.log('mutation payload:', mutation.payload)
  console.log('new state:', state)

  // 例如:同步到 localStorage
  localStorage.setItem('counter', JSON.stringify(state))
})

// 取消訂閱
unsubscribe()

訂閱 Actions

const counter = useCounterStore()

counter.$onAction(({
  name,       // action 名稱
  store,      // store 實例
  args,       // 傳遞給 action 的參數
  after,      // action 成功後的 hook
  onError     // action 發生錯誤的 hook
}) => {
  console.log(`Action ${name} 被呼叫,參數:`, args)

  after((result) => {
    console.log(`Action ${name} 完成,結果:`, result)
  })

  onError((error) => {
    console.error(`Action ${name} 發生錯誤:`, error)
  })
})

實際範例

使用者 Store

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

export const useUserStore = defineStore('user', () => {
  const user = ref(null)
  const token = ref(localStorage.getItem('token'))

  const isLoggedIn = computed(() => !!token.value)
  const fullName = computed(() => {
    if (!user.value) return ''
    return `${user.value.firstName} ${user.value.lastName}`
  })

  async function login(credentials) {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    })

    const data = await response.json()
    token.value = data.token
    user.value = data.user

    localStorage.setItem('token', data.token)
  }

  async function logout() {
    token.value = null
    user.value = null
    localStorage.removeItem('token')
  }

  async function fetchUser() {
    if (!token.value) return

    const response = await fetch('/api/user', {
      headers: { Authorization: `Bearer ${token.value}` }
    })

    user.value = await response.json()
  }

  return {
    user,
    token,
    isLoggedIn,
    fullName,
    login,
    logout,
    fetchUser
  }
})

使用:

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

const userStore = useUserStore()
const { isLoggedIn, fullName } = storeToRefs(userStore)
const { login, logout } = userStore
</script>

<template>
  <div v-if="isLoggedIn">
    <p>歡迎,{{ fullName }}</p>
    <button @click="logout">登出</button>
  </div>
  <div v-else>
    <button @click="login({ email, password })">登入</button>
  </div>
</template>

購物車 Store

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

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

  const totalItems = computed(() => {
    return items.value.reduce((total, item) => total + item.quantity, 0)
  })

  const totalPrice = computed(() => {
    return items.value.reduce((total, item) => {
      return total + item.price * item.quantity
    }, 0)
  })

  function addItem(product) {
    const existingItem = items.value.find(item => item.id === product.id)

    if (existingItem) {
      existingItem.quantity++
    } else {
      items.value.push({
        id: product.id,
        name: product.name,
        price: product.price,
        quantity: 1
      })
    }
  }

  function removeItem(productId) {
    const index = items.value.findIndex(item => item.id === productId)
    if (index > -1) {
      items.value.splice(index, 1)
    }
  }

  function updateQuantity(productId, quantity) {
    const item = items.value.find(item => item.id === productId)
    if (item) {
      item.quantity = Math.max(0, quantity)
      if (item.quantity === 0) {
        removeItem(productId)
      }
    }
  }

  function clearCart() {
    items.value = []
  }

  return {
    items,
    totalItems,
    totalPrice,
    addItem,
    removeItem,
    updateQuantity,
    clearCart
  }
})

待辦事項 Store

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

export const useTodoStore = defineStore('todos', () => {
  const todos = ref([])
  const filter = ref('all') // all, active, completed

  const filteredTodos = computed(() => {
    switch (filter.value) {
      case 'active':
        return todos.value.filter(t => !t.completed)
      case 'completed':
        return todos.value.filter(t => t.completed)
      default:
        return todos.value
    }
  })

  const remaining = computed(() => {
    return todos.value.filter(t => !t.completed).length
  })

  function addTodo(text) {
    todos.value.push({
      id: Date.now(),
      text,
      completed: false
    })
  }

  function removeTodo(id) {
    todos.value = todos.value.filter(t => t.id !== id)
  }

  function toggleTodo(id) {
    const todo = todos.value.find(t => t.id === id)
    if (todo) {
      todo.completed = !todo.completed
    }
  }

  function clearCompleted() {
    todos.value = todos.value.filter(t => !t.completed)
  }

  return {
    todos,
    filter,
    filteredTodos,
    remaining,
    addTodo,
    removeTodo,
    toggleTodo,
    clearCompleted
  }
})

Store 之間互相使用

// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'

export const useCartStore = defineStore('cart', () => {
  const userStore = useUserStore()

  async function checkout() {
    if (!userStore.isLoggedIn) {
      throw new Error('請先登入')
    }
    // ...
  }

  return { checkout }
})

持久化 Plugin

可以使用 plugin 來自動同步到 localStorage:

// 簡單的持久化 plugin
export function persistPlugin({ store }) {
  const key = `pinia-${store.$id}`

  // 初始化時從 localStorage 讀取
  const saved = localStorage.getItem(key)
  if (saved) {
    store.$patch(JSON.parse(saved))
  }

  // 訂閱變化並儲存
  store.$subscribe((_, state) => {
    localStorage.setItem(key, JSON.stringify(state))
  })
}

// 在 main.js 中使用
const pinia = createPinia()
pinia.use(persistPlugin)

或使用現成的套件:pinia-plugin-persistedstate

TypeScript 支援

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

interface User {
  id: number
  name: string
  email: string
}

export const useUserStore = defineStore('user', () => {
  const user = ref<User | null>(null)
  const token = ref<string | null>(null)

  const isLoggedIn = computed(() => !!token.value)

  async function login(email: string, password: string): Promise<void> {
    // ...
  }

  return {
    user,
    token,
    isLoggedIn,
    login
  }
})

Pinia vs Vuex

特性PiniaVuex
API 風格Composition API 友善Options API 風格
TypeScript原生支援需要額外設定
Mutations不需要必須透過 mutation
模組化自然的多 store需要 modules
DevTools支援支援
包大小~1.5KB~10KB