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

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

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

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

说实话,写这篇文章之前我抽了三根烟。不是因为别的,就是想起这些年被本地存储坑过的那些夜晚,血压有点上来了。你们懂那种感受吗?凌晨两点,用户群里突然炸锅,说数据丢了、页面白了、刷新一下东西全没了。你一边陪着笑说"马上修复",一边疯狂翻代码,最后发现是LocalStorage存满了,或者哪个手贱的同事把key写错了。

行吧,既然都聊到这儿了,咱们就把这块儿的老底儿掀个干净。从最开始那个"存个字符串而已能有多难"的 naive 想法,到现在看到存储相关的PR就条件反射式地紧张,这中间的血泪史,够写本书了。

别整那些虚的,聊聊咱们天天都要面对的"存数据"这点破事

我刚入行那会儿,觉得浏览器存储不就是 localStorage.setItem('name', '张三') 吗?简单得跟个1一样。直到有天产品经理跑过来说:“用户反馈每次刷新页面,购物车里的东西就没了,你查查咋回事。”

我当时就懵了——我明明存了啊!代码写得板板正正的:

// 当年那个天真的我写的代码functionaddToCart(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(),然后要处理 onupgradeneededonsuccessonerror 三个回调,升级版本还要手动迁移数据。新手第一次看官方文档,直接就想劝退。我贴段代码你们感受下:

// 打开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});}// 版本升级时的数据迁移逻辑,这里能写出一堆bugif(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:'[email protected]'});}; request.onerror=function(event){ console.error('数据库打开失败:', event.target.error);};// 添加数据的函数,这复杂度感受一下functionaddUser(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就四个方法:setItemgetItemremoveItemclear。但简单是有代价的。首先是容量,标准说是5MB,但不同浏览器实现不一样。Safari桌面版可能给10MB,iOS WebView可能只给2MB,而且这5MB是域名级别的,你所有页面共享。要是搞个单页应用,路由多了,一不小心就超配额。

其次是同步阻塞。LocalStorage的读写都是同步的,直接走主线程。你存个几KB的数据可能没感觉,但要是存个大的JSON字符串,比如几万条日志记录,页面直接卡死。我有次 profiling 的时候发现,一个 localStorage.setItem 调用了200多毫秒,那段时间用户点击完全没响应,体验烂透了。

还有那个让人崩溃的存储限制错误处理。超出配额时,不同浏览器表现不一样:

// 一个比较健壮的存储函数,考虑了各种边界情况functionsafeSetItem(key, value){try{const serialized =JSON.stringify(value);const size =newBlob([serialized]).size;// 预估一下大小,虽然不准确但总比没有强if(size >4.5*1024*1024){ console.warn('数据太大,建议分片存储或使用IndexedDB');returnfalse;} localStorage.setItem(key, serialized);returntrue;}catch(e){if(e.name ==='QuotaExceededError'|| e.name ==='NS_ERROR_DOM_QUOTA_REACHED'){ console.error('存储空间已满,需要清理旧数据');// 这里可以触发清理逻辑cleanupOldData();// 重试一次try{ localStorage.setItem(key,JSON.stringify(value));returntrue;}catch(retryError){ console.error('清理后仍然无法存储:', retryError);returnfalse;}} console.error('存储失败:', e);returnfalse;}}// 清理旧数据的示例逻辑functioncleanupOldData(){// 策略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做表单草稿的示例classFormDraft{constructor(formId){this.key =`draft_${formId}`;this.saveInterval =null;}// 开始自动保存,每30秒存一次startAutoSave(getDataFn){this.saveInterval =setInterval(()=>{const data =getDataFn();this.save(data); console.log('草稿已自动保存',newDate().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);}returnnull;}clear(){ sessionStorage.removeItem(this.key);if(this.saveInterval){clearInterval(this.saveInterval);}}}// 使用示例const draft =newFormDraft('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的核心,也是最容易出错的地方。所有读写操作必须在事务中进行,事务有三种模式:readonlyreadwriteversionchange。如果你在一个readonly事务里尝试写入,会直接报错。而且事务有自动提交机制,如果一段时间内没有操作,事务会自动关闭,这时候你再想读写就会报错。

