本文 AI 產出,尚未審核

Vue3 課程 – Pinia 狀態管理

主題:Pinia 與 TypeScript 支援


簡介

在 Vue 3 生態系統中,Pinia 已經取代 Vuex 成為官方推薦的狀態管理解決方案。它不僅 API 更直覺、體積更小,還天生支援 TypeScript,讓開發者可以在編譯階段即捕捉型別錯誤、提升 IDE 補全與重構的安全性。

對於 從 Vue 2 轉換至 Vue 3、或是 新建大型專案 的團隊而言,善用 Pinia + TS 的結合,能讓狀態管理更具可維護性與可測試性,降低因型別不一致而產生的 bug。本文將從核心概念出發,搭配實用範例,說明在 Vue3 + Pinia 中如何正確、有效地使用 TypeScript。


核心概念

1️⃣ Pinia 的基本型別結構

Pinia 使用 defineStore 來建立 store。配合 TypeScript,我們可以先定義 state、getter、action 的型別介面,然後在 defineStore 中套用,確保整個 store 的型別資訊完整。

// src/stores/counter.ts
import { defineStore } from 'pinia'

/** State 的介面 */
interface CounterState {
  count: number
}

/** Getter 的回傳型別(可省略,TS 會自動推斷) */
type CounterGetters = {
  doubleCount: (state: CounterState) => number
}

/** Action 的參數與回傳型別 */
type CounterActions = {
  increment: (amount?: number) => void
  reset: () => void
}

/** 使用 defineStore 建立 store */
export const useCounterStore = defineStore<'counter', CounterState, CounterGetters, CounterActions>({
  // store 的唯一 ID
  id: 'counter',

  // 初始 state
  state: (): CounterState => ({
    count: 0,
  }),

  // 只讀的 getter
  getters: {
    doubleCount: (state) => state.count * 2,
  },

  // 可變更 state 的 action
  actions: {
    increment(amount = 1) {
      this.count += amount
    },
    reset() {
      this.count = 0
    },
  },
})

重點defineStore 的第一個泛型參數是 store ID 的字串型別,接下來依序是 StateGettersActions,這樣在使用 store 時,IDE 能完整提示每個屬性與方法。


2️⃣ 在 Vue 元件中使用型別安全的 Store

<!-- src/components/CounterDisplay.vue -->
<template>
  <div>
    <p>目前計數:{{ counter.count }}</p>
    <p>雙倍計數:{{ counter.doubleCount }}</p>
    <button @click="counter.increment()">+1</button>
    <button @click="counter.increment(5)">+5</button>
    <button @click="counter.reset()">重置</button>
  </div>
</template>

<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'

// 直接取得 store,型別已自動推斷
const counter = useCounterStore()
</script>

<script setup lang="ts"> 中,useCounterStore() 會回傳已經帶有完整型別資訊的 store 實例,不需要再自行宣告介面,大幅減少重複代碼。


3️⃣ 使用 Pinia Plugin(如持久化)時的型別擴充

Pinia 允許掛載插件(plugin)來擴充 store,例如 pinia-plugin-persistedstate 用於把 state 儲存至 localStorage。若要在 TypeScript 中正確取得插件注入的屬性,需要透過 模組擴充(module augmentation)。

// src/plugins/persistedstate.ts
import { PiniaPluginContext } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persistedstate'

export const persistedState = createPersistedState({
  storage: window.localStorage,
})

// 在 Pinia 初始化時掛載插件
import { createPinia } from 'pinia'
export const pinia = createPinia()
pinia.use(persistedState)

// ---------- 型別擴充 ----------
declare module 'pinia' {
  export interface DefineStoreOptionsBase<S, Store> {
    // 讓所有 store 都擁有 $persistedState 方法(示範用)
    $persistedState?: () => void
  }
}

在任何 store 中,即可直接呼叫 $persistedState?.(),而 TypeScript 不會再報錯。


4️⃣ 使用 storeToRefs 取得響應式引用,保持型別安全

setup 中若要把 store 的屬性解構為單獨的變數,千萬不要直接解構,否則會失去響應式。正確做法是使用 Pinia 提供的 storeToRefs,它會保留原始型別。

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
const { count, doubleCount } = storeToRefs(counter) // 仍為 Ref<number>

// 直接在模板中使用
</script>

此寫法同時保留 型別推斷Ref<number>),避免在後續運算時出現 any


5️⃣ 使用 Pinia 的 defineStore 生成 Typed Store Factories

如果專案中有多個相似的 store(如 CRUD 操作),可以抽象出一個 泛型工廠函式,讓每個 store 都自帶型別。

// src/stores/createCrudStore.ts
import { defineStore } from 'pinia'

export interface CrudState<T> {
  items: T[]
  loading: boolean
  error: string | null
}

/**
 * 建立一個通用的 CRUD Store
 * @param id store ID
 * @param initial 初始資料
 */
export function createCrudStore<T>(id: string, initial: T[] = []) {
  return defineStore<'crud', CrudState<T>>({
    id,
    state: (): CrudState<T> => ({
      items: initial,
      loading: false,
      error: null,
    }),
    actions: {
      async fetch(fetcher: () => Promise<T[]>) {
        this.loading = true
        try {
          this.items = await fetcher()
        } catch (e: any) {
          this.error = e.message
        } finally {
          this.loading = false
        }
      },
    },
  })
}

