如何在 Vuex 或 Pinia 中实现一个通用的数据持久化方案,支持不同的存储介质(如 `localStorage`, `IndexedDB`)?

数据持久化方案:Vuex/Pinia 的灵活后盾

各位观众老爷们,大家好!我是你们的老朋友,代码界的段子手,今天咱们来聊聊 Vuex 和 Pinia 的数据持久化,让你的数据不再“刷新就没了”。

为什么需要持久化?

想象一下,用户辛辛苦苦登录了你的网站,填写了一堆信息,结果刷新一下页面,全没了!这体验简直糟糕透顶。数据持久化就是为了解决这个问题,它能把 Vuex 或 Pinia 里的数据保存到本地,下次打开页面的时候,直接恢复,给用户一个流畅丝滑的体验。

目标:一个通用的持久化方案

咱们的目标是做一个通用的数据持久化方案,它需要满足以下几个条件:

  • 支持多种存储介质: localStorageIndexedDB 等等,让你可以根据实际情况选择最合适的存储方式。
  • 易于配置: 简单几行代码就能搞定,不需要写大量的 boilerplate 代码。
  • 灵活控制: 可以选择需要持久化的 state,而不是一股脑全部保存。
  • 兼容性好: 最好能兼容 Vuex 和 Pinia,让你可以平滑切换状态管理方案。

设计思路:插件模式

咱们采用插件模式来实现这个通用的持久化方案。插件可以拦截 Vuex/Pinia 的状态更新,并在状态改变时,将数据保存到指定的存储介质中。同时,插件也能在应用初始化时,从存储介质中读取数据,并恢复到 Vuex/Pinia 的 state 中。

存储介质抽象:策略模式

为了支持多种存储介质,咱们采用策略模式。定义一个 StorageAdapter 接口,不同的存储介质实现这个接口,提供统一的 API。

interface StorageAdapter {
  getItem<T>(key: string): T | null;
  setItem<T>(key: string, value: T): void;
  removeItem(key: string): void;
}

接下来,咱们来实现 localStorageIndexedDBStorageAdapter

localStorage 适配器

class LocalStorageAdapter implements StorageAdapter {
  getItem<T>(key: string): T | null {
    try {
      const value = localStorage.getItem(key);
      return value ? JSON.parse(value) : null;
    } catch (error) {
      console.error(`Failed to get item from localStorage: ${error}`);
      return null;
    }
  }

  setItem<T>(key: string, value: T): void {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error(`Failed to set item to localStorage: ${error}`);
    }
  }

  removeItem(key: string): void {
    try {
      localStorage.removeItem(key);
    } catch (error) {
      console.error(`Failed to remove item from localStorage: ${error}`);
    }
  }
}

IndexedDB 适配器

IndexedDB 的实现稍微复杂一些,需要处理数据库连接和对象存储。

class IndexedDBAdapter implements StorageAdapter {
  private dbName: string;
  private storeName: string;
  private dbPromise: Promise<IDBDatabase>;

  constructor(dbName: string, storeName: string) {
    this.dbName = dbName;
    this.storeName = storeName;
    this.dbPromise = this.openDatabase();
  }

  private openDatabase(): Promise<IDBDatabase> {
    return new Promise((resolve, reject) => {
      const request = window.indexedDB.open(this.dbName, 1);

      request.onerror = () => {
        reject(new Error('Failed to open IndexedDB'));
      };

      request.onsuccess = () => {
        resolve(request.result);
      };

      request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
        const db = (event.target as IDBRequest).result as IDBDatabase;
        if (!db.objectStoreNames.contains(this.storeName)) {
          db.createObjectStore(this.storeName);
        }
      };
    });
  }

  async getItem<T>(key: string): Promise<T | null> {
    const db = await this.dbPromise;
    return new Promise((resolve, reject) => {
      const transaction = db.transaction([this.storeName], 'readonly');
      const objectStore = transaction.objectStore(this.storeName);
      const request = objectStore.get(key);

      request.onerror = () => {
        reject(new Error('Failed to get item from IndexedDB'));
      };

      request.onsuccess = () => {
        resolve(request.result || null);
      };
    });
  }

  async setItem<T>(key: string, value: T): Promise<void> {
    const db = await this.dbPromise;
    return new Promise((resolve, reject) => {
      const transaction = db.transaction([this.storeName], 'readwrite');
      const objectStore = transaction.objectStore(this.storeName);
      const request = objectStore.put(value, key);

      request.onerror = () => {
        reject(new Error('Failed to set item to IndexedDB'));
      };

      request.onsuccess = () => {
        resolve();
      };
    });
  }

  async removeItem(key: string): Promise<void> {
    const db = await this.dbPromise;
    return new Promise((resolve, reject) => {
      const transaction = db.transaction([this.storeName], 'readwrite');
      const objectStore = transaction.objectStore(this.storeName);
      const request = objectStore.delete(key);

      request.onerror = () => {
        reject(new Error('Failed to remove item from IndexedDB'));
      };

      request.onsuccess = () => {
        resolve();
      };
    });
  }
}

Vuex 插件实现

import { Store } from 'vuex';

interface VuexPersistenceOptions {
  key?: string; // 存储的 key,默认为 'vuex'
  storage?: StorageAdapter; // 存储适配器,默认为 localStorage
  reducer?: (state: any) => any; // 选择需要持久化的 state
}