// 一个更完整的IndexedDB封装类,带Promise支持classIndexedDBWrapper{constructor(dbName, version){this.dbName = dbName;this.version = version;this.db =null;}// 初始化数据库asyncinit(stores){returnnewPromise((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);};});}// 添加数据asyncadd(storeName, data){returnnewPromise((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);});}// 批量添加,使用游标优化性能asyncaddBatch(storeName, dataArray, onProgress){returnnewPromise((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;constprocessBatch=()=>{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();});}// 使用索引查询asyncgetByIndex(storeName, indexName, value){returnnewPromise((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);});}// 范围查询,支持分页asyncgetRange(storeName, options ={}){const{ indexName, lower, upper, direction ='next', offset =0, limit =50}= options;returnnewPromise((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);}elseif(lower !==undefined){ range = IDBKeyRange.lowerBound(lower);}elseif(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;}// 收集数据直到达到limitif(results.length < limit){ results.push(cursor.value); cursor.continue();}else{resolve(results);}}; request.onerror=()=>reject(request.error);});}// 删除旧数据,支持按时间戳清理asyncdeleteOldData(storeName, timestampField, maxAge){const cutoff = Date.now()- maxAge;const transaction =this.db.transaction([storeName],'readwrite');const store = transaction.objectStore(storeName);returnnewPromise((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);});}// 获取数据库统计信息asyncgetStats(storeName){const transaction =this.db.transaction([storeName],'readonly');const store = transaction.objectStore(storeName);returnnewPromise((resolve, reject)=>{const countRequest = store.count();const sizeEstimate =newPromise((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 =newIndexedDBWrapper('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 =newDate(); 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;returnthis.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){returndecodeURIComponent(cookieValue);}}returnnull;},remove(name, options ={}){// 通过设置过期时间为过去来删除this.set(name,'',{...options,expires:newDate(0)});},// 获取所有CookiegetAll(){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 =awaitopenDB('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)。

// 错误示例:可能导致脏数据asyncfunctionupdateUserWrong(userId, updates){const tx = db.transaction('users','readwrite');const store = tx.objectStore('users');const user =await store.get(userId);// 读取// 假设这里有个异步操作,比如验证数据awaitvalidateData(updates);// 事务可能在这里自动提交了!// 如果这时候另一个事务修改了user,这里就覆盖掉了 Object.assign(user, updates);await store.put(user);// 可能覆盖了别人的修改}// 正确做法:把读取和写入放在同一个同步块里,或者使用事务的完整性asyncfunctionupdateUserCorrect(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=3600Expires 控制。

协商缓存(304 Not Modified):缓存过期后,浏览器带着缓存标识(ETag或Last-Modified)问服务器"这玩意儿还能用吗",服务器说能用就返回304,浏览器继续用本地缓存。

// 一个支持HTTP缓存的fetch封装asyncfunctioncachedFetch(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 =newHeaders(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 =awaitfetch(url,{...options, headers });// 304 Not Modified,使用缓存if(response.status ===304&& cached){ console.log('使用协商缓存:', url);returnnewResponse(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);returnnewResponse(cached,{status:200,headers:{'X-From-Cache':'stale'}});}throw error;}}functionparseMaxAge(cacheControl){const match = cacheControl.match(/max-age=(\d+)/);return match ?parseInt(match[1]):0;}

Service Worker怎么拦截请求,实现离线也能嗨的骚操作

Service Worker是PWA的核心,它运行在独立线程,可以拦截网络请求、操作Cache Storage。但它的生命周期复杂,安装、激活、更新都有特定的时机和事件。

