Vue3 Pinia 狀態管理:非同步 Action 完全攻略
簡介
在 Vue3 生態系中,Pinia 已經取代 Vuex 成為官方推薦的狀態管理方案。大多數時候,我們只需要在 Store 中寫同步的 getter、state 或 mutation(在 Pinia 叫 action),就能完成 UI 與資料的雙向綁定。然而,非同步操作(如 API 請求、延遲計時、文件讀寫等)在任何前端應用裡都是不可或缺的。若把這類需求硬塞進組件中,會導致程式碼難以維護、測試困難,甚至出現 race condition。
Pinia 讓非同步流程的撰寫變得相當直觀:只要把非同步程式寫在 action 裡,Store 仍然負責管理狀態、錯誤處理與 loading 標示。這篇文章將從概念、實作到最佳實踐,帶你一步步掌握 Pinia 中的非同步 action,讓你的 Vue3 專案更具可讀性與可測試性。
核心概念
1. 為什麼要把非同步邏輯放在 Action?
單一來源的真相(Single Source of Truth)
Store 集中管理所有共享狀態,讓組件只負責呈現與使用資料。把 API 請求搬到 Store,能確保所有使用該資料的組件都看到同一個最新狀態。可預測的狀態變化
Action 是唯一可以改變state的地方。即使是非同步的改變,也必須透過 Action 完成,這樣才能在開發者工具(如 Vue Devtools)中追蹤每一次 mutation。易於測試
把副作用封裝在 Action 中,單元測試只要 mock API 回傳即可,組件測試則不必關心資料取得的細節。
2. Action 的基本寫法
import { defineStore } from 'pinia'
import axios from 'axios'
export const useUserStore = defineStore('user', {
// state 必須是函式,返回初始值
state: () => ({
profile: null,
loading: false,
error: null,
}),
// 同步或非同步的邏輯都寫在這裡
actions: {
// 非同步 Action 範例
async fetchProfile(userId) {
this.loading = true
this.error = null
try {
const { data } = await axios.get(`/api/users/${userId}`)
this.profile = data
} catch (e) {
this.error = e.message || '取得使用者資料失敗'
} finally {
this.loading = false
}
},
},
})
重點:在 Pinia 中,
this直接指向 Store 本身,寫法與 Vue 組件的methods十分相似。
3. 多個非同步 Action 的串接
有時候一個流程需要多個 API 呼叫,且必須按順序執行。可以在 Action 中呼叫其他 Action,或使用 Promise.all 併發。
export const useDashboardStore = defineStore('dashboard', {
state: () => ({
stats: null,
recentOrders: [],
loading: false,
}),
actions: {
async loadDashboard() {
this.loading = true
try {
// 並行請求兩個 API
const [statsRes, ordersRes] = await Promise.all([
this.fetchStats(),
this.fetchRecentOrders(),
])
this.stats = statsRes
this.recentOrders = ordersRes
} catch (e) {
console.error('Dashboard 載入失敗', e)
} finally {
this.loading = false
}
},
// 這兩個是被 loadDashboard 呼叫的子 Action
async fetchStats() {
const { data } = await axios.get('/api/dashboard/stats')
return data
},
async fetchRecentOrders() {
const { data } = await axios.get('/api/dashboard/orders?limit=5')
return data
},
},
})
4. 使用 await vs. 回傳 Promise
await:讓程式碼看起來更像同步流程,易於閱讀與錯誤捕捉。- 回傳 Promise:如果呼叫端需要自行決定何時等待(例如在組件的
onMounted中同時觸發多個 action),可以直接回傳 Promise。
// 回傳 Promise 的寫法
actions: {
fetchProducts() {
// 不使用 async/await,直接回傳 axios 的 Promise
return axios.get('/api/products')
.then(res => {
this.products = res.data
})
.catch(err => {
this.error = err.message
})
},
}
5. 搭配 pinia-plugin-persistedstate 實作「持久化」的非同步 Action
有時候 API 回傳的設定需要在頁面刷新後仍能保留,我們可以把 state 持久化,同時在 hydrate 時自動發起一次非同步同步。
import { defineStore } from 'pinia'
import { usePersistedState } from 'pinia-plugin-persistedstate'
export const useSettingStore = defineStore('setting', {
state: () => ({
theme: 'light',
locale: 'zh-TW',
loading: false,
}),
actions: {
// 初始化時呼叫,會在持久化資料載入後自動觸發
async init() {
this.loading = true
try {
const { data } = await axios.get('/api/user/setting')
this.theme = data.theme
this.locale = data.locale
} finally {
this.loading = false
}
},
},
// 設定持久化,只保留 theme 與 locale
persist: {
key: 'user-setting',
paths: ['theme', 'locale'],
},
})
在主程式 main.js 中:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { useSettingStore } from '@/stores/setting'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
// 先載入持久化的設定,再呼叫 init 同步遠端資料
const settingStore = useSettingStore()
await settingStore.init()
app.mount('#app')
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
直接在 Component 中呼叫 axios |
會導致同一筆資料在多個組件裡各自請求,浪費流量且難以同步 UI。 | 把所有 API 請求搬到 Store 的 Action,並在需要時共享同一個 state。 |
| 忘記處理錯誤 | 非同步失敗時 UI 仍顯示 loading,使用者無法得知問題。 | 在 Action 中使用 try / catch,並在 state 中保留 error 或 status。 |
| 在 Action 內直接改變外部變數 | 破壞了 Pinia 的單向資料流,導致 Devtools 無法追蹤。 | 僅允許改變 this.state,其他副作用應該寫成純函式或放在 service 層。 |
過度依賴 await 造成 UI 卡住 |
單一 Action 內部如果有多個串接的 await,會讓 loading 時間過長。 |
使用 Promise.all 併發不相關的請求,或把長時間的任務拆成多個 Action。 |
| 沒有取消請求 | 當組件卸載或切換路由時,仍在等待舊請求回傳,可能導致狀態更新錯誤。 | 使用 AbortController 或第三方庫(如 axios.CancelToken)在 onUnmounted 時取消。 |
最佳實踐清單
- 統一錯誤格式:在 Store 中建立
error物件(包含code、message),讓 UI 能根據code顯示不同提示。 - Loading 狀態分層:若同時有多個 API,建議使用
loadingMap: { fetchUser: false, fetchPosts: false },避免互相覆蓋。 - 分離 API Service:將
axios的呼叫封裝在services/*.js,Action 只負責呼叫 service 並更新 state,提升可測試性。 - 使用 TypeScript(若專案支援):為 Store、state、action 加上型別,能在開發階段即捕捉錯誤。
- 在測試環境 mock API:利用
jest.mock('axios')或msw(Mock Service Worker)模擬非同步回傳,確保 action 行為正確。
實際應用場景
1. 使用者登入與取得個人資料
export const useAuthStore = defineStore('auth', {
state: () => ({
token: null,
user: null,
loading: false,
error: null,
}),
actions: {
async login(credentials) {
this.loading = true
this.error = null
try {
const { data } = await axios.post('/api/login', credentials)
this.token = data.accessToken
// 把 token 放到 axios 預設 header
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`
// 立即取得使用者資料
await this.fetchUser()
} catch (e) {
this.error = e.response?.data?.msg || '登入失敗'
} finally {
this.loading = false
}
},
async fetchUser() {
try {
const { data } = await axios.get('/api/me')
this.user = data
} catch (e) {
this.error = '取得使用者資訊失敗'
}
},
logout() {
this.token = null
this.user = null
delete axios.defaults.headers.common['Authorization']
},
},
})
應用說明:登入成功後立即呼叫
fetchUser,確保所有組件在取得user前都已經有資料。若 token 失效,fetchUser失敗時可在全局攔截器中自動導向登入頁。
2. 無限滾動列表(Infinite Scroll)
export const usePostStore = defineStore('post', {
state: () => ({
posts: [],
page: 1,
pageSize: 20,
hasMore: true,
loading: false,
error: null,
}),
actions: {
async loadMore() {
if (!this.hasMore || this.loading) return
this.loading = true
try {
const { data } = await axios.get('/api/posts', {
params: { page: this.page, limit: this.pageSize },
})
this.posts.push(...data.items)
this.hasMore = data.items.length === this.pageSize
this.page += 1
} catch (e) {
this.error = '載入文章失敗'
} finally {
this.loading = false
}
},
// 重設列表(例如切換分類時使用)
reset() {
this.posts = []
this.page = 1
this.hasMore = true
},
},
})
應用說明:
loadMore內部自行檢查hasMore與loading,避免重複請求。組件只需要在捲動到底部時呼叫store.loadMore()。
3. 表單提交與即時驗證
export const useContactStore = defineStore('contact', {
state: () => ({
form: {
name: '',
email: '',
message: '',
},
submitting: false,
success: false,
error: null,
}),
actions: {
async submit() {
this.submitting = true
this.error = null
this.success = false
try {
await axios.post('/api/contact', this.form)
this.success = true
// 清空表單
this.form = { name: '', email: '', message: '' }
} catch (e) {
this.error = e.response?.data?.msg || '送出失敗,請稍後再試'
} finally {
this.submitting = false
}
},
// 即時驗證(簡易示範)
validate() {
const errors = {}
if (!this.form.name) errors.name = '姓名不可空白'
if (!/.+@.+\..+/.test(this.form.email)) errors.email = '請輸入正確的 Email'
if (this.form.message.length < 10) errors.message = '訊息至少 10 個字元'
return errors
},
},
})
應用說明:
submit完成後自動重置表單,讓 UI 能立即回到初始狀態。驗證邏輯放在 Store 中,讓多個表單元件共用同一套規則。
總結
- 非同步 Action 是 Pinia 中處理 API、計時、檔案等副作用的核心入口,保持了 單向資料流 與 可追蹤的狀態變化。
- 使用
async/await、try/catch、finally能讓程式碼更易讀,同時提供 loading、error 的完整管理。 - 透過 子 Action、
Promise.all或 回傳 Promise 的彈性寫法,我們可以輕鬆組合複雜的非同步流程。 - 常見陷阱包括 重複請求、錯誤未捕捉、未取消請求 等,遵循 最佳實踐(錯誤統一、Loading 分層、服務層分離)即可有效避免。
- 在實務上,從 登入驗證、無限滾動、表單提交 到 持久化設定,非同步 Action 都能提供乾淨、可測試且易於維護的解決方案。
掌握了這些概念與技巧,你就可以在 Vue3 + Pinia 的專案中,以最少的程式碼寫出最穩定、最可維護的非同步流程。快把今天學到的範例套用到自己的專案吧,讓狀態管理真正發揮威力!