本文 AI 產出,尚未審核

Vue3 课程 – Pinia 状态管理

主題:限制與缺點(例如非跨頁持久化)


簡介

在 Vue3 生態系中,Pinia 已成為官方推薦的狀態管理解決方案,取代了過去的 Vuex。它以「輕量、直覺」為設計理念,讓開發者能以最少的樣板程式碼就能完成全域狀態的讀寫。
然而,任何工具都有其適用範圍與局限。對於 跨頁持久化、伺服器端渲染(SSR)以及大型應用的維護,Pinia 仍有一些需要留意的限制。了解這些缺點不僅能避免在開發過程中踩坑,也能幫助你選擇合適的補強方案(例如插件或自訂儲存機制),讓應用在實務上更加穩定、可擴充。


核心概念

1. Pinia 的預設行為:記憶體內的單例 Store

Pinia 會在瀏覽器的 記憶體 中建立 Store 實例,所有組件透過 useStore() 取得同一個引用。這意味著:

  • 同一頁面內,資料會即時同步。
  • 重新整理(F5)或關閉分頁 時,所有狀態會被重置,除非自行實作持久化。

⚠️ 重點:Pinia 本身不提供跨頁(reload)或跨瀏覽器分頁的持久化功能。


2. 為什麼需要跨頁持久化?

場景 為何需要持久化
使用者登入後的 token、使用者設定 重新整理後仍需保持登入狀態
購物車商品、表單草稿 防止意外關閉或刷新導致資料遺失
多頁面 SPA(例如分頁式的後台系統) 各頁面之間共享狀態,避免重複請求

如果不處理,使用者體驗會大幅下降,且開發者必須在每個需要的地方自行寫 localStoragesessionStorageCookie 的同步程式碼,容易產生重複與錯誤。


3. 常見的持久化方式與其限制

方法 優點 缺點
localStorage 簡單、同步、跨頁面 容量上限 5~10 MB、只能儲存字串、無法直接儲存 DateMap 等型別
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)以及更大的容量,適合 筆記、離線文件 等需求。
  • 因為操作是非同步的,所有讀寫必須使用 awaitthen,否則 UI 可能會出現「資料未同步」的情況。

常見陷阱與最佳實踐

陷阱 為何會發生 解決方案
忘記在應用啟動時載入持久化資料 手動同步時只寫入 save,卻沒在 main.js 呼叫 load main.js 中統一呼叫所有 Store 的 load 方法,或使用插件的 restoreState
同步過程造成效能瓶頸 每次 mutation 都寫入 localStorage(同步 I/O) 使用 防抖(debounce)批次寫入,或改用 IndexedDB(非同步)
資料格式不相容 JSON.stringify 後失去 DateMapSet 等型別 在儲存前自行轉換(例如 date.toISOString()),讀回後再還原
在 SSR 中直接使用 localStorage 伺服器端沒有 window 物件,會拋錯 if (import.meta.env.SSR) 判斷或使用 useCookieuseState(Nuxt)等 SSR‑friendly 方法
持久化敏感資訊 把 token、密碼明文寫入瀏覽器儲存 加密(例如 crypto.subtle)或只保存 短期 token,其餘資訊透過 HTTP‑only cookie 由伺服器管理

推薦的最佳實踐

  1. 明確分層:將「需要持久化」的 state 與「僅在記憶體」的 state 分開管理,避免不必要的寫入。
  2. 使用插件pinia-plugin-persistedstate 已支援 選擇性持久化(只持久化特定屬性),減少資料量。
  3. 防抖寫入:對頻繁變更的資料(如文字編輯器草稿)使用 lodash.debounce,降低 I/O 次數。
  4. 型別安全:在 TypeScript 專案中,為持久化資料宣告介面,並在 JSON.parse 後手動轉型,避免 any 帶來的錯誤。
  5. 測試恢復流程:在 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 的前提下,安全且有效地保存資料:

  1. 辨識需要持久化的 state(token、購物車、草稿等)。
  2. 選擇合適的儲存介面localStoragesessionStorageIndexedDB插件
  3. 使用插件或封裝好的同步函式,避免手動寫入的遺漏與錯誤。
  4. 加入防抖與批次寫入,降低 I/O 對效能的影響。
  5. 在 SSR 環境下避免直接使用瀏覽器儲存,改以 Cookie 或 Server‑Side Hydration。

掌握了這些限制與對策後,你就能在 Pinia 上構建出 穩定、可持久化 的 Vue3 應用,為使用者提供更佳的體驗,同時保持程式碼的可維護性與擴充性。祝開發順利!