// service-worker.jsconstCACHE_NAME='my-app-v1';constSTATIC_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));});// 缓存优先策略asyncfunctioncacheFirst(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 =awaitfetch(request);if(response.ok){ cache.put(request, response.clone());}return response;}catch(error){// 完全离线且没有缓存时返回离线页面if(request.mode ==='navigate'){return cache.match('/offline.html');}throw error;}}// 网络优先策略asyncfunctionnetworkFirst(request){const cache =await caches.open(CACHE_NAME);try{const networkResponse =awaitfetch(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;}}functionisStaticAsset(url){returnSTATIC_ASSETS.some(asset=> url.includes(asset));}functionisAPIRequest(url){return url.includes('/api/');}// 后台同步(Background Sync)- 离线提交表单 self.addEventListener('sync',event=>{if(event.tag ==='sync-forms'){ event.waitUntil(syncFormSubmissions());}});asyncfunctionsyncFormSubmissions(){const db =awaitopenDB('form-queue',1);const submissions =await db.getAll('pending-forms');for(const submission of submissions){try{awaitfetch(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);// 保留在队列中,下次再试}}}

内存泄漏是怎么发生的,存着存着浏览器就崩了

存储相关的内存泄漏主要有几种情况:

  1. LocalStorage无限增长:没有清理机制,数据只增不减
  2. IndexedDB连接未关闭:数据库连接是资源,不关闭会累积
  3. Cache Storage版本堆积:Service Worker更新后旧缓存没清理
  4. 事件监听器未移除:特别是storage事件,页面多了会互相影响
// 内存泄漏检测和防护示例classStorageMonitor{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 =newStorageMonitor(); monitor.startMonitoring();

这几种方案各有各的坑,踩平了才是真本事

LocalStorage虽然好用但不仅裸奔还不支持过期时间,简直反人类

LocalStorage的数据是明文存储的,任何能访问你页面的人都能在控制台里 localStorage.getItem 把所有数据扒出来。所以千万别存敏感信息,比如用户密码、身份证号、银行卡号。就算存个JWT token,也要做好XSS防护,不然一个 <script>alert(localStorage.getItem('token'))</script> 就全完了。

不支持过期时间也是个大坑。你存个"今日不再提示"的标记,结果用户一年后打开页面,还在用去年的逻辑。所以必须自己实现过期机制:

// 带过期时间的LocalStorage封装,生产环境必备classExpirableStorage{constructor(namespace ='app'){this.ns = namespace;this.cleanup();// 初始化时清理过期数据}// 生成带命名空间的key_key(key){return`${this.ns}:${key}`;}// 包装数据,带上过期时间_wrap(value, ttlSeconds){returnJSON.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);returntrue;}catch(e){if(e.name ==='QuotaExceededError'){this.cleanup();// 空间不足时先清理try{ localStorage.setItem(this._key(key),this._wrap(value, ttlSeconds));returntrue;}catch(e2){ console.error('存储失败,即使清理后空间仍不足');returnfalse;}}returnfalse;}}get(key){const wrapped = localStorage.getItem(this._key(key));if(!wrapped)returnnull;const result =this._unwrap(wrapped);if(result.expired){this.remove(key);// 自动清理过期数据returnnull;}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;}// 获取所有未过期的keykeys(){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 =newExpirableStorage('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

这是缓存策略设计的问题。如果缓存时间设得太长,用户看到的就是旧数据;如果太短,又失去缓存的意义。解决方案是版本控制+后台更新:

// 带版本控制的缓存管理器classVersionedCache{constructor(){this.currentVersion =this.getAppVersion();// 从构建信息获取}getAppVersion(){// 假设构建时注入了版本号return window.APP_VERSION||'1.0.0';}asyncgetData(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);}returnJSON.parse(cached);}// 需要重新获取try{const fresh =awaitfetchFn();this.setData(key, fresh);return fresh;}catch(error){// 网络错误时返回过期缓存if(cached){ console.warn('网络错误,使用过期缓存:', key);returnJSON.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);}}asyncbackgroundUpdate(key, fetchFn){try{const fresh =awaitfetchFn();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

