跳到主要内容

带过期时间的 localStorage

问题

设计一个可以设置过期日期的 localStorage API,支持自动过期清理。

答案

原生 localStorage 不支持过期时间,需要通过包装实现。核心思路是将数据和过期时间一起存储,读取时检查是否过期。


基础实现

interface StorageValue<T> {
value: T;
expire: number | null; // 过期时间戳,null 表示永不过期
}

class ExpireStorage {
private prefix: string;

constructor(prefix: string = 'exp_') {
this.prefix = prefix;
}

/**
* 设置存储项
* @param key 键名
* @param value
* @param ttl 过期时间(毫秒),不传则永不过期
*/
set<T>(key: string, value: T, ttl?: number): void {
const data: StorageValue<T> = {
value,
expire: ttl ? Date.now() + ttl : null
};

localStorage.setItem(
this.prefix + key,
JSON.stringify(data)
);
}

/**
* 获取存储项
* @param key 键名
* @returns 值,过期或不存在返回 null
*/
get<T>(key: string): T | null {
const raw = localStorage.getItem(this.prefix + key);
if (!raw) return null;

try {
const data: StorageValue<T> = JSON.parse(raw);

// 检查是否过期
if (data.expire !== null && Date.now() > data.expire) {
this.remove(key);
return null;
}

return data.value;
} catch {
return null;
}
}

/**
* 删除存储项
*/
remove(key: string): void {
localStorage.removeItem(this.prefix + key);
}

/**
* 清空所有带前缀的存储项
*/
clear(): void {
const keys = Object.keys(localStorage);
keys.forEach((key) => {
if (key.startsWith(this.prefix)) {
localStorage.removeItem(key);
}
});
}

/**
* 检查键是否存在且未过期
*/
has(key: string): boolean {
return this.get(key) !== null;
}
}

// 使用示例
const storage = new ExpireStorage();

// 设置 1 小时后过期
storage.set('token', 'abc123', 60 * 60 * 1000);

// 设置永不过期
storage.set('user', { id: 1, name: 'Alice' });

// 获取值
const token = storage.get<string>('token'); // 'abc123' 或 null

支持过期日期的版本

interface ExpireStorageOptions {
prefix?: string;
defaultTTL?: number; // 默认过期时间
}

class ExpireStorageAdvanced {
private prefix: string;
private defaultTTL: number | null;

constructor(options: ExpireStorageOptions = {}) {
this.prefix = options.prefix ?? 'exp_';
this.defaultTTL = options.defaultTTL ?? null;
}

/**
* 设置存储项,支持多种过期时间格式
*/
set<T>(
key: string,
value: T,
options?: {
ttl?: number; // 毫秒
expireAt?: Date; // 具体过期日期
expireIn?: string; // 如 '1h', '7d', '30m'
}
): void {
let expire: number | null = null;

if (options?.expireAt) {
expire = options.expireAt.getTime();
} else if (options?.ttl) {
expire = Date.now() + options.ttl;
} else if (options?.expireIn) {
expire = Date.now() + this.parseTimeString(options.expireIn);
} else if (this.defaultTTL) {
expire = Date.now() + this.defaultTTL;
}

const data: StorageValue<T> = { value, expire };

localStorage.setItem(
this.prefix + key,
JSON.stringify(data)
);
}

/**
* 解析时间字符串
*/
private parseTimeString(str: string): number {
const match = str.match(/^(\d+)(s|m|h|d|w)$/);
if (!match) throw new Error(`Invalid time string: ${str}`);

const [, num, unit] = match;
const value = parseInt(num, 10);

const units: Record<string, number> = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
w: 7 * 24 * 60 * 60 * 1000
};

return value * units[unit];
}

get<T>(key: string): T | null {
const raw = localStorage.getItem(this.prefix + key);
if (!raw) return null;

try {
const data: StorageValue<T> = JSON.parse(raw);

if (data.expire !== null && Date.now() > data.expire) {
this.remove(key);
return null;
}

return data.value;
} catch {
return null;
}
}

