Progressive Web App (PWA) 结合多种存储技术实现离线功能:
// 注册 Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker 注册成功');
})
.catch(error => {
console.error('Service Worker 注册失败:', error);
});
}
// sw.js - Service Worker 脚本
const CACHE_NAME = 'app-cache-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/app.js',
'/offline.html'
];
// 安装事件 - 缓存静态资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
// 拦截请求 - 缓存优先策略
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(response => {
// 缓存命中则返回缓存
if (response) {
return response;
}
// 否则从网络获取
return fetch(event.request).then(response => {
// 检查是否是有效响应
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// 克隆响应并缓存
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseToCache);
});
return response;
});
})
.catch(() => {
// 离线时返回离线页面
return caches.match('/offline.html');
})
);
});class OfflineDataManager {
constructor() {
this.dbName = 'OfflineAppDB';
this.dbVersion = 1;
this.db = null;
}
// 初始化数据库
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 创建离线数据存储
if (!db.objectStoreNames.contains('offlineActions')) {
const store = db.createObjectStore('offlineActions', {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('timestamp', 'timestamp', { unique: false });
store.createIndex('synced', 'synced', { unique: false });
}
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onerror = () => reject(request.error);
});
}
// 保存离线操作
async saveOfflineAction(action) {
const transaction = this.db.transaction(['offlineActions'], 'readwrite');
const store = transaction.objectStore('offlineActions');
const actionData = {
...action,
timestamp: Date.now(),
synced: false
};
return new Promise((resolve, reject) => {
const request = store.add(actionData);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 获取未同步的操作
async getUnsyncedActions() {
const transaction = this.db.transaction(['offlineActions'], 'readonly');
const store = transaction.objectStore('offlineActions');
const index = store.index('synced');
return new Promise((resolve, reject) => {
const request = index.getAll(false);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 标记为已同步
async markAsSynced(actionId) {
const transaction = this.db.transaction(['offlineActions'], 'readwrite');
const store = transaction.objectStore('offlineActions');
return new Promise((resolve, reject) => {
const getRequest = store.get(actionId);
getRequest.onsuccess = () => {
const action = getRequest.result;
action.synced = true;
const putRequest = store.put(action);
putRequest.onsuccess = () => resolve();
putRequest.onerror = () => reject(putRequest.error);
};
getRequest.onerror = () => reject(getRequest.error);
});
}
// 同步到服务器
async syncToServer() {
// 检查网络连接
if (!navigator.onLine) {
console.log('离线状态,无法同步');
return false;
}
const unsyncedActions = await this.getUnsyncedActions();
for (const action of unsyncedActions) {
try {
// 发送到服务器
const response = await fetch('/api/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action)
});
if (response.ok) {
await this.markAsSynced(action.id);
console.log(`操作 ${action.id} 同步成功`);
}
} catch (error) {
console.error(`操作 ${action.id} 同步失败:`, error);
}
}
return true;
}
}
// 使用示例
const offlineManager = new OfflineDataManager();
// 初始化
offlineManager.init().then(() => {
console.log('离线数据管理器初始化完成');
// 监听在线状态
window.addEventListener('online', () => {
console.log('网络已连接,开始同步');
offlineManager.syncToServer();
});
});
// 保存离线操作
document.getElementById('saveBtn').addEventListener('click', async () => {
const data = {
type: 'create',
entity: 'note',
data: {
title: document.getElementById('title').value,
content: document.getElementById('content').value
}
};
await offlineManager.saveOfflineAction(data);
if (navigator.onLine) {
await offlineManager.syncToServer();
} else {
alert('已保存到本地,将在网络连接后自动同步');
}
});在不同存储方案间迁移数据:
class StorageMigrator {
constructor() {
this.dbName = 'AppDB';
this.dbVersion = 1;
}
// 从 localStorage 迁移到 IndexedDB
async migrateFromLocalStorage(storeName, keys) {
console.log('开始从 localStorage 迁移数据...');
// 打开 IndexedDB
const db = await this.openDB();
// 读取 localStorage 数据
const dataToMigrate = [];
for (const key of keys) {
const value = localStorage.getItem(key);
if (value) {
try {
const parsedValue = JSON.parse(value);
dataToMigrate.push({ key, value: parsedValue });
} catch (e) {
dataToMigrate.push({ key, value });
}
}
}
// 批量写入 IndexedDB
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
for (const item of dataToMigrate) {
store.add(item.value);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => {
console.log(`成功迁移 ${dataToMigrate.length} 条数据`);
// 清理 localStorage
keys.forEach(key => localStorage.removeItem(key));
console.log('已清理 localStorage 数据');
resolve(dataToMigrate.length);
};
transaction.onerror = () => reject(transaction.error);
});
}
// 打开数据库
openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('migratedData')) {
db.createObjectStore('migratedData', { keyPath: 'id', autoIncrement: true });
}
};
request.onsuccess = (event) => resolve(event.target.result);
request.onerror = () => reject(request.error);
});
}
// 导出 IndexedDB 数据为 JSON
async exportIndexedDB(storeName) {
const db = await this.openDB();
const transaction = db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => {
const data = request.result;
const json = JSON.stringify(data, null, 2);
// 创建下载链接
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `backup-${storeName}-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
resolve(data.length);
};
request.onerror = () => reject(request.error);
});
}
// 导入 JSON 数据到 IndexedDB
async importToIndexedDB(storeName, jsonData) {
const db = await this.openDB();
const data = JSON.parse(jsonData);
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
let count = 0;
for (const item of data) {
store.add(item);
count++;
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => {
console.log(`成功导入 ${count} 条数据`);
resolve(count);
};
transaction.onerror = () => reject(transaction.error);
});
}
}
// 使用示例
const migrator = new StorageMigrator();
// 迁移特定键的数据
document.getElementById('migrateBtn').addEventListener('click', async () => {
const keysToMigrate = ['userData', 'preferences', 'cache'];
const count = await migrator.migrateFromLocalStorage('migratedData', keysToMigrate);
alert(`成功迁移 ${count} 条数据`);
});
// 导出数据
document.getElementById('exportBtn').addEventListener('click', async () => {
await migrator.exportIndexedDB('migratedData');
});
// 导入数据
document.getElementById('importBtn').addEventListener('click', async () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = async (event) => {
const jsonData = event.target.result;
await migrator.importToIndexedDB('migratedData', jsonData);
};
reader.readAsText(file);
};
input.click();
});浏览器的同源策略限制了跨域存储访问,以下是几种解决方案:
<!-- 页面 A: https://site-a.com -->
<iframe id="storageFrame" src="https://site-b.com/storage-proxy.html" style="display:none;"></iframe>
<script>
const frame = document.getElementById('storageFrame');
// 等待 iframe 加载完成
frame.onload = function() {
// 跨域读取数据
function getCrossDomainData(key) {
return new Promise((resolve) => {
const messageHandler = (event) => {
if (event.data.type === 'storageResponse' && event.data.key === key) {
window.removeEventListener('message', messageHandler);
resolve(event.data.value);
}
};
window.addEventListener('message', messageHandler);
frame.contentWindow.postMessage({
type: 'getStorage',
key: key
}, 'https://site-b.com');
});
}
// 跨域写入数据
function setCrossDomainData(key, value) {
return new Promise((resolve) => {
const messageHandler = (event) => {
if (event.data.type === 'setStorageAck' && event.data.key === key) {
window.removeEventListener('message', messageHandler);
resolve(true);
}
};
window.addEventListener('message', messageHandler);
frame.contentWindow.postMessage({
type: 'setStorage',
key: key,
value: value
}, 'https://site-b.com');
});
}
// 使用示例
setCrossDomainData('user', { name: 'Alice', age: 28 })
.then(() => console.log('数据保存成功'));
getCrossDomainData('user')
.then(data => console.log('读取的数据:', data));
};
</script>
<!-- 页面 B (代理页面): https://site-b.com/storage-proxy.html -->
<script>
// 监听来自父页面的消息
window.addEventListener('message', (event) => {
// 验证来源
if (event.origin !== 'https://site-a.com') {
return;
}
const { type, key, value } = event.data;
switch (type) {
case 'getStorage':
// 读取 localStorage
const storedValue = localStorage.getItem(key);
event.source.postMessage({
type: 'storageResponse',
key: key,
value: storedValue ? JSON.parse(storedValue) : null
}, event.origin);
break;
case 'setStorage':
// 写入 localStorage
localStorage.setItem(key, JSON.stringify(value));
event.source.postMessage({
type: 'setStorageAck',
key: key
}, event.origin);
break;
case 'removeStorage':
// 删除 localStorage
localStorage.removeItem(key);
event.source.postMessage({
type: 'removeStorageAck',
key: key
}, event.origin);
break;
}
});
</script>// 客户端: 跨域数据存储服务
class CrossDomainStorage {
constructor(apiEndpoint) {
this.apiEndpoint = apiEndpoint;
}
// 存储
async set(key, value) {
const response = await fetch(`${this.apiEndpoint}/storage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
credentials: 'include' // 携带 Cookie
});
return response.ok;
}
// 读取
async get(key) {
const response = await fetch(`${this.apiEndpoint}/storage/${key}`, {
credentials: 'include'
});
if (response.ok) {
return await response.json();
}
return null;
}
// 删除
async remove(key) {
const response = await fetch(`${this.apiEndpoint}/storage/${key}`, {
method: 'DELETE',
credentials: 'include'
});
return response.ok;
}
}
// 服务器端 (Node.js Express 示例)
/*
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const app = express();
// CORS 配置
app.use(cors({
origin: ['https://site-a.com', 'https://site-b.com'],
credentials: true
}));
app.use(cookieParser());
app.use(express.json());
// 存储路由
app.post('/storage', (req, res) => {
const { key, value } = req.body;
const sessionId = req.cookies.sessionId;
// 存储到服务器数据库或 Redis
// db.set(`${sessionId}:${key}`, JSON.stringify(value));
res.json({ success: true });
});
app.get('/storage/:key', (req, res) => {
const { key } = req.params;
const sessionId = req.cookies.sessionId;
// 从服务器读取
// const value = db.get(`${sessionId}:${key}`);
res.json({ value }); // 返回值
});
app.delete('/storage/:key', (req, res) => {
const { key } = req.params;
const sessionId = req.cookies.sessionId;
// 删除
// db.delete(`${sessionId}:${key}`);
res.json({ success: true });
});
app.listen(3000);
*/
// 使用示例
const storage = new CrossDomainStorage('https://api.example.com');
// 在任意域名下都可以访问
await storage.set('user', { name: 'Alice' });
const user = await storage.get('user');// shared-worker.js
const connections = [];
const sharedData = {};
// 监听连接
self.onconnect = (event) => {
const port = event.ports[0];
connections.push(port);
port.onmessage = (event) => {
const { type, key, value } = event.data;
switch (type) {
case 'get':
port.postMessage({ type: 'response', key, value: sharedData[key] });
break;
case 'set':
sharedData[key] = value;
// 广播给所有连接
connections.forEach(conn => {
conn.postMessage({ type: 'update', key, value });
});
break;
case 'remove':
delete sharedData[key];
connections.forEach(conn => {
conn.postMessage({ type: 'remove', key });
});
break;
}
};
port.start();
};
// 主线程使用
const worker = new SharedWorker('shared-worker.js');
worker.port.onmessage = (event) => {
console.log('收到消息:', event.data);
};
worker.port.postMessage({ type: 'set', key: 'user', value: { name: 'Alice' } });以下是一个综合运用 Cookie、localStorage、sessionStorage 和 IndexedDB 的完整示例:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户会话管理系统</title>
<style>
body {
font-family: 'Segoe UI', Arial, sans-serif;
max-width: 1000px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1, h2 {
color: #333;
margin-top: 0;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="text"],
input[type="email"],
input[type="password"],
select,
textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
button {
padding: 10px 20px;
margin: 5px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background-color: #0056b3;
}
button.danger {
background-color: #dc3545;
}
button.danger:hover {
background-color: #bd2130;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 4px;
background-color: #d4edda;
color: #155724;
}
.error {
background-color: #f8d7da;
color: #721c24;
}
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #007bff;
padding: 15px;
margin: 10px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th, td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f8f9fa;
font-weight: bold;
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
}
.badge.online {
background-color: #28a745;
color: white;
}
.badge.offline {
background-color: #6c757d;
color: white;
}
</style>
</head>
<body>
<h1>用户会话管理系统</h1>
<!-- 在线状态指示 -->
<div id="onlineStatus" class="status">
<span class="badge online">在线</span> 网络连接正常
</div>
<!-- 登录/注册表单 -->
<div id="authSection" class="container">
<h2>用户认证</h2>
<div class="form-group">
<label for="email">邮箱:</label>
<input type="email" id="email" placeholder="请输入邮箱">
</div>
<div class="form-group">
<label for="password">密码:</label>
<input type="password" id="password" placeholder="请输入密码">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="rememberMe"> 记住我
</label>
</div>
<button onclick="login()">登录</button>
<button onclick="register()">注册</button>
</div>
<!-- 用户信息展示 -->
<div id="userSection" class="container" style="display:none;">
<h2>用户信息</h2>
<div class="info-box">
<p><strong>用户名:</strong> <span id="userName"></span></p>
<p><strong>邮箱:</strong> <span id="userEmail"></span></p>
<p><strong>登录时间:</strong> <span id="loginTime"></span></p>
<p><strong>会话ID:</strong> <code id="sessionId"></code></p>
</div>
<button onclick="logout()">退出登录</button>
<button onclick="clearAllData()" class="danger">清除所有数据</button>
</div>
<!-- 用户偏好设置 -->
<div id="preferencesSection" class="container" style="display:none;">
<h2>偏好设置</h2>
<div class="form-group">
<label for="theme">主题:</label>
<select id="theme" onchange="savePreferences()">
<option value="light">浅色</option>
<option value="dark">深色</option>
</select>
</div>
<div class="form-group">
<label for="language">语言:</label>
<select id="language" onchange="savePreferences()">
<option value="zh-CN">中文</option>
<option value="en-US">English</option>
</select>
</div>
<div class="form-group">
<label for="fontSize">字体大小:</label>
<input type="text" id="fontSize" value="14px" onchange="savePreferences()">
</div>
</div>
<!-- 离线数据展示 -->
<div id="offlineDataSection" class="container" style="display:none;">
<h2>离线数据</h2>
<div class="info-box">
<p><strong>待同步数据:</strong> <span id="pendingSync">0</span> 条</p>
</div>
<table id="offlineDataTable">
<thead>
<tr>
<th>时间</th>
<th>类型</th>
<th>数据</th>
<th>状态</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<!-- 存储统计 -->
<div class="container">
<h2>存储统计</h2>
<table>
<tr>
<th>存储类型</th>
<th>使用量</th>
<th>详情</th>
</tr>
<tr>
<td>Cookie</td>
<td id="cookieCount">0 个</td>
<td><button onclick="showCookieDetails()">查看详情</button></td>
</tr>
<tr>
<td>localStorage</td>
<td id="localStorageSize">0 KB</td>
<td><button onclick="showLocalStorageDetails()">查看详情</button></td>
</tr>
<tr>
<td>sessionStorage</td>
<td id="sessionStorageSize">0 KB</td>
<td><button onclick="showSessionStorageDetails()">查看详情</button></td>
</tr>
<tr>
<td>IndexedDB</td>
<td id="indexedDBSize">估算中...</td>
<td><button onclick="showIndexedDBDetails()">查看详情</button></td>
</tr>
</table>
</div>
<script>
// ==================== 核心管理类 ====================
class UserSessionManager {
constructor() {
this.SESSION_COOKIE = 'sessionId';
this.USER_KEY = 'currentUser';
this.PREFERENCES_KEY = 'userPreferences';
this.REMEMBER_KEY = 'rememberEmail';
this.dbName = 'UserSessionDB';
this.dbVersion = 1;
this.db = null;
this.init();
}
// 初始化
async init() {
// 初始化 IndexedDB
await this.initDB();
// 恢复会话
const session = await this.restoreSession();
// 应用用户偏好
this.applyPreferences();
// 更新UI
this.updateUI();
// 设置网络监听
this.setupNetworkListener();
// 设置存储事件监听
this.setupStorageListener();
// 恢复记住的邮箱
this.restoreRememberedEmail();
// 更新存储统计
this.updateStorageStats();
}
// 初始化 IndexedDB
async initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 用户数据存储
if (!db.objectStoreNames.contains('userData')) {
const userStore = db.createObjectStore('userData', { keyPath: 'id' });
userStore.createIndex('email', 'email', { unique: true });
userStore.createIndex('lastUpdate', 'lastUpdate', { unique: false });
}
// 离线操作队列
if (!db.objectStoreNames.contains('offlineQueue')) {
const offlineStore = db.createObjectStore('offlineQueue', {
keyPath: 'id',
autoIncrement: true
});
offlineStore.createIndex('timestamp', 'timestamp', { unique: false });
offlineStore.createIndex('synced', 'synced', { unique: false });
}
// 活动日志
if (!db.objectStoreNames.contains('activityLog')) {
const logStore = db.createObjectStore('activityLog', {
keyPath: 'id',
autoIncrement: true
});
logStore.createIndex('timestamp', 'timestamp', { unique: false });
logStore.createIndex('type', 'type', { unique: false });
}
};
});
}
// 登录
async login(email, password, rememberMe) {
try {
// 模拟API调用
const response = await this.mockLoginAPI(email, password);
if (response.success) {
// 设置会话 Cookie (模拟服务器设置)
const sessionId = this.generateSessionId();
document.cookie = `${this.SESSION_COOKIE}=${sessionId}; path=/; max-age=86400; SameSite=Lax`;
// 存储用户信息到 sessionStorage
const userData = {
id: response.user.id,
name: response.user.name,
email: response.user.email,
loginTime: new Date().toISOString()
};
sessionStorage.setItem(this.USER_KEY, JSON.stringify(userData));
// 存储到 IndexedDB (持久化)
await this.saveUserData({ ...userData, lastUpdate: Date.now() });
// 记住邮箱
if (rememberMe) {
localStorage.setItem(this.REMEMBER_KEY, email);
} else {
localStorage.removeItem(this.REMEMBER_KEY);
}
// 记录登录日志
await this.logActivity('login', { email, rememberMe });
// 更新UI
this.updateUI();
return { success: true };
} else {
return { success: false, error: response.error };
}
} catch (error) {
return { success: false, error: error.message };
}
}
// 注册
async register(email, password) {
// 离线时保存到队列
if (!navigator.onLine) {
await this.addToOfflineQueue({
type: 'register',
data: { email, password }
});
return {
success: true,
message: '已保存到离线队列,将在网络连接后同步'
};
}
// 在线时直接注册
try {
const response = await this.mockRegisterAPI(email, password);
return response;
} catch (error) {
return { success: false, error: error.message };
}
}
// 登出
async logout() {
// 记录登出日志
await this.logActivity('logout', {});
// 清除 Cookie
document.cookie = `${this.SESSION_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
// 清除 sessionStorage
sessionStorage.removeItem(this.USER_KEY);
// 更新UI
this.updateUI();
}
// 保存用户数据到 IndexedDB
async saveUserData(userData) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['userData'], 'readwrite');
const store = transaction.objectStore('userData');
const request = store.put(userData);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 获取用户数据
async getUserData(userId) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['userData'], 'readonly');
const store = transaction.objectStore('userData');
const request = store.get(userId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 恢复会话
async restoreSession() {
// 从 Cookie 检查会话ID
const sessionId = this.getCookie(this.SESSION_COOKIE);
if (!sessionId) {
return null;
}
// 从 sessionStorage 获取用户信息
const userDataStr = sessionStorage.getItem(this.USER_KEY);
if (userDataStr) {
return JSON.parse(userDataStr);
}
// 从 IndexedDB 恢复
// (这里简化处理,实际应该验证 sessionId)
const allUsers = await this.getAllUsers();
if (allUsers.length > 0) {
const user = allUsers[allUsers.length - 1];
sessionStorage.setItem(this.USER_KEY, JSON.stringify(user));
return user;
}
return null;
}
// 获取所有用户
async getAllUsers() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['userData'], 'readonly');
const store = transaction.objectStore('userData');
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
// 保存偏好设置
savePreferences(preferences) {
localStorage.setItem(this.PREFERENCES_KEY, JSON.stringify(preferences));
this.applyPreferences();
}
// 获取偏好设置
getPreferences() {
const prefs = localStorage.getItem(this.PREFERENCES_KEY);
return prefs ? JSON.parse(prefs) : {
theme: 'light',
language: 'zh-CN',
fontSize: '14px'
};
}
// 应用偏好设置
applyPreferences() {
const prefs = this.getPreferences();
// 应用主题
document.body.className = prefs.theme === 'dark' ? 'dark-theme' : '';
// 应用字体大小
document.body.style.fontSize = prefs.fontSize;
}
// 添加到离线队列
async addToOfflineQueue(action) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['offlineQueue'], 'readwrite');
const store = transaction.objectStore('offlineQueue');
const request = store.add({
...action,
timestamp: Date.now(),
synced: false
});
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 获取未同步的离线数据
async getUnsyncedData() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['offlineQueue'], 'readonly');
const store = transaction.objectStore('offlineQueue');
const index = store.index('synced');
const request = index.getAll(false);
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
// 同步离线数据
async syncOfflineData() {
if (!navigator.onLine) {
return;
}
const unsyncedData = await this.getUnsyncedData();
for (const item of unsyncedData) {
try {
// 模拟同步
console.log('同步数据:', item);
// 标记为已同步
const transaction = this.db.transaction(['offlineQueue'], 'readwrite');
const store = transaction.objectStore('offlineQueue');
item.synced = true;
store.put(item);
} catch (error) {
console.error('同步失败:', error);
}
}
this.updateUI();
}
// 记录活动日志
async logActivity(type, data) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['activityLog'], 'readwrite');
const store = transaction.objectStore('activityLog');
const request = store.add({
type,
data,
timestamp: Date.now()
});
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 设置网络监听
setupNetworkListener() {
window.addEventListener('online', async () => {
document.getElementById('onlineStatus').innerHTML =
'<span class="badge online">在线</span> 网络连接正常';
// 同步离线数据
await this.syncOfflineData();
});
window.addEventListener('offline', () => {
document.getElementById('onlineStatus').innerHTML =
'<span class="badge offline">离线</span> 网络已断开';
});
}
// 设置存储事件监听
setupStorageListener() {
window.addEventListener('storage', (event) => {
if (event.key === this.PREFERENCES_KEY) {
this.applyPreferences();
} else if (event.key === this.USER_KEY) {
this.updateUI();
}
});
}
// 更新UI
async updateUI() {
const session = await this.restoreSession();
if (session) {
// 显示用户信息
document.getElementById('authSection').style.display = 'none';
document.getElementById('userSection').style.display = 'block';
document.getElementById('preferencesSection').style.display = 'block';
document.getElementById('offlineDataSection').style.display = 'block';
document.getElementById('userName').textContent = session.name;
document.getElementById('userEmail').textContent = session.email;
document.getElementById('loginTime').textContent =
new Date(session.loginTime).toLocaleString();
document.getElementById('sessionId').textContent =
this.getCookie(this.SESSION_COOKIE) || 'N/A';
// 加载偏好设置
const prefs = this.getPreferences();
document.getElementById('theme').value = prefs.theme;
document.getElementById('language').value = prefs.language;
document.getElementById('fontSize').value = prefs.fontSize;
// 更新离线数据
const unsynced = await this.getUnsyncedData();
document.getElementById('pendingSync').textContent = unsynced.length;
// 更新离线数据表格
const tbody = document.querySelector('#offlineDataTable tbody');
tbody.innerHTML = '';
unsynced.forEach(item => {
const row = tbody.insertRow();
row.innerHTML = `
<td>${new Date(item.timestamp).toLocaleString()}</td>
<td>${item.type}</td>
<td><code>${JSON.stringify(item.data)}</code></td>
<td><span class="badge ${item.synced ? 'online' : 'offline'}">
${item.synced ? '已同步' : '待同步'}
</span></td>
`;
});
} else {
// 显示登录表单
document.getElementById('authSection').style.display = 'block';
document.getElementById('userSection').style.display = 'none';
document.getElementById('preferencesSection').style.display = 'none';
document.getElementById('offlineDataSection').style.display = 'none';
}
// 更新存储统计
this.updateStorageStats();
}
// 恢复记住的邮箱
restoreRememberedEmail() {
const email = localStorage.getItem(this.REMEMBER_KEY);
if (email) {
document.getElementById('email').value = email;
document.getElementById('rememberMe').checked = true;
}
}
// 更新存储统计
updateStorageStats() {
// Cookie 统计
const cookieCount = document.cookie ? document.cookie.split(';').length : 0;
document.getElementById('cookieCount').textContent = `${cookieCount} 个`;
// localStorage 统计
let localSize = 0;
for (let key in localStorage) {
if (localStorage.hasOwnProperty(key)) {
localSize += localStorage[key].length + key.length;
}
}
document.getElementById('localStorageSize').textContent =
`${(localSize / 1024).toFixed(2)} KB`;
// sessionStorage 统计
let sessionSize = 0;
for (let key in sessionStorage) {
if (sessionStorage.hasOwnProperty(key)) {
sessionSize += sessionStorage[key].length + key.length;
}
}
document.getElementById('sessionStorageSize').textContent =
`${(sessionSize / 1024).toFixed(2)} KB`;
// IndexedDB 统计
if ('storage' in navigator && 'estimate' in navigator.storage) {
navigator.storage.estimate().then(estimate => {
document.getElementById('indexedDBSize').textContent =
`${(estimate.usage / 1024 / 1024).toFixed(2)} MB / ${(estimate.quota / 1024 / 1024).toFixed(2)} MB`;
});
} else {
document.getElementById('indexedDBSize').textContent = '不支持 Storage API';
}
}
// 清除所有数据
async clearAllData() {
if (!confirm('确定要清除所有数据吗?')) {
return;
}
// 清除 Cookie
const cookies = document.cookie.split(';');
cookies.forEach(cookie => {
const name = cookie.split('=')[0].trim();
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
});
// 清除 localStorage
localStorage.clear();
// 清除 sessionStorage
sessionStorage.clear();
// 清除 IndexedDB
const transaction = this.db.transaction(
['userData', 'offlineQueue', 'activityLog'],
'readwrite'
);
transaction.objectStore('userData').clear();
transaction.objectStore('offlineQueue').clear();
transaction.objectStore('activityLog').clear();
// 刷新页面
location.reload();
}
// 辅助方法
generateSessionId() {
return 'sess_' + Math.random().toString(36).substr(2, 16) +
Date.now().toString(36);
}
getCookie(name) {
const nameEQ = name + "=";
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.indexOf(nameEQ) === 0) {
return cookie.substring(nameEQ.length);
}
}
return null;
}
// 模拟API
async mockLoginAPI(email, password) {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500));
if (email && password) {
return {
success: true,
user: {
id: 'user_' + Date.now(),
name: email.split('@')[0],
email: email
}
};
}
return {
success: false,
error: '邮箱或密码错误'
};
}
async mockRegisterAPI(email, password) {
await new Promise(resolve => setTimeout(resolve, 500));
return {
success: true,
message: '注册成功'
};
}
}
// ==================== 全局实例和函数 ====================
const sessionManager = new UserSessionManager();
// 登录
async function login() {
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('rememberMe').checked;
if (!email || !password) {
alert('请填写邮箱和密码');
return;
}
const result = await sessionManager.login(email, password, rememberMe);
if (!result.success) {
alert('登录失败: ' + result.error);
}
}
// 注册
async function register() {
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
if (!email || !password) {
alert('请填写邮箱和密码');
return;
}
const result = await sessionManager.register(email, password);
alert(result.message || '注册成功');
}
// 登出
async function logout() {
await sessionManager.logout();
}
// 保存偏好设置
function savePreferences() {
const preferences = {
theme: document.getElementById('theme').value,
language: document.getElementById('language').value,
fontSize: document.getElementById('fontSize').value
};
sessionManager.savePreferences(preferences);
}
// 清除所有数据
async function clearAllData() {
await sessionManager.clearAllData();
}
// 显示详细信息
function showCookieDetails() {
const cookies = document.cookie.split(';').map(c => c.trim());
alert('Cookies:\n' + cookies.join('\n'));
}
function showLocalStorageDetails() {
const items = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
items.push(`${key}: ${value}`);
}
alert('LocalStorage:\n' + items.join('\n'));
}
function showSessionStorageDetails() {
const items = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
const value = sessionStorage.getItem(key);
items.push(`${key}: ${value}`);
}
alert('SessionStorage:\n' + items.join('\n'));
}
async function showIndexedDBDetails() {
const users = await sessionManager.getAllUsers();
const unsynced = await sessionManager.getUnsyncedData();
alert(`IndexedDB:\n用户数据: ${users.length} 条\n离线队列: ${unsynced.length} 条`);
}
</script>
</body>
</html>案例要点说明:
Cookie 使用:
localStorage 使用:
sessionStorage 使用:
IndexedDB 使用:
综合特性:
HTML5 提供了多种客户端存储方案,每种方案都有其适用场景:
选择合适的存储方案,遵循最佳实践,可以显著提升应用性能和用户体验。在实际开发中,建议:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【2】表单自动保存与恢复</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 650px; margin: 0 auto; background: white; padding: 28px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
.auto-save-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 12px; background: #d4edda; color: #155724;
border-radius: 12px; font-size: 12px; font-weight: 500;
margin-left: 12px; vertical-align: middle;
}
.dot { width: 7px; height: 7px; background: #28a745; border-radius: 50%; animation: pulse 1.5s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
form { margin-top: 20px; }
.form-group { margin-bottom: 18px; }
label { display: block; font-weight: 500; margin-bottom: 6px; color: #444; font-size: 14px; }
input, textarea, select {
width: 100%; padding: 11px 14px; border: 2px solid #e0e0e0;
border-radius: 6px; font-size: 14px; transition: border-color 0.3s;
}
input:focus, textarea:focus, select:focus { border-color: #007bff; outline: none; }
textarea { min-height: 90px; resize: vertical; }
.status-bar {
display: flex; justify-content: space-between; align-items: center;
margin-top: 16px; padding: 12px 16px; background: #f8f9fa;
border-radius: 6px; font-size: 13px; color: #666;
}
.last-saved { font-family: monospace; }
.actions { display: flex; gap: 10px; margin-top: 16px; }
.btn {
padding: 10px 22px; border: none; border-radius: 6px;
cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.3s;
}
.btn-primary { background: #007bff; color: white; }
.btn-primary:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; color: white; }
.btn-secondary:hover { background: #5a6268; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">
示例:表单自动保存
<span class="auto-save-badge"><span class="dot"></span> 自动保存中</span>
</div>
<p style="color: #666; font-size: 14px; margin-top: 8px;">
输入内容后自动保存到 sessionStorage,关闭标签页后数据会清除。刷新页面可恢复之前的数据。
</p>
<form id="autoSaveForm">
<div class="form-group">
<label for="name">姓名</label>
<input type="text" id="name" placeholder="请输入您的姓名" />
</div>
<div class="form-group">
<label for="email">邮箱</label>
<input type="email" id="email" placeholder="example@mail.com" />
</div>
<div class="form-group">
<label for="category">分类</label>
<select id="category">
<option value="">请选择...</option>
<option value="tech">技术分享</option>
<option value="life">生活随笔</option>
<option value="work">工作记录</option>
<option value="other">其他</option>
</select>
</div>
<div class="form-group">
<label for="content">内容</label>
<textarea id="content" placeholder="在这里写下您的内容..."></textarea>
</div>
</form>
<div class="status-bar">
<span id="saveStatus">就绪</span>
<span class="last-saved" id="lastSaved"></span>
</div>
<div class="actions">
<button class="btn btn-primary" onclick="submitForm()">✅ 提交表单</button>
<button class="btn btn-secondary" onclick="clearDraft()">🗑️ 清除草稿</button>
</div>
</div>
<script>
const FORM_KEY = "auto_save_form_draft"
const fields = ["name", "email", "category", "content"]
let saveTimer = null
// 页面加载时恢复数据
window.addEventListener("load", restoreForm)
// 监听每个字段的变化
fields.forEach(fieldId => {
const el = document.getElementById(fieldId)
el.addEventListener("input", debounce(autoSave, 800))
el.addEventListener("change", autoSave)
})
function autoSave() {
const data = {}
fields.forEach(id => {
data[id] = document.getElementById(id).value
})
data.savedAt = Date.now()
try {
sessionStorage.setItem(FORM_KEY, JSON.stringify(data))
updateStatus("已自动保存 ✓", new Date().toLocaleTimeString())
} catch (e) {
console.error("保存失败:", e)
}
}
function restoreForm() {
try {
const raw = sessionStorage.getItem(FORM_KEY)
if (!raw) return
const data = JSON.parse(raw)
fields.forEach(id => {
if (data[id] !== undefined && data[id] !== "") {
document.getElementById(id).value = data[id]
}
})
if (data.savedAt) {
const savedTime = new Date(data.savedAt).toLocaleString()
document.getElementById("lastSaved").textContent = `上次保存: ${savedTime}`
updateStatus("已恢复草稿 ✓")
}
} catch (e) {
console.error("恢复失败:", e)
}
}
function submitForm() {
const data = {}
let valid = true
fields.forEach(id => {
const val = document.getElementById(id).value.trim()
data[id] = val
if (id === "name" || id === "content") {
if (!val) valid = false
}
})
if (!valid) {
alert("请填写必填字段(姓名、内容)")
return
}
alert(`提交成功!\n\n姓名: ${data.name}\n邮箱: ${data.email}\n分类: ${data.category}\n内容: ${data.content.substring(0, 50)}...`)
// 提交成功后清除草稿
sessionStorage.removeItem(FORM_KEY)
updateStatus("提交成功,草稿已清除")
document.getElementById("lastSaved").textContent = ""
}
function clearDraft() {
if (confirm("确定要清空草稿吗?")) {
sessionStorage.removeItem(FORM_KEY)
fields.forEach(id => document.getElementById(id).value = "")
updateStatus("草稿已清除")
document.getElementById("lastSaved").textContent = ""
}
}
function updateStatus(text, time) {
document.getElementById("saveStatus").textContent = text
if (time) document.getElementById("lastSaved").textContent = `保存时间: ${time}`
}
function debounce(fn, delay) {
return (...args) => {
clearTimeout(saveTimer)
saveTimer = setTimeout(() => fn(...args), delay)
}
}
</script>
</body>
</html>```
<h4>010-storage-quota.html</h4>
```html
<!-- 来源:14-数据存储.md - 存储配额管理 / Storage API 章节 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【10】存储配额检测与清理</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f0f2f5; color: #333; }
.demo-container { max-width: 900px; margin: 0 auto; }
.demo-title { margin-bottom: 20px; font-size: 20px; color: #1a1a1a; border-bottom: 3px solid #2c3e50; padding-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.demo-title::before { content: "🧹"; font-size: 24px; }
.panel { background: white; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 20px; margin-bottom: 18px; }
.panel-header { font-size: 15px; font-weight: 600; color: #555; margin-bottom: 14px; padding-bottom: 8px; border-bottom: 1px solid #eee; }
/* 配额可视化 */
.quota-visual {
background: linear-gradient(135deg, #2c3e50, #34495e); color: white;
border-radius: 12px; padding: 24px; margin-bottom: 18px;
}
.quota-main { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 16px; }
.quota-number { font-size: 42px; font-weight: 800; }
.quota-unit { font-size: 16px; opacity: 0.7; }
.quota-label { font-size: 13px; opacity: 0.7; margin-top: 2px; }
.quota-bar-bg { height: 24px; background: rgba(255,255,255,0.15); border-radius: 12px; overflow: hidden; position: relative; }
.quota-bar-fill { height: 100%; border-radius: 12px; transition: width 0.8s ease; position: relative; }
.quota-bar-fill.low { background: linear-gradient(90deg, #27ae60, #2ecc71); }
.quota-bar-fill.mid { background: linear-gradient(90deg, #f39c12, #f1c40f); }
.quota-bar-fill.high { background: linear-gradient(90deg, #e74c3c, #c0392b); }
.quota-bar-text { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); font-size: 11px; font-weight: 600; }
.quota-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 16px; }
.quota-stat { text-align: center; padding: 14px; background: rgba(255,255,255,0.08); border-radius: 8px; }
.quota-stat .val { font-size: 22px; font-weight: 700; }
.quota-stat .lbl { font-size: 11px; opacity: 0.6; margin-top: 4px; }
/* 存储类型分布 */
.type-list { margin-top: 12px; }
.type-item { display: flex; align-items: center; gap: 10px; padding: 10px 14px; margin: 6px 0; background: #fafafa; border-radius: 8px; font-size: 13px; }
.type-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
.type-name { flex: 1; font-weight: 500; }
.type-size { font-family: monospace; font-size: 13px; color: #666; }
/* 清理策略 */
.cleanup-item {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; margin: 8px 0; background: #fff; border: 1px solid #eee;
border-radius: 8px; transition: all 0.15s;
}
.cleanup-item:hover { border-color: #ccc; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.cleanup-info h4 { font-size: 14px; color: #333; }
.cleanup-info p { font-size: 11px; color: #999; margin-top: 2px; }
.priority-badge { font-size: 10px; padding: 3px 10px; border-radius: 10px; font-weight: 600; }
.p1 { background: #fadbd8; color: #c0392b; } /* 先清理 */
.p2 { background: #fef9e7; color: #a04000; }
.p3 { background: #d6eaf8; color: #2980b9; } /* 后清理 */
.btn { padding: 8px 18px; border: none; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.2s; }
.btn:hover { transform: translateY(-1px); }
.btn-blue { background: #3498db; color: white; } .btn-green { background: #27ae60; color: white; }
.btn-red { background: #e74c3c; color: white; } .btn-orange { background: #e67e22; color: white; }
.btn-gray { background: #95a5a6; color: white; } .btn-sm { padding: 5px 12px; font-size: 11px; }
.btn-group { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
/* 模拟数据填充 */
.fill-controls { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
.fill-controls input { width: 80px; padding: 7px 10px; border: 1.5px solid #ddd; border-radius: 6px; font-size: 13px; }
/* 日志 */
.log-area {
background: #1a1a2e; color: #e0e0e0; border-radius: 8px; padding: 14px;
font-family: 'Monaco', monospace; font-size: 12px; line-height: 1.7;
max-height: 220px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;
}
.log-area .ok { color: #55efc4; } .log-area .err { color: #ff7675; }
.log-area .info { color: #74b9ff; } .log-area .warn { color: #ffeaa7; }
.toast {
position: fixed; top: 20px; right: 20px; padding: 10px 20px;
border-radius: 8px; color: white; font-size: 13px; animation: slideIn 0.3s ease; z-index: 1000;
}
.toast-success { background: #27ae60; } .toast-error { background: #e74c3c; }
@keyframes slideIn { from{transform:translateX(100%);opacity:0} to{transform:translateX(0);opacity:1} }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">存储配额检测与智能清理</div>
<!-- 配额概览 -->
<div class="quota-visual" id="quotaVisual">
<div class="quota-main">
<div><div class="quota-number" id="usageNum">--</div><div class="quota-label">已使用</div></div>
<div style="text-align:center;"><div class="quota-number" style="font-size:28px;" id="percentNum">--%</div><div class="quota-label">使用率</div></div>
<div style="text-align:right;"><div class="quota-number" id="quotaNum">--</div><div class="quota-label">总配额</div></div>
</div>
<div class="quota-bar-bg"><div class="quota-bar-fill low" id="quotaBarFill" style="width:0%"><span class="quota-bar-text" id="barText">0%</span></div></div>
<div class="quota-grid">
<div class="quota-stat"><div class="val" id="lsUsage">--</div><div class="lbl">localStorage</div></div>
<div class="quota-stat"><div class="val" id="idbUsage">--</div><div class="lbl">IndexedDB</div></div>
<div class="quota-stat"><div class="val" id="cacheUsage">--</div><div class="lbl">Cache API</div></div>
</div>
</div>
<!-- 操作按钮 -->
<div class="panel">
<div class="panel-header">🔍 检测 & 填充测试数据</div>
<div class="fill-controls">
<button class="btn btn-blue" onclick="checkQuota()">📊 检测配额 (Storage API)</button>
<button class="btn btn-orange" onclick="requestPersistence()">🔒 请求持久化</button>
<label style="font-size:13px;color:#666;">填充测试数据:</label>
<input type="number" id="fillKB" value="100" min="1" max="4000">
<span style="font-size:12px;color:#999;">KB</span>
<button class="btn btn-gray btn-sm" onclick="fillTestData()">填充 localStorage</button>
<button class="btn btn-red btn-sm" onclick="clearAllTest()">清空测试数据</button>
</div>
</div>
<!-- 存储详情 -->
<div class="panel">
<div class="panel-header">📋 各存储类型详情</div>
<div class="type-list" id="typeList"></div>
</div>
<!-- 清理策略 -->
<div class="panel">
<div class="panel-header">🧹 分级清理策略(按优先级)</div>
<p style="font-size:13px;color:#888;margin-bottom:12px;">当存储空间不足时,按优先级从高到低依次清理。优先级数字越小越先被清理。</p>
<div class="cleanup-item" id="cleanP1">
<div class="cleanup-info">
<h4>🗑️ P1 — 临时缓存 (_temp_ 前缀)</h4>
<p>带 _temp_ 前缀的临时数据,可随时重建</p>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span class="priority-badge p1">优先级 1 (最先清理)</span>
<button class="btn btn-red btn-sm" onclick="execCleanup('temp')">执行清理</button>
<span id="countP1" style="font-size:12px;color:#999;">0 项</span>
</div>
</div>
<div class="cleanup-item" id="cleanP2">
<div class="cleanup-info">
<h4>⏰ P2 — 已过期数据 (TTL 过期)</h4>
<p>手动设置 TTL 过期的数据项</p>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span class="priority-badge p2">优先级 2</span>
<button class="btn btn-orange btn-sm" onclick="execCleanup('expired')">执行清理</button>
<span id="countP2" style="font-size:12px;color:#999;">0 项</span>
</div>
</div>
<div class="cleanup-item" id="cleanP3">
<div class="cleanup-info">
<h4>📦 P3 — 低频访问的大体积数据 (>100KB)</h4>
<p>非关键缓存数据,占用空间大且不常访问</p>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span class="priority-badge p3">优先级 3 (最后清理)</span>
<button class="btn btn-blue btn-sm" onclick="execCleanup('large')">执行清理</button>
<span id="countP3" style="font-size:12px;color:#999;">0 项</span>
</div>
</div>
<div class="btn-group" style="margin-top:14px;">
<button class="btn btn-red" onclick="autoCleanupAll()">🚀 一键自动清理全部</button>
<button class="btn btn-gray btn-sm" onclick="refreshCounts()">刷新计数</button>
</div>
</div>
<!-- 操作日志 -->
<div class="panel">
<div class="panel-header">📋 操作日志</div>
<div class="log-area" id="logArea">// 存储配额管理日志\n// 点击「检测配额」开始...\n</div>
</div>
</div>
<script>
const logEl = document.getElementById('logArea')
function log(msg, cls='info') {
const t = new Date().toLocaleTimeString()
logEl.innerHTML += `<span class="${cls}">[${t}] ${msg}</span>\n`
logEl.scrollTop = logEl.scrollHeight
}
function showToast(msg, t) {
const el = document.createElement('div'); el.className=`toast toast-${t||'success'}`; el.textContent=msg
document.body.appendChild(el); setTimeout(()=>el.remove(),2500)
}
function formatBytes(b) {
if (!b || b === 0) return '0 B'
if (b < 1024) return b + ' B'
if (b < 1024*1024) return (b/1024).toFixed(2) + ' KB'
return (b/1024/1024).toFixed(2) + ' MB'
}
// ====== 配额检测 ======
async function checkQuota() {
log(`📊 调用 navigator.storage.estimate()...`, 'info')
if (!('storage' in navigator) || !('estimate' in navigator.storage)) {
log(`❌ Storage API 不可用`, 'err')
showToast('浏览器不支持 Storage API', 'error')
updateQuotaUI(null)
return
}
try {
const estimate = await navigator.storage.estimate()
const usage = estimate.usage || 0
const quota = estimate.quota || 0
const pct = quota > 0 ? ((usage / quota) * 100) : 0
log(`✅ 配额查询成功!`, 'ok')
log(` 已使用: ${formatBytes(usage)} | 总配额: ${formatBytes(quota)} | 使用率: ${pct.toFixed(2)}%`, 'info')
// 检查持久化状态
if ('persisted' in navigator.storage) {
const persisted = await navigator.storage.persisted()
log(` 持久化状态: ${persisted ? '✅ 已授权 (不会被自动清理)' : '⚠️ 未授权 (可能被自动清理)'}`, persisted ? 'ok' : 'warn')
}
updateQuotaUI({ usage, quota, pct })
updateTypeList()
} catch(e) {
log(`❌ 查询失败: ${e.message}`, 'err')
}
}
function updateQuotaUI(data) {
if (!data) {
document.getElementById('usageNum').textContent = '--'
document.getElementById('percentNum').textContent = '--%'
document.getElementById('quotaNum').textContent = '--'
document.getElementById('quotaBarFill').style.width = '0%'
document.getElementById('barText').textContent = 'N/A'
return
}
document.getElementById('usageNum').textContent = formatBytes(data.usage)
document.getElementById('percentNum').textContent = data.pct.toFixed(1) + '%'
document.getElementById('quotaNum').textContent = formatBytes(data.quota)
const fill = document.getElementById('quotaBarFill')
fill.style.width = Math.min(data.pct, 100) + '%'
fill.className = 'quota-bar-fill ' + (data.pct < 50 ? 'low' : data.pct < 80 ? 'mid' : 'high')
document.getElementById('barText').textContent = data.pct.toFixed(1) + '%'
// 计算 localStorage 大小
let lsSize = 0
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
lsSize += k.length + (localStorage.getItem(k)||'').length
}
lsSize *= 2 // UTF-16
document.getElementById('lsUsage').textContent = formatBytes(lsSize)
document.getElementById('idbUsage').textContent = '(需 IndexedDB)'
document.getElementById('cacheUsage').textContent = '(需 Cache API)'
}
function updateTypeList() {
const el = document.getElementById('typeList')
// 统计各类型
let lsCount = localStorage.length, lsSize = 0
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
lsSize += k.length + (localStorage.getItem(k)||'').length
}
lsSize *= 2
el.innerHTML = `
<div class="type-item">
<div class="type-icon" style="background:#eaf2f8;color:#2980b9;">💾</div>
<div class="type-name">localStorage</div>
<div class="type-size">${lsCount} 项 · ${formatBytes(lsSize)}</div>
</div>
<div class="type-item">
<div class="type-icon" style="background:#fef9e7;color:#a04000;">🍪</div>
<div class="type-name">Cookie</div>
<div class="type-size">${document.cookie.split(';').filter(c=>c.trim()).length} 个 · ~${formatBytes(new Blob([document.cookie]).size)}</div>
</div>
<div class="type-item">
<div class="type-icon" style="background:#e8f8f5;color:#1e8449;">📋</div>
<div class="type-name">sessionStorage</div>
<div class="type-size">${sessionStorage.length} 项</div>
</div>`
}
async function requestPersistence() {
if (!('storage' in navigator) || !('persist' in navigator.storage)) {
showToast('不支持持久化请求', 'error'); return
}
const granted = await navigator.storage.persist()
log(`🔒 持久化请求结果: ${granted ? '✅ 已授权' : '❌ 被拒绝'}`, granted ? 'ok' : 'err')
showToast(granted ? '持久化已启用' : '用户拒绝了持久化请求', granted ? 'success' : 'error')
}
// ====== 测试数据 ======
function fillTestData() {
const kb = parseInt(document.getElementById('fillKB').value) || 100
const size = kb * 1024 // bytes of string data
const chunkSize = 50000 // 50KB per key
let filled = 0
let count = 0
while (filled < size) {
try {
const actualSize = Math.min(chunkSize, size - filled)
const data = 'x'.repeat(actualSize)
localStorage.setItem(`__test_fill_${String(count).padStart(4,'0')}__`, data)
filled += actualSize
count++
} catch(e) {
if (e.name === 'QuotaExceededError') {
log(`⚠️ 存储空间不足! 已填充 ${formatBytes(filled)} (${count} 条)`, 'warn')
showToast('空间不足!', 'error')
break
}
throw e
}
}
log(`📦 填充完成: ${formatBytes(filled)} (${count} 条数据)`, 'ok')
showToast(`已填充 ${formatBytes(filled)}`)
refreshCounts()
checkQuota()
}
function clearAllTestData() {
let count = 0
const toRemove = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k.startsWith('__test_fill_') || k.startsWith('__temp_')) toRemove.push(k)
}
toRemove.forEach(k => { localStorage.removeItem(k); count++ })
log(`🧹 清理了 ${count} 条测试数据`, 'ok')
showToast(`已清除 ${count} 条`)
refreshCounts()
checkQuota()
}
// ====== 清理策略 ======
function execCleanup(type) {
let removed = 0
const keysToRemove = []
switch(type) {
case 'temp':
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k.startsWith('__temp_')) keysToRemove.push(k)
}
break
case 'expired':
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
try {
const item = JSON.parse(localStorage.getItem(k))
if (item.__ttl && item.__expiry && Date.now() > item.__expiry) keysToRemove.push(k)
} catch(e) {}
}
break
case 'large':
const threshold = 100 * 1024 // 100KB
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
const v = localStorage.getItem(k) || ''
if ((k.length + v.length) * 2 > threshold && !k.startsWith('__test_fill_')) keysToRemove.push(k)
}
break
}
keysToRemove.forEach(k => { localStorage.removeItem(k); removed++ })
log(`🧹 [${type}] 清理完成: 移除 ${removed} 项`, removed > 0 ? 'ok' : 'warn')
showToast(`${type}: 清理了 ${removed} 项`)
refreshCounts()
checkQuota()
}
async function autoCleanupAll() {
log(`🚀 开始自动清理...`, 'warn')
execCleanup('temp')
execCleanup('expired')
execCleanup('large')
log(`✅ 自动清理全部完成!`, 'ok')
showToast('自动清理完成')
}
function refreshCounts() {
let c1=0, c2=0, c3=0, threshold=100*1024
for (let i=0;i<localStorage.length;i++) {
const k=localStorage.key(i), v=localStorage.getItem(k)||''
if (k.startsWith('__temp_')) c1++
else {
try { const item=JSON.parse(v); if(item.__ttl&&item.__expiry&&Date.now()>item.__expiry) c2++ } catch(e){}
if ((k.length+v.length)*2 > threshold && !k.startsWith('__test_fill_')) c3++
}
}
document.getElementById('countP1').textContent=c1+' 项'
document.getElementById('countP2').textContent=c2+' 项'
document.getElementById('countP3').textContent=c3+' 项'
}
// 初始化
refreshCounts()
log('// 存储配额管理系统就绪\n// 点击「检测配额」查看当前使用情况\n', 'info')
</script>
</body>
</html><!-- 来源:14-数据存储.md - 跨标签页通信 / BroadcastChannel / SharedWorker 章节 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【11】跨标签页同步方案对比</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 16px; background: #f0f2f5; color: #333; }
.demo-container { max-width: 1000px; margin: 0 auto; }
.demo-title { margin-bottom: 16px; font-size: 20px; color: #1a1a1a; border-bottom: 3px solid #2ecc71; padding-bottom: 8px; display: flex; align-items: center; gap: 8px; }
.demo-title::before { content: "🔗"; font-size: 24px; }
.panel { background: white; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 16px; margin-bottom: 14px; }
.panel-header { font-size: 14px; font-weight: 600; color: #555; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
/* 三列对比 */
.compare-cols { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.col-card { border-radius: 10px; overflow: hidden; border: 1.5px solid #eee; }
.col-header { padding: 12px 14px; color: white; font-weight: 600; font-size: 14px; text-align: center; }
.col-header.c1 { background: linear-gradient(135deg, #3498db, #2980b9); }
.col-header.c2 { background: linear-gradient(135deg, #9b59b6, #8e44ad); }
.col-header.c3 { background: linear-gradient(135deg, #e67e22, #d35400); }
.col-body { padding: 14px; }
.feature-list { list-style: none; font-size: 12px; line-height: 1.9; }
.feature-list li { padding: 2px 0; color: #555; }
.feature-list li::before { content: "• "; color: #999; }
/* 消息输入 */
.msg-input-row { display: flex; gap: 8px; margin-bottom: 10px; }
.msg-input-row input { flex: 1; padding: 7px 11px; border: 1.5px solid #ddd; border-radius: 6px; font-size: 13px; }
.msg-input-row select { padding: 7px 10px; border: 1.5px solid #ddd; border-radius: 6px; font-size: 13px; width: 100px; }
.btn { padding: 7px 14px; border: none; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.15s; }
.btn:hover { transform: translateY(-1px); }
.btn-blue { background: #3498db; color: white; } .btn-purple { background: #9b59b6; color: white; }
.btn-orange { background: #e67e22; color: white; } .btn-green { background: #27ae60; color: white; }
.btn-gray { background: #95a5a6; color: white; } .btn-sm { padding: 4px 10px; font-size: 11px; }
.btn-group { display: flex; gap: 6px; flex-wrap: wrap; }
/* 日志区域 - 三列各一个 */
.msg-log {
background: #1a1a2e; color: #d4d4d4; border-radius: 6px; padding: 10px;
font-family: monospace; font-size: 11px; line-height: 1.6;
min-height: 180px; max-height: 240px; overflow-y: auto;
white-space: pre-wrap; word-break: break-all;
}
.msg-log .send { color: #74b9ff; } .msg-log .recv { color: #55efc4; }
.msg-log .info { color: #ffeaa7; } .msg-log .err { color: #ff7675; }
.msg-log .self { color: #fd79a8; font-style: italic; opacity: 0.6; }
/* 状态指示 */
.status-badge {
display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 10px;
font-weight: 500; vertical-align: middle; margin-left: 6px;
}
.status-on { background: #d5f5e3; color: #27ae60; }
.status-off { background: #fadbd8; color: #c0392b; }
/* 提示 */
.tip-box {
background: linear-gradient(135deg, #eaf2f8, #d6eaf8); border-left: 4px solid #3498db;
padding: 12px 16px; border-radius: 0 8px 8px 0; font-size: 13px; color: #2c3e50; margin-bottom: 14px; line-height: 1.6;
}
.tip-box strong { color: #2980b9; }
.toast {
position: fixed; top: 16px; right: 16px; padding: 8px 18px;
border-radius: 6px; color: white; font-size: 12px; animation: slideIn 0.3s ease; z-index: 1000;
}
.toast-success { background: #27ae60; }
@keyframes slideIn { from{transform:translateX(100%);opacity:0} to{transform:translateX(0);opacity:1} }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">跨标签页同步方案对比</div>
<div class="tip-box">
<strong>🧪 使用方法:</strong>请用浏览器打开 <strong>多个标签页</strong>访问本页面(同一 URL),然后在任一标签页发送消息,观察三种方案的接收差异。
<br/>💡 提示:Storage Event 仅在「其他」标签页触发(当前页不会收到自己发的消息),而 BroadcastChannel 可以在所有标签页间双向通信。
</div>
<!-- 三列对比面板 -->
<div class="compare-cols">
<!-- 方案1: Storage Event -->
<div class="col-card">
<div class="col-header c1">① Storage Event (localStorage)</div>
<div class="col-body">
<ul class="feature-list">
<li><strong>原理:</strong> 监听 localStorage 变化触发事件</li>
<li><strong>优点:</strong> 兼容性最好,无需额外 API</li>
<li><strong>缺点:</strong> 只能传字符串;当前页收不到自己的事件</li>
<li><strong>适用:</strong> 简单键值同步</li>
</ul>
<hr style="border:none;border-top:1px solid #eee;margin:10px 0;">
<div class="msg-input-row">
<input type="text" id="seMsg" placeholder="消息内容...">
<button class="btn btn-blue" onclick="sendSE()">发送</button>
</div>
<div class="btn-group"><button class="btn btn-gray btn-sm" onclick="clearLog('se')">清空</button></div>
<div class="msg-log" id="seLog">// Storage Event 日志\n// 发送消息到 localStorage 触发...\n</div>
</div>
</div>
<!-- 方案2: BroadcastChannel -->
<div class="col-card">
<div class="col-header c2">② BroadcastChannel
<span id="bcStatus" class="status-badge status-on">✅ 可用</span>
</div>
<div class="col-body">
<ul class="feature-list">
<li><strong>原理:</strong> 多对多消息通道,类似发布-订阅</li>
<li><strong>优点:</strong> 支持任意结构化数据;所有页面都能收到</li>
<li><strong>缺点:</strong> IE 不支持;需手动管理通道名</li>
<li><strong>适用:</strong> 复杂消息传递、实时通知</li>
</ul>
<hr style="border:none;border-top:1px solid #eee;margin:10px 0;">
<div class="msg-input-row">
<select id="bcType"><option value="TEXT">文本</option><option value="USER_UPDATE">用户更新</option><option value="THEME">主题切换</option><option value="LOGOUT">登出通知</option></select>
<input type="text" id="bcMsg" placeholder="消息内容...">
<button class="btn btn-purple" onclick="sendBC()">发送</button>
</div>
<div class="btn-group"><button class="btn btn-gray btn-sm" onclick="clearLog('bc')">清空</button></div>
<div class="msg-log" id="bcLog">// BroadcastChannel 日志\n// 等待消息...\n</div>
</div>
</div>
<!-- 方案3: 对比说明 -->
<div class="col-card">
<div class="col-header c3">③ 方案对比总结</div>
<div class="col-body">
<table style="width:100%;font-size:12px;border-collapse:collapse;">
<tr><th style="background:#fef5e7;padding:6px;text-align:left;color:#a04000;">特性</th><th style="background:#fef5e7;padding:6px;">Storage</th><th style="background:#fef5e7;padding:6px;">BCh</th></tr>
<tr><td>数据类型</td><td>仅字符串</td><td>任意对象</td></tr>
<tr><td>当前页接收</td><td style="color:#e74c3c;">❌ 不能</td><td style="color:#27ae60;">✅ 能</td></tr>
<tr><td>跨域</td><td>❌ 不行</td><td>❌ 不行</td></tr>
<tr><td>兼容性</td><td style="color:#27ae60;">极佳</td><td style="color:#f39c12;">良好</td></tr>
<tr><td>持久性</td><td>自动持久化</td><td>仅在运行时</td></tr>
<tr><td>API 复杂度</td><td style="color:#27ae60;">简单</td><td style="color:#f39c12;">中等</td></tr>
</table>
<hr style="border:none;border-top:1px solid #eee;margin:12px 0;">
<h4 style="font-size:13px;margin-bottom:8px;">📌 选择建议</h4>
<ul class="feature-list">
<li>简单配置同步 → <strong style="color:#3498db;">Storage Event</strong></li>
<li>复杂业务通知 → <strong style="color:#9b59b6;">BroadcastChannel</strong></li>
<li>共享状态/计算 → <strong style="color:#e67e22;">SharedWorker</strong></li>
<li>跨域场景 → <strong>postMessage + iframe</strong></li>
</ul>
</div>
</div>
</div>
<!-- 统一操作 -->
<div class="panel">
<div class="panel-header">🎮 全局控制</div>
<div class="btn-group">
<button class="btn btn-green" onclick="broadcastAll()">📡 三种方式同时广播测试消息</button>
<button class="btn btn-blue btn-sm" onclick="checkTabId()">🏷️ 查看本标签 ID</button>
<button class="btn btn-gray btn-sm" onclick="clearAllLogs()">清空全部日志</button>
</div>
</div>
</div>
<script>
// ====== 标签ID ======
let tabId = sessionStorage.getItem('tab_id')
if (!tabId) { tabId = 'Tab-' + Math.random().toString(36).slice(2, 8); sessionStorage.setItem('tab_id', tabId) }
// ====== 工具函数 ======
function logTo(id, msg, cls) {
const el = document.getElementById(id)
const t = new Date().toLocaleTimeString()
el.innerHTML += `<span class="${cls||'info'}">[${t}] ${msg}</span>\n`
el.scrollTop = el.scrollHeight
}
function clearLog(type) { document.getElementById(type+'Log').innerHTML = '// 已清空\n' }
function clearAllLogs() { clearLog('se'); clearLog('bc'); showToast('已清空') }
function showToast(msg) {
const el = document.createElement('div'); el.className='toast toast-success'; el.textContent=msg
document.body.appendChild(el); setTimeout(()=>el.remove(),2000)
}
function esc(s) { return s?s.replace(/&/g,'&').replace(/</g,'<'):'' }
// ====== 方案1: Storage Event ======
const STORAGE_KEY = '__cross_tab_sync_demo__'
window.addEventListener('storage', (e) => {
if (e.key === STORAGE_KEY && e.newValue) {
try {
const data = JSON.parse(e.newValue)
if (data.from === tabId) return // 忽略自己发的
logTo('seLog', `📩 收到 [${data.from}]: ${data.text}`, 'recv')
} catch(err) {
logTo('seLog', `📩 收到原始变化: key="${e.key}"`, 'recv')
}
}
})
function sendSE() {
const msg = document.getElementById('seMsg').value.trim()
if (!msg) return
const payload = JSON.stringify({ from: tabId, text: msg, ts: Date.now(), method: 'storage-event' })
localStorage.setItem(STORAGE_KEY, payload)
logTo('seLog', `📤 发送 (localStorage): ${msg}`, 'send')
logTo('seLog', ` ⚠️ 注意:当前标签页不会触发 storage 事件!`, 'info')
document.getElementById('seMsg').value = ''
}
// ====== 方案2: BroadcastChannel ======
let bcChannel = null
if ('BroadcastChannel' in window) {
bcChannel = new BroadcastChannel('cross_tab_sync_demo')
bcChannel.onmessage = (event) => {
const data = event.data
const isSelf = data.from === tabId
logTo('bcLog', `${isSelf ? '(自己)' : ''} 📩 [${data.from}] 类型=${data.type}: ${data.text}`, isSelf ? 'self' : 'recv')
}
} else {
document.getElementById('bcStatus').className = 'status-badge status-off'
document.getElementById('bcStatus').textContent = '❌ 不可用'
logTo('bcLog', '⚠️ 当前浏览器不支持 BroadcastChannel\n', 'err')
}
function sendBC() {
if (!bcChannel) return showToast('BroadcastChannel 不可用')
const type = document.getElementById('bcType').value
const msg = document.getElementById('bcMsg').value.trim()
if (!msg) return
const payload = { type, text: msg, from: tabId, ts: Date.now(), method: 'broadcast-channel' }
bcChannel.postMessage(payload)
logTo('bcLog', `📤 广播 [${type}]: ${msg}`, 'send')
document.getElementById('bcMsg').value = ''
}
// ====== 全局 ======
function broadcastAll() {
const testMsg = `[${tabId}] 测试消息 @ ${new Date().toLocaleTimeString()}`
// Storage Event
localStorage.setItem(STORAGE_KEY, JSON.stringify({from:tabId, text:testMsg, method:'all'}))
logTo('seLog', `📤 [全局] 发送: ${testMsg}`, 'send')
// BroadcastChannel
if (bcChannel) {
bcChannel.postMessage({type:'TEST', text:testMsg, from:tabId, ts:Date.now()})
logTo('bcLog', `📤 [全局] 广播: ${testMsg}`, 'send')
}
showToast('已通过两种方式广播')
}
function checkTabId() {
alert(`本标签页 ID:\n${tabId}\n\n打开多个标签页后每个会有不同 ID`)
}
// 初始化日志
logTo('seLog', `// 本标签 ID: ${tabId}\n// Storage Event 就绪 — 打开新标签页测试\n`, 'info')
if (bcChannel) logTo('bcLog', `// 本标签 ID: ${tabId}\n// BroadcastChannel "${bcChannel.name}" 已连接\n`, 'info')
</script>
</body>
</html>