使用方式:

// src/stores/userStore.ts
import { createCrudStore } from './createCrudStore'

export const useUserStore = createCrudStore<User>('user')

此模式讓 每個 store 的 items 型別 都正確對應到 UserProduct 等介面,提升可讀性與維護性。


常見陷阱與最佳實踐

陷阱 說明 解決方案
直接解構 store const { count } = counter 會失去響應式,且型別變成普通值。 使用 storeToRefs(counter),或保留原始 counter 直接存取。
未使用泛型定義 Store 只寫 defineStore({}),TS 只能推斷 any,失去型別保護。 stategettersactions 明確寫出介面或型別別名。
插件注入的屬性未聲明 使用 pinia-plugin-persistedstate 後,store.$persist 會被 TS 標記為不存在。 透過 module augmentationPinia 擴充介面。
跨 Store 呼叫未加 this 在 actions 中直接使用 otherStore.someState,但未透過 this 取得正確的 this 绑定。 在 actions 內使用 const other = useOtherStore(),或在同一 store 中使用 this.otherStore(需要 pinia-plugin-persistedstate 提供的 store.$pinia)。
忘記在 main.ts 設定 Pinia 未將 Pinia 注入 Vue 應用,所有 useStore 會回傳 undefined。 main.tsapp.use(pinia),且在 src/stores 內統一匯出 pinia 實例。

最佳實踐

  1. 始終使用 TypeScript 泛型:從 defineStore 到自訂插件,都應寫出完整型別。
  2. 使用 storeToRefs 取代手動解構,確保響應式與型別正確。
  3. 將 Store ID 與檔案名稱保持一致,方便自動匯入與代碼閱讀。
  4. 把複雜的業務邏輯放在 Action 中,保持 state 純粹、getter 只作計算。
  5. 利用 Pinia Plugin(如持久化、devtools)提升開發體驗,但務必做好型別擴充。

實際應用場景

📦 電商平台的購物車

  • 需求:多頁面共享購物車資料,且在刷新後仍保持。
  • 解法:建立 useCartStore,使用 pinia-plugin-persistedstateitems 儲存至 localStorage,並透過 TypeScript 定義 CartItem 介面,保證每個商品的 pricequantity 型別正確。
// src/stores/cart.ts
import { defineStore } from 'pinia'
import { persistedState } from '@/plugins/persistedstate'

interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}

export const useCartStore = defineStore<'cart', { items: CartItem[] }>({
  id: 'cart',
  state: () => ({
    items: [],
  }),
  getters: {
    totalAmount: (state) => state.items.reduce((sum, i) => sum + i.price * i.quantity, 0),
  },
  actions: {
    add(item: CartItem) {
      const exist = this.items.find((i) => i.id === item.id)
      if (exist) exist.quantity += item.quantity
      else this.items.push(item)
    },
    clear() {
      this.items = []
    },
  },
  // 持久化設定
  persist: true,
})

📊 儀表板的即時資料流

  • 需求:多個圖表共用同一筆即時統計資料,且需要在組件間共享。
  • 解法:使用 useRealtimeStore,在 actions 中透過 WebSocket 更新 state,所有使用 storeToRefs 的圖表元件會自動重新渲染。 TypeScript 幫助我們限制 WebSocket 訊息的結構。
interface Stats {
  onlineUsers: number
  cpuLoad: number
  memoryUsage: number
}

export const useRealtimeStore = defineStore<'realtime', { stats: Stats | null }>({
  id: 'realtime',
  state: () => ({
    stats: null,
  }),
  actions: {
    connect(wsUrl: string) {
      const ws = new WebSocket(wsUrl)
      ws.onmessage = (e) => {
        const data: Stats = JSON.parse(e.data)
        this.stats = data
      }
    },
  },
})

🛠 管理員後台的 CRUD 模組

  • 需求:多個資源(使用者、商品、訂單)都有相同的 CRUD 流程。
  • 解法:利用前面提到的 泛型 CRUD Store Factory,為每個資源生成獨立且型別安全的 Store,減少重複程式碼。
// 使用範例
export const useProductStore = createCrudStore<Product>('product')
export const useOrderStore   = createCrudStore<Order>('order')

總結

Pinia 與 TypeScript 的結合,是 Vue 3 生態系 中最具生產力的組合之一。透過明確的介面與泛型,我們能在編譯期捕捉錯誤、提升 IDE 提示,並在大型專案中保持狀態管理的可讀性與可測試性。

本文重點回顧:

  • 使用 defineStore 時,完整寫出 StateGettersActions 的型別
  • 在元件中透過 storeToRefs 取得響應式且型別安全的引用
  • 插件(如持久化)需要透過 module augmentation 進行型別擴充
  • 善用泛型工廠建立可重用的 CRUD Store,減少重複程式碼;
  • 避免直接解構 Store、忘記注入 Pinia、或忽略型別聲明 等常見陷阱。

掌握以上概念與實務技巧後,無論是 小型個人專案,或是 企業級大型系統,都能以簡潔、可維護的方式管理全域狀態,並在開發過程中享受到 TypeScript 帶來的安全感與開發效率提升。祝你在 Vue3 + Pinia 的旅程中寫出更乾淨、更可靠的程式碼! 🎉