
1. 三种缓存方式到底怎么选先搞清楚各自的边界js本地缓存这件事说简单也简单说坑多也是真的多。只要你做过一段时间前端早晚都会撞上这类需求用户表单填了一半手滑刷新页面数据全没了从列表页点进详情页返回时列表的滚动位置和筛选条件全部重置省市区编码和名称那一大坨 js 数据每次进页面都要重新请求一遍接口接口连着挂了三次页面直接白给。这些问题的解法都指向同一个方向——把数据在浏览器端先存一份。摆在台面上的主要方案就三个localStorage、sessionStorage、Cookie。很多人第一次接触时会有种错觉觉得这仨差不多无非是 key-value 存点东西随手挑一个用就行。真到线上出问题才发现它们的差别比想象中大得多生命周期不一样容量差着三个数量级是否会跟着网络请求跑出去也完全不同。选错了轻则功能失效重则整个站的请求头被撑爆首屏慢得让人想砸键盘。所以这篇文章我不打算给你一段用法演示就完事而是把三种方式从边界、原理、实操、踩坑四个角度掰开揉碎让你在具体场景下能一眼判断该用哪个。1.1 先用一句话把它们区分开如果你只想记一句话那就记这个**localStorage 和 sessionStorage 是浏览器自己的一块抽屉Cookie 是每次出门都要塞进信封里的一张小纸条。**抽屉里的东西只有你自己看得到纸条上写的内容每送一次信都要跟着跑一趟。localStorage同源下永久保存除非你手动删或者用户清了浏览器数据。容量大概 5MB 量级。sessionStorage只在当前标签页的这次会话里有效关掉标签页就没了。容量同样 5MB 量级。Cookie跟着 HTTP 请求自动发送给同域服务端单个大约 4KB还会带上过期时间、域、路径这些元信息。这三个都属于只能存字符串的存储。你没看错js 对象、js 数组、数字、布尔值统统要转成字符串才能塞进去。这一点是新手翻车最集中的地方后面会专门用一节讲。1.2 为什么没有最好只有最合适我见过不少团队在项目初期纠结到底统一用哪一种这种思路本身就是错的。它们的定位根本不在一个层面上。localStorage 适合放那种跟用户绑定的、不需要实时性的、体积不算太大的数据比如主题偏好、语言设置、上次登录的用户名、接口返回后不常变的基础字典。sessionStorage 适合放只服务于当前这一次操作流程的数据比如多步表单的中间态、从列表页带到详情页的参数。Cookie 则更多承担服务端也需要知道的职责比如会话标识、灰度分流标记这类需要在服务端渲染阶段就读到的东西。注意千万不要拿 Cookie 当通用缓存使。它不是为存业务数据设计的4KB 的上限和每次请求都必须携带这两个特性注定它一旦被滥用性能账单会来得非常快。我再补一个容易被忽视的点它们三个都是同步 API。localStorage 和 sessionStorage 的读写会阻塞主线程这一点在存大对象时尤其明显。你可能觉得 5MB 不大但当你在一次事件回调里循环写入几百条数据时掉帧是实打实的。这也是为什么后面讲容量和性能时我会建议你给写入做节流、给大数据换方案。2. localStorage容量最大坑也最多localStorage 是这三者里用得最多的也是被误用最狠的。它足够简单简单到很多人上手五分钟就开始往里面塞 token、塞整个接口响应、塞图片的 base64。等应用跑起来发现内存和首屏都不对劲了回头一看好家伙localStorage 里躺了 3MB 的垃圾。2.1 五个 API 和那几个容易忽略的细节核心 API 就五个闭着眼都能敲出来// 写入 localStorage.setItem(theme, dark); // 读取不存在时返回 null const theme localStorage.getItem(theme); // 删除单个 localStorage.removeItem(theme); // 清空当前域名下所有 localStorage.clear(); // 按索引取 key常用来遍历 const firstKey localStorage.key(0);看着简单但有几个细节值得单独拎出来说。第一getItem 读不到时返回的是 null不是 undefined也不是空字符串。所以判断存在性的时候要写! null别写if (value)否则你存进去一个空字符串就会误判成不存在。第二key 方法接收的是索引序号不是键名它的用处是配合localStorage.length做遍历别搞反了。第三键名相同时后面的会覆盖前面的不会报错也不会提示这个特性在多人协作的项目里偶尔会造成数据莫名其妙被改了的诡异现象。还有一个很实际的坑本地存储是按源隔离的。协议、域名、端口三者只要有一个不一样就是不同的源互相看不见对方的数据。你在http://localhost:3000存的东西http://localhost:8080里读不到从 http 切到 https之前的数据也读不到。调试时遇到明明存了却读不出来先检查这两点能省下大把时间。2.2 只能存字符串js 对象和数组的序列化套路这是新手第一天就会撞上的墙。你想存一个 js 对象const user { id: 1, name: 张三, tags: [前端, 缓存] }; localStorage.setItem(user, user); // 存进去是 [object Object]读出来的时候你会发现拿到的是字符串[object Object].name直接是 undefined。原因就是存储层会把非字符串的值强制调用toString()。正确做法是用JSON.stringify和JSON.parse转一道const user { id: 1, name: 张三, tags: [前端, 缓存] }; // 存对象转成 JSON 字符串 localStorage.setItem(user, JSON.stringify(user)); // 取字符串再解析回对象 const raw localStorage.getItem(user); const userObj raw ? JSON.parse(raw) : null;听起来很基础但真正容易出事的是解析失败的兜底。只要存储里那份字符串被手动改过、被旧版本代码写成了别的格式、或者被截断了JSON.parse就会直接抛SyntaxError整个函数当场中断。线上最典型的表现就是某个用户一进页面就白屏别人都没事。所以只要涉及解析就该默认套一层 try/catchfunction readJSON(key, fallback null) { const raw localStorage.getItem(key); if (raw null) return fallback; try { return JSON.parse(raw); } catch (err) { // 数据脏了清掉比留着强 localStorage.removeItem(key); return fallback; } }实操心得我现在的习惯是凡是从本地存储读 JSON一律走上面这个封装绝不在业务里裸写JSON.parse。加上之后因为脏数据导致的白屏问题基本绝迹了。另外提醒一句JSON.stringify处理不了的东西也不少函数会被丢掉undefined会被丢掉Date对象会变成字符串读回来还是字符串需要自己转回 Date循环引用的对象会直接抛错。如果你的数据结构里带这些要么在序列化前手动整理一遍要么考虑更重的方案。2.3 跨标签页通信storage 事件怎么用这是个被严重低估的能力。用户在 A 标签页点了退出登录B 标签页还傻乎乎地显示着已登录状态——这种体验非常割裂。而 localStorage 的变更可以广播给同源的其他标签页靠的就是storage事件。window.addEventListener(storage, (e) { // 只有其他标签页改动时才会触发当前页自己改不会触发 if (e.key token e.newValue null) { // 别的页面退出了登录这里同步跳转 location.href /login; } });这个事件的回调参数里有key、oldValue、newValue、url和storageArea信息量够用。但有两个必须记住的特性当前页面自己修改 localStorage 时不会触发自己的 storage 事件。它只通知其他同源页面。clear()触发时key是 null所以只判断 key 名字的逻辑要额外兜一层。我还遇到过一个场景地图页面和列表页面并排开在两个标签页列表里改了筛选范围希望地图同步刷新。用 storage 事件做个轻量的消息总线比引入一套状态管理库省事得多。当然如果是要传复杂对象还是得自己约定一套消息格式把 payload 塞在 newValue 里。2.4 手写一个带过期时间和容量统计的缓存封装原生 localStorage 没有过期机制存进去就是永久这对缓存类数据很不友好。我自己的做法是在值外面包一层元信息把过期时间一起存进去。const cache { set(key, value, ttlSeconds) { const payload { v: value, exp: ttlSeconds ? Date.now() ttlSeconds * 1000 : null }; try { localStorage.setItem(key, JSON.stringify(payload)); return true; } catch (err) { // 多半是容量超了 console.warn(本地缓存写入失败, err); return false; } }, get(key) { const raw localStorage.getItem(key); if (raw null) return null; try { const payload JSON.parse(raw); if (payload.exp Date.now() payload.exp) { localStorage.removeItem(key); return null; } return payload.v; } catch (err) { localStorage.removeItem(key); return null; } } }; // 用法缓存省市区数据一天 cache.set(area:list, areaData, 24 * 60 * 60);关键就是那个exp字段。过期判断只在读取时做不主动清理这是有意为之——主动清理需要定时器反而增加复杂度。读的时候顺手删掉代价最低。再给一个统计当前占用大小的函数出问题时特别有用function currentSize() { let bytes 0; for (let i 0; i localStorage.length; i) { const key localStorage.key(i); const value localStorage.getItem(key) || ; // 粗略估算JS 字符串按 UTF-16 计每个字符 2 字节 bytes (key.length value.length) * 2; } return (bytes / 1024).toFixed(1) KB; }注意浏览器报的容量上限是近似值不同浏览器给到 5MB 或 10MB 不等。上面的算法是保守估算实际能写多少还受键名长度、编码方式影响别拿它当精确值用判断快满了足够了。3. sessionStorage会话级数据的正确打开方式sessionStorage 的 API 和 localStorage 一模一样连细节坑都一样唯一的区别在生命周期。就这一条差异决定了它适合的场景完全不同。3.1 生命周期到底怎么算刷新、跳转、新标签页先把会话这个词掰开。sessionStorage 的生命周期绑定的是标签页更准确说是浏览上下文不是浏览器进程也不是登录状态。具体表现操作sessionStorage 是否保留按 F5 刷新页面保留同标签页内点击链接跳转保留在地址栏改路径回车保留复制链接到新标签页打开不保留新会话通过window.open打开新标签页不保留一般情况关闭当前标签页销毁同标签页内的同源 iframe共享同一份这张表我建议你直接记住因为线上很多数据丢了的报错本质就是用户用了一种跨会话的方式打开了页面。有一点要特别注意如果新标签页是通过带noopener的方式打开的浏览器有时会复制一份 sessionStorage 过去这个行为在各浏览器里并不完全一致别把业务逻辑建立在一定会复制或一定不会复制上。稳妥的做法是跨标签页要传的数据一律走 URL 参数或服务端不要指望 sessionStorage。3.2 三个典型场景表单暂存和多步流程**场景一长表单的草稿暂存。**一个注册页有二十个字段用户填到一半去接了个电话回来发现页面被自己误关了。这种体验非常伤人。做法很简单在输入事件上做防抖把当前表单状态序列化后写进 sessionStorage页面初始化时读回来。const formKey register:draft; const formEl document.querySelector(#registerForm); // 恢复 const draft sessionStorage.getItem(formKey); if (draft) { const data JSON.parse(draft); Object.keys(data).forEach((name) { const input formEl.elements[name]; if (input) input.value data[name]; }); } // 暂存300ms 防抖避免每次按键都写 let timer null; formEl.addEventListener(input, () { clearTimeout(timer); timer setTimeout(() { const data {}; new FormData(formEl).forEach((v, k) { if (typeof v string) data[k] v; }); sessionStorage.setItem(formKey, JSON.stringify(data)); }, 300); });这里的防抖不是可选项。sessionStorage 是同步写每次按键都写会直接体现在输入延迟上尤其是低端安卓机。**场景二多步流程的状态传递。**比如三步的实名认证、分页式的问卷用户可能中途点浏览器后退键。用 sessionStorage 存一个步骤索引和已填数据后退时能恢复现场。**场景三页面间传参。**列表页点进详情页如果参数很多比如一个复杂的筛选条件对象塞 URL 会很难看塞 localStorage 又会污染持久数据。折中方案是详情页读一次就删// 列表页 sessionStorage.setItem(detail:params, JSON.stringify(params)); location.href /detail; // 详情页初始化 const raw sessionStorage.getItem(detail:params); if (raw) { const params JSON.parse(raw); sessionStorage.removeItem(detail:params); // 用完就删 renderDetail(params); }3.3 和 localStorage 混用的常见翻车现场最常见的错误是把当前登录用户信息存在 sessionStorage 里然后发现用户开了两个标签页一个登录了另一个还是未登录状态界面上出现两套逻辑。这类需要全局一致的数据就该放 localStorage 或者干脆由服务端下发。反过来另一个错误是把一次性流程数据放进 localStorage用户在几个月后重新打开页面发现表单里还躺着上次没提交完的内容吓一跳。判断标准其实很清晰这份数据离开当前这次操作还有意义吗有 → localStorage没有 → sessionStorage。4. Cookie老而弥坚但不能只当缓存使Cookie 的历史比 localStorage 长得多它是 HTTP 协议层面的东西不是浏览器发明的便利工具。理解这一点你对它的所有奇怪限制就都能想通了。4.1 Cookie 的结构与那些必须懂的属性一条 Cookie 本质上就是一段文本格式是namevalue后面跟一堆用分号分隔的属性。写入方式是通过给document.cookie赋值document.cookie themedark; path/; max-age604800; SameSiteLax;几个属性必须搞清楚pathCookie 在哪些路径下可见。path/表示全站可见。默认是当前路径这经常导致在 A 页面设的 CookieB 页面读不到。domain允许哪些域共享。设置成.example.com后a.example.com和b.example.com都能读到。max-age和expires前者是相对秒数后者是绝对时间。两者同时存在时max-age优先。不设就是不设过期时间浏览器关闭即失效。secure只在 HTTPS 下传输。HttpOnly服务端设置时使用前端 JS 无法读取用来防脚本窃取。这个属性前端改不了。SameSite控制跨站请求是否携带取值Strict、Lax、None。这是防跨站请求伪造的关键开关现代浏览器默认是Lax。有个细节特别反直觉读取document.cookie拿到的是当前页面可见的所有 Cookie 拼成的一个长字符串形如a1; b2; c3你得自己解析。4.2 为什么它会让请求变慢容量与性能代价这是 Cookie 最容易被忽视的代价。同域下所有 Cookie 都会自动附加在每个请求的头部这意味着你存进 Cookie 的每一个字节都会在网络上来回跑无数遍。单个 Cookie 上限大约 4KB同域下的 Cookie 数量一般限制在 50 个左右总量也就 200KB 上下的天花板。听起来不多但你要知道**一个只有 1KB 的 Cookie在一天 10 万次请求的量级下就是 100MB 的额外上行流量。**在移动网络下这个开销是实打实的。注意判断某个数据该不该放 Cookie问自己一个问题——服务端在渲染时需要读到它吗不需要的话就老老实实放 Web Storage。还有一个坑是 Cookie 的写法不合法会静默失败。名字里带分号、空格、逗号或者值里带分号这条 Cookie 就直接被丢弃而且不报错。所以值一定要encodeURIComponent处理尤其是存中文的时候。4.3 前端读写 Cookie 的正确姿势原生的document.cookie读写体验很差我一般会封两个小函数function setCookie(name, value, options {}) { const parts [${encodeURIComponent(name)}${encodeURIComponent(value)}]; if (options.maxAge) parts.push(max-age${options.maxAge}); if (options.path) parts.push(path${options.path}); if (options.domain) parts.push(domain${options.domain}); if (options.sameSite) parts.push(SameSite${options.sameSite}); if (options.secure) parts.push(Secure); document.cookie parts.join(; ); } function getCookie(name) { const target encodeURIComponent(name) ; const items document.cookie ? document.cookie.split(; ) : []; for (const item of items) { if (item.indexOf(target) 0) { return decodeURIComponent(item.slice(target.length)); } } return null; }用起来就是setCookie(theme, dark, { path: /, maxAge: 604800, sameSite: Lax })。这里的indexOf判断其实就是在做一个字符串是否包含的判断只不过加了前缀匹配比正则在简单场景下更快更直白。删除 Cookie 的办法是把它设成过期很多人以为有deleteCookie这种 API// 删除必须带上和设置时一致的 path 和 domain否则删不掉 setCookie(theme, , { path: /, maxAge: 0 });这个path 和 domain 必须一致的坑我踩过。当时设置的 Cookie 用了默认 path也就是当前页面的路径结果想删的时候在另一个路径下操作怎么都删不掉最后是在开发者工具里手动清掉的。所以设 Cookie 时习惯性加上path/能省掉后面一大堆麻烦。5. 三方对比与选型什么数据放哪里前面分开讲了三种方式的脾气这一节做个横向对照顺便说说超出它们能力范围时该怎么办。5.1 容量、性能、安全性横向对照表对比项localStoragesessionStorageCookie容量上限约 5MB约 5MB单个约 4KB同域约 50 个生命周期永久除非手动清除当前标签页会话内由 max-age / expires 决定是否随请求发送否否是同域请求全带服务端是否可读否否是HttpOnly 除外跨标签页共享是同源否是API 类型同步同步同步能否存二进制否否否典型用途主题、字典、用户偏好表单草稿、流程状态会话标识、分流标记看完这张表选型逻辑就很清楚了数据只在本页面流程内用且不希望它活得比会话长 → sessionStorage数据要跨会话、跨标签页且体积不大 → localStorage数据服务端也要读 → Cookie。5.2 数据量超过 5MB 怎么办IndexedDB 应急方案如果你的数据是一份几万条的省市区编码表或者是缓存的富文本内容、离线资源清单那 5MB 的天花板很快就会撞到。这时候该上的是IndexedDB。IndexedDB 是一个跑在浏览器里的数据库支持事务、索引能存结构化数据甚至 Blob容量按磁盘剩余空间分配动辄几百 MB 起步。缺点也很明显API 是全异步的基于事件回调写起来啰嗦。一段最简单的写入大概是这个画风const req indexedDB.open(myDB, 1); req.onupgradeneeded (e) { const db e.target.result; if (!db.objectStoreNames.contains(areas)) { db.createObjectStore(areas, { keyPath: code }); } }; req.onsuccess (e) { const db e.target.result; const tx db.transaction(areas, readwrite); tx.objectStore(areas).put({ code: 110100, name: 北京市 }); tx.oncomplete () console.log(写入完成); };你要是觉得这套 API 太劝退实际项目里通常会引入一个轻封装库来简化。我自己的判断标准是数据量在 1MB 以内、结构简单用 localStorage 足够超过 1MB 或者需要检索、需要存二进制直接上 IndexedDB别硬撑。顺便说一个异步性能相关的点。IndexedDB 的回调是走事件循环的跟Promise、async/await是一套调度机制。这里顺便提一句网页里所有异步任务最终都归 event loop 管IndexedDB 的回调、setTimeout、fetch的回调、Promise 的微任务谁先执行谁后执行取决于微任务队列和宏任务队列的顺序这个知识点在处理缓存读写时序时很关键。5.3 一个真实项目里的分层缓存设计我参与过的一个后台管理系统是这么分层的你可以参考这个思路**第一层内存缓存。**页面内用 js 对象或Map存当前页会用到的数据速度最快页面关闭即销毁。适合接口返回后短时间内反复读取的列表数据。**第二层sessionStorage。**存当前操作流程的状态比如刚才说到的那张复杂查询表单的条件。**第三层localStorage。**存字典数据、用户偏好、主题、上次选择的组织架构。这类数据变更频率低用带过期时间的封装缓存个几小时到一天都合理。**第四层IndexedDB。**存离线场景下需要的大批量数据比如给移动端做的离线台账。这套分层的核心思想是**越靠前的层读取越快、活得越短、容量越小。**按数据活得久不久和读取频不频繁两个维度往格子里填基本不会错。实操心得我们曾经把用户头像的 base64 存进 localStorage一条就顶到 200KB存了二十个用户直接爆容量。后来改成只存 URL图片交给浏览器自带的 HTTP 缓存去管问题立刻消失。能让浏览器自己做的事别抢过来做。6. 常见问题与排查技巧实录前面讲的是原理和设计这一节全是实打实的排查经验。都是我这些年真踩过的坑整理成表方便你对照。6.1 存进去读不出来的六种原因现象可能原因排查方向getItem 返回 null源不一致协议/域名/端口对比两处页面的完整 origin拿到[object Object]直接存了对象没序列化检查是否漏了JSON.stringifyJSON.parse 报 SyntaxError存储里有脏数据或旧格式封装 try/catch读失败即清理Cookie 读不到path 或 domain 不匹配检查设置和读取的路径是否一致刷新后数据还在新标签页没了用的是 sessionStorage按需求换成 localStorage某些用户就是写不进去隐私模式或存储被禁用检测可用性并做降级关于最后一条我补充一下检测方法。在隐私模式的某些实现里setItem会直接抛QuotaExceededError。所以初始化阶段做个探测很有必要function storageAvailable(type) { try { const storage window[type]; const testKey __storage_test__; storage.setItem(testKey, 1); storage.removeItem(testKey); return true; } catch (err) { return false; } } if (!storageAvailable(localStorage)) { // 降级全部走内存变量功能可用但刷新即丢 }这个检测只要做一次把结果缓起来就行别每次读写都跑一遍。6.2 容量超限与异常兜底的正确姿势容量写满之后setItem会抛异常这个异常不处理后面的代码就全断了。线上最常见的场景是某段逻辑写到一半存储满了抛出异常导致后面的渲染步骤都没执行用户看到的是半截页面。正确的姿势是给所有写入套上 try/catch并在失败时做一次清理老数据后重试function safeSet(key, value) { try { localStorage.setItem(key, value); return true; } catch (err) { if (err.name QuotaExceededError) { // 清理带缓存前缀的旧数据后再试一次 Object.keys(localStorage) .filter((k) k.startsWith(cache:)) .forEach((k) localStorage.removeItem(k)); try { localStorage.setItem(key, value); return true; } catch (e) { return false; } } return false; } }用统一前缀比如cache:来区分可清理的缓存和不能动的用户数据这是关键。清理逻辑只删有前缀的绝不碰用户的偏好设置。6.3 我踩过的坑和避坑清单最后把这些年攒下来的经验列一遍每一条背后都是真实事故**不要在本地存储里放敏感信息。**任何能在浏览器执行的脚本都能读到 localStorage 和 sessionStorage 的内容。token 这类东西怎么存是另一个话题但至少别明文塞一堆用户隐私数据进去。**不要存储会被频繁写入的大对象。**同步写加上序列化开销在低端机上非常明显。写之前先问一句这个数据真的需要每次都落盘吗**给所有缓存键加命名空间。**用模块名:业务名的格式比如user:profile、cache:areaList。不然项目一大键名冲突几乎是必然的。**写好清理策略再上线。**缓存只进不出早晚会爆。要么带 ttl要么在版本号变更时整体清一次。**版本号是个好习惯。**在 key 里带上版本比如v2:user:profile发新版时直接换前缀比写迁移代码省事得多。**别在 SSR 环境里裸用。**服务端渲染阶段没有windowlocalStorage是 undefined。要么判断环境要么把读取逻辑放到onMounted这类只在客户端跑的生命周期里。**Cookie 的改动要及时通知后端。**前端改了 Cookie 的 path 或 domain服务端可能就读不到了这类问题排查起来特别费时间改之前先对齐。还有个体验层面的小建议。缓存读取失败时不要让界面呈现空白给一个默认值和一次重新请求。用户能接受的是一次稍慢的加载接受不了的是页面上什么都没有。缓存本来就该是提升体验的可选项它挂了不该把主流程拖下水。我在实际项目里的体会是本地缓存这套东西用好了能省掉大量重复请求、把交互体验拉高一个档次用滥了就是一堆看不见的脏数据在角落里慢慢发酵等到线上出问题再去翻成本高得离谱。所以每次往存储里写东西之前多花十秒钟想清楚三件事这条数据活多久、谁会读它、写满了怎么办。这三个问题想明白了选哪个 API 基本就是顺手的事。