/**
* 获取剩余过期时间(毫秒)
*/
getTTL(key: string): number | null {
const raw = localStorage.getItem(this.prefix + key);
if (!raw) return null;

try {
const data: StorageValue<unknown> = JSON.parse(raw);

if (data.expire === null) return Infinity;

const remaining = data.expire - Date.now();
return remaining > 0 ? remaining : null;
} catch {
return null;
}
}

/**
* 续期
*/
refresh(key: string, ttl: number): boolean {
const value = this.get(key);
if (value === null) return false;

this.set(key, value, { ttl });
return true;
}

remove(key: string): void {
localStorage.removeItem(this.prefix + key);
}

clear(): void {
Object.keys(localStorage)
.filter((key) => key.startsWith(this.prefix))
.forEach((key) => localStorage.removeItem(key));
}

/**
* 获取所有键
*/
keys(): string[] {
return Object.keys(localStorage)
.filter((key) => key.startsWith(this.prefix))
.map((key) => key.slice(this.prefix.length));
}
}

// 使用示例
const storage = new ExpireStorageAdvanced({ defaultTTL: 24 * 60 * 60 * 1000 });

// 1 小时后过期
storage.set('session', { userId: 1 }, { expireIn: '1h' });

// 指定具体日期
storage.set('campaign', { id: 'summer' }, {
expireAt: new Date('2024-08-31')
});

// 7 天后过期
storage.set('remember', true, { expireIn: '7d' });

// 获取剩余时间
const ttl = storage.getTTL('session'); // 毫秒数

自动清理过期项

class AutoCleanStorage extends ExpireStorageAdvanced {
private cleanInterval: ReturnType<typeof setInterval> | null = null;

constructor(
options: ExpireStorageOptions & {
autoCleanInterval?: number; // 自动清理间隔
} = {}
) {
super(options);

if (options.autoCleanInterval) {
this.startAutoClean(options.autoCleanInterval);
}
}

/**
* 启动自动清理
*/
startAutoClean(interval: number): void {
this.stopAutoClean();
this.cleanInterval = setInterval(() => {
this.cleanExpired();
}, interval);
}

/**
* 停止自动清理
*/
stopAutoClean(): void {
if (this.cleanInterval) {
clearInterval(this.cleanInterval);
this.cleanInterval = null;
}
}

/**
* 清理所有过期项
*/
cleanExpired(): number {
let count = 0;
const keys = this.keys();

keys.forEach((key) => {
const ttl = this.getTTL(key);
if (ttl === null) {
this.remove(key);
count++;
}
});

return count;
}

/**
* 获取存储统计
*/
getStats(): {
total: number;
expired: number;
valid: number;
size: number;
} {
const keys = this.keys();
let expired = 0;
let size = 0;

keys.forEach((key) => {
const ttl = this.getTTL(key);
if (ttl === null) expired++;

const raw = localStorage.getItem(this.prefix + key);
if (raw) size += raw.length * 2; // UTF-16 编码
});

return {
total: keys.length,
expired,
valid: keys.length - expired,
size
};
}

private prefix = 'exp_';
}

// 使用示例
const storage = new AutoCleanStorage({
autoCleanInterval: 60 * 1000 // 每分钟清理一次
});

支持复杂类型的完整实现

type Serializable =
| string
| number
| boolean
| null
| Serializable[]
| { [key: string]: Serializable };

interface StorageItem<T> {
value: T;
expire: number | null;
created: number;
updated: number;
}

interface ExpireStorageConfig {
prefix?: string;
defaultTTL?: number;
serialize?: (value: unknown) => string;
deserialize?: (raw: string) => unknown;
}