// 大数据分片存储方案classChunkedStorage{constructor(dbName){this.db =newIndexedDBWrapper(dbName,1);this.CHUNK_SIZE=1000;// 每片1000条}asyncinit(){awaitthis.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'}]);}// 分片存储大数据asyncsaveLargeData(dataId, items, onProgress){const totalChunks = Math.ceil(items.length /this.CHUNK_SIZE);// 保存元数据awaitthis.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);awaitthis.db.add('chunks',{id:`${dataId}_${i}`, dataId,index: i,data: chunk,count: chunk.length });if(onProgress){onProgress(i +1, totalChunks);}// 每存10片让出主线程,避免阻塞UIif(i %10===0){awaitnewPromise(resolve=>setTimeout(resolve,0));}}return{ dataId,chunks: totalChunks };}// 分片读取,支持流式加载 async *streamLargeData(dataId){const meta =awaitthis.getMetadata(dataId);if(!meta)thrownewError('数据不存在');for(let i =0; i < meta.chunks; i++){const chunk =awaitthis.db.getByIndex('chunks','id',`${dataId}_${i}`);yield* chunk[0].data;// 使用生成器逐条产出}}// 分页读取特定范围asyncgetRange(dataId, start, end){const meta =awaitthis.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 =awaitthis.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;}asyncgetMetadata(dataId){const result =awaitthis.db.getByIndex('metadata','dataId', dataId);return result[0];}// 删除大数据asyncdeleteLargeData(dataId){const meta =awaitthis.getMetadata(dataId);if(!meta)return;// 删除所有分片for(let i =0; i < meta.chunks; i++){awaitthis.db.delete('chunks',`${dataId}_${i}`);}awaitthis.db.delete('metadata', dataId);}}

接口数据怎么利用HTTP缓存,减少服务器压力还能省流量

除了前面提到的 cachedFetch,还可以结合 Service Worker 实现更精细的控制:

// 在Service Worker中实现API缓存策略constAPI_CACHE='api-cache-v1';// 安装时不需要预缓存API,动态缓存即可 self.addEventListener('fetch',event=>{if(isAPIRequest(event.request)){ event.respondWith(cacheAPI(event.request));}});asyncfunctioncacheAPI(request){const cache =await caches.open(API_CACHE);const url =newURL(request.url);// 对列表数据使用Stale-While-Revalidateif(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 =awaitfetch(request);if(response.ok){// 添加自定义头部记录缓存时间const modified =newResponse(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;}}returnfetch(request);}

版本更新时如何优雅地清理旧缓存,防止新旧代码打架

这是PWA的痛点。如果Service Worker更新后,旧缓存没清理,用户可能看到新旧资源混合的页面,导致JS报错。解决方案是在activate阶段清理,并使用版本号隔离:

// 版本控制策略constVERSION='2.1.0';// 每次发版更新constCACHE_PREFIX='my-app';constCACHE_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里的所有数据。防护措施:

  1. HttpOnly Cookie:敏感token放Cookie里,设置HttpOnly,JS读不到
  2. 输入过滤:严格过滤用户输入,防止注入
  3. CSP策略:限制内联脚本执行
  4. 短有效期:即使被盗,也很快失效
// 安全的Token管理方案classSecureTokenManager{// 使用Cookie存储refresh token(HttpOnly,服务端设置)// 使用内存存储access token(页面刷新丢失,需要重新获取)constructor(){this.accessToken =null;this.refreshPromise =null;}// 从Cookie获取refresh token(实际上JS读不到HttpOnly Cookie,这里只是示意流程)asyncgetAccessToken(){if(this.accessToken){// 检查是否即将过期const payload =this.parseJwt(this.accessToken);if(payload.exp - Date.now()/1000>60){// 还有1分钟以上有效期returnthis.accessToken;}}// 需要刷新returnthis.refreshAccessToken();}asyncrefreshAccessToken(){// 防止重复刷新if(this.refreshPromise){returnthis.refreshPromise;}this.refreshPromise =fetch('/api/refresh',{method:'POST',credentials:'include'// 携带Cookie}).then(res=>{if(!res.ok)thrownewError('刷新失败');return res.json();}).then(data=>{this.accessToken = data.accessToken;// 不存LocalStorage,只存内存returnthis.accessToken;}).finally(()=>{this.refreshPromise =null;});returnthis.refreshPromise;}parseJwt(token){try{returnJSON.parse(atob(token.split('.')[1]));}catch(e){returnnull;}}// 登出时清除logout(){this.accessToken =null;// 调用服务端清除HttpOnly Cookiefetch('/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还在拦截请求。解决方案:

  1. 打开DevTools -> Application -> Service Workers
  2. 勾选"Update on reload"(开发时)
  3. 点击"Unregister"删除当前SW
  4. 或者长按刷新按钮选择"清空缓存并硬性重新加载"

