手写 pinia 持久化缓存插件,兼容indexDB

手写 Pinia 持久化插件(基于 localforage)

很久之前就想着写一个三层缓存(内存,本地缓存,服务端缓存)的实践方案。在 Web 端,IndexedDB 的缓存量是最大的(通常可达用户磁盘空间的 50%),而 pinia-plugin-persistedstate 仅支持 localStorage/sessionStorage,无法满足大容量数据持久化需求。于是就有了自己写这个插件的想法,刚好可以练一练。

为什么需要 IndexedDB 持久化?

前端存储方案对比:

存储方式 容量限制 异步操作 支持二进制 适用场景
IndexedDB 50% 磁盘空间 大容量数据持久化
localStorage ~5MB 小型配置数据
sessionStorage ~5MB 会话级临时数据
Web SQL ~50MB 已废弃,不推荐使用

当需要存储用户配置、离线数据、大型状态树时,IndexedDB 是唯一可行的客户端方案。本文实现的插件将解决以下痛点:

  • 突破 localStorage 5MB 容量限制
  • 避免同步操作阻塞主线程
  • 支持复杂对象和二进制数据存储
  • 实现优雅的状态恢复机制

核心技术解析

1. IndexedDB:浏览器的本地数据库

IndexedDB 是浏览器内置的低级 API,本质是一个 NoSQL 数据库,具有以下关键特性:

graph LR A[IndexedDB] --> B[数据库 Database] B --> C[对象仓库 Object Store] C --> D[索引 Index] C --> E[数据记录]
  • 事务性操作 :所有操作必须在事务中执行(readwrite/readonly
  • 键值对存储:数据以键值对形式存储在对象仓库(Object Store)中
  • 异步非阻塞:通过事件回调或 Promise 避免 UI 阻塞
  • 大容量存储:现代浏览器默认提供 50% 磁盘空间配额(Chrome 中可手动提升)

典型使用流程:

javascript 复制代码
// 1. 打开数据库
const request = indexedDB.open('MyDB', 1);

// 2. 创建对象仓库
request.onupgradeneeded = (e) => {
  const db = e.target.result;
  if (!db.objectStoreNames.contains('store')) {
    db.createObjectStore('store');
  }
};

// 3. 异步操作
request.onsuccess = (e) => {
  const db = e.target.result;
  const tx = db.transaction('store', 'readwrite');
  const store = tx.objectStore('store');
  
  // 写入数据
  store.put({ data: 'value' }, 'key');
  
  // 读取数据
  store.get('key').onsuccess = (e) => {
    console.log(e.target.result);
  };
};

2. localforage:IndexedDB 的优雅封装

localforage 是 Mozilla 开发的库,其功能丰富其兼容性强:

  • 自动降级:优先使用 IndexedDB → WebSQL → localStorage
  • 简化 API:提供类似 localStorage 的同步风格 API(实际异步)
  • 序列化支持:自动处理 JSON 对象、Blob、ArrayBuffer
  • Promise 化:原生支持 async/await
javascript 复制代码
// localforage 简化版操作
await localforage.setItem('user', { name: 'Alice', avatar: blob });
const user = await localforage.getItem('user');

插件实现详解

整体架构设计

sequenceDiagram Pinia->>Plugin: 初始化 Plugin->>localforage: 创建实例 Plugin->>IndexedDB: 读取状态 IndexedDB-->>Plugin: 返回状态 Plugin->>Pinia: 恢复状态 Pinia->>Plugin: 状态变更 Plugin->>Debounce: 触发防抖 Debounce->>localforage: 持久化状态

关键实现代码解析

1. 存储模式配置
typescript 复制代码
type piniaStorageConfig = LocalForageOptions & { 
  mode?: "single" | "multiple" 
};

const baseConfig: piniaStorageConfig = {
  driver: localforage.INDEXEDDB,
  name: "DB",
  version: 1.0,
  storeName: "pinia-storage", // 默认仓库名
};
  • single 模式:所有 store 共享同一个 IndexedDB 仓库(节省资源)
  • multiple 模式:每个 store 独立仓库(避免 key 冲突,推荐)
2. 存储实例管理
typescript 复制代码
let singleStore: LocalForage | null = null;
const multipleStores = new Map<string, LocalForage>();

function getStore(mode: string, storeName: string, config: LocalForageOptions) {
  if (mode === "single") {
    if (!singleStore)
      singleStore = localforage.createInstance({ ...baseConfig, ...config });
    return singleStore;
  }
  if (!multipleStores.has(storeName)) {
    multipleStores.set(
      storeName,
      localforage.createInstance({ ...baseConfig, ...config, storeName })
    );
  }
  return multipleStores.get(storeName)!;
}
  • 单实例模式 :全局共享一个 DB 实例(pinia-storage 仓库)
  • 多实例模式 :为每个 store 创建独立仓库配合Map避免重新创建实例(pinia-storage-user
3. 状态恢复机制
typescript 复制代码
lf.getItem(key)
  .then((state) => {
    if (state) context.store.$patch(state); // 恢复状态
  })
  .catch(console.error);
  • 初始化时机:在 Pinia store 创建后立即执行
  • 安全恢复:仅当存在持久化状态时才合并(避免覆盖初始状态)
4. 防抖持久化
typescript 复制代码
let timer: ReturnType<typeof setTimeout> | null = null;
const unsubscribe = context.store.$subscribe((_, state) => {
  if (timer) clearTimeout(timer);
  timer = setTimeout(() => {
    lf.setItem(key, JSON.parse(JSON.stringify(state))).catch(console.error);
  }, 300); // 300ms 防抖
});
  • 防抖设计:避免高频状态变更导致频繁写入
  • 深拷贝序列化JSON.parse(JSON.stringify()) 确保可序列化
5. 资源清理
typescript 复制代码
context.store.$onAction(({ after }) => {
  after(() => {
    if (context.store.$state._disposed && timer) {
      clearTimeout(timer);
      unsubscribe(); // 移除状态订阅
    }
  });
});
  • 优雅销毁:当 store 被销毁时清除定时器和订阅
  • 内存安全:防止已销毁 store 的状态写入

为什么需要深拷贝?

javascript 复制代码
lf.setItem(key, JSON.parse(JSON.stringify(state)))

IndexedDB 要求存储可序列化数据,而 Pinia 包含 响应式代理对象(Proxy)

通过 JSON.parse(JSON.stringify()) 剥离响应式代理

使用示例

完整代码

typescript 复制代码
import type { PiniaPluginContext } from "pinia";
import localforage from "localforage";

type piniaStorageConfig = LocalForageOptions & { mode?: "single" | "multiple" };
const baseConfig: piniaStorageConfig = {
  driver: localforage.INDEXEDDB,
  name: "DB",
  version: 1.0,
  description: "this is a localforage store",
  storeName: "pinia-storage",
};

let singleStore: LocalForage | null = null;
const multipleStores = new Map<string, LocalForage>();

function getStore(mode: string, storeName: string, config: LocalForageOptions) {
  if (mode === "single") {
    if (!singleStore)
      singleStore = localforage.createInstance({ ...baseConfig, ...config });
    return singleStore;
  }
  if (!multipleStores.has(storeName)) {
    multipleStores.set(
      storeName,
      localforage.createInstance({ ...baseConfig, ...config, storeName })
    );
  }
  return multipleStores.get(storeName)!;
}

function getKey(mode: string, storeName: string, ctxId: string) {
  return mode === "single" ? `${storeName}-${ctxId}` : storeName;
}

export default (
  config: piniaStorageConfig
): ((context: PiniaPluginContext) => void) => {
  const { mode = "single", ...forageConfig } = config || {};
  return (context: PiniaPluginContext) => {
    const ctxId = context.store.$id;
    const storeName =
      mode === "multiple"
        ? `${forageConfig.storeName ?? baseConfig.storeName}-${ctxId}`
        : forageConfig.storeName ?? baseConfig.storeName ?? "";
    const lf = getStore(mode, storeName, forageConfig);
    const key = getKey(mode, storeName, ctxId);

    lf.getItem(key)
      .then((state) => {
        if (state) context.store.$patch(state);
      })
      .catch(console.error);

    let timer: ReturnType<typeof setTimeout> | null = null;
    const unsubscribe = context.store.$subscribe((_, state) => {
      if (timer) clearTimeout(timer);
      timer = setTimeout(() => {
        lf.setItem(key, JSON.parse(JSON.stringify(state))).catch(console.error);
      }, 300);
    });

    context.store.$onAction(({ after }) => {
      after(() => {
        if (context.store.$state._disposed && timer) {
          clearTimeout(timer);
          unsubscribe();
        }
      });
    });
  };
};

创建插件实例

typescript 复制代码
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import piniaPlugin from './stores/plugin'
import localforage from 'localforage'
const app = createApp(App)
const pinia = createPinia()
pinia.use(
  piniaPersistedstate({
    driver: localforage.INDEXEDDB,
    storeName: 'pinia',
    mode: 'multiple',
  }),
)
app.use(pinia)
app.use(router)
app.mount('#app')

持久化特定 Store

typescript 复制代码
// stores/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    name: '',
  }),
})

