前端老鸟血泪史:本地存储爆雷怎么办?5招搞定缓存顽疾还能让页面秒开
说实话,写这篇文章之前我抽了三根烟。不是因为别的,就是想起这些年被本地存储坑过的那些夜晚,血压有点上来了。你们懂那种感受吗?凌晨两点,用户群里突然炸锅,说数据丢了、页面白了、刷新一下东西全没了。你一边陪着笑说"马上修复",一边疯狂翻代码,最后发现是LocalStorage存满了,或者哪个手贱的同事把key写错了。行吧,既然都聊到这儿了,咱们就把这块儿的老底儿掀个干净。从最开始那个"存个字符串而已能
前端老鸟血泪史:本地存储爆雷怎么办?5招搞定缓存顽疾还能让页面秒开
前端老鸟血泪史:本地存储爆雷怎么办?5招搞定缓存顽疾还能让页面秒开
说实话,写这篇文章之前我抽了三根烟。不是因为别的,就是想起这些年被本地存储坑过的那些夜晚,血压有点上来了。你们懂那种感受吗?凌晨两点,用户群里突然炸锅,说数据丢了、页面白了、刷新一下东西全没了。你一边陪着笑说"马上修复",一边疯狂翻代码,最后发现是LocalStorage存满了,或者哪个手贱的同事把key写错了。
行吧,既然都聊到这儿了,咱们就把这块儿的老底儿掀个干净。从最开始那个"存个字符串而已能有多难"的 naive 想法,到现在看到存储相关的PR就条件反射式地紧张,这中间的血泪史,够写本书了。
别整那些虚的,聊聊咱们天天都要面对的"存数据"这点破事
我刚入行那会儿,觉得浏览器存储不就是 localStorage.setItem('name', '张三') 吗?简单得跟个1一样。直到有天产品经理跑过来说:“用户反馈每次刷新页面,购物车里的东西就没了,你查查咋回事。”
我当时就懵了——我明明存了啊!代码写得板板正正的:
// 当年那个天真的我写的代码
function addToCart(item) {
let cart = localStorage.getItem('cart');
cart.push(item); // 等等,这里好像有问题?
localStorage.setItem('cart', cart);
}
看到bug在哪了吗?getItem 拿出来的是字符串啊兄弟!"null".push() 直接报错,但我当时居然没加try-catch,用户那边就是静默失败,数据直接蒸发。更要命的是,有些浏览器在隐私模式下会直接抛出 QuotaExceededError,你不去catch它,整个脚本都崩了。
还有那种"明明存了却读不到"的玄学问题。有次测试妹子跟我说,她明明点了保存,刷新后设置项全恢复了默认。我在自己电脑上复现了八百遍都没问题,最后跑到她工位一看——她用的是Safari的无痕模式。那玩意儿对LocalStorage的支持就跟薛定谔的猫一样,有时候能用有时候不能用,全看苹果那天心情怎么样。
说到这个我就来气。你们有没有遇到过控制台报错报到你怀疑人生的情况?比如这个:
// 这段代码在95%的情况下工作正常
try {
localStorage.setItem('bigData', JSON.stringify(hugeObject));
} catch (e) {
console.error('存储失败:', e);
}
看起来没问题对吧?但如果是iOS的WebView,在某些版本里,存储满了不会抛错,而是直接静默失败。你看着控制台干干净净,以为数据存进去了,用户一刷新,啥也没有。这种"假成功"才是最可怕的,你连排查方向都没有。
再说说选型的问题。LocalStorage、SessionStorage、IndexedDB、Cookie,还有那个Cache Storage,到底该用哪个?我见过太多项目里滥用LocalStorage的,把用户头像的base64往里塞,把整个表格数据往里怼,最后页面加载慢得像蜗牛爬。5MB的空间看着挺大,但算上UTF-16的编码,实际能存的东西真没多少。而且它是同步的!主线程就这么被卡住了,用户点个按钮没反应,其实后台正在拼命写硬盘呢。
SessionStorage更是个"渣男",标签页一关就翻脸不认人。有次我们做个多步骤表单,用户填到第三步,手贱点了个外链,回来一看,第一步的数据还在,第二步第三步没了。为啥?因为新开标签页SessionStorage不共享啊!这设计就离谱,但你还不能说它错,毕竟人家文档写得清清楚楚,只怪我们没仔细看。
IndexedDB呢?功能确实强大,能存结构化数据,支持索引、事务、游标,简直就是浏览器里的SQLite。但那个API写得,我怀疑设计者是故意的。打开数据库要 indexedDB.open(),然后要处理 onupgradeneeded、onsuccess、onerror 三个回调,升级版本还要手动迁移数据。新手第一次看官方文档,直接就想劝退。我贴段代码你们感受下:
// 打开IndexedDB的标准姿势,这还只是个开始
const request = indexedDB.open('MyDatabase', 2);
request.onupgradeneeded = function(event) {
const db = event.target.result;
// 创建对象仓库,相当于表
if (!db.objectStoreNames.contains('users')) {
const objectStore = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
// 创建索引
objectStore.createIndex('name', 'name', { unique: false });
objectStore.createIndex('email', 'email', { unique: true });
}
// 版本升级时的数据迁移逻辑,这里能写出一堆bug
if (event.oldVersion < 2) {
// 从版本1升级到版本2,可能要改结构
const store = event.target.transaction.objectStore('users');
// ... 迁移代码
}
};
request.onsuccess = function(event) {
const db = event.target.result;
console.log('数据库打开成功');
// 增删改查还得另写函数
addUser(db, { name: '老王', email: 'wang@example.com' });
};
request.onerror = function(event) {
console.error('数据库打开失败:', event.target.error);
};
// 添加数据的函数,这复杂度感受一下
function addUser(db, user) {
const transaction = db.transaction(['users'], 'readwrite');
const store = transaction.objectStore('users');
const request = store.add(user);
request.onsuccess = function() {
console.log('用户添加成功');
};
request.onerror = function() {
console.error('用户添加失败');
};
// 事务完成监听
transaction.oncomplete = function() {
console.log('事务已完成');
};
}
就上面这一坨,还只是"Hello World"级别的操作。要是涉及到分页查询、范围检索、索引优化,代码量能翻三倍。所以后来社区出了好多封装库,像localForage、Dexie.js,但引入第三方库又有新的问题——包体积、维护性、兼容性,头疼得很。
Cookie就更别提了,带着服务器到处跑,每个请求都要夹带。存个几KB的JWT token还好,要是有人把用户偏好设置全塞Cookie里,每次接口请求都带上几百字节,移动端用户流量不要钱的吗?而且Cookie还有同源限制、Secure属性、HttpOnly属性、SameSite属性,稍微配错一点,要么存不进去,要么安全漏洞,要么跨域问题。
至于HTTP缓存策略,强缓存和协商缓存那套,我到现在有时候还得翻MDN确认。Cache-Control的max-age和no-cache、no-store啥区别?ETag和Last-Modified用哪个?304状态码到底算不算成功?这些问题面试常考,但真到项目里,经常是"先禁用缓存试试,能跑就行"。
扒一扒浏览器给咱们留的那些"储物柜"底细
咱们挨个把这些"储物柜"的底细扒清楚,免得以后再用错地方。
LocalStorage:简单粗暴但只有5MB的小肚量
这玩意儿最大的特点就是简单,键值对存储,API就四个方法:setItem、getItem、removeItem、clear。但简单是有代价的。首先是容量,标准说是5MB,但不同浏览器实现不一样。Safari桌面版可能给10MB,iOS WebView可能只给2MB,而且这5MB是域名级别的,你所有页面共享。要是搞个单页应用,路由多了,一不小心就超配额。
其次是同步阻塞。LocalStorage的读写都是同步的,直接走主线程。你存个几KB的数据可能没感觉,但要是存个大的JSON字符串,比如几万条日志记录,页面直接卡死。我有次 profiling 的时候发现,一个 localStorage.setItem 调用了200多毫秒,那段时间用户点击完全没响应,体验烂透了。
还有那个让人崩溃的存储限制错误处理。超出配额时,不同浏览器表现不一样:
// 一个比较健壮的存储函数,考虑了各种边界情况
function safeSetItem(key, value) {
try {
const serialized = JSON.stringify(value);
const size = new Blob([serialized]).size;
// 预估一下大小,虽然不准确但总比没有强
if (size > 4.5 * 1024 * 1024) {
console.warn('数据太大,建议分片存储或使用IndexedDB');
return false;
}
localStorage.setItem(key, serialized);
return true;
} catch (e) {
if (e.name === 'QuotaExceededError' ||
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
console.error('存储空间已满,需要清理旧数据');
// 这里可以触发清理逻辑
cleanupOldData();
// 重试一次
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (retryError) {
console.error('清理后仍然无法存储:', retryError);
return false;
}
}
console.error('存储失败:', e);
return false;
}
}
// 清理旧数据的示例逻辑
function cleanupOldData() {
// 策略1:删除最久未使用的
// 策略2:删除特定前缀的缓存
// 策略3:只保留最近N条
const keys = Object.keys(localStorage);
const cacheKeys = keys.filter(k => k.startsWith('cache_'));
// 按时间戳排序,删掉最老的
cacheKeys.sort((a, b) => {
const itemA = JSON.parse(localStorage.getItem(a) || '{}');
const itemB = JSON.parse(localStorage.getItem(b) || '{}');
return (itemA.timestamp || 0) - (itemB.timestamp || 0);
});
// 删掉一半
const toDelete = cacheKeys.slice(0, Math.floor(cacheKeys.length / 2));
toDelete.forEach(key => localStorage.removeItem(key));
console.log(`清理了 ${toDelete.length} 项旧数据`);
}
看到没?就一个 setItem,为了健壮性要包这么多层。而且LocalStorage还有个致命缺陷:不支持过期时间。你存进去的数据,除非手动删,否则永久存在。这在很多业务场景下是反人类的,比如缓存接口数据,总得有个有效期吧?所以后来大家都自己封装带过期时间的版本,后面我会贴代码。
SessionStorage:标签页一关就翻脸不认人的短期记忆
SessionStorage和LocalStorageAPI完全一样,但生命周期不同。它只在当前标签页有效,关闭标签页数据就消失。而且它有个很诡异的特性:不同标签页之间不共享,哪怕你是通过 window.open 打开的新标签页,在Chrome里有时候能共享,有时候不能,取决于你怎么打开的。
这玩意儿适合存那种临时状态,比如表单填写到一半的数据、多步骤向导的当前步骤。但千万别用来存重要信息,用户手一抖关了浏览器,数据就没了。
// 用SessionStorage做表单草稿的示例
class FormDraft {
constructor(formId) {
this.key = `draft_${formId}`;
this.saveInterval = null;
}
// 开始自动保存,每30秒存一次
startAutoSave(getDataFn) {
this.saveInterval = setInterval(() => {
const data = getDataFn();
this.save(data);
console.log('草稿已自动保存', new Date().toLocaleTimeString());
}, 30000);
// 页面关闭前再存一次
window.addEventListener('beforeunload', () => {
const data = getDataFn();
this.save(data);
});
}
save(data) {
try {
sessionStorage.setItem(this.key, JSON.stringify({
data,
timestamp: Date.now(),
url: window.location.href
}));
} catch (e) {
console.error('保存草稿失败:', e);
}
}
load() {
try {
const saved = sessionStorage.getItem(this.key);
if (saved) {
const parsed = JSON.parse(saved);
// 检查是否是当前页面的草稿(防止跨页面污染)
if (parsed.url === window.location.href) {
// 检查是否过期(比如超过7天)
if (Date.now() - parsed.timestamp < 7 * 24 * 60 * 60 * 1000) {
return parsed.data;
}
}
}
} catch (e) {
console.error('读取草稿失败:', e);
}
return null;
}
clear() {
sessionStorage.removeItem(this.key);
if (this.saveInterval) {
clearInterval(this.saveInterval);
}
}
}
// 使用示例
const draft = new FormDraft('user-profile');
const savedData = draft.load();
if (savedData) {
// 恢复表单数据
restoreForm(savedData);
showToast('已恢复上次未提交的草稿');
}
draft.startAutoSave(() => collectFormData());
IndexedDB:功能强大但API写得像天书一样的诺亚方舟
IndexedDB是真正的数据库,支持结构化数据、索引、事务、游标。适合存大量数据,比如离线应用的完整数据集、复杂的用户生成内容。但它的API确实反人类,全是异步的基于事件的API,直到后来出了Promise包装器才稍微好点。
IndexedDB的容量限制比LocalStorage宽松很多,一般能达到50MB以上,甚至几百MB,具体取决于浏览器和磁盘空间。但申请更大空间时,浏览器可能会弹窗询问用户权限,这点要注意。
事务机制是IndexedDB的核心,也是最容易出错的地方。所有读写操作必须在事务中进行,事务有三种模式:readonly、readwrite、versionchange。如果你在一个readonly事务里尝试写入,会直接报错。而且事务有自动提交机制,如果一段时间内没有操作,事务会自动关闭,这时候你再想读写就会报错。
// 一个更完整的IndexedDB封装类,带Promise支持
class IndexedDBWrapper {
constructor(dbName, version) {
this.dbName = dbName;
this.version = version;
this.db = null;
}
// 初始化数据库
async init(stores) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 根据配置创建或更新对象仓库
stores.forEach(storeConfig => {
const { name, keyPath, indexes, deleteOnUpgrade } = storeConfig;
if (db.objectStoreNames.contains(name)) {
if (deleteOnUpgrade) {
db.deleteObjectStore(name);
} else {
// 如果存在且不删除,检查是否需要新增索引
const store = event.target.transaction.objectStore(name);
indexes?.forEach(idx => {
if (!store.indexNames.contains(idx.name)) {
store.createIndex(idx.name, idx.keyPath, idx.options);
}
});
return;
}
}
const store = db.createObjectStore(name, {
keyPath: keyPath || 'id',
autoIncrement: !keyPath
});
// 创建索引
indexes?.forEach(idx => {
store.createIndex(idx.name, idx.keyPath, idx.options);
});
});
};
request.onsuccess = (event) => {
this.db = event.target.result;
// 监听数据库异常
this.db.onerror = (event) => {
console.error('数据库错误:', event.target.error);
};
resolve(this.db);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
}
// 添加数据
async add(storeName, data) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.add(data);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 批量添加,使用游标优化性能
async addBatch(storeName, dataArray, onProgress) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
let completed = 0;
// 分批处理,避免单次事务过长
const batchSize = 100;
const batches = [];
for (let i = 0; i < dataArray.length; i += batchSize) {
batches.push(dataArray.slice(i, i + batchSize));
}
let currentBatch = 0;
const processBatch = () => {
if (currentBatch >= batches.length) {
resolve({ total: dataArray.length, success: completed });
return;
}
const batch = batches[currentBatch];
batch.forEach(item => {
const request = store.add(item);
request.onsuccess = () => {
completed++;
if (onProgress) {
onProgress(completed, dataArray.length);
}
};
request.onerror = () => {
console.warn('单条数据添加失败:', item, request.error);
};
});
currentBatch++;
// 使用setTimeout让出主线程,避免阻塞
setTimeout(processBatch, 0);
};
transaction.oncomplete = () => {
console.log('批量添加事务完成');
};
transaction.onerror = () => {
reject(transaction.error);
};
processBatch();
});
}
// 使用索引查询
async getByIndex(storeName, indexName, value) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const index = store.index(indexName);
const request = index.getAll(value); // 获取所有匹配项
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 范围查询,支持分页
async getRange(storeName, options = {}) {
const { indexName, lower, upper, direction = 'next', offset = 0, limit = 50 } = options;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const source = indexName ? store.index(indexName) : store;
const results = [];
let skipped = 0;
// 创建范围
let range = null;
if (lower !== undefined && upper !== undefined) {
range = IDBKeyRange.bound(lower, upper);
} else if (lower !== undefined) {
range = IDBKeyRange.lowerBound(lower);
} else if (upper !== undefined) {
range = IDBKeyRange.upperBound(upper);
}
const request = source.openCursor(range, direction);
request.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) {
resolve(results);
return;
}
// 跳过offset条
if (skipped < offset) {
skipped++;
cursor.continue();
return;
}
// 收集数据直到达到limit
if (results.length < limit) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
// 删除旧数据,支持按时间戳清理
async deleteOldData(storeName, timestampField, maxAge) {
const cutoff = Date.now() - maxAge;
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
return new Promise((resolve, reject) => {
const request = store.openCursor();
let deletedCount = 0;
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const record = cursor.value;
if (record[timestampField] < cutoff) {
cursor.delete();
deletedCount++;
}
cursor.continue();
} else {
resolve(deletedCount);
}
};
request.onerror = () => reject(request.error);
});
}
// 获取数据库统计信息
async getStats(storeName) {
const transaction = this.db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
return new Promise((resolve, reject) => {
const countRequest = store.count();
const sizeEstimate = new Promise((res) => {
// 估算大小:遍历所有数据并计算JSON长度
let totalSize = 0;
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
totalSize += JSON.stringify(cursor.value).length;
cursor.continue();
} else {
res(totalSize);
}
};
});
Promise.all([countRequest, sizeEstimate]).then(([count, size]) => {
resolve({
count,
estimatedSize: size,
avgItemSize: count > 0 ? Math.round(size / count) : 0
});
}).catch(reject);
});
}
}
// 使用示例:创建一个用户数据仓库
const db = new IndexedDBWrapper('MyAppDB', 1);
await db.init([
{
name: 'users',
keyPath: 'id',
indexes: [
{ name: 'email', keyPath: 'email', options: { unique: true } },
{ name: 'lastLogin', keyPath: 'lastLogin', options: { unique: false } },
{ name: 'status', keyPath: 'status', options: { unique: false } }
]
},
{
name: 'logs',
autoIncrement: true,
indexes: [
{ name: 'timestamp', keyPath: 'timestamp', options: { unique: false } },
{ name: 'level', keyPath: 'level', options: { unique: false } }
]
}
]);
// 批量导入用户数据,带进度回调
const users = Array.from({length: 1000}, (_, i) => ({
id: i,
email: `user${i}@example.com`,
name: `User ${i}`,
lastLogin: Date.now() - Math.random() * 86400000,
status: 'active'
}));
await db.addBatch('users', users, (done, total) => {
console.log(`导入进度: ${done}/${total} (${Math.round(done/total*100)}%)`);
});
// 查询最近登录的用户(分页)
const recentUsers = await db.getRange('users', {
indexName: 'lastLogin',
lower: Date.now() - 7 * 24 * 60 * 60 * 1000, // 7天内
direction: 'prev', // 倒序
offset: 0,
limit: 20
});
上面这个封装类已经考虑了事务管理、批量操作、分页查询、索引使用等常见场景,但代码量已经相当可观了。这就是为什么很多项目宁愿用LocalStorage凑合,也不想上IndexedDB——学习成本和开发成本确实高。
Cookie:带着服务器到处跑,还要被每个请求夹带的累赘
Cookie的设计初衷是服务端和客户端共享状态,所以它会自动随每个HTTP请求发送给服务器。这既是优点也是缺点。优点是简单,服务端可以直接读写;缺点是浪费带宽,而且大小限制严格(一般4KB)。
现代Web开发中,Cookie主要用于身份认证(Session ID、JWT)和追踪( analytics )。但 SameSite 属性的引入让跨域场景变得复杂,再加上 Chrome 逐步淘汰第三方 Cookie,这块儿的兼容性坑越来越多。
// 一个健壮的Cookie操作工具,考虑了编码和安全性
const CookieUtil = {
// 设置Cookie,支持各种属性
set(name, value, options = {}) {
let cookieString = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
if (options.expires) {
if (typeof options.expires === 'number') {
// 天数
const date = new Date();
date.setTime(date.getTime() + options.expires * 24 * 60 * 60 * 1000);
cookieString += `; expires=${date.toUTCString()}`;
} else {
cookieString += `; expires=${options.expires.toUTCString()}`;
}
}
if (options.path) cookieString += `; path=${options.path}`;
if (options.domain) cookieString += `; domain=${options.domain}`;
if (options.secure) cookieString += '; secure';
if (options.sameSite) cookieString += `; samesite=${options.sameSite}`;
if (options.httpOnly) {
// 注意:前端JS无法设置HttpOnly,这是服务端专用的
console.warn('HttpOnly属性必须由服务器设置');
}
document.cookie = cookieString;
return this.get(name) === value; // 验证是否设置成功
},
get(name) {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [cookieName, cookieValue] = cookie.trim().split('=');
if (decodeURIComponent(cookieName) === name) {
return decodeURIComponent(cookieValue);
}
}
return null;
},
remove(name, options = {}) {
// 通过设置过期时间为过去来删除
this.set(name, '', {
...options,
expires: new Date(0)
});
},
// 获取所有Cookie
getAll() {
const cookies = {};
if (document.cookie) {
document.cookie.split(';').forEach(cookie => {
const [name, value] = cookie.trim().split('=');
cookies[decodeURIComponent(name)] = decodeURIComponent(value);
});
}
return cookies;
},
// 检查Cookie是否可用(考虑第三方Cookie限制)
checkEnabled() {
const testKey = '__cookie_test__';
this.set(testKey, '1');
const enabled = this.get(testKey) === '1';
this.remove(testKey);
return enabled;
}
};
// 使用示例:设置一个7天有效期的JWT,只允许HTTPS,SameSite=Lax
CookieUtil.set('auth_token', jwtString, {
expires: 7,
path: '/',
secure: true, // 生产环境必须开启
sameSite: 'lax' // 防止CSRF攻击的基础保护
});
Cache Storage:PWA的好基友,专门囤积静态资源的仓库
这是Service Worker的配套API,专门用来缓存静态资源(JS、CSS、图片)。和前面几个不一样,它是基于请求的缓存,支持HTTP缓存语义(Headers、Methods),适合实现离线应用。
但Cache Storage只在HTTPS环境下可用(或者localhost开发环境),而且操作相对复杂,需要配合Service Worker使用。后面讲PWA的时候会详细说。
深究那些让你又爱又恨的存储黑科技
现在咱们往深里挖挖,看看这些存储机制背后的原理,以及那些让人头秃的坑。
为什么LocalStorage是同步的,存多了直接卡死主线程的真相
LocalStorage的同步特性源于它的简单设计。它本质上是一个同步的键值对存储,读写操作直接阻塞JavaScript主线程。当你调用 setItem 时,浏览器需要将数据序列化、编码,然后写入磁盘(或SQLite数据库,取决于浏览器实现)。这个过程虽然快,但如果是大量数据或者磁盘IO繁忙时,就会明显卡顿。
更坑的是,LocalStorage没有内置的批量操作API。你要存100条数据,就得循环调用100次 setItem,每次都要走一遍完整的IO流程。相比之下,IndexedDB的事务机制允许你在一个事务里批量提交多个操作,效率高出不少。
// 测试LocalStorage同步阻塞的示例
console.time('localStorage-write');
for (let i = 0; i < 1000; i++) {
localStorage.setItem(`key_${i}`, 'x'.repeat(1000)); // 存1KB数据
}
console.timeEnd('localStorage-write'); // 在我的电脑上大概200-500ms
// 对比:IndexedDB批量写入
console.time('indexedDB-write');
const db = await openDB('test', 1);
const tx = db.transaction('store', 'readwrite');
const store = tx.objectStore('store');
for (let i = 0; i < 1000; i++) {
store.add({ id: i, data: 'x'.repeat(1000) });
}
await tx.done;
console.timeEnd('indexedDB-write'); // 通常快3-5倍
IndexedDB的事务机制怎么搞,别再写出脏数据了
IndexedDB的事务自动提交机制是个双刃剑。好处是你不用手动commit,坏处是如果你忘了事务的生命周期,很容易在事务关闭后还尝试操作,导致报错。
事务的三种模式:
readonly:并发性能好,多个readonly事务可以同时执行readwrite:独占式,同一时间只能有一个readwrite事务versionchange:数据库升级时用,会阻塞所有其他事务
脏数据通常发生在异步操作里。比如你在一个事务中先读了数据,然后异步修改,再写回去,这时候如果另一个事务已经修改了同一条数据,你就覆盖了别人的修改(Lost Update)。
// 错误示例:可能导致脏数据
async function updateUserWrong(userId, updates) {
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
const user = await store.get(userId); // 读取
// 假设这里有个异步操作,比如验证数据
await validateData(updates); // 事务可能在这里自动提交了!
// 如果这时候另一个事务修改了user,这里就覆盖掉了
Object.assign(user, updates);
await store.put(user); // 可能覆盖了别人的修改
}
// 正确做法:把读取和写入放在同一个同步块里,或者使用事务的完整性
async function updateUserCorrect(userId, updateFn) {
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
// 读取和修改要在事务的同一个事件循环里完成
const user = await store.get(userId);
const updatedUser = updateFn(user); // 同步修改
await store.put(updatedUser);
await tx.done; // 确保事务完成
}
HTTP缓存头Cache-Control和ETag怎么配合打组合拳
HTTP缓存是前端性能优化的重头戏,但很多人(包括我)经常搞混强缓存和协商缓存。
强缓存(200 from cache):浏览器直接从本地缓存拿数据,不发请求给服务器。通过 Cache-Control: max-age=3600 或 Expires 控制。
协商缓存(304 Not Modified):缓存过期后,浏览器带着缓存标识(ETag或Last-Modified)问服务器"这玩意儿还能用吗",服务器说能用就返回304,浏览器继续用本地缓存。
// 一个支持HTTP缓存的fetch封装
async function cachedFetch(url, options = {}) {
const cacheKey = `http_cache_${url}`;
const cacheMetaKey = `${cacheKey}_meta`;
// 尝试读取本地缓存
const cached = localStorage.getItem(cacheKey);
const cachedMeta = JSON.parse(localStorage.getItem(cacheMetaKey) || '{}');
const headers = new Headers(options.headers || {});
// 如果有缓存且需要验证,添加条件请求头
if (cached && cachedMeta.etag) {
headers.set('If-None-Match', cachedMeta.etag);
}
if (cached && cachedMeta.lastModified) {
headers.set('If-Modified-Since', cachedMeta.lastModified);
}
try {
const response = await fetch(url, {
...options,
headers
});
// 304 Not Modified,使用缓存
if (response.status === 304 && cached) {
console.log('使用协商缓存:', url);
return new Response(cached, {
status: 200,
headers: {
'Content-Type': cachedMeta.contentType || 'application/json'
}
});
}
// 200 OK,更新缓存
if (response.ok) {
const clone = response.clone();
const body = await clone.text();
const etag = response.headers.get('ETag');
const lastModified = response.headers.get('Last-Modified');
const cacheControl = response.headers.get('Cache-Control');
// 只缓存允许缓存的响应
if (cacheControl && !cacheControl.includes('no-store')) {
const maxAge = parseMaxAge(cacheControl);
const meta = {
etag,
lastModified,
contentType: response.headers.get('Content-Type'),
cachedAt: Date.now(),
maxAge: maxAge * 1000 // 转为毫秒
};
localStorage.setItem(cacheKey, body);
localStorage.setItem(cacheMetaKey, JSON.stringify(meta));
console.log('更新HTTP缓存:', url, 'Max-Age:', maxAge);
}
}
return response;
} catch (error) {
// 网络错误时尝试使用过期缓存(Stale-While-Revalidate策略)
if (cached) {
console.warn('网络错误,使用过期缓存:', url);
return new Response(cached, {
status: 200,
headers: {
'X-From-Cache': 'stale'
}
});
}
throw error;
}
}
function parseMaxAge(cacheControl) {
const match = cacheControl.match(/max-age=(\d+)/);
return match ? parseInt(match[1]) : 0;
}
Service Worker怎么拦截请求,实现离线也能嗨的骚操作
Service Worker是PWA的核心,它运行在独立线程,可以拦截网络请求、操作Cache Storage。但它的生命周期复杂,安装、激活、更新都有特定的时机和事件。
// service-worker.js
const CACHE_NAME = 'my-app-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/app.js',
'/styles.css',
'/icon.png'
];
// 安装时缓存静态资源
self.addEventListener('install', event => {
console.log('Service Worker 安装中...');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('缓存静态资源');
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting()) // 立即激活
.catch(err => console.error('预缓存失败:', err))
);
});
// 激活时清理旧缓存
self.addEventListener('activate', event => {
console.log('Service Worker 激活中...');
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => {
console.log('删除旧缓存:', name);
return caches.delete(name);
})
);
}).then(() => self.clients.claim()) // 立即控制所有客户端
);
});
// 拦截网络请求
self.addEventListener('fetch', event => {
const { request } = event;
// 只处理GET请求
if (request.method !== 'GET') return;
// 策略1:缓存优先(Cache First)- 适合静态资源
if (isStaticAsset(request.url)) {
event.respondWith(cacheFirst(request));
return;
}
// 策略2:网络优先(Network First)- 适合API数据
if (isAPIRequest(request.url)) {
event.respondWith(networkFirst(request));
return;
}
// 策略3:仅网络(Network Only)- 其他请求
event.respondWith(fetch(request));
});
// 缓存优先策略
async function cacheFirst(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
if (cached) {
// 后台更新缓存(Stale-While-Revalidate)
fetch(request).then(response => {
if (response.ok) {
cache.put(request, response.clone());
}
}).catch(() => {});
return cached;
}
// 缓存未命中,走网络并缓存结果
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
// 完全离线且没有缓存时返回离线页面
if (request.mode === 'navigate') {
return cache.match('/offline.html');
}
throw error;
}
}
// 网络优先策略
async function networkFirst(request) {
const cache = await caches.open(CACHE_NAME);
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
// 更新缓存
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.log('网络请求失败,尝试缓存:', request.url);
const cached = await cache.match(request);
if (cached) {
return cached;
}
throw error;
}
}
function isStaticAsset(url) {
return STATIC_ASSETS.some(asset => url.includes(asset));
}
function isAPIRequest(url) {
return url.includes('/api/');
}
// 后台同步(Background Sync)- 离线提交表单
self.addEventListener('sync', event => {
if (event.tag === 'sync-forms') {
event.waitUntil(syncFormSubmissions());
}
});
async function syncFormSubmissions() {
const db = await openDB('form-queue', 1);
const submissions = await db.getAll('pending-forms');
for (const submission of submissions) {
try {
await fetch(submission.url, {
method: 'POST',
body: JSON.stringify(submission.data),
headers: { 'Content-Type': 'application/json' }
});
await db.delete('pending-forms', submission.id);
console.log('后台同步成功:', submission.id);
} catch (error) {
console.error('后台同步失败:', error);
// 保留在队列中,下次再试
}
}
}
内存泄漏是怎么发生的,存着存着浏览器就崩了
存储相关的内存泄漏主要有几种情况:
- LocalStorage无限增长:没有清理机制,数据只增不减
- IndexedDB连接未关闭:数据库连接是资源,不关闭会累积
- Cache Storage版本堆积:Service Worker更新后旧缓存没清理
- 事件监听器未移除:特别是storage事件,页面多了会互相影响
// 内存泄漏检测和防护示例
class StorageMonitor {
constructor() {
this.checkInterval = null;
this.thresholds = {
localStorage: 4 * 1024 * 1024, // 4MB预警
indexedDB: 50 * 1024 * 1024, // 50MB预警
memory: 100 * 1024 * 1024 // JS堆内存100MB预警
};
}
startMonitoring() {
this.checkInterval = setInterval(() => this.checkHealth(), 60000); // 每分钟检查
// 监听存储变化
window.addEventListener('storage', (e) => {
console.log('跨标签页存储变化:', e.key, e.newValue?.length);
this.checkQuota();
});
}
checkHealth() {
this.checkLocalStorage();
this.checkMemory();
}
checkLocalStorage() {
let totalSize = 0;
for (let key in localStorage) {
if (localStorage.hasOwnProperty(key)) {
totalSize += localStorage[key].length * 2; // UTF-16,每个字符2字节
}
}
console.log(`LocalStorage使用: ${(totalSize/1024/1024).toFixed(2)}MB`);
if (totalSize > this.thresholds.localStorage) {
console.warn('LocalStorage接近上限,触发清理');
this.cleanupLocalStorage();
}
return totalSize;
}
cleanupLocalStorage() {
// 策略:删除最旧的缓存数据
const items = [];
for (let key in localStorage) {
if (key.startsWith('cache_')) {
try {
const data = JSON.parse(localStorage[key]);
items.push({
key,
timestamp: data.timestamp || 0,
size: localStorage[key].length
});
} catch (e) {
// 非JSON数据,按字符串长度估算
items.push({
key,
timestamp: 0,
size: localStorage[key].length
});
}
}
}
// 按时间排序,删除最旧的50%
items.sort((a, b) => a.timestamp - b.timestamp);
const toDelete = items.slice(0, Math.floor(items.length / 2));
toDelete.forEach(item => {
localStorage.removeItem(item.key);
console.log('清理旧缓存:', item.key);
});
}
checkMemory() {
if (performance.memory) {
const used = performance.memory.usedJSHeapSize;
const total = performance.memory.totalJSHeapSize;
const limit = performance.memory.jsHeapSizeLimit;
console.log(`内存使用: ${(used/1024/1024).toFixed(2)}MB / ${(limit/1024/1024).toFixed(2)}MB`);
if (used > this.thresholds.memory) {
console.warn('JS堆内存过高,可能存在泄漏');
// 可以触发垃圾回收建议(虽然不能强制GC)
if (window.gc) {
window.gc();
console.log('建议垃圾回收');
}
}
}
}
checkQuota() {
// 检查存储配额(需要用户授权)
if (navigator.storage && navigator.storage.estimate) {
navigator.storage.estimate().then(estimate => {
const usage = (estimate.usage / 1024 / 1024).toFixed(2);
const quota = (estimate.quota / 1024 / 1024).toFixed(2);
const percent = ((estimate.usage / estimate.quota) * 100).toFixed(1);
console.log(`存储配额: ${usage}MB / ${quota}MB (${percent}%)`);
if (percent > 80) {
console.warn('存储配额使用超过80%,建议清理');
}
});
}
}
destroy() {
if (this.checkInterval) {
clearInterval(this.checkInterval);
}
}
}
// 使用
const monitor = new StorageMonitor();
monitor.startMonitoring();
这几种方案各有各的坑,踩平了才是真本事
LocalStorage虽然好用但不仅裸奔还不支持过期时间,简直反人类
LocalStorage的数据是明文存储的,任何能访问你页面的人都能在控制台里 localStorage.getItem 把所有数据扒出来。所以千万别存敏感信息,比如用户密码、身份证号、银行卡号。就算存个JWT token,也要做好XSS防护,不然一个 <script>alert(localStorage.getItem('token'))</script> 就全完了。
不支持过期时间也是个大坑。你存个"今日不再提示"的标记,结果用户一年后打开页面,还在用去年的逻辑。所以必须自己实现过期机制:
// 带过期时间的LocalStorage封装,生产环境必备
class ExpirableStorage {
constructor(namespace = 'app') {
this.ns = namespace;
this.cleanup(); // 初始化时清理过期数据
}
// 生成带命名空间的key
_key(key) {
return `${this.ns}:${key}`;
}
// 包装数据,带上过期时间
_wrap(value, ttlSeconds) {
return JSON.stringify({
value,
expires: ttlSeconds ? Date.now() + ttlSeconds * 1000 : null,
created: Date.now()
});
}
// 解包数据,检查是否过期
_unwrap(wrapped) {
try {
const data = JSON.parse(wrapped);
if (data.expires && Date.now() > data.expires) {
return { expired: true, data: null };
}
return { expired: false, data: data.value };
} catch (e) {
return { expired: true, data: null };
}
}
set(key, value, ttlSeconds = null) {
try {
const wrapped = this._wrap(value, ttlSeconds);
localStorage.setItem(this._key(key), wrapped);
return true;
} catch (e) {
if (e.name === 'QuotaExceededError') {
this.cleanup(); // 空间不足时先清理
try {
localStorage.setItem(this._key(key), this._wrap(value, ttlSeconds));
return true;
} catch (e2) {
console.error('存储失败,即使清理后空间仍不足');
return false;
}
}
return false;
}
}
get(key) {
const wrapped = localStorage.getItem(this._key(key));
if (!wrapped) return null;
const result = this._unwrap(wrapped);
if (result.expired) {
this.remove(key); // 自动清理过期数据
return null;
}
return result.data;
}
remove(key) {
localStorage.removeItem(this._key(key));
}
// 清理所有过期的数据
cleanup() {
const keys = Object.keys(localStorage);
let cleaned = 0;
keys.forEach(key => {
if (key.startsWith(this.ns + ':')) {
const wrapped = localStorage.getItem(key);
const result = this._unwrap(wrapped);
if (result.expired) {
localStorage.removeItem(key);
cleaned++;
}
}
});
if (cleaned > 0) {
console.log(`清理了 ${cleaned} 条过期数据`);
}
return cleaned;
}
// 获取所有未过期的key
keys() {
return Object.keys(localStorage)
.filter(key => key.startsWith(this.ns + ':'))
.map(key => key.replace(this.ns + ':', ''))
.filter(key => this.get(key) !== null); // 过滤掉已过期但还没清理的
}
// 清空命名空间下的所有数据
clear() {
this.keys().forEach(key => this.remove(key));
}
}
// 使用示例
const storage = new ExpirableStorage('myApp');
// 存一个1小时有效的验证码
storage.set('verification_code', '123456', 3600);
// 存一个永久有效的用户偏好
storage.set('theme', 'dark');
// 读取
const code = storage.get('verification_code');
if (!code) {
console.log('验证码已过期或不存在');
}
IndexedDB学习曲线陡峭得像攀岩,新手上来就想劝退
IndexedDB的难点在于它的异步事件模型和事务管理。虽然现在有Promise封装,但理解其底层原理仍然很重要。常见的坑包括:
- 忘记处理
onupgradeneeded,导致数据库版本不匹配 - 在事务外操作数据
- 没有处理并发写入的冲突
- 游标使用不当导致内存溢出
缓存更新不及时,用户看着昨天的旧新闻以为是Bug
这是缓存策略设计的问题。如果缓存时间设得太长,用户看到的就是旧数据;如果太短,又失去缓存的意义。解决方案是版本控制+后台更新:
// 带版本控制的缓存管理器
class VersionedCache {
constructor() {
this.currentVersion = this.getAppVersion(); // 从构建信息获取
}
getAppVersion() {
// 假设构建时注入了版本号
return window.APP_VERSION || '1.0.0';
}
async getData(key, fetchFn, options = {}) {
const { ttl = 3600, backgroundUpdate = true } = options;
const cacheKey = `v2_${key}`;
const versionKey = `${cacheKey}_ver`;
const cached = localStorage.getItem(cacheKey);
const cachedVersion = localStorage.getItem(versionKey);
const cachedTime = localStorage.getItem(`${cacheKey}_time`);
const isExpired = !cachedTime || (Date.now() - parseInt(cachedTime)) > ttl * 1000;
const isOldVersion = cachedVersion !== this.currentVersion;
// 如果有缓存且未过期且版本匹配,直接返回
if (cached && !isExpired && !isOldVersion) {
console.log('使用缓存:', key);
// 后台更新(Stale-While-Revalidate)
if (backgroundUpdate) {
this.backgroundUpdate(key, fetchFn);
}
return JSON.parse(cached);
}
// 需要重新获取
try {
const fresh = await fetchFn();
this.setData(key, fresh);
return fresh;
} catch (error) {
// 网络错误时返回过期缓存
if (cached) {
console.warn('网络错误,使用过期缓存:', key);
return JSON.parse(cached);
}
throw error;
}
}
setData(key, data) {
const cacheKey = `v2_${key}`;
try {
localStorage.setItem(cacheKey, JSON.stringify(data));
localStorage.setItem(`${cacheKey}_ver`, this.currentVersion);
localStorage.setItem(`${cacheKey}_time`, Date.now().toString());
} catch (e) {
console.error('缓存写入失败:', e);
}
}
async backgroundUpdate(key, fetchFn) {
try {
const fresh = await fetchFn();
this.setData(key, fresh);
console.log('后台更新完成:', key);
} catch (e) {
console.log('后台更新失败:', key);
}
}
// 版本升级时清理旧版本缓存
migrate() {
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith('v2_') && key.endsWith('_ver')) {
const ver = localStorage.getItem(key);
if (ver !== this.currentVersion) {
const baseKey = key.replace('_ver', '');
localStorage.removeItem(baseKey);
localStorage.removeItem(key);
localStorage.removeItem(`${baseKey}_time`);
console.log('清理旧版本缓存:', baseKey);
}
}
});
}
}
跨域存储那些弯弯绕绕,第三方Cookie被禁用的后路在哪
随着隐私保护增强,第三方Cookie越来越受限。Storage Access API 和 Partitioned Cookies 是新方向,但兼容性还不行。目前比较稳妥的方案是:
- 同域部署:把相关服务放在同一主域下
- 使用POST Message:跨域页面间通信
- 后端代理:通过同域接口中转数据
真实项目里大家都是怎么"缝缝补补"过日子的
封装一个带过期时间的Storage工具类,再也不用手动算时间戳
上面的 ExpirableStorage 类已经展示了基础实现,但在真实项目中,你可能还需要:
- 命名空间隔离(多团队协作时防止key冲突)
- 压缩存储(大数据量时减少占用)
- 加密存储(敏感数据)
- 读写统计(监控使用频率)
大列表数据怎么分片存入IndexedDB,避免一次读写卡成PPT
// 大数据分片存储方案
class ChunkedStorage {
constructor(dbName) {
this.db = new IndexedDBWrapper(dbName, 1);
this.CHUNK_SIZE = 1000; // 每片1000条
}
async init() {
await this.db.init([{
name: 'chunks',
keyPath: 'id',
indexes: [
{ name: 'dataId', keyPath: 'dataId', options: { unique: false } },
{ name: 'index', keyPath: 'index', options: { unique: false } }
]
}, {
name: 'metadata',
keyPath: 'dataId'
}]);
}
// 分片存储大数据
async saveLargeData(dataId, items, onProgress) {
const totalChunks = Math.ceil(items.length / this.CHUNK_SIZE);
// 保存元数据
await this.db.add('metadata', {
dataId,
total: items.length,
chunks: totalChunks,
created: Date.now()
});
// 分批存储
for (let i = 0; i < totalChunks; i++) {
const chunk = items.slice(i * this.CHUNK_SIZE, (i + 1) * this.CHUNK_SIZE);
await this.db.add('chunks', {
id: `${dataId}_${i}`,
dataId,
index: i,
data: chunk,
count: chunk.length
});
if (onProgress) {
onProgress(i + 1, totalChunks);
}
// 每存10片让出主线程,避免阻塞UI
if (i % 10 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
return { dataId, chunks: totalChunks };
}
// 分片读取,支持流式加载
async *streamLargeData(dataId) {
const meta = await this.getMetadata(dataId);
if (!meta) throw new Error('数据不存在');
for (let i = 0; i < meta.chunks; i++) {
const chunk = await this.db.getByIndex('chunks', 'id', `${dataId}_${i}`);
yield* chunk[0].data; // 使用生成器逐条产出
}
}
// 分页读取特定范围
async getRange(dataId, start, end) {
const meta = await this.getMetadata(dataId);
const startChunk = Math.floor(start / this.CHUNK_SIZE);
const endChunk = Math.floor(end / this.CHUNK_SIZE);
const results = [];
for (let i = startChunk; i <= endChunk && i < meta.chunks; i++) {
const chunk = await this.db.getByIndex('chunks', 'id', `${dataId}_${i}`);
const data = chunk[0].data;
// 计算边界
const chunkStart = i * this.CHUNK_SIZE;
const sliceStart = Math.max(0, start - chunkStart);
const sliceEnd = Math.min(data.length, end - chunkStart + 1);
results.push(...data.slice(sliceStart, sliceEnd));
}
return results;
}
async getMetadata(dataId) {
const result = await this.db.getByIndex('metadata', 'dataId', dataId);
return result[0];
}
// 删除大数据
async deleteLargeData(dataId) {
const meta = await this.getMetadata(dataId);
if (!meta) return;
// 删除所有分片
for (let i = 0; i < meta.chunks; i++) {
await this.db.delete('chunks', `${dataId}_${i}`);
}
await this.db.delete('metadata', dataId);
}
}
接口数据怎么利用HTTP缓存,减少服务器压力还能省流量
除了前面提到的 cachedFetch,还可以结合 Service Worker 实现更精细的控制:
// 在Service Worker中实现API缓存策略
const API_CACHE = 'api-cache-v1';
// 安装时不需要预缓存API,动态缓存即可
self.addEventListener('fetch', event => {
if (isAPIRequest(event.request)) {
event.respondWith(cacheAPI(event.request));
}
});
async function cacheAPI(request) {
const cache = await caches.open(API_CACHE);
const url = new URL(request.url);
// 对列表数据使用Stale-While-Revalidate
if (url.pathname.includes('/list')) {
const cached = await cache.match(request);
// 后台更新
const fetchPromise = fetch(request).then(response => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
}).catch(() => cached); // 网络失败返回缓存
return cached || await fetchPromise;
}
// 对详情数据使用Cache First,但设置较短有效期
if (url.pathname.includes('/detail')) {
const cached = await cache.match(request);
if (cached) {
// 检查缓存时间
const dateHeader = cached.headers.get('sw-cached-date');
if (dateHeader) {
const age = Date.now() - parseInt(dateHeader);
if (age < 5 * 60 * 1000) { // 5分钟内有效
return cached;
}
}
}
const response = await fetch(request);
if (response.ok) {
// 添加自定义头部记录缓存时间
const modified = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: {
...Object.fromEntries(response.headers),
'sw-cached-date': Date.now().toString()
}
});
cache.put(request, modified);
return response;
}
}
return fetch(request);
}
版本更新时如何优雅地清理旧缓存,防止新旧代码打架
这是PWA的痛点。如果Service Worker更新后,旧缓存没清理,用户可能看到新旧资源混合的页面,导致JS报错。解决方案是在activate阶段清理,并使用版本号隔离:
// 版本控制策略
const VERSION = '2.1.0'; // 每次发版更新
const CACHE_PREFIX = 'my-app';
const CACHE_NAME = `${CACHE_PREFIX}-${VERSION}`;
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
.map(name => {
console.log('删除旧版本缓存:', name);
return caches.delete(name);
})
);
}).then(() => {
// 通知所有客户端新版本已激活
return self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'NEW_VERSION',
version: VERSION
});
});
});
})
);
});
// 前端监听版本更新
navigator.serviceWorker.addEventListener('message', event => {
if (event.data.type === 'NEW_VERSION') {
// 提示用户刷新
showUpdateNotification('新版本已就绪,请刷新页面');
}
});
敏感信息千万别往LocalStorage里扔,XSS攻击教你做人
XSS(跨站脚本攻击)可以轻易读取LocalStorage里的所有数据。防护措施:
- HttpOnly Cookie:敏感token放Cookie里,设置HttpOnly,JS读不到
- 输入过滤:严格过滤用户输入,防止注入
- CSP策略:限制内联脚本执行
- 短有效期:即使被盗,也很快失效
// 安全的Token管理方案
class SecureTokenManager {
// 使用Cookie存储refresh token(HttpOnly,服务端设置)
// 使用内存存储access token(页面刷新丢失,需要重新获取)
constructor() {
this.accessToken = null;
this.refreshPromise = null;
}
// 从Cookie获取refresh token(实际上JS读不到HttpOnly Cookie,这里只是示意流程)
async getAccessToken() {
if (this.accessToken) {
// 检查是否即将过期
const payload = this.parseJwt(this.accessToken);
if (payload.exp - Date.now()/1000 > 60) { // 还有1分钟以上有效期
return this.accessToken;
}
}
// 需要刷新
return this.refreshAccessToken();
}
async refreshAccessToken() {
// 防止重复刷新
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = fetch('/api/refresh', {
method: 'POST',
credentials: 'include' // 携带Cookie
}).then(res => {
if (!res.ok) throw new Error('刷新失败');
return res.json();
}).then(data => {
this.accessToken = data.accessToken;
// 不存LocalStorage,只存内存
return this.accessToken;
}).finally(() => {
this.refreshPromise = null;
});
return this.refreshPromise;
}
parseJwt(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
return null;
}
}
// 登出时清除
logout() {
this.accessToken = null;
// 调用服务端清除HttpOnly Cookie
fetch('/api/logout', { method: 'POST', credentials: 'include' });
}
}
遇到诡异的缓存问题别慌,按这个路子查准没错
开发者工具Application面板怎么看,一眼识别谁在占坑
Chrome DevTools的Application面板是调试存储的利器:
- Local Storage:查看键值对,注意Size列显示的是字符数,不是字节数
- Session Storage:同上,但标签页关闭就没了
- IndexedDB:可以查看数据库结构、对象仓库、索引,甚至执行查询
- Cookies:查看所有Cookie的属性,检查HttpOnly、Secure、SameSite
- Cache Storage:查看Service Worker缓存的具体内容
- Service Workers:检查SW的状态,模拟离线,跳过等待
清除了缓存还是没变?可能是Service Worker在后台作祟
这是最常见的坑。用户说"我清除了浏览器缓存还是旧页面",其实是因为Service Worker还在拦截请求。解决方案:
- 打开DevTools -> Application -> Service Workers
- 勾选"Update on reload"(开发时)
- 点击"Unregister"删除当前SW
- 或者长按刷新按钮选择"清空缓存并硬性重新加载"
沙箱环境下的存储限制,无痕模式里的"薛定谔的存储"
无痕模式(隐私模式)下,各浏览器对存储的处理不同:
- Chrome:LocalStorage可用,但关闭标签页后清除;IndexedDB可用但容量受限
- Safari:LocalStorage和IndexedDB都可能被禁用,或者表现为"写成功但读不到"
- Firefox:类似Chrome,但IndexedDB在无痕模式下可能行为异常
检测方法:
async function checkStorageInIncognito() {
try {
const testKey = '__incognito_test__';
localStorage.setItem(testKey, '1');
const result = localStorage.getItem(testKey);
localStorage.removeItem(testKey);
if (result !== '1') {
return { available: false, reason: '写入后读取不一致' };
}
// 检查IndexedDB
const db = await indexedDB.open('test');
await new Promise((resolve, reject) => {
db.onsuccess = resolve;
db.onerror = reject;
});
return { available: true };
} catch (e) {
return { available: false, reason: e.message };
}
}
移动端WebView的奇葩行为,安卓和iOS各自为政的坑
移动端WebView的存储问题更多:
- iOS WKWebView:IndexedDB在某些版本有bug,数据可能随机丢失;LocalStorage在内存不足时可能被清理
- Android WebView:不同厂商定制差异大,有些会限制存储配额;清除App数据会同时清除所有Web存储
- 微信/支付宝内置浏览器:有额外的缓存层,有时候需要特定的清理策略
线上用户反馈数据丢失,怎么通过日志还原现场抓鬼
建立存储操作的日志系统:
// 存储操作日志系统
class StorageLogger {
constructor() {
this.logs = [];
this.maxLogs = 100;
}
log(operation, key, success, details = {}) {
const entry = {
time: new Date().toISOString(),
operation, // 'read' | 'write' | 'delete' | 'clear'
key: key?.substring(0, 50), // 截断避免过大
success,
userAgent: navigator.userAgent.substring(0, 100),
url: window.location.href,
...details
};
this.logs.push(entry);
// 限制日志数量
if (this.logs.length > this.maxLogs) {
this.logs.shift();
}
// 同步到服务器(如果是关键错误)
if (!success && details.critical) {
this.reportToServer(entry);
}
}
// 包装LocalStorage
wrapLocalStorage() {
const original = {
setItem: localStorage.setItem.bind(localStorage),
getItem: localStorage.getItem.bind(localStorage),
removeItem: localStorage.removeItem.bind(localStorage)
};
localStorage.setItem = (key, value) => {
try {
original.setItem(key, value);
this.log('write', key, true, { size: value?.length });
} catch (e) {
this.log('write', key, false, {
error: e.name,
message: e.message,
critical: true
});
throw e;
}
};
localStorage.getItem = (key) => {
const value = original.getItem(key);
this.log('read', key, true, { hit: value !== null });
return value;
};
}
// 导出日志给用户下载(排查问题时用)
export() {
return JSON.stringify(this.logs, null, 2);
}
reportToServer(entry) {
// 发送到错误监控服务
if (window.Sentry) {
window.Sentry.captureMessage('Storage Operation Failed', {
extra: entry
});
}
}
}
// 初始化
const storageLogger = new StorageLogger();
storageLogger.wrapLocalStorage();
几个让代码更健壮、性能更起飞的老司机经验
别把所有鸡蛋放在一个篮子里,组合拳出击才稳当
根据数据特点选择存储方案:
- 用户配置:LocalStorage + 内存缓存
- 大列表数据:IndexedDB + 分页加载
- 静态资源:Cache Storage + Service Worker
- 敏感信息:HttpOnly Cookie + 内存
- 临时状态:SessionStorage 或 内存
序列化大对象前先压缩一下,空间利用率直接翻倍
// 使用LZ-string进行客户端压缩
const LZString = require('lz-string');
class CompressedStorage {
set(key, value) {
const json = JSON.stringify(value);
const compressed = LZString.compressToUTF16(json); // 压缩为UTF-16字符串
localStorage.setItem(key, compressed);
console.log(`压缩率: ${(compressed.length / json.length * 100).toFixed(1)}%`);
return compressed.length < json.length;
}
get(key) {
const compressed = localStorage.getItem(key);
if (!compressed) return null;
const json = LZString.decompressFromUTF16(compressed);
return JSON.parse(json);
}
}
利用requestIdleCallback在浏览器空闲时慢慢存,别阻塞渲染
// 空闲时批量写入
class IdleStorage {
constructor() {
this.queue = [];
this.isProcessing = false;
}
addToQueue(key, value) {
this.queue.push({ key, value });
this.scheduleProcess();
}
scheduleProcess() {
if (this.isProcessing || this.queue.length === 0) return;
if ('requestIdleCallback' in window) {
requestIdleCallback(() => this.processQueue(), { timeout: 2000 });
} else {
setTimeout(() => this.processQueue(), 100);
}
}
processQueue() {
this.isProcessing = true;
// 每次空闲时处理一部分
const batch = this.queue.splice(0, 5);
batch.forEach(({ key, value }) => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error('写入失败:', key, e);
}
});
this.isProcessing = false;
// 如果还有队列,继续调度
if (this.queue.length > 0) {
this.scheduleProcess();
}
}
}
给缓存数据加个版本号,升级逻辑写得明明白白
前面已经展示过版本控制,这里再强调一下数据结构的版本兼容:
// 数据迁移示例
function migrateUserData(oldData) {
const version = oldData._v || 1;
if (version === 1) {
// v1 -> v2: 重命名字段
oldData.fullName = oldData.name;
delete oldData.name;
oldData._v = 2;
}
if (version === 2) {
// v2 -> v3: 添加新字段
oldData.preferences = oldData.preferences || { theme: 'light' };
oldData._v = 3;
}
return oldData;
}
监控存储空间配额,快满的时候提前给用户提个醒
// 存储空间监控
async function checkStorageQuota() {
if (!navigator.storage || !navigator.storage.estimate) return;
const estimate = await navigator.storage.estimate();
const usage = estimate.usage || 0;
const quota = estimate.quota || Infinity;
const percent = (usage / quota * 100).toFixed(1);
if (percent > 90) {
showWarning('存储空间即将用尽,建议清理缓存');
} else if (percent > 70) {
console.warn(`存储空间使用: ${percent}%`);
}
return { usage, quota, percent };
}
// 定期监控
setInterval(checkStorageQuota, 5 * 60 * 1000); // 每5分钟检查
要是看完你还觉得缓存简单,那一定是你遇到的坑还不够多
说真的,写这篇文章的时候,我脑子里像放电影一样闪过无数次凌晨被叫起来修bug的画面。有次是因为LocalStorage存满了导致整个单页应用白屏;有次是IndexedDB版本升级逻辑写错了,用户数据全"消失"了(其实还在,只是读不出来);还有一次是Service Worker缓存了HTML,但JS文件没缓存,结果新旧代码不匹配,页面按钮点了没反应。
每次解决完问题,我都觉得"这次总该学乖了吧",但下次总能遇到新花样。这就是前端开发的魅力(或者叫折磨)——你以为你懂了,其实你只是熟悉了上次那个坑的形状。
所以下次再有人跟你吹嘘"缓存很简单,不就是set和get吗",请把这篇文章甩他脸上。然后微笑着问他:“兄弟,处理过QuotaExceededError吗?写过IndexedDB的版本迁移吗?调试过Service Worker的激活状态吗?”
记住,前端没有银弹,只有填不完的坑和修不完的Bug。但正是这些坑,让我们从"写页面的"变成了"工程师"。每次解决一个存储相关的诡异bug,你对浏览器的理解就深了一层,下次就能少熬一个夜。
愿你的代码永远没有缓存污染,愿用户的浏览器永远不抽风。如果抽风了,愿你能快速定位问题,而不是在控制台面面相觑。
共勉。

更多推荐


所有评论(0)