欢迎光临
我们一直在努力

前端老鸟血泪史:本地存储爆雷怎么办?5招搞定缓存顽疾还能让页面秒开

前端老鸟血泪史:本地存储爆雷怎么办?5招搞定缓存顽疾还能让页面秒开

  • 前端老鸟血泪史:本地存储爆雷怎么办?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)
    未经允许不得转载:171主机测评 » 前端老鸟血泪史:本地存储爆雷怎么办?5招搞定缓存顽疾还能让页面秒开
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址