跳轉到內容

Vue 3 狀態管理模式與 Pinia 進階實戰 2026 | 響應式狀態完全指南

Vue 3 狀態管理與 Pinia

狀態管理是 Vue 應用開發的核心課題。從 Vue 2 的 Vuex 到 Vue 3 的 Pinia,再到 Composition API 的響應式 API,Vue 的狀態管理方案不斷演進。本文將深入響應式原理,系統講解 Pinia 進階用法、模塊化設計、持久化存儲及性能優化。


一、Vue 3 響應式原理

1.1 Proxy vs Object.defineProperty

typescript
// Vue 2: Object.defineProperty(只能監聽屬性訪問)
const data = {}
Object.defineProperty(data, 'count', {
  get() { /* 依賴收集 */ },
  set() { /* 觸發更新 */ }
})

// Vue 3: Proxy(可以監聽屬性增刪、數組變化)
const proxy = new Proxy(data, {
  get(target, key) { /* 依賴收集 */ },
  set(target, key, value) { /* 觸發更新 */ },
  deleteProperty(target, key) { /* 觸發更新 */ }
})

1.2 ref 和 reactive 的區別

typescript
import { ref, reactive, watch, watchEffect } from 'vue'

// ref:用於基本類型和對象
const count = ref(0)
count.value++  // 需要 .value

const user = ref({ name: 'Alice' })
user.value.name = 'Bob'  // 對象內部響應式

// reactive:僅用於對象
const state = reactive({ count: 0 })
state.count++  // 不需要 .value

// watch:監聽特定數據源
watch(count, (newVal, oldVal) => {
  console.log(`count 從 ${oldVal} 變為 ${newVal}`)
})

// watchEffect:自動追蹤依賴
watchEffect(() => {
  console.log(`當前 count: ${count.value}`)
})

1.3 shallowRef 和 shallowReactive

typescript
import { shallowRef, shallowReactive } from 'vue'

// shallowRef:只監聽 .value 本身的變化
const shallowUser = shallowRef({ name: 'Alice' })
shallowUser.value.name = 'Bob'  // 不會觸發更新!
shallowUser.value = { name: 'Bob' }  // 會觸發更新

// shallowReactive:只監聽第一層屬性變化
const shallowState = shallowReactive({
  nested: { count: 0 }
})
shallowState.nested.count++  // 不會觸發更新!
shallowState.nested = { count: 1 }  // 會觸發更新

1.4 computed 和 watch 的選擇

typescript
import { ref, computed, watch } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

// computed:用於派生值,有緩存
const fullName = computed(() => {
  console.log('計算 fullName')
  return `${firstName.value} ${lastName.value}`
})

// watch:用於副作用,無緩存
watch([firstName, lastName], ([newFirst, newLast]) => {
  console.log(`名字變為: ${newFirst} ${newLast}`)
  // 發送到服務器、更新 localStorage 等
})

二、Pinia 基礎

2.1 什麼是 Pinia

Pinia 是 Vue 官方推薦的狀態管理庫,具有以下特點:

  • 直觀的 API(類似組件)
  • 完整的 TypeScript 支持
  • 模塊化設計(每個 store 獨立)
  • 無 mutations(直接修改狀態)
  • 支持插件系統

2.2 安裝與配置

bash
npm install pinia
typescript
// main.ts
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.3 創建 Store

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

export const useUserStore = defineStore('user', () => {
  // 狀態(state)
  const name = ref('')
  const email = ref('')
  const isLoggedIn = ref(false)

  // 計算屬性(getters)
  const displayName = computed(() => {
    return name.value || 'Guest'
  })

  // 方法(actions)
  async function login(username: string, password: string) {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ username, password })
    })
    const data = await response.json()
    
    name.value = data.name
    email.value = data.email
    isLoggedIn.value = true
  }

  function logout() {
    name.value = ''
    email.value = ''
    isLoggedIn.value = false
  }

  return { name, email, isLoggedIn, displayName, login, logout }
})

2.4 在組件中使用

vue
<script setup lang="ts">
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

// 直接訪問狀態
console.log(userStore.name)

// 調用方法
await userStore.login('admin', 'password')

// 解構狀態(保持響應式)
import { storeToRefs } from 'pinia'
const { name, isLoggedIn } = storeToRefs(userStore)
</script>

三、Pinia 進階技巧

3.1 模塊化 Store 設計

typescript
// stores/index.ts - 導出所有 store
export { useUserStore } from './user'
export { useCartStore } from './cart'
export { useProductStore } from './product'

// stores/user.ts - 用戶模塊
export const useUserStore = defineStore('user', () => { /* ... */ })

// stores/cart.ts - 購物車模塊
export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  
  const totalPrice = computed(() => 
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )
  
  const totalCount = computed(() => 
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  )
  
  function addItem(product: 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: string) {
    const index = items.value.findIndex(item => item.id === productId)
    if (index !== -1) {
      items.value.splice(index, 1)
    }
  }
  
  function clearCart() {
    items.value = []
  }
  
  return { items, totalPrice, totalCount, addItem, removeItem, clearCart }
})

