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