手写 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的视频,感觉还是很好写的,但是写的过程还是出现了小问题,不写不知道,哎切图仔这辈子就这样了