前端状态持久化策略
大约 15 分钟约 4411 字
前端状态持久化策略
简介
前端状态持久化是指将应用运行时的状态数据保存到浏览器的本地存储中,使得页面刷新、标签页关闭甚至浏览器重启后,应用能够恢复到之前的状态。从简单的用户偏好设置到复杂的离线数据缓存,状态持久化在现代 Web 应用中扮演着越来越重要的角色。
随着 PWA(渐进式 Web 应用)和离线优先架构的兴起,状态持久化已经从"锦上添花"变成了"必备能力"。本文将系统讲解前端状态持久化的各种策略、技术方案和生产实践。
特点
- 多层次存储:从 Cookie 到 IndexedDB,不同场景使用不同的存储方案
- 离线可用:数据持久化是离线优先架构的基础
- 状态恢复:应用启动时从本地存储恢复状态,提升用户体验
- 数据同步:本地状态与服务端状态的协调一致
- 容量管理:合理使用存储配额,避免超出限制
核心技术方案
一、localStorage 与 sessionStorage
// ============ 类型安全的 localStorage 封装 ============
interface StorageOptions {
/** 过期时间(毫秒) */
ttl?: number;
/** 是否加密 */
encrypt?: boolean;
/** 序列化器 */
serializer?: {
serialize: (value: unknown) => string;
deserialize: (value: string) => unknown;
};
}
interface StorageItem<T> {
value: T;
timestamp: number;
ttl?: number;
version: number;
}
class TypedStorage {
private static VERSION = 1;
/**
* 设置值
*/
static set<T>(key: string, value: T, options: StorageOptions = {}): void {
const item: StorageItem<T> = {
value,
timestamp: Date.now(),
ttl: options.ttl,
version: this.VERSION,
};
try {
let serialized = options.serializer
? options.serializer.serialize(item)
: JSON.stringify(item);
if (options.encrypt) {
serialized = this.simpleEncrypt(serialized);
}
localStorage.setItem(key, serialized);
} catch (error) {
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
console.warn(`localStorage 配额已满,无法写入 key: ${key}`);
this.cleanup();
}
throw error;
}
}
/**
* 获取值
*/
static get<T>(key: string, defaultValue?: T): T | undefined {
const raw = localStorage.getItem(key);
if (raw === null) return defaultValue;
try {
const item: StorageItem<T> = JSON.parse(raw);
// 检查版本
if (item.version !== this.VERSION) {
localStorage.removeItem(key);
return defaultValue;
}
// 检查过期
if (item.ttl && Date.now() - item.timestamp > item.ttl) {
localStorage.removeItem(key);
return defaultValue;
}
return item.value;
} catch {
localStorage.removeItem(key);
return defaultValue;
}
}
/**
* 删除值
*/
static remove(key: string): void {
localStorage.removeItem(key);
}
/**
* 清理过期的条目
*/
static cleanup(): void {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) continue;
const raw = localStorage.getItem(key);
if (!raw) continue;
try {
const item: StorageItem<unknown> = JSON.parse(raw);
if (item.ttl && Date.now() - item.timestamp > item.ttl) {
keysToRemove.push(key);
}
} catch {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
}
/**
* 简单加密(生产环境应使用 Web Crypto API)
*/
private static simpleEncrypt(text: string): string {
return btoa(encodeURIComponent(text));
}
private static simpleDecrypt(encoded: string): string {
return decodeURIComponent(atob(encoded));
}
}
// ============ 使用示例 ============
// 用户偏好设置
interface UserPreferences {
theme: 'light' | 'dark' | 'system';
language: string;
sidebarCollapsed: boolean;
fontSize: number;
}
const defaultPreferences: UserPreferences = {
theme: 'system',
language: 'zh-CN',
sidebarCollapsed: false,
fontSize: 14,
};
function getUserPreferences(): UserPreferences {
return TypedStorage.get<UserPreferences>('user_preferences') ?? defaultPreferences;
}
function setUserPreferences(prefs: Partial<UserPreferences>): void {
const current = getUserPreferences();
TypedStorage.set('user_preferences', { ...current, ...prefs }, { ttl: 365 * 24 * 60 * 60 * 1000 });
}二、IndexedDB 完整封装
// ============ IndexedDB 类型安全封装 ============
interface DBConfig {
name: string;
version: number;
stores: {
name: string;
keyPath: string;
indexes?: { name: string; keyPath: string; options?: IDBIndexParameters }[];
}[];
}
class TypedIndexedDB {
private db: IDBDatabase | null = null;
constructor(private config: DBConfig) {}
/**
* 初始化数据库
*/
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.config.name, this.config.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
for (const store of this.config.stores) {
if (!db.objectStoreNames.contains(store.name)) {
const objectStore = db.createObjectStore(store.name, {
keyPath: store.keyPath,
});
store.indexes?.forEach((index) => {
objectStore.createIndex(index.name, index.keyPath, index.options);
});
}
}
};
});
}
/**
* 添加或更新记录
*/
async put<T>(storeName: string, item: T): Promise<void> {
const db = this.getDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const request = store.put(item);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* 批量写入
*/
async putBatch<T>(storeName: string, items: T[]): Promise<void> {
const db = this.getDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
items.forEach((item) => store.put(item));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* 获取单条记录
*/
async get<T>(storeName: string, key: IDBValidKey): Promise<T | undefined> {
const db = this.getDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const request = store.get(key);
request.onsuccess = () => resolve(request.result as T | undefined);
request.onerror = () => reject(request.error);
});
}
/**
* 获取所有记录
*/
async getAll<T>(storeName: string): Promise<T[]> {
const db = this.getDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const request = store.getAll();
request.onsuccess = () => resolve(request.result as T[]);
request.onerror = () => reject(request.error);
});
}
/**
* 通过索引查询
*/
async getByIndex<T>(
storeName: string,
indexName: string,
value: IDBValidKey
): Promise<T[]> {
const db = this.getDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const index = store.index(indexName);
const request = index.getAll(value);
request.onsuccess = () => resolve(request.result as T[]);
request.onerror = () => reject(request.error);
});
}
/**
* 删除记录
*/
async delete(storeName: string, key: IDBValidKey): Promise<void> {
const db = this.getDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* 清空 Store
*/
async clear(storeName: string): Promise<void> {
const db = this.getDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* 计数
*/
async count(storeName: string): Promise<number> {
const db = this.getDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const request = store.count();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
private getDb(): IDBDatabase {
if (!this.db) {
throw new Error('IndexedDB 未初始化,请先调用 init()');
}
return this.db;
}
}
// ============ 数据库配置示例 ============
const dbConfig: DBConfig = {
name: 'MyAppDB',
version: 2,
stores: [
{
name: 'articles',
keyPath: 'id',
indexes: [
{ name: 'by_category', keyPath: 'category' },
{ name: 'by_updatedAt', keyPath: 'updatedAt' },
{ name: 'by_status', keyPath: 'status', options: { unique: false } },
],
},
{
name: 'drafts',
keyPath: 'localId',
indexes: [
{ name: 'by_syncStatus', keyPath: 'syncStatus' },
{ name: 'by_modifiedAt', keyPath: 'modifiedAt' },
],
},
{
name: 'syncQueue',
keyPath: 'id',
indexes: [
{ name: 'by_timestamp', keyPath: 'timestamp' },
{ name: 'by_operation', keyPath: 'operation' },
],
},
],
};
const db = new TypedIndexedDB(dbConfig);
await db.init();三、状态水合(State Hydration)
// ============ React 状态水合 Hook ============
import { useState, useEffect, useCallback, useRef } from 'react';
interface UsePersistedStateOptions<T> {
/** 存储键 */
key: string;
/** 默认值 */
defaultValue: T;
/** 存储类型 */
storage?: 'localStorage' | 'sessionStorage';
/** 过期时间(毫秒) */
ttl?: number;
/** 是否在写入时压缩 */
compress?: boolean;
/** 水合完成回调 */
onHydrated?: (state: T) => void;
}
function usePersistedState<T>(options: UsePersistedStateOptions<T>) {
const {
key,
defaultValue,
storage = 'localStorage',
ttl,
onHydrated,
} = options;
const [state, setState] = useState<T>(defaultValue);
const [isHydrated, setIsHydrated] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// 水合:从存储中恢复状态
useEffect(() => {
try {
const storageApi = storage === 'localStorage' ? localStorage : sessionStorage;
const raw = storageApi.getItem(key);
if (raw) {
const parsed = JSON.parse(raw);
// TTL 检查
if (parsed._ttl && Date.now() > parsed._ttl) {
storageApi.removeItem(key);
} else {
setState(parsed.value ?? defaultValue);
}
}
} catch (error) {
console.warn(`状态水合失败: ${key}`, error);
}
setIsHydrated(true);
onHydrated?.(state);
}, [key]);
// 持久化:状态变更时写入存储
const persistState = useCallback(
(newState: T) => {
setState(newState);
// 防抖写入
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
try {
const storageApi = storage === 'localStorage' ? localStorage : sessionStorage;
const data = {
value: newState,
_timestamp: Date.now(),
_ttl: ttl ? Date.now() + ttl : undefined,
};
storageApi.setItem(key, JSON.stringify(data));
} catch (error) {
console.warn(`状态持久化失败: ${key}`, error);
}
}, 300);
},
[key, storage, ttl]
);
// 清理
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
return { state, setState: persistState, isHydrated };
}
// ============ 使用示例 ============
function useUserSettings() {
return usePersistedState<UserPreferences>({
key: 'app:user-settings',
defaultValue: {
theme: 'system',
language: 'zh-CN',
sidebarCollapsed: false,
fontSize: 14,
},
storage: 'localStorage',
ttl: 30 * 24 * 60 * 60 * 1000, // 30天
});
}
// 在组件中使用
function SettingsPanel() {
const { state: settings, setState: setSettings, isHydrated } = useUserSettings();
if (!isHydrated) {
return <div>加载设置中...</div>;
}
return (
<div>
<select
value={settings.theme}
onChange={(e) => setSettings({ ...settings, theme: e.target.value as UserPreferences['theme'] })}
>
<option value="light">浅色</option>
<option value="dark">深色</option>
<option value="system">跟随系统</option>
</select>
</div>
);
}四、离线优先架构
// ============ 离线数据管理器 ============
interface SyncableEntity {
id: string;
_syncStatus: 'synced' | 'pending' | 'conflict';
_localModifiedAt: number;
_serverVersion?: number;
}
class OfflineDataManager<T extends SyncableEntity> {
private db: TypedIndexedDB;
private storeName: string;
private syncEndpoint: string;
constructor(db: TypedIndexedDB, storeName: string, syncEndpoint: string) {
this.db = db;
this.storeName = storeName;
this.syncEndpoint = syncEndpoint;
}
/**
* 创建记录(离线可用)
*/
async create(item: Omit<T, 'id' | '_syncStatus' | '_localModifiedAt'>): Promise<T> {
const entity = {
...item,
id: `local_${crypto.randomUUID()}`,
_syncStatus: 'pending' as const,
_localModifiedAt: Date.now(),
} as T;
await this.db.put(this.storeName, entity);
await this.addToSyncQueue('create', entity);
return entity;
}
/**
* 更新记录(离线可用)
*/
async update(id: string, updates: Partial<T>): Promise<T | undefined> {
const existing = await this.db.get<T>(this.storeName, id);
if (!existing) return undefined;
const updated = {
...existing,
...updates,
_syncStatus: 'pending' as const,
_localModifiedAt: Date.now(),
};
await this.db.put(this.storeName, updated);
await this.addToSyncQueue('update', updated);
return updated;
}
/**
* 删除记录(离线可用)
*/
async delete(id: string): Promise<void> {
const existing = await this.db.get<T>(this.storeName, id);
if (!existing) return;
if (id.startsWith('local_')) {
// 本地创建的未同步记录,直接删除
await this.db.delete(this.storeName, id);
} else {
// 已同步的记录,标记为待删除
const deleted = {
...existing,
_syncStatus: 'pending' as const,
_localModifiedAt: Date.now(),
};
await this.db.put(this.storeName, deleted);
await this.addToSyncQueue('delete', { id } as T);
}
}
/**
* 获取所有本地记录
*/
async getAll(): Promise<T[]> {
return this.db.getAll<T>(this.storeName);
}
/**
* 添加到同步队列
*/
private async addToSyncQueue(operation: string, entity: T): Promise<void> {
await this.db.put('syncQueue', {
id: crypto.randomUUID(),
operation,
storeName: this.storeName,
entityId: entity.id,
data: entity,
timestamp: Date.now(),
retryCount: 0,
});
}
}五、Service Worker 缓存策略
// ============ Service Worker 注册与缓存管理 ============
// sw.ts - Service Worker 脚本
const CACHE_NAME = 'myapp-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/static/js/main.js',
'/static/css/main.css',
'/manifest.json',
];
// 安装:预缓存静态资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
// 激活:清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});
// 请求拦截
self.addEventListener('fetch', (event) => {
const { request } = event;
// API 请求:网络优先
if (request.url.includes('/api/')) {
event.respondWith(networkFirstWithCache(request));
return;
}
// 静态资源:缓存优先
event.respondWith(cacheFirstWithNetwork(request));
});
// 网络优先策略(API 数据)
async function networkFirstWithCache(request: Request): Promise<Response> {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await caches.match(request);
if (cachedResponse) return cachedResponse;
return new Response(JSON.stringify({ error: '离线状态' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
}
// 缓存优先策略(静态资源)
async function cacheFirstWithNetwork(request: Request): Promise<Response> {
const cachedResponse = await caches.match(request);
if (cachedResponse) return cachedResponse;
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// 离线时的兜底页面
if (request.destination === 'document') {
return caches.match('/offline.html') as Promise<Response>;
}
return new Response('', { status: 503 });
}
}// ============ 应用端注册 Service Worker ============
class ServiceWorkerManager {
private registration: ServiceWorkerRegistration | null = null;
async register(): Promise<void> {
if (!('serviceWorker' in navigator)) {
console.warn('当前浏览器不支持 Service Worker');
return;
}
try {
this.registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
// 监听更新
this.registration.addEventListener('updatefound', () => {
const newWorker = this.registration!.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 新版本可用,提示用户更新
this.showUpdateNotification();
}
});
}
});
console.log('Service Worker 注册成功');
} catch (error) {
console.error('Service Worker 注册失败:', error);
}
}
/**
* 更新 Service Worker
*/
async update(): Promise<void> {
if (this.registration) {
await this.registration.update();
}
}
/**
* 提示用户更新
*/
private showUpdateNotification(): void {
const updateBanner = document.createElement('div');
updateBanner.className = 'update-banner';
updateBanner.innerHTML = `
<span>发现新版本</span>
<button id="update-btn">立即更新</button>
`;
document.body.appendChild(updateBanner);
document.getElementById('update-btn')?.addEventListener('click', () => {
navigator.serviceWorker.controller?.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
});
}
}六、数据同步与冲突解决
// ============ 双向同步引擎 ============
interface SyncResult {
pulled: number;
pushed: number;
conflicts: ConflictInfo[];
}
interface ConflictInfo {
entityId: string;
localVersion: number;
serverVersion: number;
resolution: 'local' | 'server' | 'merged';
}
class SyncEngine<T extends SyncableEntity> {
private manager: OfflineDataManager<T>;
constructor(manager: OfflineDataManager<T>) {
this.manager = manager;
}
/**
* 执行同步
*/
async sync(): Promise<SyncResult> {
const result: SyncResult = { pulled: 0, pushed: 0, conflicts: [] };
// 1. 拉取服务端变更
const serverChanges = await this.fetchServerChanges();
for (const serverItem of serverChanges) {
const localItem = await this.getLocal(serverItem.id);
if (!localItem) {
// 服务端新增,直接写入本地
await this.saveLocal({ ...serverItem, _syncStatus: 'synced' });
result.pulled++;
} else if (localItem._syncStatus === 'synced') {
// 本地未修改,直接更新
await this.saveLocal({ ...serverItem, _syncStatus: 'synced' });
result.pulled++;
} else {
// 冲突:本地和服务端都修改了
const resolved = await this.resolveConflict(localItem, serverItem);
result.conflicts.push({
entityId: serverItem.id,
localVersion: localItem._serverVersion ?? 0,
serverVersion: serverItem._serverVersion ?? 0,
resolution: resolved._syncStatus === 'synced' ? 'server' : 'local',
});
}
}
// 2. 推送本地变更
const pendingItems = await this.getPendingItems();
for (const item of pendingItems) {
try {
const serverItem = await this.pushToServer(item);
await this.saveLocal({ ...serverItem, _syncStatus: 'synced' });
result.pushed++;
} catch (error) {
console.error(`推送失败: ${item.id}`, error);
}
}
return result;
}
/**
* 冲突解决策略:Last-Write-Wins
*/
private async resolveConflict(
local: T,
server: T
): Promise<T> {
if (local._localModifiedAt > (server._localModifiedAt ?? 0)) {
// 本地更新,以本地为准
return local;
}
// 服务端更新,以服务端为准
return server;
}
private async fetchServerChanges(): Promise<T[]> {
const response = await fetch('/api/sync/changes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ since: this.getLastSyncTimestamp() }),
});
return response.json();
}
private async pushToServer(item: T): Promise<T> {
const response = await fetch('/api/sync/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
return response.json();
}
private getLastSyncTimestamp(): number {
return TypedStorage.get<number>('last_sync_timestamp') ?? 0;
}
private async getLocal(id: string): Promise<T | undefined> {
return undefined; // 简化
}
private async saveLocal(item: T): Promise<void> {
// 简化
}
private async getPendingItems(): Promise<T[]> {
return [];
}
}七、存储配额管理
// ============ 存储配额监控 ============
class StorageQuotaManager {
/**
* 获取存储使用情况
*/
static async getUsage(): Promise<{
usage: number;
quota: number;
percentage: number;
}> {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
const usage = estimate.usage ?? 0;
const quota = estimate.quota ?? 0;
return {
usage,
quota,
percentage: quota > 0 ? (usage / quota) * 100 : 0,
};
}
return { usage: 0, quota: 0, percentage: 0 };
}
/**
* 检查存储压力
*/
static async isStoragePressure(): Promise<boolean> {
const { percentage } = await this.getUsage();
return percentage > 80; // 超过 80% 即为存储压力
}
/**
* 清理策略:LRU 清理
*/
static async cleanupLRU(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise<number> {
let cleaned = 0;
// 清理过期的 localStorage 条目
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (!key) continue;
try {
const raw = localStorage.getItem(key);
if (!raw) continue;
const item = JSON.parse(raw);
if (item._timestamp && Date.now() - item._timestamp > maxAge) {
localStorage.removeItem(key);
cleaned++;
}
} catch {
// 无法解析的条目跳过
}
}
// 清理过期的 Cache Storage
if ('caches' in window) {
const cacheNames = await caches.keys();
for (const name of cacheNames) {
if (name.startsWith('temp-')) {
await caches.delete(name);
cleaned++;
}
}
}
return cleaned;
}
/**
* 请求持久化存储(防止浏览器自动清理)
*/
static async requestPersistentStorage(): Promise<boolean> {
if ('storage' in navigator && 'persist' in navigator.storage) {
return await navigator.storage.persist();
}
return false;
}
}八、加密存储
// ============ 基于 Web Crypto API 的加密存储 ============
class EncryptedStorage {
private static encoder = new TextEncoder();
private static decoder = new TextDecoder();
/**
* 生成加密密钥
*/
static async generateKey(): Promise<string> {
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
const exported = await crypto.subtle.exportKey('raw', key);
return btoa(String.fromCharCode(...new Uint8Array(exported)));
}
/**
* 加密并存储
*/
static async setEncrypted(key: string, value: unknown, encryptionKey: string): Promise<void> {
const json = JSON.stringify(value);
const encoded = this.encoder.encode(json);
const cryptoKey = await this.importKey(encryptionKey);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
encoded
);
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
localStorage.setItem(key, btoa(String.fromCharCode(...combined)));
}
/**
* 解密并读取
*/
static async getEncrypted<T>(key: string, encryptionKey: string): Promise<T | null> {
const stored = localStorage.getItem(key);
if (!stored) return null;
try {
const combined = Uint8Array.from(atob(stored), (c) => c.charCodeAt(0));
const iv = combined.slice(0, 12);
const data = combined.slice(12);
const cryptoKey = await this.importKey(encryptionKey);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
data
);
const json = this.decoder.decode(decrypted);
return JSON.parse(json) as T;
} catch {
return null;
}
}
private static async importKey(base64Key: string): Promise<CryptoKey> {
const rawKey = Uint8Array.from(atob(base64Key), (c) => c.charCodeAt(0));
return crypto.subtle.importKey('raw', rawKey, { name: 'AES-GCM' }, false, [
'encrypt',
'decrypt',
]);
}
}九、存储迁移策略
// ============ 版本化存储迁移 ============
interface MigrationStep {
fromVersion: number;
toVersion: number;
migrate: (data: Record<string, unknown>) => Record<string, unknown>;
}
class StorageMigration {
private static CURRENT_VERSION = 3;
private static migrations: MigrationStep[] = [
{
fromVersion: 1,
toVersion: 2,
migrate: (data) => {
// v1 -> v2: 用户设置结构变更
const settings = data['user_settings'] as Record<string, unknown>;
if (settings) {
data['user_preferences'] = {
theme: settings.darkMode ? 'dark' : 'light',
language: settings.lang ?? 'zh-CN',
sidebarCollapsed: settings.sidebarHidden ?? false,
fontSize: 14,
};
delete data['user_settings'];
}
return data;
},
},
{
fromVersion: 2,
toVersion: 3,
migrate: (data) => {
// v2 -> v3: 添加搜索历史
const preferences = data['user_preferences'] as Record<string, unknown>;
if (preferences) {
data['search_history'] = [];
data['recently_viewed'] = [];
}
return data;
},
},
];
/**
* 执行迁移
*/
static migrate(): void {
const storedVersion = parseInt(
localStorage.getItem('_storage_version') ?? '1',
10
);
if (storedVersion >= this.CURRENT_VERSION) return;
let data = this.exportAll();
let currentVersion = storedVersion;
while (currentVersion < this.CURRENT_VERSION) {
const migration = this.migrations.find(
(m) => m.fromVersion === currentVersion
);
if (!migration) {
console.warn(`找不到 ${currentVersion} -> ${currentVersion + 1} 的迁移脚本`);
break;
}
console.log(`执行存储迁移: v${currentVersion} -> v${migration.toVersion}`);
data = migration.migrate(data);
currentVersion = migration.toVersion;
}
this.importAll(data);
localStorage.setItem('_storage_version', this.CURRENT_VERSION.toString());
}
private static exportAll(): Record<string, unknown> {
const data: Record<string, unknown> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && !key.startsWith('_')) {
try {
data[key] = JSON.parse(localStorage.getItem(key)!);
} catch {
data[key] = localStorage.getItem(key);
}
}
}
return data;
}
private static importAll(data: Record<string, unknown>): void {
Object.entries(data).forEach(([key, value]) => {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
});
}
}
// 应用启动时执行迁移
StorageMigration.migrate();优点
- 离线体验:本地缓存数据使应用在离线状态下也能部分使用
- 快速加载:水合机制使页面加载后立即可用,无需等待 API 响应
- 用户友好:记住用户偏好设置,提供个性化体验
- 减少请求:缓存机制减少不必要的网络请求
缺点
- 存储容量有限:localStorage 通常 5-10MB,IndexedDB 虽然更大但也有上限
- 数据安全风险:本地存储的数据可被用户或恶意脚本访问
- 同步复杂:本地与服务端数据的同步需要处理各种冲突场景
- 调试困难:IndexedDB 操作是异步的,调试不如 localStorage 直观
性能注意事项
- 写入防抖:频繁的状态变更不应每次都写入存储,应做防抖处理(300ms 左右)
- 读写分离:启动时批量读取,运行时增量写入
- IndexedDB 事务:将多个操作放在一个事务中执行,减少 IO 开销
- 序列化成本:大型对象的 JSON 序列化/反序列化会阻塞主线程,考虑使用 Web Worker
- localStorage 阻塞:localStorage 是同步 API,大量读写会阻塞主线程
总结
前端状态持久化是构建高质量 Web 应用的基础设施。从简单的 localStorage 到功能丰富的 IndexedDB,从单机缓存到离线优先架构,不同的场景需要不同的策略。关键是理解每种存储方案的特点和限制,在性能、可靠性和复杂度之间找到平衡。
关键知识点
| 知识点 | 要点 |
|---|---|
| localStorage | 5-10MB,同步 API,适合简单键值对 |
| sessionStorage | 标签页级别,关闭即清除 |
| IndexedDB | 大容量,异步 API,适合结构化数据 |
| State Hydration | 启动时从存储恢复状态 |
| Service Worker | 离线缓存和请求拦截 |
| 冲突解决 | Last-Write-Wins / 自定义合并策略 |
| Web Crypto API | 浏览器原生加密 |
| Storage API | 查询配额和请求持久化 |
常见误区
误区:localStorage 什么都能存
- 事实:只能存字符串,且有 5-10MB 限制
误区:IndexedDB 像关系型数据库
- 事实:IndexedDB 是文档数据库,不支持 SQL 查询和 JOIN
误区:Service Worker 缓存能替代 API
- 事实:缓存是离线兜底,不能替代真实数据请求
误区:本地存储不需要加密
- 事实:敏感数据(Token、个人信息)应加密后存储
误区:持久化存储永不过期
- 事实:浏览器可能在存储压力下清除数据
进阶路线
- CRDT(无冲突复制数据类型):实现真正的多人协作离线编辑
- OPFS(Origin Private File System):浏览器内的文件系统 API
- Web Locks API:跨标签页的锁机制
- Broadcast Channel API:跨标签页通信
- Storage Buckets:分组管理存储配额(Chrome 120+)
适用场景
| 场景 | 推荐方案 |
|---|---|
| 用户偏好 | localStorage + TTL |
| 表单草稿 | IndexedDB |
| 离线数据 | IndexedDB + Service Worker |
| 缓存 API 响应 | Cache API + Service Worker |
| 敏感数据 | Web Crypto API + localStorage |
| 临时状态 | sessionStorage |
落地建议
- 封装统一的存储抽象层,屏蔽底层 API 差异
- 所有持久化数据添加版本号,支持迁移
- 敏感数据加密存储,非敏感数据明文存储
- 实现存储配额监控,超出阈值时自动清理
- 在应用启动时执行水合,显示加载状态
排错清单
复盘问题
- 用户本地存储的数据有多大?平均使用了多少配额?
- 离线场景下,应用的核心功能可用率是多少?
- 本地与服务端数据冲突的发生频率如何?
- 上一次存储迁移是否平滑?有没有数据丢失的反馈?
- 敏感数据在本地存储中是否做了加密处理?
