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 的字串型別,接下來依序是State、Getters、Actions,這樣在使用 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 型別 都正確對應到 User、Product 等介面,提升可讀性與維護性。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
直接解構 store |
const { count } = counter 會失去響應式,且型別變成普通值。 |
使用 storeToRefs(counter),或保留原始 counter 直接存取。 |
| 未使用泛型定義 Store | 只寫 defineStore({}),TS 只能推斷 any,失去型別保護。 |
為 state、getters、actions 明確寫出介面或型別別名。 |
| 插件注入的屬性未聲明 | 使用 pinia-plugin-persistedstate 後,store.$persist 會被 TS 標記為不存在。 |
透過 module augmentation 為 Pinia 擴充介面。 |
跨 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.ts 中 app.use(pinia),且在 src/stores 內統一匯出 pinia 實例。 |
最佳實踐
- 始終使用 TypeScript 泛型:從
defineStore到自訂插件,都應寫出完整型別。 - 使用
storeToRefs取代手動解構,確保響應式與型別正確。 - 將 Store ID 與檔案名稱保持一致,方便自動匯入與代碼閱讀。
- 把複雜的業務邏輯放在 Action 中,保持
state純粹、getter只作計算。 - 利用 Pinia Plugin(如持久化、devtools)提升開發體驗,但務必做好型別擴充。
實際應用場景
📦 電商平台的購物車
- 需求:多頁面共享購物車資料,且在刷新後仍保持。
- 解法:建立
useCartStore,使用pinia-plugin-persistedstate把items儲存至localStorage,並透過 TypeScript 定義CartItem介面,保證每個商品的price、quantity型別正確。
// 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時,完整寫出State、Getters、Actions的型別; - 在元件中透過
storeToRefs取得響應式且型別安全的引用; - 插件(如持久化)需要透過 module augmentation 進行型別擴充;
- 善用泛型工廠建立可重用的 CRUD Store,減少重複程式碼;
- 避免直接解構 Store、忘記注入 Pinia、或忽略型別聲明 等常見陷阱。
掌握以上概念與實務技巧後,無論是 小型個人專案,或是 企業級大型系統,都能以簡潔、可維護的方式管理全域狀態,並在開發過程中享受到 TypeScript 帶來的安全感與開發效率提升。祝你在 Vue3 + Pinia 的旅程中寫出更乾淨、更可靠的程式碼! 🎉