3.2 Store 間通信

typescript
// stores/order.ts - 訂單模塊依賴購物車模塊
import { defineStore } from 'pinia'
import { useCartStore } from './cart'

export const useOrderStore = defineStore('order', () => {
  const orders = ref<Order[]>([])
  
  async function createOrder() {
    const cartStore = useCartStore()
    
    if (cartStore.totalCount === 0) {
      throw new Error('購物車為空')
    }
    
    const order: Order = {
      id: Date.now().toString(),
      items: [...cartStore.items],
      total: cartStore.totalPrice,
      createdAt: new Date()
    }
    
    await fetch('/api/orders', {
      method: 'POST',
      body: JSON.stringify(order)
    })
    
    orders.value.push(order)
    cartStore.clearCart()  // 清空購物車
  }
  
  return { orders, createOrder }
})

3.3 狀態持久化

typescript
// 使用 pinia-plugin-persistedstate
npm install pinia-plugin-persistedstate
typescript
// main.ts
import { createPinia } from 'pinia'
import persistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(persistedstate)
typescript
// stores/user.ts - 配置持久化
export const useUserStore = defineStore('user', () => {
  // ...
}, {
  persist: {
    key: 'user-store',
    storage: localStorage,  // 默認 localStorage
    paths: ['name', 'email', 'isLoggedIn']  // 只持久化指定屬性
  }
})

自定義持久化邏輯:

typescript
export const useUserStore = defineStore('user', () => {
  const name = ref('')
  
  // 從 localStorage 初始化
  if (localStorage.getItem('user-name')) {
    name.value = localStorage.getItem('user-name')!
  }
  
  // watch 監聽變化自動保存
  watch(name, (newVal) => {
    localStorage.setItem('user-name', newVal)
  })
  
  return { name }
})

3.4 全局插件

typescript
// plugins/logger.ts
export function loggerPlugin() {
  return (context: PiniaPluginContext) => {
    const { store } = context
    
    // 記錄所有 action 調用
    store.$onAction(({ name, args, after, onError }) => {
      console.log(`[Pinia] ${store.$id}.${name} 被調用,參數:`, args)
      
      after((result) => {
        console.log(`[Pinia] ${store.$id}.${name} 成功,返回:`, result)
      })
      
      onError((error) => {
        console.error(`[Pinia] ${store.$id}.${name} 失敗:`, error)
      })
    })
  }
}

// main.ts
pinia.use(loggerPlugin())

3.5 狀態重置

typescript
// stores/user.ts
export const useUserStore = defineStore('user', () => {
  const name = ref('')
  const email = ref('')
  const isLoggedIn = ref(false)
  
  // 定義初始狀態
  const resetState = () => {
    name.value = ''
    email.value = ''
    isLoggedIn.value = false
  }
  
  return { name, email, isLoggedIn, resetState }
})

// 使用
const userStore = useUserStore()
userStore.resetState()

// 或使用內置方法(需要定義 $reset)
userStore.$reset()

四、組合式狀態管理模式

4.1 使用 Composition API 管理局部狀態

typescript
// composables/useCounter.ts
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const doubled = computed(() => count.value * 2)
  
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  function reset() {
    count.value = initialValue
  }
  
  return { count, doubled, increment, decrement, reset }
}

// 在組件中使用
<script setup>
const { count, doubled, increment } = useCounter(10)
</script>

4.2 組合式 Store(無 Pinia)

typescript
// stores/todo.ts - 使用 reactive 實現輕量級狀態管理
import { reactive, computed } from 'vue'

const state = reactive({
  todos: [] as Todo[],
  filter: 'all' as 'all' | 'active' | 'completed'
})

export function useTodoStore() {
  const filteredTodos = computed(() => {
    switch (state.filter) {
      case 'active':
        return state.todos.filter(todo => !todo.completed)
      case 'completed':
        return state.todos.filter(todo => todo.completed)
      default:
        return state.todos
    }
  })
  
  const activeCount = computed(() => 
    state.todos.filter(todo => !todo.completed).length
  )
  
  function addTodo(text: string) {
    state.todos.push({
      id: Date.now(),
      text,
      completed: false
    })
  }
  
  function toggleTodo(id: number) {
    const todo = state.todos.find(t => t.id === id)
    if (todo) {
      todo.completed = !todo.completed
    }
  }
  
  function setFilter(filter: 'all' | 'active' | 'completed') {
    state.filter = filter
  }
  
  return {
    todos: () => state.todos,
    filter: () => state.filter,
    filteredTodos,
    activeCount,
    addTodo,
    toggleTodo,
    setFilter
  }
}

五、性能優化

5.1 避免不必要的計算

typescript
// ❌ 每次渲染都會重新計算
const expensiveResult = computed(() => {
  // 耗時操作
  return heavyComputation(state.data)
})

// ✅ 使用緩存
import { ref, computed } from 'vue'