class TypedExpireStorage {
private prefix: string;
private defaultTTL: number | null;
private serialize: (value: unknown) => string;
private deserialize: (raw: string) => unknown;

constructor(config: ExpireStorageConfig = {}) {
this.prefix = config.prefix ?? 'typed_';
this.defaultTTL = config.defaultTTL ?? null;
this.serialize = config.serialize ?? JSON.stringify;
this.deserialize = config.deserialize ?? JSON.parse;
}

set<T extends Serializable>(
key: string,
value: T,
ttl?: number
): void {
const now = Date.now();
const effectiveTTL = ttl ?? this.defaultTTL;

const existing = this.getRaw(key);
const created = existing?.created ?? now;

const item: StorageItem<T> = {
value,
expire: effectiveTTL ? now + effectiveTTL : null,
created,
updated: now
};

try {
localStorage.setItem(
this.prefix + key,
this.serialize(item)
);
} catch (e) {
// 存储满了,清理过期项后重试
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
this.cleanExpired();
localStorage.setItem(
this.prefix + key,
this.serialize(item)
);
} else {
throw e;
}
}
}

get<T extends Serializable>(key: string): T | null {
const item = this.getRaw(key) as StorageItem<T> | null;
if (!item) return null;

if (item.expire !== null && Date.now() > item.expire) {
this.remove(key);
return null;
}

return item.value;
}

private getRaw(key: string): StorageItem<unknown> | null {
const raw = localStorage.getItem(this.prefix + key);
if (!raw) return null;

try {
return this.deserialize(raw) as StorageItem<unknown>;
} catch {
return null;
}
}

/**
* 获取完整元数据
*/
getMeta(key: string): Omit<StorageItem<unknown>, 'value'> | null {
const item = this.getRaw(key);
if (!item) return null;

const { value, ...meta } = item;
return meta;
}

/**
* 获取或设置(缓存模式)
*/
getOrSet<T extends Serializable>(
key: string,
factory: () => T,
ttl?: number
): T {
const cached = this.get<T>(key);
if (cached !== null) return cached;

const value = factory();
this.set(key, value, ttl);
return value;
}

/**
* 异步获取或设置
*/
async getOrSetAsync<T extends Serializable>(
key: string,
factory: () => Promise<T>,
ttl?: number
): Promise<T> {
const cached = this.get<T>(key);
if (cached !== null) return cached;

const value = await factory();
this.set(key, value, ttl);
return value;
}

remove(key: string): void {
localStorage.removeItem(this.prefix + key);
}

clear(): void {
Object.keys(localStorage)
.filter((key) => key.startsWith(this.prefix))
.forEach((key) => localStorage.removeItem(key));
}

cleanExpired(): number {
let count = 0;
const now = Date.now();

Object.keys(localStorage)
.filter((key) => key.startsWith(this.prefix))
.forEach((fullKey) => {
try {
const item = this.deserialize(
localStorage.getItem(fullKey)!
) as StorageItem<unknown>;

if (item.expire !== null && now > item.expire) {
localStorage.removeItem(fullKey);
count++;
}
} catch {
// 解析失败,删除损坏的项
localStorage.removeItem(fullKey);
count++;
}
});

return count;
}

keys(): string[] {
return Object.keys(localStorage)
.filter((key) => key.startsWith(this.prefix))
.map((key) => key.slice(this.prefix.length));
}
}

// 使用示例
const cache = new TypedExpireStorage({
prefix: 'app_',
defaultTTL: 30 * 60 * 1000 // 默认 30 分钟
});

// 缓存用户信息
cache.set('user', { id: 1, name: 'Alice' }, 60 * 60 * 1000);

// 获取或加载
const config = cache.getOrSet('config', () => ({
theme: 'dark',
language: 'zh-CN'
}));

// 异步获取或加载
const profile = await cache.getOrSetAsync(
'profile',
async () => {
const res = await fetch('/api/profile');
return res.json();
},
5 * 60 * 1000
);

常见面试问题

Q1: 为什么 localStorage 不原生支持过期时间?