function createVuexPersistence(options: VuexPersistenceOptions = {}) {
  const {
    key = 'vuex',
    storage = new LocalStorageAdapter(),
    reducer = (state: any) => state,
  } = options;

  return (store: Store<any>) => {
    // 1. 初始化时,从存储中读取数据
    const savedState = storage.getItem(key);
    if (savedState) {
      store.replaceState(Object.assign({}, store.state, savedState)); // 合并已有的 state
    }

    // 2. 订阅 mutations,在每次 mutation 发生后,保存数据
    store.subscribe((mutation, state) => {
      const reducedState = reducer(state);
      storage.setItem(key, reducedState);
    });
  };
}

export default createVuexPersistence;

Pinia 插件实现

Pinia 的插件实现方式与 Vuex 类似,但 API 略有不同。

import { PiniaPluginContext } from 'pinia';

interface PiniaPersistenceOptions {
  key?: string; // 存储的 key,默认为 'pinia'
  storage?: StorageAdapter; // 存储适配器,默认为 localStorage
  reducer?: (state: any) => any; // 选择需要持久化的 state
  stores?: string[]; // 指定要持久化的 store 的 id 数组
}

function createPiniaPersistence(options: PiniaPersistenceOptions = {}) {
  const {
    key = 'pinia',
    storage = new LocalStorageAdapter(),
    reducer = (state: any) => state,
    stores,
  } = options;

  return (context: PiniaPluginContext) => {
    const { store } = context;

    // 检查当前 store 是否在 stores 数组中
    if (stores && !stores.includes(store.$id)) {
      return;
    }

    // 1. 初始化时,从存储中读取数据
    const savedState = storage.getItem(`${key}-${store.$id}`);
    if (savedState) {
      store.$patch(savedState);
    }

    // 2. 订阅状态变化,在状态改变后,保存数据
    store.$subscribe((mutation, state) => {
      const reducedState = reducer(state);
      storage.setItem(`${key}-${store.$id}`, reducedState);
    }, { detached: true });
  };
}

export default createPiniaPersistence;

使用方法

Vuex

import Vue from 'vue';
import Vuex from 'vuex';
import createVuexPersistence from './vuex-persist';

Vue.use(Vuex);

const store = new Vuex.Store({
  state: {
    user: {
      id: null,
      name: '',
    },
    settings: {
      theme: 'light',
      language: 'en',
    },
  },
  mutations: {
    setUser(state, user) {
      state.user = user;
    },
    setTheme(state, theme) {
      state.settings.theme = theme;
    },
  },
  plugins: [
    createVuexPersistence({
      key: 'my-app',
      storage: new LocalStorageAdapter(),
      reducer: (state) => ({
        user: state.user, // 只持久化 user 状态
        settings: {
          theme: state.settings.theme,
        }, // 只持久化 theme
      }),
    }),
  ],
});

export default store;

Pinia

import { createPinia } from 'pinia';
import createPiniaPersistence from './pinia-persist';

const pinia = createPinia();

pinia.use(
  createPiniaPersistence({
    key: 'my-app',
    storage: new LocalStorageAdapter(),
    reducer: (state) => ({
      // 只持久化 user 的 name 和 id
      name: state.name,
      id: state.id,
    }),
    stores: ['user', 'settings'], // 只持久化 user 和 settings store
  })
);

export default pinia;

关键代码解释

  • StorageAdapter 接口: 定义了存储介质的 API,方便切换不同的存储方式。
  • LocalStorageAdapterIndexedDBAdapter StorageAdapter 的具体实现,分别使用 localStorageIndexedDB 来存储数据。
  • createVuexPersistencecreatePiniaPersistence Vuex 和 Pinia 的插件,负责拦截状态更新,并将数据保存到存储介质中。
  • reducer 函数: 允许你选择需要持久化的 state,避免保存不必要的数据。
  • stores 数组 (Pinia): 指定要持久化的 store 的 id 数组,更细粒度地控制持久化范围。

优点

  • 通用性: 支持多种存储介质,可以根据实际情况选择。
  • 灵活性: 可以选择需要持久化的 state,避免保存不必要的数据。
  • 易用性: 简单几行代码就能搞定,不需要写大量的 boilerplate 代码。
  • 可维护性: 代码结构清晰,易于理解和维护。

缺点

  • IndexedDB 实现较为复杂: 需要处理数据库连接和对象存储,代码量稍多。
  • 需要手动处理序列化和反序列化: 对于复杂的数据结构,可能需要自定义序列化和反序列化逻辑。

扩展

  • 加密: 可以对存储的数据进行加密,提高安全性。
  • 过期时间: 可以设置数据的过期时间,避免长期占用存储空间。
  • 版本控制: 可以对数据进行版本控制,方便数据迁移和回滚。
  • 集成 Redux DevTools (Vuex): 可以与 Redux DevTools 集成,方便调试和查看持久化数据。

总结

今天咱们一起实现了一个通用的 Vuex/Pinia 数据持久化方案,它支持多种存储介质,易于配置,灵活控制。希望这个方案能帮助你解决数据持久化的问题,提升用户体验。

记住,代码不是死的,要根据实际情况灵活运用。希望大家多多实践,不断学习,成为代码界的翘楚!下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注