Pinia 狀態管理
2025年5月21日大约 8 分鐘
學習目標
- 了解狀態管理的概念與必要性
- 學會安裝和配置 Pinia
- 掌握 Store 的創建與使用
- 理解 state、getters、actions 的運用
- 學會組件間的狀態共享與持久化
核心概念解釋
什麼是狀態管理?
狀態管理是指在應用程式中統一管理和控制資料狀態的方式。在大型 Vue 應用中,組件間需要共享狀態時,傳統的 props/emit 方式會變得複雜且難以維護。
為什麼選擇 Pinia?
Pinia 是 Vue 的新一代官方狀態管理庫,相比 Vuex 有以下優勢:
- 類型安全:完整的 TypeScript 支援
- 模組化:天然支援多個 store
- 簡潔 API:更直觀的語法
- DevTools 支援:完整的開發工具整合
- Tree-shaking:只打包使用的部分
Pinia 核心概念
- Store:包含狀態、actions 和 getters 的容器
- State:應用的資料狀態
- Getters:從 state 衍生的計算值
- Actions:修改 state 的方法
程式碼範例及詳細註釋
1. 安裝與設定
安裝 Pinia:
npm install pinia
在 Vue 應用中配置 Pinia:
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
// 創建 Pinia 實例
const pinia = createPinia()
const app = createApp(App)
// 使用 Pinia
app.use(pinia)
app.mount('#app')
2. 創建基本 Store
使用組合式 API 風格創建 store:
// src/stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
// State:使用 ref 定義響應式狀態
const count = ref(0)
const history = ref([])
// Getters:使用 computed 定義計算屬性
const doubleCount = computed(() => count.value * 2)
const isEven = computed(() => count.value % 2 === 0)
const historyCount = computed(() => history.value.length)
// Actions:定義修改狀態的方法
function increment() {
count.value++
addToHistory('increment')
}
function decrement() {
count.value--
addToHistory('decrement')
}
function incrementBy(amount) {
count.value += amount
addToHistory(`increment by ${amount}`)
}
function reset() {
count.value = 0
history.value.push({
action: 'reset',
timestamp: new Date().toISOString(),
previousValue: count.value
})
}
// 私有方法
function addToHistory(action) {
history.value.push({
action,
timestamp: new Date().toISOString(),
value: count.value
})
}
// 返回所有要暴露的狀態和方法
return {
// State
count,
history,
// Getters
doubleCount,
isEven,
historyCount,
// Actions
increment,
decrement,
incrementBy,
reset
}
})
3. 在組件中使用 Store
<!-- src/components/Counter.vue -->
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
// 取得 store 實例
const counterStore = useCounterStore()
// 解構響應式狀態和 getters(需要使用 storeToRefs)
const { count, history, doubleCount, isEven, historyCount } = storeToRefs(counterStore)
// 解構 actions(不需要 storeToRefs)
const { increment, decrement, incrementBy, reset } = counterStore
</script>
<template>
<div class="counter">
<h2>計數器</h2>
<!-- 顯示當前狀態 -->
<div class="counter-display">
<p class="count">當前數值: {{ count }}</p>
<p class="double">雙倍數值: {{ doubleCount }}</p>
<p class="even-odd">
數值是 {{ isEven ? '偶數' : '奇數' }}
</p>
</div>
<!-- 控制按鈕 -->
<div class="counter-controls">
<button @click="decrement" class="btn btn-danger">-1</button>
<button @click="increment" class="btn btn-primary">+1</button>
<button @click="incrementBy(5)" class="btn btn-success">+5</button>
<button @click="incrementBy(10)" class="btn btn-info">+10</button>
<button @click="reset" class="btn btn-warning">重置</button>
</div>
<!-- 歷史記錄 -->
<div class="counter-history">
<h3>操作歷史 ({{ historyCount }} 筆)</h3>
<div class="history-list">
<div
v-for="(item, index) in history.slice(-5)"
:key="index"
class="history-item"
>
<span class="action">{{ item.action }}</span>
<span class="value">值: {{ item.value }}</span>
<span class="time">{{ new Date(item.timestamp).toLocaleTimeString() }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.counter {
max-width: 500px;
margin: 0 auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
}
.counter-display {
text-align: center;
margin-bottom: 2rem;
}
.count {
font-size: 2rem;
font-weight: bold;
color: #2c3e50;
}
.counter-controls {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-bottom: 2rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
font-weight: bold;
}
.btn-primary { background: #3498db; }
.btn-danger { background: #e74c3c; }
.btn-success { background: #27ae60; }
.btn-info { background: #17a2b8; }
.btn-warning { background: #f39c12; }
.counter-history {
margin-top: 2rem;
}
.history-list {
max-height: 200px;
overflow-y: auto;
}
.history-item {
display: flex;
justify-content: space-between;
padding: 0.5rem;
border-bottom: 1px solid #eee;
font-size: 0.9rem;
}
.action { font-weight: bold; }
.value { color: #3498db; }
.time { color: #7f8c8d; font-size: 0.8rem; }
</style>
4. 使用者認證 Store
創建一個更複雜的用戶認證 store:
// src/stores/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref(null)
const token = ref(localStorage.getItem('token') || null)
const loading = ref(false)
const error = ref(null)
// Getters
const isAuthenticated = computed(() => !!token.value && !!user.value)
const userRole = computed(() => user.value?.role || 'guest')
const userName = computed(() => user.value?.name || '')
// Actions
async function login(credentials) {
loading.value = true
error.value = null
try {
// 模擬 API 呼叫
const response = await mockLoginAPI(credentials)
if (response.success) {
user.value = response.user
token.value = response.token
// 保存到 localStorage
localStorage.setItem('token', response.token)
localStorage.setItem('user', JSON.stringify(response.user))
return { success: true }
} else {
throw new Error(response.message)
}
} catch (err) {
error.value = err.message
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
async function register(userData) {
loading.value = true
error.value = null
try {
const response = await mockRegisterAPI(userData)
if (response.success) {
// 註冊成功後自動登入
return await login({
email: userData.email,
password: userData.password
})
} else {
throw new Error(response.message)
}
} catch (err) {
error.value = err.message
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
function logout() {
user.value = null
token.value = null
error.value = null
// 清除本地存儲
localStorage.removeItem('token')
localStorage.removeItem('user')
}
async function refreshUserData() {
if (!token.value) return
try {
loading.value = true
const response = await mockGetUserAPI(token.value)
if (response.success) {
user.value = response.user
localStorage.setItem('user', JSON.stringify(response.user))
}
} catch (err) {
console.error('刷新用戶資料失敗:', err)
// 如果 token 無效,執行登出
if (err.status === 401) {
logout()
}
} finally {
loading.value = false
}
}
// 初始化時從 localStorage 恢復用戶資料
function initialize() {
const savedUser = localStorage.getItem('user')
if (savedUser && token.value) {
try {
user.value = JSON.parse(savedUser)
// 可選:驗證 token 是否仍然有效
refreshUserData()
} catch (err) {
console.error('恢復用戶資料失敗:', err)
logout()
}
}
}
return {
// State
user,
token,
loading,
error,
// Getters
isAuthenticated,
userRole,
userName,
// Actions
login,
register,
logout,
refreshUserData,
initialize
}
})
// 模擬 API 函數
async function mockLoginAPI(credentials) {
await new Promise(resolve => setTimeout(resolve, 1000)) // 模擬延遲
if (credentials.email === 'admin@example.com' && credentials.password === 'password') {
return {
success: true,
user: {
id: 1,
name: '管理員',
email: 'admin@example.com',
role: 'admin',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=admin'
},
token: 'mock-jwt-token-' + Date.now()
}
} else {
return {
success: false,
message: '郵箱或密碼錯誤'
}
}
}
async function mockRegisterAPI(userData) {
await new Promise(resolve => setTimeout(resolve, 1000))
return {
success: true,
message: '註冊成功'
}
}
async function mockGetUserAPI(token) {
await new Promise(resolve => setTimeout(resolve, 500))
return {
success: true,
user: {
id: 1,
name: '管理員',
email: 'admin@example.com',
role: 'admin',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=admin'
}
}
}
5. 登入組件實作
<!-- src/components/LoginForm.vue -->
<script setup>
import { ref, reactive } from 'vue'
import { storeToRefs } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const authStore = useAuthStore()
const router = useRouter()
// 解構狀態
const { loading, error, isAuthenticated } = storeToRefs(authStore)
// 表單資料
const form = reactive({
email: 'admin@example.com', // 預設值方便測試
password: 'password'
})
const showPassword = ref(false)
// 登入處理
const handleLogin = async () => {
const result = await authStore.login(form)
if (result.success) {
// 登入成功,導航到首頁或重定向頁面
const redirect = router.currentRoute.value.query.redirect || '/'
router.push(redirect)
}
}
// 如果已經登入,自動重定向
if (isAuthenticated.value) {
router.push('/')
}
</script>
<template>
<div class="login-form">
<div class="form-container">
<h2>用戶登入</h2>
<!-- 錯誤訊息 -->
<div v-if="error" class="error-message">
{{ error }}
</div>
<form @submit.prevent="handleLogin">
<!-- 郵箱輸入 -->
<div class="form-group">
<label for="email">郵箱地址</label>
<input
id="email"
v-model="form.email"
type="email"
required
placeholder="請輸入郵箱地址"
:disabled="loading"
>
</div>
<!-- 密碼輸入 -->
<div class="form-group">
<label for="password">密碼</label>
<div class="password-input">
<input
id="password"
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
required
placeholder="請輸入密碼"
:disabled="loading"
>
<button
type="button"
@click="showPassword = !showPassword"
class="toggle-password"
:disabled="loading"
>
{{ showPassword ? '隱藏' : '顯示' }}
</button>
</div>
</div>
<!-- 登入按鈕 -->
<button
type="submit"
class="submit-btn"
:disabled="loading"
>
{{ loading ? '登入中...' : '登入' }}
</button>
</form>
<!-- 測試提示 -->
<div class="test-hint">
<p>測試帳號:</p>
<p>郵箱:admin@example.com</p>
<p>密碼:password</p>
</div>
</div>
</div>
</template>
<style scoped>
.login-form {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f5f5;
}
.form-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
h2 {
text-align: center;
margin-bottom: 2rem;
color: #2c3e50;
}
.error-message {
background: #ffebee;
color: #c62828;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: #2c3e50;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
input:focus {
outline: none;
border-color: #3498db;
}
input:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
.password-input {
display: flex;
gap: 0.5rem;
}
.password-input input {
flex: 1;
}
.toggle-password {
padding: 0.75rem 1rem;
background: #ecf0f1;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.submit-btn {
width: 100%;
padding: 0.75rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
}
.submit-btn:hover:not(:disabled) {
background: #2980b9;
}
.submit-btn:disabled {
background: #95a5a6;
cursor: not-allowed;
}
.test-hint {
margin-top: 2rem;
padding: 1rem;
background: #e8f5e8;
border-radius: 4px;
font-size: 0.9rem;
color: #2d5a2d;
}
.test-hint p {
margin: 0.25rem 0;
}
</style>
6. 多個 Store 的組合使用
創建購物車 store 並與用戶認證整合:
// src/stores/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAuthStore } from './auth'
export const useCartStore = defineStore('cart', () => {
// State
const items = ref([])
const discountCode = ref('')
const shippingMethod = ref('standard')
// 依賴其他 store
const authStore = useAuthStore()
// Getters
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const subtotal = computed(() =>
items.value.reduce((sum, item) => sum + (item.price * item.quantity), 0)
)
const discount = computed(() => {
if (discountCode.value === 'VIP10' && authStore.userRole === 'vip') {
return subtotal.value * 0.1
}
return 0
})
const shippingCost = computed(() => {
if (subtotal.value > 1000) return 0 // 免運費
return shippingMethod.value === 'express' ? 100 : 50
})
const total = computed(() =>
subtotal.value - discount.value + shippingCost.value
)
const isEmpty = computed(() => items.value.length === 0)
// Actions
function addItem(product) {
const existingItem = items.value.find(item => item.id === product.id)
if (existingItem) {
existingItem.quantity += 1
} else {
items.value.push({
id: product.id,
name: product.name,
price: product.price,
image: product.image,
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) {
if (quantity <= 0) {
removeItem(productId)
} else {
item.quantity = quantity
}
}
}
function clearCart() {
items.value = []
discountCode.value = ''
}
function applyDiscountCode(code) {
discountCode.value = code
}
function setShippingMethod(method) {
shippingMethod.value = method
}
// 結帳
async function checkout() {
if (!authStore.isAuthenticated) {
throw new Error('請先登入')
}
if (isEmpty.value) {
throw new Error('購物車為空')
}
try {
// 模擬結帳 API 呼叫
const orderData = {
items: items.value,
subtotal: subtotal.value,
discount: discount.value,
shippingCost: shippingCost.value,
total: total.value,
user: authStore.user
}
await mockCheckoutAPI(orderData)
// 結帳成功後清空購物車
clearCart()
return { success: true }
} catch (error) {
return { success: false, error: error.message }
}
}
return {
// State
items,
discountCode,
shippingMethod,
// Getters
totalItems,
subtotal,
discount,
shippingCost,
total,
isEmpty,
// Actions
addItem,
removeItem,
updateQuantity,
clearCart,
applyDiscountCode,
setShippingMethod,
checkout
}
})
// 模擬結帳 API
async function mockCheckoutAPI(orderData) {
await new Promise(resolve => setTimeout(resolve, 2000))
// 模擬隨機失敗
if (Math.random() < 0.1) {
throw new Error('支付處理失敗,請稍後再試')
}
console.log('訂單已提交:', orderData)
return { success: true, orderId: 'ORDER-' + Date.now() }
}
7. Pinia 持久化插件
安裝並配置持久化插件:
npm install pinia-plugin-persistedstate
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
const app = createApp(App)
app.use(pinia)
app.mount('#app')
在 store 中啟用持久化:
// src/stores/settings.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSettingsStore = defineStore('settings', () => {
const theme = ref('light')
const language = ref('zh-TW')
const notifications = ref(true)
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
function setLanguage(lang) {
language.value = lang
}
function toggleNotifications() {
notifications.value = !notifications.value
}
return {
theme,
language,
notifications,
toggleTheme,
setLanguage,
toggleNotifications
}
}, {
persist: {
// 自訂持久化選項
key: 'app-settings',
storage: localStorage,
pick: ['theme', 'language', 'notifications'] // 只持久化指定字段
}
})
補充資源
總結
Pinia 為 Vue 應用提供了簡潔而強大的狀態管理解決方案。通過本次學習,你應該能夠:
- 理解狀態管理的重要性和 Pinia 的優勢
- 創建和使用 store 來管理應用狀態
- 使用 state、getters、actions 構建完整的狀態邏輯
- 在多個組件間共享和同步狀態
- 整合多個 store 並實現複雜的業務邏輯
- 使用持久化插件保存重要狀態
掌握 Pinia 將大大提升你開發大型 Vue 應用的能力!