本文 AI 產出,尚未審核

Vue3 Pinia 狀態管理 – Persist Plugin 實現本地儲存


簡介

在單頁應用 (SPA) 中,使用 Pinia 作為全域狀態管理已成為 Vue3 的主流選擇。雖然 Pinia 本身提供了乾淨、類型安全的 Store 結構,但在實務開發裡,我們常常需要讓使用者的資料在 頁面重新整理、關閉瀏覽器甚至跨分頁 時仍能保持。

這時候 persist plugin 就派上用場:它會自動把 Store 的指定狀態序列化後寫入 localStorage(或 sessionStorage),下次載入時再把資料還原回 Store。透過這個機制,我們可以輕鬆實現「登入後保持登入狀態」或「表單暫存」等需求,而不必自行撰寫繁雜的同步程式碼。

本文將從核心概念說明開始,逐步帶你完成 Pinia persist 的設定與最佳實踐,並提供多個實用範例,幫助你在 Vue3 專案中快速上手本地儲存。


核心概念

1. Pinia Plugin 的運作原理

Pinia 允許開發者以 plugin 的形式掛載額外功能。插件本質上是一個接受 context(包括 storeoptionsapp)的函式,並在 Store 建立時執行。透過 plugin,我們可以:

  • 攔截 Store 的 state 變更
  • 在 Store 初始化時注入自訂屬性或方法
  • 執行副作用(例如寫入 localStorage

2. 為什麼使用 persist 而不是自行實作?

自行在每個 Store 中寫 watch 監聽並同步到 localStorage,會產生以下問題:

問題 直接自行實作 使用 persist plugin
重複程式碼 每個 Store 都要寫相同的 watch 只寫一次 plugin
錯誤容忍度 容易遺漏或寫錯 key 統一管理 key 命名
可維護性 改變儲存策略需遍歷所有 Store 只改 plugin 即可
型別安全 需要自行處理序列化/反序列化 插件內建支援 JSON、custom serializer

3. persist plugin 的基本結構

以下是一個最簡版的 persist plugin:

// src/plugins/piniaPersist.js
export function createPersistPlugin(options = {}) {
  const { storage = localStorage, keyPrefix = 'pinia_' } = options;

  return ({ store }) => {
    const storageKey = `${keyPrefix}${store.$id}`;

    // 1. 初始化時從 storage 還原 state
    const fromStorage = storage.getItem(storageKey);
    if (fromStorage) {
      store.$patch(JSON.parse(fromStorage));
    }

    // 2. 監聽 state 變化,寫回 storage
    store.$subscribe((_mutation, state) => {
      storage.setItem(storageKey, JSON.stringify(state));
    });
  };
}

這段程式碼完成了 「讀 → 寫」 的完整循環,且支援自訂 storage(例如 sessionStorage)與 key 前綴,方便在大型專案中避免衝突。


程式碼範例

下面提供五個常見的實作範例,從最基礎到進階應用,讓你一步步掌握 persist plugin 的使用方式。

範例 1:在 main.ts 中全域註冊 persist plugin

// main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { createPersistPlugin } from '@/plugins/piniaPersist';

const app = createApp(App);
const pinia = createPinia();

// 註冊 plugin,所有 Store 都會自動持久化
pinia.use(createPersistPlugin({
  storage: localStorage,          // 可改成 sessionStorage
  keyPrefix: 'myapp_'             // 自訂前綴,避免與其他專案衝突
}));

app.use(pinia);
app.mount('#app');

重點:只要在 Pinia 實例上呼叫 use,所有 Store 都會套用此 plugin,除非在 Store 中自行關閉(稍後說明)。


範例 2:只持久化特定 Store(使用 persist: true

有時候不希望所有 Store 都寫入本地儲存,這時可以在 Store 定義時加入 persist 設定,plugin 會根據此 flag 判斷是否執行。

// stores/user.js
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    name: '',
    // 不想持久化的資料
    tempData: null,
  }),
  actions: {
    login(payload) {
      this.token = payload.token;
      this.name = payload.name;
    },
    logout() {
      this.$reset(); // 清空 state
    },
  },
  // 只持久化 token 與 name
  persist: {
    paths: ['token', 'name']   // 指定要保存的屬性
  }
});

說明paths 讓你挑選需要持久化的欄位,未列出的 tempData 將不會寫入 localStorage,減少不必要的儲存空間與隱私風險。


範例 3:自訂序列化與反序列化(加密範例)

若要在本地儲存前加密資料,可在 plugin 中注入 serializerdeserializer

// src/plugins/piniaPersistSecure.js
import CryptoJS from 'crypto-js';

export function createSecurePersistPlugin(secretKey, options = {}) {
  const { storage = localStorage, keyPrefix = 'secure_' } = options;

  return ({ store }) => {
    const storageKey = `${keyPrefix}${store.$id}`;

    // 讀取時解密
    const raw = storage.getItem(storageKey);
    if (raw) {
      const bytes = CryptoJS.AES.decrypt(raw, secretKey);
      const decrypted = bytes.toString(CryptoJS.enc.Utf8);
      store.$patch(JSON.parse(decrypted));
    }

    // 變更時加密寫入
    store.$subscribe((_mutation, state) => {
      const json = JSON.stringify(state);
      const encrypted = CryptoJS.AES.encrypt(json, secretKey).toString();
      storage.setItem(storageKey, encrypted);
    });
  };
}

