Vue3 課程 – Pinia 狀態管理
為什麼 Vuex 被 Pinia 取代
簡介
在 Vue 生態系統中,狀態管理一直是大型應用程式不可或缺的基礎建設。Vue 2 時代的官方狀態管理方案是 Vuex,它在 2016 年推出後,為 Vue 開發者提供了集中式、可預測的資料流。隨著 Vue 3 與 Composition API 的到來,Vuex 的設計逐漸顯露出與新特性不兼容、學習曲線陡峭等問題,導致開發者在新專案上尋找更輕量、直觀的替代方案。
Pinia 正是在這樣的背景下誕生的。它不僅保留了 Vuex 的核心概念(store、state、getter、action),同時以 更簡潔的 API、原生支援 TypeScript、與 Composition API 天生相容 為賣點,迅速成為 Vue 3 官方推薦的狀態管理庫。本文將從技術層面說明「為什麼 Vuex 被 Pinia 取代」,並提供實作範例、常見陷阱與最佳實踐,幫助初學者與中階開發者快速上手 Pinia。
核心概念
1. API 設計的差異
| 項目 | Vuex (2.x) | Pinia (3.x) |
|---|---|---|
| Store 建立方式 | new Vuex.Store({ state, getters, mutations, actions }) |
defineStore('id', { state, getters, actions }) |
Mutation 必須透過 commit |
必須寫 mutations,且所有變更都要走 commit |
不再需要 mutations,直接在 actions 或 state 中改寫 |
| 模組化 | 手動註冊子模組 modules |
自動模組化:每個 defineStore 本身就是獨立模組 |
| TypeScript 支援 | 需要大量類型聲明 | 原生支援,IDE 能自動推斷類型 |
重點:Pinia 把 mutation 的概念抽掉,讓「直接改變 state」變得安全且直觀。這讓開發者不必在
commit與dispatch之間切換,減少心智負擔。
2. 與 Composition API 的天生相容
在 Vue 3 中,Composition API 成為主流寫法。Pinia 的 defineStore 本身就是一個 Composable,可以在 setup() 中直接使用:
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
name: '',
token: '',
}),
getters: {
isLoggedIn: (state) => !!state.token,
},
actions: {
setUser(name, token) {
this.name = name
this.token = token
},
},
})
在組件內:
import { useUserStore } from '@/stores/user'
export default {
setup() {
const userStore = useUserStore()
// 直接使用 state、getter、action
const login = async () => {
await userStore.setUser('Alice', 'abc123')
}
return { userStore, login }
},
}
這種寫法與 Vuex 必須透過 mapState、mapGetters、mapActions 的 options API 形成鮮明對比。
3. 更好的模組化與代碼分離
Pinia 鼓勵每個功能領域(如 user、cart、settings)各自建立 獨立的 store 檔案,不再需要在根 store 中匯入大量 modules,減少耦合度。
src/
└─ stores/
├─ user.js
├─ cart.js
└─ settings.js
每個檔案只負責自己的 state、getter、action,開發者可以根據需求 按需載入,提升效能與維護性。
4. 開發者體驗(DX)提升
- 熱重載(HMR):Pinia 完全支援 Vite、Webpack 的熱重載,修改 store 代碼即時反映在應用。
- DevTools 整合:Pinia 官方提供的 Vue DevTools 插件,可視化 state、時間旅行、快照等功能,使用方式與 Vuex 相同,但更直觀。
- 自動持久化:透過
pinia-plugin-persistedstate等插件,可在一行程式碼完成本地存儲,減少自行撰寫的樣板程式。
程式碼範例
以下提供 5 個實用範例,從最基礎的 store 建立到進階的插件使用,幫助你快速掌握 Pinia。
範例 1️⃣:最簡單的 Counter Store
// src/stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
// state 必須是函式,才能保證每個實例都是獨立的
state: () => ({
count: 0,
}),
// getter 可寫成 computed 的形式
getters: {
doubleCount: (state) => state.count * 2,
},
// actions 可以是同步或非同步
actions: {
increment() {
this.count++
},
async incrementAsync(delay = 500) {
await new Promise((r) => setTimeout(r, delay))
this.increment()
},
},
})
說明:
this指向當前 store 實例,可直接修改state。incrementAsync示範 非同步 action,不需要commit。
範例 2️⃣:使用 Pinia 與 Vue Router 的權限守衛
// src/stores/auth.js
import { defineStore } from 'pinia'
import router from '@/router'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: '',
userInfo: null,
}),
getters: {
isAuthenticated: (state) => !!state.token,
},
actions: {
login(payload) {
// 假設呼叫 API 後拿到 token 與 userInfo
this.token = payload.token
this.userInfo = payload.user
router.push({ name: 'Dashboard' })
},
logout() {
this.token = ''
this.userInfo = null
router.replace({ name: 'Login' })
},
},
})
在路由守衛中使用:
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [ /* ... */ ]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: 'Login' })
} else {
next()
}
})
export default router
說明:Pinia 的 store 可以在任何普通的 JavaScript 檔案中直接使用,無需透過
this.$store。
範例 3️⃣:使用 Pinia Plugin 實作持久化(localStorage)
// src/plugins/piniaPersistedstate.js
import { PiniaPluginContext } from 'pinia'
export const persistedState = ({ store }: PiniaPluginContext) => {
const savedState = localStorage.getItem(store.$id)
if (savedState) {
store.$patch(JSON.parse(savedState))
}
store.$subscribe((_mutation, state) => {
localStorage.setItem(store.$id, JSON.stringify(state))
})
}
在主程式中註冊插件:
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { persistedState } from '@/plugins/piniaPersistedstate'
import App from './App.vue'
const pinia = createPinia()
pinia.use(persistedState) // <─ 全局掛載
createApp(App).use(pinia).mount('#app')
說明:只要套用一次插件,所有 store 都會自動持久化,免除重複撰寫
localStorage的程式碼。
範例 4️⃣:型別安全的 Store(TypeScript)
// src/stores/todo.ts
import { defineStore } from 'pinia'
export interface Todo {
id: number
title: string
done: boolean
}
export const useTodoStore = defineStore('todo', {
state: (): { list: Todo[] } => ({
list: [],
}),
getters: {
undone: (state) => state.list.filter((t) => !t.done),
},
actions: {
add(title: string) {
const newTodo: Todo = {
id: Date.now(),
title,
done: false,
}
this.list.push(newTodo)
},
toggle(id: number) {
const todo = this.list.find((t) => t.id === id)
if (todo) todo.done = !todo.done
},
},
})
在組件內使用:
<script setup lang="ts">
import { useTodoStore } from '@/stores/todo'
const todoStore = useTodoStore()
todoStore.add('寫 Pinia 教學')
</script>
說明:
state回傳型別為()=>{ list: Todo[] },IDE 能即時提示list、add、toggle的正確型別,提升開發效率。
範例 5️⃣:多 Store 組合(跨 Store 呼叫)
// src/stores/cart.js
import { defineStore } from 'pinia'
import { useProductStore } from '@/stores/product'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [], // [{ productId, qty }]
}),
getters: {
totalAmount: (state) => {
const productStore = useProductStore()
return state.items.reduce((sum, item) => {
const product = productStore.getById(item.productId)
return sum + product.price * item.qty
}, 0)
},
},
actions: {
add(productId, qty = 1) {
const exist = this.items.find((i) => i.productId === productId)
if (exist) exist.qty += qty
else this.items.push({ productId, qty })
},
remove(productId) {
this.items = this.items.filter((i) => i.productId !== productId)
},
},
})
說明:在 getter 中可直接呼叫其他 Store 的方法,這在 Vuex 中只能透過
rootGetters或dispatch,寫法更直觀。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 / 最佳實踐 |
|---|---|---|
| 1. State 直接修改外部物件 | 若把外部的陣列或物件直接指派給 state,會產生 共享引用,導致跨組件意外變更。 |
使用 深拷貝(JSON.parse(JSON.stringify(obj)))或在 state 初始化時自行建立新物件。 |
2. 在 Store 外部直接使用 this |
Pinia 的 this 只在 actions 內有效,外部使用會得到 undefined。 |
只在 actions、getters 裡使用 this,其他地方透過 storeInstance 取得。 |
| 3. 大型 Store 無法拆分 | 把所有功能塞進同一個 store,會讓檔案變得難以維護。 | 依功能拆分(user、cart、settings),每個 store 只負責自己的 domain。 |
4. 沒有使用 store.$reset |
在登出或重置流程時,需要把 state 復原。 | Pinia 內建 $reset(),可在 actions 內呼叫。 |
| 5. 忽略 TypeScript 型別 | 雖然 Pinia 支援 TS,若不寫型別,IDE 失去提示,會增加錯誤機率。 | 為 state、getters、actions 明確寫型別,或使用 defineStore 的泛型參數。 |
最佳實踐
- 保持 Store 輕量:只放置「共享」且「跨組件」的狀態,局部狀態仍建議放在 component 本地
ref。 - 使用
store.$patch:一次性更新多個屬性,能避免多次觸發 reactivity。 - 搭配 DevTools:開發階段開啟 Pinia DevTools,檢查 mutation、state 變化,確保預期行為。
- 避免循環依賴:Store 之間若相互引用,可能產生循環依賴。可將公共邏輯抽成 utility 或 Composable。
- 持久化只針對必要資料:不要把整個 store 都寫入 localStorage,僅保留 token、theme 等少量資料,減少 IO 與安全風險。
實際應用場景
| 場景 | 為何選擇 Pinia | 範例說明 |
|---|---|---|
| 大型電商平台(商品、購物車、使用者、訂單) | 多個獨立功能需要獨立 store,且 跨 Store 讀寫頻繁。 | productStore、cartStore、orderStore 各自管理,cartStore 中的 getter 直接呼叫 productStore.getById 計算金額。 |
| 即時協作工具(聊天、文件編輯、線上狀態) | 需要 即時更新 與 時間旅行(Debug),Pinia DevTools 完美支援。 | 每個房間的訊息放在 chatStore,使用 store.$subscribe 監聽變更,推送至 WebSocket。 |
| 單頁應用(SPA) 需要持久化登入狀態 | Pinia 的 插件機制 可快速加入持久化,避免自行撰寫繁雜的 localStorage 邏輯。 |
authStore 加上 persistedState 插件,token 自動保留於 localStorage。 |
| 多語系或主題切換 | 只要改變全局設定即可,其他 UI 直接透過 getter 取得最新值。 | settingsStore 保存 locale、theme,組件使用 computed(() => settingsStore.locale) 即可自動更新。 |
| 微前端(Micro‑frontend) | 每個子應用可擁有自己的 Pinia store,根應用透過 provide/inject 或 global pinia 共享少量公共狀態。 | 子應用 A 有 profileStore,子應用 B 有 notificationStore,根層僅共享 authStore。 |
總結
- Pinia 之所以取代 Vuex,核心在於 API 更簡潔、與 Composition API 完全相容、原生支援 TypeScript、以及更友善的開發者體驗。
- 從 建立 Store、跨 Store 呼叫、持久化、型別安全 四個面向,我們看到 Pinia 能以更少的樣板程式碼達成同等甚至更強的功能。
- 在實務開發中,建議把 全局共享的狀態 放在 Pinia,局部狀態 留在 component 本身,並遵循 拆分、持久化、型別化 的最佳實踐,這樣才能在大型專案中保持程式碼可讀、可維護與可擴充。
透過本文的概念說明與實作範例,你應該已經能夠在 Vue 3 專案中 自信地使用 Pinia,並了解為何它已成為官方首推的狀態管理解決方案。祝開發順利,期待你在未來的 Vue 生態系統中創造出更好的使用者體驗!