const cache = ref<Map<string, Result>>(new Map())
const expensiveResult = computed(() => {
  const key = JSON.stringify(state.data)
  if (cache.value.has(key)) {
    return cache.value.get(key)!
  }
  const result = heavyComputation(state.data)
  cache.value.set(key, result)
  return result
})

5.2 使用 watch 替代 computed

typescript
// ❌ computed 用於副作用
const saveToServer = computed(() => {
  fetch('/api/save', {
    method: 'POST',
    body: JSON.stringify(state.data)
  })
})

// ✅ 使用 watch
watch(() => state.data, async (newData) => {
  await fetch('/api/save', {
    method: 'POST',
    body: JSON.stringify(newData)
  })
}, { deep: true })

5.3 優化列表渲染

vue
<script setup>
import { computed } from 'vue'
import { useProductStore } from '@/stores/product'

const productStore = useProductStore()

// 只計算一次
const products = computed(() => [...productStore.products])
</script>

<template>
  <!-- 使用 v-for 時提供唯一 key -->
  <div v-for="product in products" :key="product.id">
    {{ product.name }}
  </div>
</template>

5.4 緩存 Store 實例

typescript
// stores/user.ts
let userStoreInstance: ReturnType<typeof useUserStore> | null = null

export function useUserStore() {
  if (!userStoreInstance) {
    userStoreInstance = defineStore('user', () => { /* ... */ })()
  }
  return userStoreInstance
}

六、Pinia vs Vuex

6.1 核心差異

特性VuexPinia
狀態修改必須通過 mutations直接修改
模塊化modules + namespaced天然模塊化
TypeScript需要手動聲明類型完整推斷
API複雜(state/mutations/actions/getters)簡潔(state/computed/actions)
代碼組織單一 store 大文件每個 store 獨立文件
插件系統有限強大

6.2 遷移指南

Vuex 代碼:

typescript
// store/modules/user.ts
const state = {
  name: '',
  isLoggedIn: false
}

const mutations = {
  SET_NAME(state, name) {
    state.name = name
  },
  LOGIN(state) {
    state.isLoggedIn = true
  },
  LOGOUT(state) {
    state.name = ''
    state.isLoggedIn = false
  }
}

const actions = {
  async login({ commit }, { username, password }) {
    const data = await api.login(username, password)
    commit('SET_NAME', data.name)
    commit('LOGIN')
  }
}

const getters = {
  displayName: state => state.name || 'Guest'
}

export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
}

Pinia 等價代碼:

typescript
// stores/user.ts
export const useUserStore = defineStore('user', () => {
  const name = ref('')
  const isLoggedIn = ref(false)
  
  const displayName = computed(() => name.value || 'Guest')
  
  async function login(username: string, password: string) {
    const data = await api.login(username, password)
    name.value = data.name
    isLoggedIn.value = true
  }
  
  function logout() {
    name.value = ''
    isLoggedIn.value = false
  }
  
  return { name, isLoggedIn, displayName, login, logout }
})

6.3 遷移步驟

  1. 安裝 Pinianpm install pinia
  2. 創建 Pinia 實例:在 main.ts 中配置
  3. 逐個遷移模塊:將 Vuex modules 轉換為 Pinia stores
  4. 更新組件引用this.$store.dispatch('user/login')useUserStore().login()
  5. 移除 Vuex:刪除 Vuex 相關依賴和配置
  6. 測試驗證:確保所有功能正常

七、最佳實踐

7.1 Store 組織原則

  1. 單一職責:每個 store 只管理一個業務領域
  2. 扁平結構:避免嵌套 store
  3. 可複用性:將通用邏輯提取為 composables
  4. 測試友好:確保 store 可以獨立測試

7.2 狀態管理選擇指南

場景推薦方案
全局共享狀態Pinia
組件間通信props + emit 或 Pinia
頁面級狀態Composition API(composables)
表單狀態組件局部狀態
路由狀態Vue Router query/params

7.3 常見錯誤

typescript
// ❌ 錯誤:直接解構會丟失響應式
const { name } = useUserStore()  // name 不是響應式的

// ✅ 正確:使用 storeToRefs
const { name } = storeToRefs(useUserStore())

// ❌ 錯誤:在 action 中使用 await 但沒有 async
function fetchData() {
  await api.getData()  // 報錯!
}

// ✅ 正確:action 必須是 async
async function fetchData() {
  await api.getData()
}

// ❌ 錯誤:在組件外調用 store 方法時沒有初始化
// 必須在組件或 setup 函數中調用 useStore()

八、總結

  • ✅ 深入理解 Vue 3 響應式原理(Proxy、ref、reactive)
  • ✅ 掌握 Pinia 基礎用法(state、computed、actions)
  • ✅ 實戰模塊化 Store 設計與 Store 間通信
  • ✅ 實現狀態持久化與全局插件
  • ✅ 使用 Composition API 管理局部狀態
  • ✅ 性能優化技巧(緩存、watch、列表渲染)
  • ✅ Pinia 與 Vuex 對比及遷移指南

狀態管理的核心原則是:保持簡單,按需選擇。對於大多數應用,Pinia + Composition API 的組合已經足夠強大。


相關閱讀:

最後更新於: