Vue3 课程 – Pinia 状态管理
主題:限制與缺點(例如非跨頁持久化)
簡介
在 Vue3 生態系中,Pinia 已成為官方推薦的狀態管理解決方案,取代了過去的 Vuex。它以「輕量、直覺」為設計理念,讓開發者能以最少的樣板程式碼就能完成全域狀態的讀寫。
然而,任何工具都有其適用範圍與局限。對於 跨頁持久化、伺服器端渲染(SSR)以及大型應用的維護,Pinia 仍有一些需要留意的限制。了解這些缺點不僅能避免在開發過程中踩坑,也能幫助你選擇合適的補強方案(例如插件或自訂儲存機制),讓應用在實務上更加穩定、可擴充。
核心概念
1. Pinia 的預設行為:記憶體內的單例 Store
Pinia 會在瀏覽器的 記憶體 中建立 Store 實例,所有組件透過 useStore() 取得同一個引用。這意味著:
- 同一頁面內,資料會即時同步。
- 重新整理(F5)或關閉分頁 時,所有狀態會被重置,除非自行實作持久化。
⚠️ 重點:Pinia 本身不提供跨頁(reload)或跨瀏覽器分頁的持久化功能。
2. 為什麼需要跨頁持久化?
| 場景 | 為何需要持久化 |
|---|---|
| 使用者登入後的 token、使用者設定 | 重新整理後仍需保持登入狀態 |
| 購物車商品、表單草稿 | 防止意外關閉或刷新導致資料遺失 |
| 多頁面 SPA(例如分頁式的後台系統) | 各頁面之間共享狀態,避免重複請求 |
如果不處理,使用者體驗會大幅下降,且開發者必須在每個需要的地方自行寫 localStorage、sessionStorage 或 Cookie 的同步程式碼,容易產生重複與錯誤。
3. 常見的持久化方式與其限制
| 方法 | 優點 | 缺點 |
|---|---|---|
localStorage |
簡單、同步、跨頁面 | 容量上限 5~10 MB、只能儲存字串、無法直接儲存 Date、Map 等型別 |
sessionStorage |
僅在同一分頁有效,較安全 | 刷新仍會保留,但關閉分頁即失效 |
| Cookie | 可設定過期時間、可在 Server 端讀取 | 受同源政策、容量更小(≈4KB) |
| IndexedDB | 大容量、支援結構化資料 | API 較繁瑣、需要封裝或使用第三方函式庫 |
插件(如 pinia-plugin-persistedstate) |
自動同步、支援多種儲存方式 | 仍受底層儲存介面的限制、需額外安裝與設定 |
程式碼範例
以下示範 4 種在 Pinia 中實作持久化的常見方式,並說明每段程式碼的意圖與注意點。
1️⃣ 基礎 localStorage 手動同步
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
token: '',
name: '',
}),
actions: {
// 讀取本地端儲存的資料
loadFromLocalStorage() {
const raw = localStorage.getItem('user')
if (raw) {
const data = JSON.parse(raw)
this.token = data.token
this.name = data.name
}
},
// 每次狀態改變時手動寫回
saveToLocalStorage() {
const data = {
token: this.token,
name: this.name,
}
localStorage.setItem('user', JSON.stringify(data))
},
// 範例:登入後呼叫
login(payload) {
this.token = payload.token
this.name = payload.name
this.saveToLocalStorage()
},
logout() {
this.token = ''
this.name = ''
localStorage.removeItem('user')
},
},
})
說明:
loadFromLocalStorage()必須在應用啟動時(例如main.js中)呼叫,確保 Store 取得先前的狀態。- 每次修改狀態後手動呼叫
saveToLocalStorage(),如果忘記呼叫就會失去持久化效果。
2️⃣ 使用 Pinia 官方插件 pinia-plugin-persistedstate
npm install pinia-plugin-persistedstate
// stores/cart.js
import { defineStore } from 'pinia'
import { createPersistedStatePlugin } from 'pinia-plugin-persistedstate'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [], // [{ id, qty }]
}),
getters: {
totalQty: (state) => state.items.reduce((sum, i) => sum + i.qty, 0),
},
actions: {
addItem(product) {
const exist = this.items.find(i => i.id === product.id)
if (exist) exist.qty++
else this.items.push({ id: product.id, qty: 1 })
},
},
// **插件設定**:自動把整個 state 存到 localStorage
persist: {
key: 'cart', // LocalStorage 的 key
storage: localStorage // 預設就是 localStorage
},
})
// 在 main.js 中安裝插件
import { createPinia } from 'pinia'
import { createPersistedStatePlugin } from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(createPersistedStatePlugin())
app.use(pinia)
優點:不需要自行在每個 action 中呼叫
saveToLocalStorage,插件會在 mutation 後自動同步。
缺點:仍受localStorage容量限制,若 Store 很大會造成效能問題。
3️⃣ 使用 sessionStorage 只在同一分頁有效
// stores/temp.js
import { defineStore } from 'pinia'
export const useTempStore = defineStore('temp', {
state: () => ({
draft: '',
}),
actions: {
setDraft(text) {
this.draft = text
sessionStorage.setItem('temp-draft', text)
},
loadDraft() {
const saved = sessionStorage.getItem('temp-draft')
if (saved) this.draft = saved
},
},
})
適用情境:表單暫存、一次性操作的資料。關閉分頁即會清除,較符合「一次性」的安全需求。
4️⃣ 使用 IndexedDB 儲存大量或結構化資料
這裡以
idb-keyval為例,封裝了簡易的 IndexedDB API。
npm install idb-keyval
// stores/notes.js
import { defineStore } from 'pinia'
import { set, get, del } from 'idb-keyval'
export const useNotesStore = defineStore('notes', {
state: () => ({
list: [], // [{ id, title, content }]
}),
actions: {
async loadFromDB() {
const saved = await get('notes')
if (saved) this.list = saved
},
async addNote(note) {
this.list.push(note)
await set('notes', this.list) // 每次變更都寫回 IndexedDB
},
async deleteNote(id) {
this.list = this.list.filter(n => n.id !== id)
await set('notes', this.list)
},
async clearAll() {
this.list = []
await del('notes')
},
},
})
說明:
- IndexedDB 支援 二進位資料(Blob、File)以及更大的容量,適合 筆記、離線文件 等需求。
- 因為操作是非同步的,所有讀寫必須使用
await或then,否則 UI 可能會出現「資料未同步」的情況。
常見陷阱與最佳實踐
| 陷阱 | 為何會發生 | 解決方案 |
|---|---|---|
| 忘記在應用啟動時載入持久化資料 | 手動同步時只寫入 save,卻沒在 main.js 呼叫 load |
在 main.js 中統一呼叫所有 Store 的 load 方法,或使用插件的 restoreState |
| 同步過程造成效能瓶頸 | 每次 mutation 都寫入 localStorage(同步 I/O) |
使用 防抖(debounce) 或 批次寫入,或改用 IndexedDB(非同步) |
| 資料格式不相容 | JSON.stringify 後失去 Date、Map、Set 等型別 |
在儲存前自行轉換(例如 date.toISOString()),讀回後再還原 |
在 SSR 中直接使用 localStorage |
伺服器端沒有 window 物件,會拋錯 |
在 if (import.meta.env.SSR) 判斷或使用 useCookie、useState(Nuxt)等 SSR‑friendly 方法 |
| 持久化敏感資訊 | 把 token、密碼明文寫入瀏覽器儲存 | 加密(例如 crypto.subtle)或只保存 短期 token,其餘資訊透過 HTTP‑only cookie 由伺服器管理 |
推薦的最佳實踐
- 明確分層:將「需要持久化」的 state 與「僅在記憶體」的 state 分開管理,避免不必要的寫入。
- 使用插件:
pinia-plugin-persistedstate已支援 選擇性持久化(只持久化特定屬性),減少資料量。 - 防抖寫入:對頻繁變更的資料(如文字編輯器草稿)使用
lodash.debounce,降低 I/O 次數。 - 型別安全:在 TypeScript 專案中,為持久化資料宣告介面,並在
JSON.parse後手動轉型,避免any帶來的錯誤。 - 測試恢復流程:在 CI 中加入「刷新頁面後 Store 是否正確還原」的測試,確保持久化不會因程式碼變動而斷裂。
實際應用場景
| 場景 | 需要的持久化方式 | 為何選擇這種方式 |
|---|---|---|
| 電商網站的購物車 | localStorage + pinia-plugin-persistedstate |
使用者關閉頁面仍保留購物車,容量需求不大,且同步即時。 |
| SAAS 後台的使用者設定 | sessionStorage + 手動同步 |
設定僅在同一次登入有效,關閉分頁即清除,避免設定殘留。 |
| 離線筆記 App | IndexedDB |
需要儲存大量文字、圖片,且支援離線閱讀。 |
| 需要在 SSR 首頁預先載入使用者資料 | 不直接使用 localStorage,改用 Cookie + Server‑Side State Hydration |
在伺服器渲染階段無法存取 window,必須透過 HTTP‑only cookie 傳遞 token,然後在 setup() 中把資料注入 Pinia。 |
| 即時聊天訊息暫存 | sessionStorage + 防抖寫入 |
訊息僅在本次會話需要,刷新後仍保留;防抖避免每條訊息都寫入磁碟。 |
總結
Pinia 為 Vue3 提供了 輕量且易上手 的全域狀態管理方案,但它的 預設不支援跨頁持久化,這是許多實務專案必須自行解決的痛點。
透過以下步驟,你可以在不犧牲 Pinia 優雅 API 的前提下,安全且有效地保存資料:
- 辨識需要持久化的 state(token、購物車、草稿等)。
- 選擇合適的儲存介面:
localStorage、sessionStorage、IndexedDB或 插件。 - 使用插件或封裝好的同步函式,避免手動寫入的遺漏與錯誤。
- 加入防抖與批次寫入,降低 I/O 對效能的影響。
- 在 SSR 環境下避免直接使用瀏覽器儲存,改以 Cookie 或 Server‑Side Hydration。
掌握了這些限制與對策後,你就能在 Pinia 上構建出 穩定、可持久化 的 Vue3 應用,為使用者提供更佳的體驗,同時保持程式碼的可維護性與擴充性。祝開發順利!