沙箱环境下的存储限制,无痕模式里的"薛定谔的存储"

无痕模式(隐私模式)下,各浏览器对存储的处理不同:

  • Chrome:LocalStorage可用,但关闭标签页后清除;IndexedDB可用但容量受限
  • Safari:LocalStorage和IndexedDB都可能被禁用,或者表现为"写成功但读不到"
  • Firefox:类似Chrome,但IndexedDB在无痕模式下可能行为异常

检测方法:

asyncfunctioncheckStorageInIncognito(){try{const testKey ='__incognito_test__'; localStorage.setItem(testKey,'1');const result = localStorage.getItem(testKey); localStorage.removeItem(testKey);if(result !=='1'){return{available:false,reason:'写入后读取不一致'};}// 检查IndexedDBconst db =await indexedDB.open('test');awaitnewPromise((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存储
  • 微信/支付宝内置浏览器:有额外的缓存层,有时候需要特定的清理策略

线上用户反馈数据丢失,怎么通过日志还原现场抓鬼

建立存储操作的日志系统:

// 存储操作日志系统classStorageLogger{constructor(){this.logs =[];this.maxLogs =100;}log(operation, key, success, details ={}){const entry ={time:newDate().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);}}// 包装LocalStoragewrapLocalStorage(){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(){returnJSON.stringify(this.logs,null,2);}reportToServer(entry){// 发送到错误监控服务if(window.Sentry){ window.Sentry.captureMessage('Storage Operation Failed',{extra: entry });}}}// 初始化const storageLogger =newStorageLogger(); storageLogger.wrapLocalStorage();

几个让代码更健壮、性能更起飞的老司机经验

别把所有鸡蛋放在一个篮子里,组合拳出击才稳当

根据数据特点选择存储方案:

  • 用户配置:LocalStorage + 内存缓存
  • 大列表数据:IndexedDB + 分页加载
  • 静态资源:Cache Storage + Service Worker
  • 敏感信息:HttpOnly Cookie + 内存
  • 临时状态:SessionStorage 或 内存

序列化大对象前先压缩一下,空间利用率直接翻倍

// 使用LZ-string进行客户端压缩const LZString =require('lz-string');classCompressedStorage{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)returnnull;const json = LZString.decompressFromUTF16(compressed);returnJSON.parse(json);}}

利用requestIdleCallback在浏览器空闲时慢慢存,别阻塞渲染

// 空闲时批量写入classIdleStorage{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();}}}

给缓存数据加个版本号,升级逻辑写得明明白白

前面已经展示过版本控制,这里再强调一下数据结构的版本兼容:

// 数据迁移示例functionmigrateUserData(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;}

监控存储空间配额,快满的时候提前给用户提个醒

// 存储空间监控asyncfunctioncheckStorageQuota(){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('存储空间即将用尽,建议清理缓存');}elseif(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,你对浏览器的理解就深了一层,下次就能少熬一个夜。

愿你的代码永远没有缓存污染,愿用户的浏览器永远不抽风。如果抽风了,愿你能快速定位问题,而不是在控制台面面相觑。

共勉。

在这里插入图片描述

Read more

【Linux/C++网络篇(一) 】网络编程入门:一文搞懂 TCP/UDP 编程模型与 Socket 网络编程

【Linux/C++网络篇(一) 】网络编程入门:一文搞懂 TCP/UDP 编程模型与 Socket 网络编程

⭐️在这个怀疑的年代,我们依然需要信仰。 个人主页:YYYing. ⭐️Linux/C++进阶系列专栏:【从零开始的linux/c++进阶编程】 系列上期内容:【Linux/C++多线程篇(二) 】同步互斥机制& C++ 11下的多线程 系列下期内容:暂无 目录 引言:程序如何“联网”? 网络编程基本概念 一、字节序 二、IP地址 IP地址的分类 特殊的IP地址 点分十进制 三、端口号 端口号的分类 网络编程基础 一、套接字(socket)的概念 二、基于TCP面向连接的通信方式  📖 bind函数  📖 listen函数  📖 accept函数  📖 recv、send数据收发  📖 close关闭套接字  📖 connect连接函数

By Ne0inhk
C++学习之旅【C++伸展树介绍以及红黑树的实现】

C++学习之旅【C++伸展树介绍以及红黑树的实现】

🔥承渊政道:个人主页 ❄️个人专栏: 《C语言基础语法知识》《数据结构与算法》 《C++知识内容》《Linux系统知识》 ✨逆境不吐心中苦,顺境不忘来时路!🎬 博主简介: 引言:前篇文章,小编已经介绍了关于C++AVL树的实现!相信大家应该有所收获!接下来我将带领大家继续深入学习C++的相关内容!本篇文章着重介绍关于C++伸展树介绍以及红黑树的实现!伸展树与红黑树是两类极具代表性的BBST,且在工程实践中各有不可替代的价值:伸展树摒弃了"严格平衡”的执念,通过“伸展”操作将最近访问的节点移至根节点,利用“局部性原理”优化频繁访问的场景,实现均摊O(logn)的时间复杂度,适合缓存、热点数据查询等场景;红黑树则通过给节点着色并遵守严格的颜色规则,确保树的最长路径不超过最短路径的两倍,以 “弱平衡” 换稳定的最坏O(logn)性能,是C++ STL 中 std::map、std:

By Ne0inhk
C++ 运算符重载:自定义类型的运算扩展

C++ 运算符重载:自定义类型的运算扩展

C++ 运算符重载:自定义类型的运算扩展 💡 学习目标:掌握运算符重载的核心语法与规则,能够为自定义类型重载常用运算符,实现类对象的灵活运算。 💡 学习重点:运算符重载的基本形式、成员函数与全局函数重载的区别、常见运算符的重载实现、禁止重载的运算符。 一、运算符重载的概念与核心价值 ✅ 结论:运算符重载是 C++ 静态多态的重要体现,允许为自定义类型(如类、结构体)重新定义运算符的行为,让自定义对象可以像内置类型一样使用运算符。 运算符重载的核心价值: 1. 简化代码书写:用直观的运算符替代繁琐的成员函数调用,提升代码可读性 2. 统一操作风格:让自定义类型的运算逻辑与内置类型保持一致,降低学习和使用成本 3. 扩展类型功能:根据业务需求定制运算符的行为,满足自定义类型的运算需求 ⚠️ 注意事项:运算符重载不会改变运算符的优先级和结合性,也不会改变运算符的操作数个数。 二、运算符重载的基本语法 运算符重载的本质是函数重载,分为成员函数重载和全局函数重载两种形式。 2.1 成员函数重载语法 将运算符重载函数定义为类的成员函数,语法格式如下: class

By Ne0inhk
【C++】第二十一节—一文详解 | 红黑树实现(规则+效率+结构+插入+查找+验证)

【C++】第二十一节—一文详解 | 红黑树实现(规则+效率+结构+插入+查找+验证)

Hi,我是云边有个稻草人......who?me,be like——→ 《C++》本篇文章所属专栏—持续更新中—欢迎订阅 目录 一、红黑树的概念 1.1 红黑树的规则 1.2 思考⼀下,红黑树如何确保最长路径不超过最短路径的2倍的? 1.3 红黑树的效率 二、红黑树的实现 2.1 红黑树的结构 2.2 红⿊树的插⼊ 【红⿊树树插⼊⼀个值的⼤概过程】 【情况1:变⾊】 【情况2:单旋+变⾊】 【情况2:双旋+变⾊】 2.3 红黑树的插入代码实现 2.4

By Ne0inhk