总结

这个简单的 pinia 持久化缓存插件主要是通过 localforage + IndexedDB 来实现了,之前看过别人封装localstorage的视频,感觉还是很好写的,但是写的过程还是出现了小问题,不写不知道,哎切图仔这辈子就这样了

相关推荐
前端工作日常1 小时前
H5 实时摄像头 + 麦克风:完整可运行 Demo 与深度拆解
前端·javascript
韩沛晓1 小时前
uniapp跨域怎么解决
前端·javascript·uni-app
前端工作日常1 小时前
以 Vue 项目为例串联eslint整个流程
前端·eslint
程序员鱼皮1 小时前
太香了!我连夜给项目加上了这套 Java 监控系统
java·前端·程序员
该用户已不存在2 小时前
这几款Rust工具,开发体验直线上升
前端·后端·rust
前端雾辰2 小时前
Uniapp APP 端实现 TCP Socket 通信(ZPL 打印实战)
前端
无羡仙2 小时前
虚拟列表:怎么显示大量数据不卡
前端·react.js
云水边2 小时前
前端网络性能优化
前端
用户51681661458412 小时前
[微前端 qiankun] 加载报错:Target container with #child-container not existed while devi
前端
武昌库里写JAVA2 小时前
使用 Java 开发 Android 应用:Kotlin 与 Java 的混合编程
java·vue.js·spring boot·sql·学习