答案

localStorage 设计为持久化存储,与 sessionStorage(会话结束清除)和 Cookie(支持过期)定位不同:

存储方式生命周期容量服务端访问
localStorage永久~5MB
sessionStorage会话~5MB
Cookie可设置4KB
IndexedDB永久无限制

如需过期机制,可通过以下方式实现:

  1. 封装 API(本文方案)
  2. 使用 IndexedDB + 时间索引
  3. 使用第三方库如 localforage

Q2: 如何处理存储容量限制?

答案

class SafeStorage extends TypedExpireStorage {
private maxSize: number;

constructor(maxSize: number = 4 * 1024 * 1024) { // 4MB
super();
this.maxSize = maxSize;
}

set<T extends Serializable>(key: string, value: T, ttl?: number): boolean {
try {
super.set(key, value, ttl);
return true;
} catch (e) {
if (e instanceof DOMException) {
// 容量不足,执行 LRU 淘汰
this.evictLRU();
try {
super.set(key, value, ttl);
return true;
} catch {
return false;
}
}
throw e;
}
}

private evictLRU(): void {
const items = this.keys()
.map((key) => ({
key,
meta: this.getMeta(key)
}))
.filter((item) => item.meta !== null)
.sort((a, b) => a.meta!.updated - b.meta!.updated);

// 删除最旧的 20% 项
const deleteCount = Math.ceil(items.length * 0.2);
items.slice(0, deleteCount).forEach((item) => {
this.remove(item.key);
});
}
}

Q3: 如何确保数据安全?

答案

localStorage 数据可被 XSS 攻击窃取,敏感数据应加密存储:

class SecureStorage extends TypedExpireStorage {
private encryptKey: string;

constructor(encryptKey: string) {
super({
serialize: (value) => this.encrypt(JSON.stringify(value)),
deserialize: (raw) => JSON.parse(this.decrypt(raw))
});
this.encryptKey = encryptKey;
}

private encrypt(text: string): string {
// 简单示例,生产环境使用 Web Crypto API
return btoa(
text
.split('')
.map((c, i) =>
String.fromCharCode(
c.charCodeAt(0) ^ this.encryptKey.charCodeAt(i % this.encryptKey.length)
)
)
.join('')
);
}

private decrypt(encoded: string): string {
const text = atob(encoded);
return text
.split('')
.map((c, i) =>
String.fromCharCode(
c.charCodeAt(0) ^ this.encryptKey.charCodeAt(i % this.encryptKey.length)
)
)
.join('');
}
}
安全建议
  • 敏感信息(token、密码)尽量不存 localStorage
  • 使用 HttpOnly Cookie 存储认证信息
  • 定期清理过期数据
  • 考虑使用 sessionStorage 替代短期数据

Q4: 如何实现跨标签页同步?

答案

class SyncStorage extends TypedExpireStorage {
private listeners: Map<string, Set<(value: unknown) => void>> = new Map();

constructor() {
super();

// 监听其他标签页的变化
window.addEventListener('storage', (e) => {
if (!e.key?.startsWith(this.prefix)) return;

const key = e.key.slice(this.prefix.length);
const callbacks = this.listeners.get(key);

if (callbacks) {
const value = e.newValue ? this.get(key) : null;
callbacks.forEach((cb) => cb(value));
}
});
}

/**
* 订阅变化
*/
subscribe<T>(key: string, callback: (value: T | null) => void): () => void {
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}

this.listeners.get(key)!.add(callback as (value: unknown) => void);

// 返回取消订阅函数
return () => {
this.listeners.get(key)?.delete(callback as (value: unknown) => void);
};
}

private prefix = 'sync_';
}

// 使用
const storage = new SyncStorage();

// 标签页 A
storage.subscribe('theme', (theme) => {
console.log('Theme changed:', theme);
});

// 标签页 B
storage.set('theme', 'dark'); // 标签页 A 收到通知

相关链接