parent
b5dacd992f
commit
e555ee065e
|
|
@ -0,0 +1,419 @@
|
||||||
|
# Cache 模块
|
||||||
|
|
||||||
|
基于**策略模式**的异步存储管理方案,支持多种存储后端(localStorage、IndexedDB、Memory),提供统一的 API
|
||||||
|
接口。
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ StorageManager │
|
||||||
|
│ ┌───────────────┐ ┌────────────────────────┐ │
|
||||||
|
│ │ Prefix 隔离 │ │ TTL 过期管理 │ │
|
||||||
|
│ └───────────────┘ └────────────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ IStorageDriver │
|
||||||
|
├──────────┬──────────────────┬───────────────────┤
|
||||||
|
│ Local │ IndexedDB │ Memory │
|
||||||
|
│ Storage │ Driver │ Driver │
|
||||||
|
│ Driver │ │ │
|
||||||
|
└──────────┴──────────────────┴───────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**分层职责:**
|
||||||
|
|
||||||
|
| 层级 | 职责 |
|
||||||
|
|------------------|----------------------------|
|
||||||
|
| `StorageManager` | 命名空间前缀隔离、TTL 过期检查、统一对外 API |
|
||||||
|
| `IStorageDriver` | 纯粹的 KV 存取抽象接口 |
|
||||||
|
| 各 Driver 实现 | 对接具体存储引擎,不感知前缀和 TTL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 基本使用(默认 localStorage)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {StorageManager} from '@vben-core/shared/cache';
|
||||||
|
|
||||||
|
const cache = new StorageManager({prefix: 'myapp'});
|
||||||
|
// 使用 IndexedDB
|
||||||
|
//new StorageManager({ driver: new IndexedDBDriver(), prefix: 'app' });
|
||||||
|
|
||||||
|
// 使用 sessionStorage
|
||||||
|
//new StorageManager({ driver: new LocalStorageDriver({ storageType: 'sessionStorage' }), prefix: 'app' });
|
||||||
|
|
||||||
|
// 测试环境
|
||||||
|
//new StorageManager({ driver: new MemoryStorageDriver(), prefix: 'test' });
|
||||||
|
|
||||||
|
// 存储数据
|
||||||
|
await cache.setItem('user', {name: '张三', age: 28});
|
||||||
|
|
||||||
|
// 读取数据
|
||||||
|
const user = await cache.getItem('user');
|
||||||
|
// => { name: '张三', age: 28 }
|
||||||
|
|
||||||
|
// 带默认值读取
|
||||||
|
const settings = await cache.getItem('settings', {theme: 'light'});
|
||||||
|
// 如果不存在,返回 { theme: 'light' }
|
||||||
|
|
||||||
|
// 删除数据
|
||||||
|
await cache.removeItem('user');
|
||||||
|
|
||||||
|
// 清除当前前缀下所有数据
|
||||||
|
await cache.clear();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 带 TTL 过期
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const cache = new StorageManager({prefix: 'session'});
|
||||||
|
|
||||||
|
// 设置 5 分钟后过期(TTL 单位为毫秒)
|
||||||
|
await cache.setItem('token', 'abc123', 5 * 60 * 1000);
|
||||||
|
|
||||||
|
// 5 分钟内可以正常读取
|
||||||
|
const token = await cache.getItem('token');
|
||||||
|
// => 'abc123'
|
||||||
|
|
||||||
|
// 5 分钟后自动返回 null(惰性删除)
|
||||||
|
const expiredToken = await cache.getItem('token');
|
||||||
|
// => null
|
||||||
|
|
||||||
|
// 主动清理所有过期项
|
||||||
|
await cache.clearExpiredItems();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 存储驱动
|
||||||
|
|
||||||
|
### LocalStorageDriver(默认)
|
||||||
|
|
||||||
|
基于浏览器 `localStorage` 或 `sessionStorage`,数据持久化存储。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { LocalStorageDriver, StorageManager } from '@vben-core/shared/cache';
|
||||||
|
|
||||||
|
// 使用 localStorage(默认)
|
||||||
|
const cache = new StorageManager({
|
||||||
|
driver: new LocalStorageDriver(),
|
||||||
|
prefix: 'app',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 sessionStorage
|
||||||
|
const sessionCache = new StorageManager({
|
||||||
|
driver: new LocalStorageDriver({ storageType: 'sessionStorage' }),
|
||||||
|
prefix: 'app',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**特点:**
|
||||||
|
|
||||||
|
- 同步 API 用 async 包装,保持接口统一
|
||||||
|
- 自动处理 JSON 序列化/反序列化
|
||||||
|
- 数据损坏时自动清除并返回 null
|
||||||
|
- 存储上限约 5-10MB(视浏览器而定)
|
||||||
|
|
||||||
|
**适用场景:** 用户偏好设置、小型配置数据、Token 存储
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### IndexedDBDriver
|
||||||
|
|
||||||
|
基于浏览器 IndexedDB,支持大容量结构化数据存储。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {IndexedDBDriver, StorageManager} from '@vben-core/shared/cache';
|
||||||
|
|
||||||
|
const cache = new StorageManager({
|
||||||
|
driver: new IndexedDBDriver({
|
||||||
|
dbName: 'my-app-db', // 数据库名称,默认 'vben-storage'
|
||||||
|
dbVersion: 1, // 数据库版本,默认 1
|
||||||
|
storeName: 'cache-store', // 对象存储名称,默认 'kv-store'
|
||||||
|
}),
|
||||||
|
prefix: 'data',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 存储大量数据
|
||||||
|
await cache.setItem('table-data', largeDataArray);
|
||||||
|
|
||||||
|
// 存储二进制友好的结构(IndexedDB 原生支持)
|
||||||
|
await cache.setItem('config', {
|
||||||
|
columns: [...],
|
||||||
|
filters: [...],
|
||||||
|
pagination: {page: 1, size: 20},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**特点:**
|
||||||
|
|
||||||
|
- 懒初始化:首次操作时自动打开数据库,无需手动调用 `init()`
|
||||||
|
- 存储容量大(通常数百 MB 到 GB 级别)
|
||||||
|
- 支持结构化克隆(可存储 Date、RegExp、Blob 等复杂类型)
|
||||||
|
- 天然异步,不阻塞主线程
|
||||||
|
|
||||||
|
**适用场景:** 离线数据缓存、大型表格数据、文件/图片缓存、复杂业务数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### MemoryStorageDriver
|
||||||
|
|
||||||
|
基于内存 Map,数据不持久化,页面刷新即丢失。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MemoryStorageDriver, StorageManager } from '@vben-core/shared/cache';
|
||||||
|
|
||||||
|
const cache = new StorageManager({
|
||||||
|
driver: new MemoryStorageDriver(),
|
||||||
|
prefix: 'test',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**特点:**
|
||||||
|
|
||||||
|
- 读写速度最快
|
||||||
|
- 无浏览器 API 依赖
|
||||||
|
- 数据随页面生命周期销毁
|
||||||
|
|
||||||
|
**适用场景:** 单元测试、SSR 服务端渲染、临时运行时缓存
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
### StorageManager
|
||||||
|
|
||||||
|
#### 构造函数
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
new StorageManager(options?: StorageManagerOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
|
|----------|------------------|----------------------------|--------------|
|
||||||
|
| `driver` | `IStorageDriver` | `new LocalStorageDriver()` | 存储驱动实例 |
|
||||||
|
| `prefix` | `string` | `''` | 键前缀,用于命名空间隔离 |
|
||||||
|
|
||||||
|
#### 方法
|
||||||
|
|
||||||
|
| 方法 | 签名 | 说明 |
|
||||||
|
|---------------------|-------------------------------------------------------------------------|-------------------|
|
||||||
|
| `getItem` | `getItem<T>(key: string, defaultValue?: T \| null): Promise<T \| null>` | 获取存储项,过期或不存在返回默认值 |
|
||||||
|
| `setItem` | `setItem<T>(key: string, value: T, ttl?: number): Promise<void>` | 设置存储项,可选 TTL(毫秒) |
|
||||||
|
| `removeItem` | `removeItem(key: string): Promise<void>` | 删除指定存储项 |
|
||||||
|
| `clear` | `clear(): Promise<void>` | 清除当前前缀下所有存储项 |
|
||||||
|
| `clearExpiredItems` | `clearExpiredItems(): Promise<void>` | 主动清理所有过期项 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### IStorageDriver 接口
|
||||||
|
|
||||||
|
自定义驱动需要实现此接口:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IStorageDriver {
|
||||||
|
clear(): Promise<void>;
|
||||||
|
|
||||||
|
getItem<T>(key: string): Promise<null | T>;
|
||||||
|
|
||||||
|
keys(): Promise<string[]>;
|
||||||
|
|
||||||
|
removeItem(key: string): Promise<void>;
|
||||||
|
|
||||||
|
setItem<T>(key: string, value: T): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 高级用法
|
||||||
|
|
||||||
|
### 自定义 Driver
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type {IStorageDriver} from '@vben-core/shared/cache';
|
||||||
|
|
||||||
|
class CookieStorageDriver implements IStorageDriver {
|
||||||
|
async getItem<T>(key: string): Promise<null | T> {
|
||||||
|
const value = getCookie(key);
|
||||||
|
return value ? JSON.parse(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setItem<T>(key: string, value: T): Promise<void> {
|
||||||
|
setCookie(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeItem(key: string): Promise<void> {
|
||||||
|
deleteCookie(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
clearAllCookies();
|
||||||
|
}
|
||||||
|
|
||||||
|
async keys(): Promise<string[]> {
|
||||||
|
return getAllCookieNames();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用自定义 Driver
|
||||||
|
const cache = new StorageManager({
|
||||||
|
driver: new CookieStorageDriver(),
|
||||||
|
prefix: 'ck',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 根据环境动态选择 Driver
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
IndexedDBDriver,
|
||||||
|
LocalStorageDriver,
|
||||||
|
MemoryStorageDriver,
|
||||||
|
StorageManager,
|
||||||
|
} from '@vben-core/shared/cache';
|
||||||
|
|
||||||
|
function createStorageManager(prefix: string) {
|
||||||
|
// SSR 环境使用内存驱动
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return new StorageManager({
|
||||||
|
driver: new MemoryStorageDriver(),
|
||||||
|
prefix,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大数据场景使用 IndexedDB
|
||||||
|
if (needsLargeStorage()) {
|
||||||
|
return new StorageManager({
|
||||||
|
driver: new IndexedDBDriver({ dbName: `${prefix}-db` }),
|
||||||
|
prefix,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认使用 localStorage
|
||||||
|
return new StorageManager({ prefix });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 命名空间隔离
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 不同模块使用不同前缀,互不干扰
|
||||||
|
const userCache = new StorageManager({prefix: 'user'});
|
||||||
|
const configCache = new StorageManager({prefix: 'config'});
|
||||||
|
|
||||||
|
await userCache.setItem('profile', {name: '张三'});
|
||||||
|
await configCache.setItem('profile', {theme: 'dark'});
|
||||||
|
|
||||||
|
// 各自独立
|
||||||
|
await userCache.getItem('profile'); // => { name: '张三' }
|
||||||
|
await configCache.getItem('profile'); // => { theme: 'dark' }
|
||||||
|
|
||||||
|
// 只清除 user 前缀的数据
|
||||||
|
await userCache.clear();
|
||||||
|
await configCache.getItem('profile'); // => { theme: 'dark' }(不受影响)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 定时清理过期数据
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const cache = new StorageManager({ prefix: 'app' });
|
||||||
|
|
||||||
|
// 应用启动时清理一次
|
||||||
|
await cache.clearExpiredItems();
|
||||||
|
|
||||||
|
// 或者定时清理(每 10 分钟)
|
||||||
|
setInterval(async () => {
|
||||||
|
await cache.clearExpiredItems();
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据存储格式
|
||||||
|
|
||||||
|
`StorageManager` 在 Driver 层存储的数据结构为:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StorageItem<T> {
|
||||||
|
expiry?: number; // 过期时间戳(毫秒),undefined 表示永不过期
|
||||||
|
value: T; // 实际业务数据
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实际存储的 key 格式为:`{prefix}-{key}`
|
||||||
|
|
||||||
|
例如 `prefix = 'app'`,`key = 'user'`,则实际存储键为 `app-user`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 过期策略
|
||||||
|
|
||||||
|
采用**惰性删除 + 主动清理**双重策略:
|
||||||
|
|
||||||
|
| 策略 | 触发时机 | 说明 |
|
||||||
|
|------|--------------------------|---------------------|
|
||||||
|
| 惰性删除 | 调用 `getItem` 时 | 读取时检查过期,过期则删除并返回默认值 |
|
||||||
|
| 主动清理 | 调用 `clearExpiredItems` 时 | 遍历所有带前缀的 key,删除已过期项 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 各 Driver 对比
|
||||||
|
|
||||||
|
| 特性 | LocalStorageDriver | IndexedDBDriver | MemoryStorageDriver |
|
||||||
|
|-------|--------------------|-----------------|---------------------|
|
||||||
|
| 持久化 | ✅ | ✅ | ❌ |
|
||||||
|
| 容量 | 5-10 MB | 数百 MB+ | 受内存限制 |
|
||||||
|
| 速度 | 快(同步) | 中等(异步 I/O) | 最快 |
|
||||||
|
| 数据类型 | 仅 JSON 可序列化 | 结构化克隆 | 任意 JS 对象 |
|
||||||
|
| 浏览器支持 | 所有现代浏览器 | 所有现代浏览器 | 任意环境 |
|
||||||
|
| 阻塞主线程 | 是 | 否 | 否 |
|
||||||
|
| 适用场景 | 配置、Token、小数据 | 离线缓存、大数据 | 测试、SSR |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 在项目中的使用
|
||||||
|
|
||||||
|
本项目中 `StorageManager` 主要被 `PreferenceManager` 消费,用于持久化用户偏好设置:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/@core/preferences/src/preferences.ts
|
||||||
|
class PreferenceManager {
|
||||||
|
private cache: StorageManager;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.cache = new StorageManager();
|
||||||
|
this.state = reactive<Preferences>({...defaultPreferences});
|
||||||
|
}
|
||||||
|
|
||||||
|
initPreferences = async ({namespace}) => {
|
||||||
|
// 用应用命名空间重新初始化
|
||||||
|
this.cache = new StorageManager({prefix: namespace});
|
||||||
|
|
||||||
|
// 从缓存加载偏好设置
|
||||||
|
const cached = await this.cache.getItem<Preferences>('preferences');
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **所有方法都是异步的** — 即使底层是同步的 localStorage,API 也返回 Promise,确保切换 Driver
|
||||||
|
时无需改动调用方。
|
||||||
|
|
||||||
|
2. **TTL 单位是毫秒** — `setItem('key', value, 60000)` 表示 60 秒后过期。
|
||||||
|
|
||||||
|
3. **IndexedDB 懒初始化** — 不需要手动调用 `init()` 或 `open()`,首次操作时自动打开数据库连接并复用。
|
||||||
|
|
||||||
|
4. **前缀隔离是逻辑隔离** — `clear()` 只清除当前前缀下的数据,不影响其他前缀或无前缀的数据。
|
||||||
|
|
||||||
|
5. **错误处理** — LocalStorageDriver 在 JSON 解析失败时自动清除损坏数据;StorageManager 的
|
||||||
|
`saveToCache` 内部 try-catch 防止未捕获异常。
|
||||||
|
|
||||||
|
6. **IndexedDB 版本升级** — 如果需要修改 objectStore 结构,需要递增 `dbVersion`。当前实现在
|
||||||
|
`upgradeneeded` 事件中自动创建 objectStore。
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {MemoryStorageDriver} from '../memory-storage-driver';
|
||||||
import { StorageManager } from '../storage-manager';
|
import { StorageManager } from '../storage-manager';
|
||||||
|
|
||||||
describe('storageManager', () => {
|
describe('storageManager', () => {
|
||||||
|
|
@ -7,123 +8,139 @@ describe('storageManager', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
localStorage.clear();
|
|
||||||
storageManager = new StorageManager({
|
storageManager = new StorageManager({
|
||||||
|
driver: new MemoryStorageDriver(),
|
||||||
prefix: 'test_',
|
prefix: 'test_',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set and get an item', () => {
|
it('should set and get an item', async () => {
|
||||||
storageManager.setItem('user', { age: 30, name: 'John Doe' });
|
await storageManager.setItem('user', {age: 30, name: 'John Doe'});
|
||||||
const user = storageManager.getItem('user');
|
const user = await storageManager.getItem('user');
|
||||||
expect(user).toEqual({ age: 30, name: 'John Doe' });
|
expect(user).toEqual({ age: 30, name: 'John Doe' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return default value if item does not exist', () => {
|
it('should return default value if item does not exist', async () => {
|
||||||
const user = storageManager.getItem('nonexistent', {
|
const user = await storageManager.getItem('nonexistent', {
|
||||||
age: 0,
|
age: 0,
|
||||||
name: 'Default User',
|
name: 'Default User',
|
||||||
});
|
});
|
||||||
expect(user).toEqual({ age: 0, name: 'Default User' });
|
expect(user).toEqual({ age: 0, name: 'Default User' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove an item', () => {
|
it('should remove an item', async () => {
|
||||||
storageManager.setItem('user', { age: 30, name: 'John Doe' });
|
await storageManager.setItem('user', {age: 30, name: 'John Doe'});
|
||||||
storageManager.removeItem('user');
|
await storageManager.removeItem('user');
|
||||||
const user = storageManager.getItem('user');
|
const user = await storageManager.getItem('user');
|
||||||
expect(user).toBeNull();
|
expect(user).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear all items with the prefix', () => {
|
it('should clear all items with the prefix', async () => {
|
||||||
storageManager.setItem('user1', { age: 30, name: 'John Doe' });
|
await storageManager.setItem('user1', {age: 30, name: 'John Doe'});
|
||||||
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' });
|
await storageManager.setItem('user2', {age: 25, name: 'Jane Doe'});
|
||||||
storageManager.clear();
|
await storageManager.clear();
|
||||||
expect(storageManager.getItem('user1')).toBeNull();
|
expect(await storageManager.getItem('user1')).toBeNull();
|
||||||
expect(storageManager.getItem('user2')).toBeNull();
|
expect(await storageManager.getItem('user2')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear expired items', () => {
|
it('should clear expired items', async () => {
|
||||||
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
|
await storageManager.setItem(
|
||||||
|
'user',
|
||||||
|
{age: 30, name: 'John Doe'},
|
||||||
|
1000,
|
||||||
|
); // 1秒过期
|
||||||
vi.advanceTimersByTime(1001); // 快进时间
|
vi.advanceTimersByTime(1001); // 快进时间
|
||||||
storageManager.clearExpiredItems();
|
await storageManager.clearExpiredItems();
|
||||||
const user = storageManager.getItem('user');
|
const user = await storageManager.getItem('user');
|
||||||
expect(user).toBeNull();
|
expect(user).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not clear non-expired items', () => {
|
it('should not clear non-expired items', async () => {
|
||||||
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
|
await storageManager.setItem(
|
||||||
|
'user',
|
||||||
|
{age: 30, name: 'John Doe'},
|
||||||
|
10_000,
|
||||||
|
); // 10秒过期
|
||||||
vi.advanceTimersByTime(5000); // 快进时间
|
vi.advanceTimersByTime(5000); // 快进时间
|
||||||
storageManager.clearExpiredItems();
|
await storageManager.clearExpiredItems();
|
||||||
const user = storageManager.getItem('user');
|
const user = await storageManager.getItem('user');
|
||||||
expect(user).toEqual({ age: 30, name: 'John Doe' });
|
expect(user).toEqual({ age: 30, name: 'John Doe' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle JSON parse errors gracefully', () => {
|
it('should return null for non-existent items without default value', async () => {
|
||||||
localStorage.setItem('test_user', '{ invalid JSON }');
|
const user = await storageManager.getItem('nonexistent');
|
||||||
const user = storageManager.getItem('user', {
|
|
||||||
age: 0,
|
|
||||||
name: 'Default User',
|
|
||||||
});
|
|
||||||
expect(user).toEqual({ age: 0, name: 'Default User' });
|
|
||||||
});
|
|
||||||
it('should return null for non-existent items without default value', () => {
|
|
||||||
const user = storageManager.getItem('nonexistent');
|
|
||||||
expect(user).toBeNull();
|
expect(user).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should overwrite existing items', () => {
|
it('should overwrite existing items', async () => {
|
||||||
storageManager.setItem('user', { age: 30, name: 'John Doe' });
|
await storageManager.setItem('user', {age: 30, name: 'John Doe'});
|
||||||
storageManager.setItem('user', { age: 25, name: 'Jane Doe' });
|
await storageManager.setItem('user', {age: 25, name: 'Jane Doe'});
|
||||||
const user = storageManager.getItem('user');
|
const user = await storageManager.getItem('user');
|
||||||
expect(user).toEqual({ age: 25, name: 'Jane Doe' });
|
expect(user).toEqual({ age: 25, name: 'Jane Doe' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle items without expiry correctly', () => {
|
it('should handle items without expiry correctly', async () => {
|
||||||
storageManager.setItem('user', { age: 30, name: 'John Doe' });
|
await storageManager.setItem('user', {age: 30, name: 'John Doe'});
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
const user = storageManager.getItem('user');
|
const user = await storageManager.getItem('user');
|
||||||
expect(user).toEqual({ age: 30, name: 'John Doe' });
|
expect(user).toEqual({ age: 30, name: 'John Doe' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove expired items when accessed', () => {
|
it('should remove expired items when accessed', async () => {
|
||||||
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
|
await storageManager.setItem(
|
||||||
|
'user',
|
||||||
|
{age: 30, name: 'John Doe'},
|
||||||
|
1000,
|
||||||
|
); // 1秒过期
|
||||||
vi.advanceTimersByTime(1001); // 快进时间
|
vi.advanceTimersByTime(1001); // 快进时间
|
||||||
const user = storageManager.getItem('user');
|
const user = await storageManager.getItem('user');
|
||||||
expect(user).toBeNull();
|
expect(user).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not remove non-expired items when accessed', () => {
|
it('should not remove non-expired items when accessed', async () => {
|
||||||
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
|
await storageManager.setItem(
|
||||||
|
'user',
|
||||||
|
{age: 30, name: 'John Doe'},
|
||||||
|
10_000,
|
||||||
|
); // 10秒过期
|
||||||
vi.advanceTimersByTime(5000); // 快进时间
|
vi.advanceTimersByTime(5000); // 快进时间
|
||||||
const user = storageManager.getItem('user');
|
const user = await storageManager.getItem('user');
|
||||||
expect(user).toEqual({ age: 30, name: 'John Doe' });
|
expect(user).toEqual({ age: 30, name: 'John Doe' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple items with different expiry times', () => {
|
it('should handle multiple items with different expiry times', async () => {
|
||||||
storageManager.setItem('user1', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
|
await storageManager.setItem(
|
||||||
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }, 2000); // 2秒过期
|
'user1',
|
||||||
|
{age: 30, name: 'John Doe'},
|
||||||
|
1000,
|
||||||
|
); // 1秒过期
|
||||||
|
await storageManager.setItem(
|
||||||
|
'user2',
|
||||||
|
{age: 25, name: 'Jane Doe'},
|
||||||
|
2000,
|
||||||
|
); // 2秒过期
|
||||||
vi.advanceTimersByTime(1500); // 快进时间
|
vi.advanceTimersByTime(1500); // 快进时间
|
||||||
storageManager.clearExpiredItems();
|
await storageManager.clearExpiredItems();
|
||||||
const user1 = storageManager.getItem('user1');
|
const user1 = await storageManager.getItem('user1');
|
||||||
const user2 = storageManager.getItem('user2');
|
const user2 = await storageManager.getItem('user2');
|
||||||
expect(user1).toBeNull();
|
expect(user1).toBeNull();
|
||||||
expect(user2).toEqual({ age: 25, name: 'Jane Doe' });
|
expect(user2).toEqual({ age: 25, name: 'Jane Doe' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle items with no expiry', () => {
|
it('should handle items with no expiry', async () => {
|
||||||
storageManager.setItem('user', { age: 30, name: 'John Doe' });
|
await storageManager.setItem('user', {age: 30, name: 'John Doe'});
|
||||||
vi.advanceTimersByTime(10_000); // 快进时间
|
vi.advanceTimersByTime(10_000); // 快进时间
|
||||||
storageManager.clearExpiredItems();
|
await storageManager.clearExpiredItems();
|
||||||
const user = storageManager.getItem('user');
|
const user = await storageManager.getItem('user');
|
||||||
expect(user).toEqual({ age: 30, name: 'John Doe' });
|
expect(user).toEqual({ age: 30, name: 'John Doe' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear all items correctly', () => {
|
it('should clear all items correctly', async () => {
|
||||||
storageManager.setItem('user1', { age: 30, name: 'John Doe' });
|
await storageManager.setItem('user1', {age: 30, name: 'John Doe'});
|
||||||
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' });
|
await storageManager.setItem('user2', {age: 25, name: 'Jane Doe'});
|
||||||
storageManager.clear();
|
await storageManager.clear();
|
||||||
const user1 = storageManager.getItem('user1');
|
const user1 = await storageManager.getItem('user1');
|
||||||
const user2 = storageManager.getItem('user2');
|
const user2 = await storageManager.getItem('user2');
|
||||||
expect(user1).toBeNull();
|
expect(user1).toBeNull();
|
||||||
expect(user2).toBeNull();
|
expect(user2).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1 +1,5 @@
|
||||||
|
export * from './indexeddb-driver';
|
||||||
|
export * from './local-storage-driver';
|
||||||
|
export * from './memory-storage-driver';
|
||||||
export * from './storage-manager';
|
export * from './storage-manager';
|
||||||
|
export type * from './types';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import type {IStorageDriver} from './types';
|
||||||
|
|
||||||
|
interface IndexedDBDriverOptions {
|
||||||
|
/** 数据库名称 */
|
||||||
|
dbName?: string;
|
||||||
|
/** 数据库版本 */
|
||||||
|
dbVersion?: number;
|
||||||
|
/** 对象存储名称 */
|
||||||
|
storeName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IndexedDB 驱动
|
||||||
|
* 采用懒初始化模式,首次操作时自动打开数据库
|
||||||
|
*/
|
||||||
|
class IndexedDBDriver implements IStorageDriver {
|
||||||
|
private dbName: string;
|
||||||
|
private dbPromise: null | Promise<IDBDatabase> = null;
|
||||||
|
private dbVersion: number;
|
||||||
|
private storeName: string;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
dbName = 'vben-storage',
|
||||||
|
dbVersion = 1,
|
||||||
|
storeName = 'kv-store',
|
||||||
|
}: IndexedDBDriverOptions = {}) {
|
||||||
|
this.dbName = dbName;
|
||||||
|
this.dbVersion = dbVersion;
|
||||||
|
this.storeName = storeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
const db = await this.getDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(this.storeName, 'readwrite');
|
||||||
|
const store = tx.objectStore(this.storeName);
|
||||||
|
const request = store.clear();
|
||||||
|
|
||||||
|
request.addEventListener('success', () => resolve());
|
||||||
|
request.addEventListener('error', () => reject(request.error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getItem<T>(key: string): Promise<null | T> {
|
||||||
|
const db = await this.getDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(this.storeName, 'readonly');
|
||||||
|
const store = tx.objectStore(this.storeName);
|
||||||
|
const request = store.get(key);
|
||||||
|
|
||||||
|
request.addEventListener('success', () =>
|
||||||
|
resolve(request.result ?? null),
|
||||||
|
);
|
||||||
|
request.addEventListener('error', () => reject(request.error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async keys(): Promise<string[]> {
|
||||||
|
const db = await this.getDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(this.storeName, 'readonly');
|
||||||
|
const store = tx.objectStore(this.storeName);
|
||||||
|
const request = store.getAllKeys();
|
||||||
|
|
||||||
|
request.addEventListener('success', () =>
|
||||||
|
resolve(request.result.map(String)),
|
||||||
|
);
|
||||||
|
request.addEventListener('error', () => reject(request.error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeItem(key: string): Promise<void> {
|
||||||
|
const db = await this.getDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(this.storeName, 'readwrite');
|
||||||
|
const store = tx.objectStore(this.storeName);
|
||||||
|
const request = store.delete(key);
|
||||||
|
|
||||||
|
request.addEventListener('success', () => resolve());
|
||||||
|
request.addEventListener('error', () => reject(request.error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setItem<T>(key: string, value: T): Promise<void> {
|
||||||
|
const db = await this.getDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(this.storeName, 'readwrite');
|
||||||
|
const store = tx.objectStore(this.storeName);
|
||||||
|
const request = store.put(value, key);
|
||||||
|
|
||||||
|
request.addEventListener('success', () => resolve());
|
||||||
|
request.addEventListener('error', () => reject(request.error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 懒初始化:首次调用时打开数据库,后续复用同一个 Promise
|
||||||
|
*/
|
||||||
|
private getDB(): Promise<IDBDatabase> {
|
||||||
|
if (!this.dbPromise) {
|
||||||
|
this.dbPromise = this.openDB();
|
||||||
|
}
|
||||||
|
return this.dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private openDB(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||||
|
|
||||||
|
request.addEventListener('upgradeneeded', () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||||
|
db.createObjectStore(this.storeName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
request.addEventListener('success', () => resolve(request.result));
|
||||||
|
request.addEventListener('error', () => reject(request.error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {IndexedDBDriver};
|
||||||
|
export type {IndexedDBDriverOptions};
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import type {IStorageDriver} from './types';
|
||||||
|
|
||||||
|
type StorageType = 'localStorage' | 'sessionStorage';
|
||||||
|
|
||||||
|
interface LocalStorageDriverOptions {
|
||||||
|
/** 使用 localStorage 还是 sessionStorage */
|
||||||
|
storageType?: StorageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalStorage / SessionStorage 驱动
|
||||||
|
* 用 async 包装同步 API,保持接口统一
|
||||||
|
*/
|
||||||
|
class LocalStorageDriver implements IStorageDriver {
|
||||||
|
private storage: Storage;
|
||||||
|
|
||||||
|
constructor({storageType = 'localStorage'}: LocalStorageDriverOptions = {}) {
|
||||||
|
this.storage =
|
||||||
|
storageType === 'localStorage'
|
||||||
|
? window.localStorage
|
||||||
|
: window.sessionStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
this.storage.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getItem<T>(key: string): Promise<null | T> {
|
||||||
|
const raw = this.storage.getItem(key);
|
||||||
|
if (raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
// 数据损坏,清除并返回 null
|
||||||
|
this.storage.removeItem(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async keys(): Promise<string[]> {
|
||||||
|
const result: string[] = [];
|
||||||
|
for (let i = 0; i < this.storage.length; i++) {
|
||||||
|
const key = this.storage.key(i);
|
||||||
|
if (key !== null) {
|
||||||
|
result.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeItem(key: string): Promise<void> {
|
||||||
|
this.storage.removeItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setItem<T>(key: string, value: T): Promise<void> {
|
||||||
|
this.storage.setItem(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {LocalStorageDriver};
|
||||||
|
export type {LocalStorageDriverOptions};
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type {IStorageDriver} from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内存存储驱动
|
||||||
|
* 适用于测试环境和 SSR 场景,数据不持久化
|
||||||
|
*/
|
||||||
|
class MemoryStorageDriver implements IStorageDriver {
|
||||||
|
private store = new Map<string, unknown>();
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
this.store.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getItem<T>(key: string): Promise<null | T> {
|
||||||
|
const value = this.store.get(key);
|
||||||
|
return (value as T) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async keys(): Promise<string[]> {
|
||||||
|
return [...this.store.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeItem(key: string): Promise<void> {
|
||||||
|
this.store.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setItem<T>(key: string, value: T): Promise<void> {
|
||||||
|
this.store.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {MemoryStorageDriver};
|
||||||
|
|
@ -1,53 +1,42 @@
|
||||||
type StorageType = 'localStorage' | 'sessionStorage';
|
import type {IStorageDriver, StorageItem, StorageManagerOptions} from './types';
|
||||||
|
|
||||||
interface StorageManagerOptions {
|
import {LocalStorageDriver} from './local-storage-driver';
|
||||||
prefix?: string;
|
|
||||||
storageType?: StorageType;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StorageItem<T> {
|
|
||||||
expiry?: number;
|
|
||||||
value: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储管理器(策略模式)
|
||||||
|
* - prefix(命名空间隔离)在此层处理
|
||||||
|
* - TTL(过期机制)在此层处理
|
||||||
|
* - Driver 只负责纯粹的 KV 存取
|
||||||
|
*/
|
||||||
class StorageManager {
|
class StorageManager {
|
||||||
|
private driver: IStorageDriver;
|
||||||
private prefix: string;
|
private prefix: string;
|
||||||
private storage: Storage;
|
|
||||||
|
|
||||||
constructor({
|
constructor({driver, prefix = ''}: StorageManagerOptions = {}) {
|
||||||
prefix = '',
|
this.driver = driver || new LocalStorageDriver();
|
||||||
storageType = 'localStorage',
|
|
||||||
}: StorageManagerOptions = {}) {
|
|
||||||
this.prefix = prefix;
|
this.prefix = prefix;
|
||||||
this.storage =
|
|
||||||
storageType === 'localStorage'
|
|
||||||
? window.localStorage
|
|
||||||
: window.sessionStorage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除所有带前缀的存储项
|
* 清除所有带前缀的存储项
|
||||||
*/
|
*/
|
||||||
clear(): void {
|
async clear(): Promise<void> {
|
||||||
const keysToRemove: string[] = [];
|
const allKeys = await this.driver.keys();
|
||||||
for (let i = 0; i < this.storage.length; i++) {
|
const prefixedKeys = allKeys.filter((key) => key.startsWith(this.prefix));
|
||||||
const key = this.storage.key(i);
|
await Promise.all(prefixedKeys.map((key) => this.driver.removeItem(key)));
|
||||||
if (key && key.startsWith(this.prefix)) {
|
|
||||||
keysToRemove.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keysToRemove.forEach((key) => this.storage.removeItem(key));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除所有过期的存储项
|
* 清除所有过期的存储项
|
||||||
*/
|
*/
|
||||||
clearExpiredItems(): void {
|
async clearExpiredItems(): Promise<void> {
|
||||||
for (let i = 0; i < this.storage.length; i++) {
|
const allKeys = await this.driver.keys();
|
||||||
const key = this.storage.key(i);
|
const prefixedKeys = allKeys.filter((key) => key.startsWith(this.prefix));
|
||||||
if (key && key.startsWith(this.prefix)) {
|
|
||||||
const shortKey = key.replace(this.prefix, '');
|
for (const fullKey of prefixedKeys) {
|
||||||
this.getItem(shortKey); // 调用 getItem 方法检查并移除过期项
|
const raw = await this.driver.getItem<StorageItem<unknown>>(fullKey);
|
||||||
|
if (raw && raw.expiry && Date.now() > raw.expiry) {
|
||||||
|
await this.driver.removeItem(fullKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,36 +45,32 @@ class StorageManager {
|
||||||
* 获取存储项
|
* 获取存储项
|
||||||
* @param key 键
|
* @param key 键
|
||||||
* @param defaultValue 当项不存在或已过期时返回的默认值
|
* @param defaultValue 当项不存在或已过期时返回的默认值
|
||||||
* @returns 值,如果项已过期或解析错误则返回默认值
|
* @returns 值,如果项已过期则返回默认值
|
||||||
*/
|
*/
|
||||||
getItem<T>(key: string, defaultValue: null | T = null): null | T {
|
async getItem<T>(key: string, defaultValue: null | T = null): Promise<null | T> {
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
const itemStr = this.storage.getItem(fullKey);
|
const raw = await this.driver.getItem<StorageItem<T>>(fullKey);
|
||||||
if (!itemStr) {
|
|
||||||
|
if (!raw) {
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// TTL 检查
|
||||||
const item: StorageItem<T> = JSON.parse(itemStr);
|
if (raw.expiry && Date.now() > raw.expiry) {
|
||||||
if (item.expiry && Date.now() > item.expiry) {
|
await this.driver.removeItem(fullKey);
|
||||||
this.storage.removeItem(fullKey);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
return item.value;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error parsing item with key "${fullKey}":`, error);
|
|
||||||
this.storage.removeItem(fullKey); // 如果解析失败,删除该项
|
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return raw.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除存储项
|
* 移除存储项
|
||||||
* @param key 键
|
* @param key 键
|
||||||
*/
|
*/
|
||||||
removeItem(key: string): void {
|
async removeItem(key: string): Promise<void> {
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
this.storage.removeItem(fullKey);
|
await this.driver.removeItem(fullKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -94,24 +79,20 @@ class StorageManager {
|
||||||
* @param value 值
|
* @param value 值
|
||||||
* @param ttl 存活时间(毫秒)
|
* @param ttl 存活时间(毫秒)
|
||||||
*/
|
*/
|
||||||
setItem<T>(key: string, value: T, ttl?: number): void {
|
async setItem<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
const expiry = ttl ? Date.now() + ttl : undefined;
|
const expiry = ttl ? Date.now() + ttl : undefined;
|
||||||
const item: StorageItem<T> = { expiry, value };
|
const item: StorageItem<T> = { expiry, value };
|
||||||
try {
|
await this.driver.setItem(fullKey, item);
|
||||||
this.storage.setItem(fullKey, JSON.stringify(item));
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error setting item with key "${fullKey}":`, error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取完整的存储键
|
* 获取完整的存储键(带前缀)
|
||||||
* @param key 原始键
|
* @param key 原始键
|
||||||
* @returns 带前缀的完整键
|
* @returns 带前缀的完整键
|
||||||
*/
|
*/
|
||||||
private getFullKey(key: string): string {
|
private getFullKey(key: string): string {
|
||||||
return `${this.prefix}-${key}`;
|
return this.prefix ? `${this.prefix}-${key}` : key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,39 @@
|
||||||
type StorageType = 'localStorage' | 'sessionStorage';
|
/**
|
||||||
|
* 存储驱动接口(策略模式核心抽象)
|
||||||
|
* 所有存储实现(localStorage、IndexedDB、Memory 等)都需要实现此接口
|
||||||
|
* Driver 层只负责纯粹的 KV 存取,不感知 TTL 和前缀
|
||||||
|
*/
|
||||||
|
interface IStorageDriver {
|
||||||
|
/** 清除所有存储项 */
|
||||||
|
clear(): Promise<void>;
|
||||||
|
|
||||||
interface StorageValue<T> {
|
/** 获取存储项 */
|
||||||
data: T;
|
getItem<T>(key: string): Promise<null | T>;
|
||||||
expiry: null | number;
|
|
||||||
|
/** 获取所有 key */
|
||||||
|
keys(): Promise<string[]>;
|
||||||
|
|
||||||
|
/** 移除存储项 */
|
||||||
|
removeItem(key: string): Promise<void>;
|
||||||
|
|
||||||
|
/** 设置存储项 */
|
||||||
|
setItem<T>(key: string, value: T): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IStorageCache {
|
/**
|
||||||
clear(): void;
|
* 带 TTL 的存储项包装结构
|
||||||
getItem<T>(key: string): null | T;
|
* TTL 逻辑由 StorageManager 统一管理,Driver 层不感知
|
||||||
key(index: number): null | string;
|
*/
|
||||||
length(): number;
|
interface StorageItem<T> {
|
||||||
removeItem(key: string): void;
|
expiry?: number;
|
||||||
setItem<T>(key: string, value: T, expiryInMinutes?: number): void;
|
value: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { IStorageCache, StorageType, StorageValue };
|
interface StorageManagerOptions {
|
||||||
|
/** 存储驱动实例 */
|
||||||
|
driver?: IStorageDriver;
|
||||||
|
/** 键前缀,用于命名空间隔离 */
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {IStorageDriver, StorageItem, StorageManagerOptions};
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ describe('preferences', () => {
|
||||||
expect(preferenceManager.getPreferences().app.colorWeakMode).toBe(true);
|
expect(preferenceManager.getPreferences().app.colorWeakMode).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets preferences to default', () => {
|
it('resets preferences to default', async () => {
|
||||||
// 先更新一些偏好设置
|
// 先更新一些偏好设置
|
||||||
preferenceManager.updatePreferences({
|
preferenceManager.updatePreferences({
|
||||||
theme: {
|
theme: {
|
||||||
|
|
@ -114,7 +114,7 @@ describe('preferences', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// 然后重置偏好设置
|
// 然后重置偏好设置
|
||||||
preferenceManager.resetPreferences();
|
await preferenceManager.resetPreferences();
|
||||||
|
|
||||||
expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
|
expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
|
||||||
});
|
});
|
||||||
|
|
@ -174,7 +174,7 @@ describe('preferences', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets preferences to default correctly', () => {
|
it('resets preferences to default correctly', async () => {
|
||||||
// 先更新一些偏好设置
|
// 先更新一些偏好设置
|
||||||
preferenceManager.updatePreferences({
|
preferenceManager.updatePreferences({
|
||||||
app: { locale: 'en-US' },
|
app: { locale: 'en-US' },
|
||||||
|
|
@ -185,7 +185,7 @@ describe('preferences', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// 然后重置偏好设置
|
// 然后重置偏好设置
|
||||||
preferenceManager.resetPreferences();
|
await preferenceManager.resetPreferences();
|
||||||
|
|
||||||
expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
|
expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
|
||||||
});
|
});
|
||||||
|
|
@ -377,7 +377,7 @@ describe('preferences', () => {
|
||||||
reportTitle: '月报',
|
reportTitle: '月报',
|
||||||
});
|
});
|
||||||
|
|
||||||
preferenceManager.resetPreferences();
|
await preferenceManager.resetPreferences();
|
||||||
|
|
||||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
|
|
|
||||||
|
|
@ -41,17 +41,19 @@ class PreferenceManager {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.cache = new StorageManager();
|
this.cache = new StorageManager();
|
||||||
this.state = reactive<Preferences>(
|
// 构造函数不再同步读取缓存,使用默认值初始化
|
||||||
this.loadFromCache() || { ...defaultPreferences },
|
// 真正的缓存加载在 initPreferences 中完成(已经是 async)
|
||||||
);
|
this.state = reactive<Preferences>({...defaultPreferences});
|
||||||
this.debouncedSave = useDebounceFn(() => this.saveToCache(), 150);
|
this.debouncedSave = useDebounceFn(() => this.saveToCache(), 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除所有缓存的偏好设置
|
* 清除所有缓存的偏好设置
|
||||||
*/
|
*/
|
||||||
clearCache = () => {
|
clearCache = async () => {
|
||||||
Object.values(STORAGE_KEYS).forEach((key) => this.cache.removeItem(key));
|
await Promise.all(
|
||||||
|
Object.values(STORAGE_KEYS).map((key) => this.cache.removeItem(key)),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -130,7 +132,7 @@ class PreferenceManager {
|
||||||
);
|
);
|
||||||
|
|
||||||
// 加载缓存的偏好设置并与初始配置合并
|
// 加载缓存的偏好设置并与初始配置合并
|
||||||
const cachedPreferences = this.loadFromCache() || {};
|
const cachedPreferences = (await this.loadFromCache()) || {};
|
||||||
const mergedPreference = merge(
|
const mergedPreference = merge(
|
||||||
{},
|
{},
|
||||||
cachedPreferences,
|
cachedPreferences,
|
||||||
|
|
@ -139,14 +141,16 @@ class PreferenceManager {
|
||||||
|
|
||||||
// 更新偏好设置
|
// 更新偏好设置
|
||||||
this.updatePreferences(mergedPreference);
|
this.updatePreferences(mergedPreference);
|
||||||
|
|
||||||
|
const cachedCustom = (await this.loadCustomFromCache()) || {};
|
||||||
this.replaceCustomPreferences(
|
this.replaceCustomPreferences(
|
||||||
merge(
|
merge(
|
||||||
{},
|
{},
|
||||||
this.sanitizeCustomPreferences(this.loadCustomFromCache() || {}),
|
this.sanitizeCustomPreferences(cachedCustom),
|
||||||
this.initialCustomPreferences,
|
this.initialCustomPreferences,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
this.saveToCache();
|
await this.saveToCache();
|
||||||
|
|
||||||
// 设置监听器
|
// 设置监听器
|
||||||
this.setupWatcher();
|
this.setupWatcher();
|
||||||
|
|
@ -160,13 +164,13 @@ class PreferenceManager {
|
||||||
/**
|
/**
|
||||||
* 重置偏好设置到初始状态
|
* 重置偏好设置到初始状态
|
||||||
*/
|
*/
|
||||||
resetPreferences = () => {
|
resetPreferences = async () => {
|
||||||
// 将状态重置为初始偏好设置
|
// 将状态重置为初始偏好设置
|
||||||
Object.assign(this.state, this.initialPreferences);
|
Object.assign(this.state, this.initialPreferences);
|
||||||
this.replaceCustomPreferences(this.initialCustomPreferences);
|
this.replaceCustomPreferences(this.initialCustomPreferences);
|
||||||
|
|
||||||
// 保存偏好设置至缓存
|
// 保存偏好设置至缓存
|
||||||
this.saveToCache();
|
await this.saveToCache();
|
||||||
|
|
||||||
// 直接触发 UI 更新
|
// 直接触发 UI 更新
|
||||||
this.handleUpdates(this.state);
|
this.handleUpdates(this.state);
|
||||||
|
|
@ -211,7 +215,7 @@ class PreferenceManager {
|
||||||
// 根据更新的值执行更新
|
// 根据更新的值执行更新
|
||||||
this.handleUpdates(updates);
|
this.handleUpdates(updates);
|
||||||
|
|
||||||
// 保存到缓存
|
// 保存到缓存(fire-and-forget,通过 debounce 控制频率)
|
||||||
this.debouncedSave();
|
this.debouncedSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -320,7 +324,7 @@ class PreferenceManager {
|
||||||
* 从缓存加载扩展偏好设置
|
* 从缓存加载扩展偏好设置
|
||||||
* @returns 缓存的扩展偏好设置,如果不存在则返回 null
|
* @returns 缓存的扩展偏好设置,如果不存在则返回 null
|
||||||
*/
|
*/
|
||||||
private loadCustomFromCache(): CustomPreferencesRecord | null {
|
private async loadCustomFromCache(): Promise<CustomPreferencesRecord | null> {
|
||||||
return this.cache.getItem<CustomPreferencesRecord>(STORAGE_KEYS.CUSTOM);
|
return this.cache.getItem<CustomPreferencesRecord>(STORAGE_KEYS.CUSTOM);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,7 +332,7 @@ class PreferenceManager {
|
||||||
* 从缓存加载偏好设置
|
* 从缓存加载偏好设置
|
||||||
* @returns 缓存的偏好设置,如果不存在则返回 null
|
* @returns 缓存的偏好设置,如果不存在则返回 null
|
||||||
*/
|
*/
|
||||||
private loadFromCache(): null | Preferences {
|
private async loadFromCache(): Promise<null | Preferences> {
|
||||||
return this.cache.getItem<Preferences>(STORAGE_KEYS.MAIN);
|
return this.cache.getItem<Preferences>(STORAGE_KEYS.MAIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -387,17 +391,23 @@ class PreferenceManager {
|
||||||
/**
|
/**
|
||||||
* 保存偏好设置到缓存
|
* 保存偏好设置到缓存
|
||||||
*/
|
*/
|
||||||
private saveToCache() {
|
private async saveToCache() {
|
||||||
this.cache.setItem(STORAGE_KEYS.MAIN, this.state);
|
try {
|
||||||
this.cache.setItem(STORAGE_KEYS.LOCALE, this.state.app.locale);
|
await this.cache.setItem(STORAGE_KEYS.MAIN, this.state);
|
||||||
this.cache.setItem(STORAGE_KEYS.THEME, this.state.theme.mode);
|
await this.cache.setItem(STORAGE_KEYS.LOCALE, this.state.app.locale);
|
||||||
|
await this.cache.setItem(STORAGE_KEYS.THEME, this.state.theme.mode);
|
||||||
|
|
||||||
if (this.customPreferencesExtension) {
|
if (this.customPreferencesExtension) {
|
||||||
this.cache.setItem(STORAGE_KEYS.CUSTOM, { ...this.customState });
|
await this.cache.setItem(STORAGE_KEYS.CUSTOM, {
|
||||||
return;
|
...this.customState,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.cache.removeItem(STORAGE_KEYS.CUSTOM);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save preferences to cache:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cache.removeItem(STORAGE_KEYS.CUSTOM);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -283,8 +283,8 @@ async function handleCopy() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClearCache() {
|
async function handleClearCache() {
|
||||||
resetPreferences();
|
await resetPreferences();
|
||||||
clearCache();
|
await clearCache();
|
||||||
emit('clearPreferencesAndLogout');
|
emit('clearPreferencesAndLogout');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -292,7 +292,7 @@ async function handleReset() {
|
||||||
if (!mergedDiffPreference.value) {
|
if (!mergedDiffPreference.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resetPreferences();
|
await resetPreferences();
|
||||||
await loadLocaleMessages(preferences.app.locale);
|
await loadLocaleMessages(preferences.app.locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue