🍍 Pinia 狀態管理:跨元件資料共享
📌 為什麼需要狀態管理?
當你的應用越來越大,元件之間需要共享資料時:
問題場景:
Header 需要知道「使用者是否登入」
Sidebar 需要知道「購物車有幾件商品」
ProductPage 需要修改「購物車內容」
CartPage 需要顯示「購物車所有商品」
如果只靠 props / emit,傳遞鏈會非常複雜:
App → Header (props)
App → Sidebar (props)
App → ProductPage → AddButton (emit 好幾層)
狀態管理 = 把共用資料放到一個「全域倉庫」(store),任何元件都能直接存取。
Pinia 底層也是 JavaScript!它用的是 Vue 的響應式系統。
📌 Pinia vs Vuex
| 比較 | Pinia (推薦) | Vuex 4 |
|---|---|---|
| API 風格 | Composition API 風格 | mutations 繁瑣 |
| TypeScript | 完美支援 | 支援但麻煩 |
| DevTools | 支援 | 支援 |
| 模組化 | 天生模組化 | 需要 modules |
| 學習曲線 | 簡單 | 較複雜 |
| 檔案大小 | ~1KB | ~10KB |
Pinia 是 Vue 官方推薦的狀態管理工具,已取代 Vuex。
📌 安裝與設定
npm install pinia
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia()) // 安裝 Pinia
app.mount('#app')
📌 定義 Store
基本結構
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// defineStore 的第一個參數是 store 的唯一 ID
export const useCounterStore = defineStore('counter', () => {
// state — 用 ref() 定義狀態
const count = ref(0)
const name = ref('計數器')
// getters — 用 computed() 定義衍生狀態
const doubleCount = computed(() => count.value * 2)
const isPositive = computed(() => count.value > 0)
// actions — 用普通函式定義操作
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = 0
}
// 非同步 action
async function fetchCount() {
const response = await fetch('/api/count')
const data = await response.json()
count.value = data.count
}
// 必須 return 所有要公開的狀態和方法
return {
count, name,
doubleCount, isPositive,
increment, decrement, reset, fetchCount
}
})
📌 在元件中使用 Store
<template>
<div>
<h2>{{ counterStore.name }}</h2>
<p>數量:{{ counterStore.count }}</p>
<p>雙倍:{{ counterStore.doubleCount }}</p>
<button @click="counterStore.increment()">+1</button>
<button @click="counterStore.decrement()">-1</button>
<button @click="counterStore.reset()">歸零</button>
</div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
// 直接呼叫就能取得 store 實例
const counterStore = useCounterStore()
// ⚠️ 不能解構!會失去響應性
// ❌ const { count } = counterStore
// ✅ 用 storeToRefs 解構
import { storeToRefs } from 'pinia'
const { count, doubleCount } = storeToRefs(counterStore)
// actions 可以直接解構(函式不需要響應性)
const { increment, decrement } = counterStore
</script>
📌 完整範例:購物車狀態管理
定義購物車 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((sum, item) => sum + item.quantity, 0)
})
// 總金額
const totalPrice = computed(() => {
return items.value.reduce((sum, item) => {
return sum + item.price * item.quantity
}, 0)
})
// 加入購物車
function addItem(product) {
const existing = items.value.find(item => item.id === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
// 移除商品
function removeItem(productId) {
items.value = items.value.filter(item => item.id !== productId)
}
// 更新數量
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
}
})
商品頁面使用
<!-- views/Products.vue -->
<template>
<div>
<h1>商品列表</h1>
<div class="product-grid">
<div v-for="product in products" :key="product.id" class="product-card">
<h3>{{ product.name }}</h3>
<p>${{ product.price }}</p>
<button @click="cartStore.addItem(product)">
加入購物車 🛒
</button>
</div>
</div>
<!-- 購物車小圖示 -->
<div class="cart-badge">
🛒 {{ cartStore.totalItems }} 件
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
const products = ref([
{ id: 1, name: 'Vue 教學書', price: 580 },
{ id: 2, name: 'JavaScript 大全', price: 750 },
{ id: 3, name: 'TypeScript 入門', price: 420 },
{ id: 4, name: 'CSS 設計模式', price: 350 }
])
</script>
購物車頁面
<!-- views/Cart.vue -->
<template>
<div>
<h1>購物車</h1>
<div v-if="cartStore.items.length === 0">
<p>購物車是空的 😢</p>
</div>
<div v-else>
<div v-for="item in cartStore.items" :key="item.id" class="cart-item">
<span>{{ item.name }}</span>
<span>${{ item.price }}</span>
<div>
<button @click="cartStore.updateQuantity(item.id, item.quantity - 1)">-</button>
<span>{{ item.quantity }}</span>
<button @click="cartStore.updateQuantity(item.id, item.quantity + 1)">+</button>
</div>
<span>小計:${{ item.price * item.quantity }}</span>
<button @click="cartStore.removeItem(item.id)">🗑️</button>
</div>
<hr />
<p>總計:${{ cartStore.totalPrice }}</p>
<button @click="cartStore.clearCart()">清空購物車</button>
</div>
</div>
</template>
<script setup>
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
</script>
📌 持久化存儲
購物車資料重新整理後會消失,用 localStorage 持久化:
// stores/cart.js(加入持久化)
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
export const useCartStore = defineStore('cart', () => {
// 從 localStorage 初始化
const saved = localStorage.getItem('cart-items')
const items = ref(saved ? JSON.parse(saved) : [])
// 監聽變化,自動存到 localStorage
// watch 是 Vue 的 API,底層用的是 JS 的 Proxy
watch(items, (newItems) => {
localStorage.setItem('cart-items', JSON.stringify(newItems))
}, { deep: true })
// ... 其他 getters 和 actions 同上
return { items /* ... */ }
})
注意:
localStorage是瀏覽器原生 API,不是 Vue 的東西。watch則是 Vue 的響應式 API。分清楚哪些是框架、哪些是原生,很重要!
💡 小提醒
- 不是所有狀態都需要放 Store——只有「跨元件共享」的資料才需要
- 局部狀態(只有一個元件用)就用
ref()/reactive() - Store 是單例的——整個 App 只有一個 counter store 實例
- Pinia 支援 Vue DevTools,可以在瀏覽器中查看/修改 Store 狀態