main.ts 中使用:

import { createSecurePersistPlugin } from '@/plugins/piniaPersistSecure';
pinia.use(createSecurePersistPlugin('mySuperSecretKey'));

安全提醒:雖然加密能防止明文資料被直接讀取,但 前端加密仍無法阻止惡意使用者查看程式碼,若涉及高度敏感資訊,仍建議放在後端或使用 HttpOnly cookie。


範例 4:切換 localStoragesessionStorage(根據使用者選擇)

// stores/settings.js
import { defineStore } from 'pinia';

export const useSettingsStore = defineStore('settings', {
  state: () => ({
    storageMode: 'local', // 'local' | 'session'
    theme: 'light',
  }),
  persist: {
    // 動態決定 storage
    storage: () => {
      const mode = useSettingsStore().storageMode;
      return mode === 'session' ? sessionStorage : localStorage;
    },
    // 只持久化 theme
    paths: ['theme']
  }
});

技巧persist.storage 可以是 函式,於每次寫入前重新取得 storage 實例,讓使用者在設定頁面切換儲存方式時即時生效。


範例 5:在 Nuxt 3 中使用 Pinia Persist(SSR 注意事項)

Nuxt 3 預設支援 SSR,直接使用 localStorage 會在伺服器端拋出錯誤。解法是 在客戶端才執行 plugin

// plugins/piniaPersist.client.ts
import { defineNuxtPlugin } from '#app';
import { createPersistPlugin } from '@/plugins/piniaPersist';

export default defineNuxtPlugin((nuxtApp) => {
  const pinia = nuxtApp.vueApp._context.provides.pinia;
  if (pinia) {
    pinia.use(
      createPersistPlugin({
        storage: localStorage,
        keyPrefix: 'nuxt_',
      })
    );
  }
});

要點:檔名加上 .client.ts,Nuxt 只會在瀏覽器端載入,避免 SSR 時的 ReferenceError: localStorage is not defined


常見陷阱與最佳實踐

陷阱 說明 解決方案
存取 localStorage 時機不對 在 SSR 或測試環境中直接呼叫會拋錯 使用 if (typeof window !== 'undefined').client 檔案
序列化循環參考 Store 中若有 MapSet 或自訂 class,JSON.stringify 會失敗 轉換成普通陣列或物件,或自訂 serializer / deserializer
過大資料寫入 localStorage 容量約 5 MB,過多資料會觸發 QuotaExceededError 僅持久化必要欄位,使用 paths 限制範圍
同名 key 衝突 多個 Store 使用相同 $id 或未加前綴,會互相覆蓋 keyPrefix + $id 為 key,或手動設定 persist.key
忘記清除登入資訊 使用者登出時未呼叫 $reset,導致舊 token 留在 storage 在 logout action 中 this.$reset()localStorage.removeItem(key)

最佳實踐

  1. 只持久化必要欄位:使用 persist.paths 明確列出需要保存的屬性,減少儲存量與隱私風險。
  2. 統一管理 key 前綴:在 plugin 中設定 keyPrefix,避免與第三方腳本產生衝突。
  3. 分離敏感資訊:如 JWT、API 金鑰等,建議使用 HttpOnly cookie,或在儲存前先加密。
  4. 版本化存儲結構:若未來 Store 結構變更,可在讀取時檢查 stateVersion,若不匹配則清除舊資料。
  5. 測試覆蓋:在單元測試中 mock localStorage,確保 $subscribe 正確觸發。

實際應用場景

場景 為何需要 persist 實作重點
使用者登入狀態 讓使用者刷新頁面後仍保持已登入 持久化 tokenuserId,登出時 $resetremoveItem
購物車暫存 使用者離開或關閉分頁,商品仍保留 持久化整個 cart 陣列,使用 sessionStorage 可避免長期保存
表單草稿 編輯長表單時,意外關閉頁面不丟失資料 持久化表單欄位,使用 localStorage,可在 mounted 時自動填入
使用者主題與偏好設定 UI 主題、語系、顯示密度等個人化設定 持久化 settings Store,僅保存簡單字串/布林值
即時協作狀態快取 多人編輯時,將本機暫存的變更先保留 持久化 draft 狀態,搭配 WebSocket 同步最終結果

總結

  • Pinia persist plugin 為 Vue3 專案提供了「一行程式碼即完成本地儲存」的便利功能,讓開發者可以把狀態持久化的繁瑣工作交給插件處理。
  • 透過 persist 設定路徑過濾自訂儲存方式(local / session / 加密),我們可以在不同需求間取得彈性與安全的平衡。
  • 實務上,建議僅持久化必要的欄位、使用前綴避免衝突、在登出或資料過期時主動清除,以免產生資安與效能問題。
  • 在 SSR(Nuxt)或測試環境中,務必確保插件只在客戶端執行,避免 localStorage 未定義的錯誤。

掌握了上述概念與範例後,你就能在 Vue3 + Pinia 的應用中,輕鬆實現 資料持久化使用者體驗提升,並保持程式碼的可維護性與安全性。祝開發順利,寫出更貼近使用者需求的互動式 Web 應用!