localStorage、IndexedDB 和 Cookie:前端本地存储怎么选
团队里但凡有人问"这个要不要存本地",答案从来不是"存 localStorage 就行"。真正该问的是四个具体问题:这份数据有多大,会被多频繁地读写,要不要跨标签页同步,丢了能不能接受。这四个问题的答案组合起来,才能定下该用 localStorage、sessionStorage、Cookie 还是 IndexedDB——单看"能不能存进去"是不够的,localStorage 什么都能塞,但塞得进去不代表塞得合适。
组里最近排查过一个真实案例:中后台的表格页面把导出前的原始数据缓存进了 localStorage,几百行数据序列化后有 2MB 多,页面每次刷新都要同步 JSON.parse 一次,低配的办公电脑上主线程直接卡住小半秒,滚动都跟着顿。另一个历史遗留的问题是把长期登录 token 存在 localStorage 里,这次做 XSS 加固复查的时候被单独点出来——只要页面上有一处第三方脚本注入的机会,这个 token 就能被直接读走。这两件事放一起看,本质是同一类误判:把"用起来最方便"当成了"该用它"的理由。
先分清楚:同步阻塞还是异步排队
localStorage、sessionStorage 和 document.cookie 都是同步 API,读写发生在主线程上,数据量一大就会直接卡渲染。IndexedDB 从设计上就是异步的,所有操作走事务和回调(或者 Promise 封装),不会阻塞页面。
1// 同步:调用这一行时主线程就被占住了 2localStorage.setItem('theme', 'dark') 3const theme = localStorage.getItem('theme') 4 5// 异步:调用立刻返回,结果通过事件或 Promise 拿到 6const req = indexedDB.open('app-cache', 1) 7req.onsuccess = function () { 8 const db = req.result 9 const tx = db.transaction('drafts', 'readonly') 10 const store = tx.objectStore('drafts') 11 const getReq = store.get('draft-1') 12 getReq.onsuccess = function () { 13 console.log(getReq.result) 14 } 15}
判断标准很直接:数据量小、访问频繁、逻辑简单,用同步 API 换个代码简洁;数据量大或者结构复杂,宁可多写几行异步代码,也别让主线程背这个包袱。
localStorage 适合小而简单的数据
localStorage 的优点是简单,适合存主题、语言、简单用户偏好这类不敏感的小型配置。
它的限制也很明确:
- 同步 API,数据量大了会阻塞主线程
- 单个源通常只有 5MB 左右额度(不同浏览器实现不完全一致)
- 只能存字符串,存对象要自己
JSON.stringify/JSON.parse - 明文存储,能被同源的任意脚本直接读取
- 没有过期机制,写进去就一直在,除非手动清
如果存对象,要处理解析失败的情况——数据可能被浏览器插件改坏,也可能是老版本代码写入的旧格式:
1function readJson(key, fallback) { 2 try { 3 var raw = localStorage.getItem(key) 4 return raw ? JSON.parse(raw) : fallback 5 } catch (err) { 6 return fallback 7 } 8}
本地数据不能默认可信,这条对所有同步存储都成立。
storage 事件:跨标签页同步的免费能力
localStorage 有个经常被忽略的特性:同源的另一个标签页修改了 localStorage,当前标签页会收到 storage 事件,前提是触发写入的那个标签页自己收不到(这是规范里明确的行为,自己写自己不通知)。
1window.addEventListener('storage', function (e) { 2 if (e.key === 'theme') { 3 applyTheme(e.newValue) 4 } 5})
这个机制拿来做"多标签页主题同步""登出后其他标签页也跟着退出"很顺手,不需要引入 BroadcastChannel 或者轮询。缺点也明显:只能监听 localStorage 的变化,sessionStorage 不会触发;而且事件里能拿到的只有 key、旧值、新值,没有更细的语义,复杂场景还是得自己在业务层封装一层。
storage 事件和 BroadcastChannel 该怎么选
跨标签页通信除了借 storage 事件的"顺风车",浏览器还提供了专门的 BroadcastChannel API,同源的多个标签页/worker 之间可以直接互发消息,不需要真的写一份数据到 localStorage 里:
1var channel = new BroadcastChannel('app-sync') 2 3// 发送方 4channel.postMessage({ type: 'logout' }) 5 6// 接收方(可以是同一个页面里的另一处代码,也可以是别的标签页) 7channel.onmessage = function (e) { 8 if (e.data.type === 'logout') { 9 clearLocalSession() 10 } 11}
两者的取舍很清楚:storage 事件不需要额外的 API、不占地方,但只能挂在 localStorage 的写入上,等于是"借用副作用"实现通信,语义上有点绕;BroadcastChannel 是专门设计来通信的,消息体可以是任意可结构化克隆的对象,不需要真的持久化一份数据,但 Safari 对它的支持还比较新(iOS/macOS Safari 是今年 3 月的 15.4 才补上,之前完全不支持),做移动端项目如果要兼容旧版 Safari,暂时还得靠 storage 事件或者退化成轮询兜底。团队内部工具这类不用太操心 Safari 版本的场景,选 BroadcastChannel 会更干净。
sessionStorage 适合会话内临时状态
sessionStorage 和 localStorage 用法一致,但生命周期绑定在当前标签页会话上:刷新页面还在,关闭标签页就没了,而且每个标签页互相隔离,不会像 localStorage 那样触发 storage 事件同步。
适合存多步骤表单的临时状态、当前 tab 的筛选条件、登录跳转带的临时参数。这个隔离性有时是优点——不同标签页各自独立操作互不干扰;有时是坑——同一个页面开两个标签页调试时,容易以为状态是共享的,结果各查各的。
Cookie 适合和请求一起发送的数据
Cookie 最大的特点是会随请求自动发送,适合服务端鉴权、SSR 场景下服务端要读取的偏好设置、CSRF token 这类需要浏览器和服务端配合的数据。
Cookie 要控制体积,因为每次请求都会带上,大对象塞进 Cookie 只会拖慢所有接口。高风险的登录态建议用 HttpOnly Cookie:
1Set-Cookie: session_id=xxx; HttpOnly; Secure; SameSite=Lax
HttpOnly 能挡住 JS 直接读取,把 XSS 窃取 token 的路径堵掉一大截。但 Cookie 会自动随请求发送,SameSite 设置不当就可能被跨站请求带着走,这块要和后端一起确认策略,不是前端单方面能控制的。
IndexedDB 适合大量结构化数据
IndexedDB 容量大得多(通常是可用磁盘空间的一个百分比,具体阈值各浏览器不完全一致),异步操作不阻塞主线程,还原生支持索引查询,适合离线草稿、大列表缓存、文件元数据这类"真的需要本地数据库"的场景。
原生 API 写起来确实繁琐,光是建库建表就要处理版本升级:
1var request = indexedDB.open('app-db', 2) 2 3request.onupgradeneeded = function (event) { 4 var db = event.target.result 5 if (!db.objectStoreNames.contains('drafts')) { 6 var store = db.createObjectStore('drafts', { keyPath: 'id' }) 7 store.createIndex('by_updated', 'updatedAt') 8 } 9} 10 11request.onsuccess = function (event) { 12 var db = event.target.result 13 var tx = db.transaction('drafts', 'readwrite') 14 tx.objectStore('drafts').put({ id: 'd1', content: '草稿内容', updatedAt: Date.now() }) 15 tx.oncomplete = function () { 16 console.log('写入完成') 17 } 18}
项目里基本不会手写这套模板代码,通常会用一层轻量封装库把 Promise 化和错误处理包起来,但理解底层的事务模型还是必要的,不然出了问题不知道该往哪查。
事务模型决定了能不能"读完再写"
IndexedDB 的事务是自动提交的:一个事务只要当前这一轮宏任务/微任务里没有新的请求排进来,就会在下一轮自动 commit,不需要也不能手动提交。这意味着不能在事务中间插入一个 await fetch(...) 之类的异步操作再回来接着操作同一个事务——事务早就结束了,后面的请求会直接报 TransactionInactiveError。
1var tx = db.transaction('drafts', 'readwrite') 2var store = tx.objectStore('drafts') 3store.get('d1').onsuccess = function (e) { 4 // 这里如果中间插一次网络请求再回来 put,事务大概率已经失效 5 store.put(e.target.result) 6}
排查这类报错的经验是:先看事务里有没有夹带跟 IndexedDB 无关的异步调用,把所有依赖数据先在事务外准备好,事务内部只做纯粹的读写操作。
事务本身也分读写级别,transaction(storeNames, mode) 的第二个参数默认是 readonly,只有显式传 readwrite 才能写入。同一个 objectStore 如果同时有多个 readwrite 事务在排队,浏览器会按提交顺序串行执行,不会出现两个事务同时改同一份数据的抢跑问题,这一点比手写一套基于 localStorage 的锁机制要省心得多——localStorage 本身没有事务概念,多个标签页同时读改写同一个 key 完全可能互相覆盖,IndexedDB 的事务模型是它相对 localStorage 最大的工程价值之一,不只是"容量大"这一个卖点。
错误处理也要分层看:onerror 挂在 request 上能拿到具体是哪次读写失败,挂在 transaction 上能拿到整个事务级别的失败(比如 QuotaExceededError 通常在这一层冒出来),两个都不监听的话,出错就是静默失败,界面上什么反馈都没有:
1var tx = db.transaction('drafts', 'readwrite') 2tx.onerror = function (e) { 3 reportError('idb.transaction_failed', e.target.error) 4} 5var putReq = tx.objectStore('drafts').put(draftData) 6putReq.onerror = function (e) { 7 reportError('idb.put_failed', e.target.error) 8}
IndexedDB 更适合大数据量和结构化查询
IndexedDB 不是永久可靠存储。浏览器在整体存储空间紧张时,可能按最近最少使用的策略清理某些站点的数据,包括 IndexedDB。所以不要拿它当"绝对不会丢"的地方,重要数据还是要有服务端兜底。
版本升级:onupgradeneeded 只在版本号变化时触发
IndexedDB 建库时要传一个版本号,onupgradeneeded 只在打开的版本号比已存在的数据库版本高时才会触发,这是唯一能修改表结构(增删 objectStore、增删索引)的时机。项目里给草稿功能加一个新字段索引,实际操作是把 indexedDB.open('app-db', 2) 改成 indexedDB.open('app-db', 3),然后在 onupgradeneeded 里用 event.oldVersion 判断要不要执行迁移逻辑:
1request.onupgradeneeded = function (event) { 2 var db = event.target.result 3 var oldVersion = event.oldVersion 4 5 if (oldVersion < 2) { 6 db.createObjectStore('drafts', { keyPath: 'id' }) 7 } 8 if (oldVersion < 3) { 9 var store = event.target.transaction.objectStore('drafts') 10 store.createIndex('by_title', 'title') 11 } 12}
这里最容易踩的坑是升级期间如果还有别的标签页开着旧版本的连接,新连接的 open 请求会一直卡在等待,直到旧连接关闭,onblocked 事件就是用来提示这种情况的。多标签页的项目最好都监听一下 onversionchange,收到就主动关闭旧连接,不然用户会看到页面莫名其妙卡住,还以为是网络问题。
Cache Storage 适合请求和资源缓存
Service Worker 常配合 Cache Storage 缓存 JS/CSS、图片、HTML fallback 和接口响应。它是按 Request/Response 模型设计的,不适合当普通 key-value 数据库用——存个用户偏好这种小数据没必要动用它。
接口响应缓存要谨慎,用户私有数据、权限数据、支付状态不要随便长期缓存,命中速度快,但过期的数据同样"快",容易让用户看到已经作废的状态。
Safari 隐私模式和无痕窗口的配额差异
线上反馈里出现过一类问题:同一段写入 localStorage 的代码,在 Safari 的隐私浏览模式下概率性失败,抛出 QuotaExceededError,而正常模式下从没出过问题。查下来才知道 Safari 在隐私模式里对 localStorage 的配额限制远低于正常模式,甚至历史上有版本干脆把 localStorage 实现成内存级、页面关闭就清空,写入失败也更容易触发。iOS Safari 的无痕标签页同样偏严格。
这类问题没法靠"换个 API"根治,只能在写入路径上做好防御:
1function safeSetItem(key, value) { 2 try { 3 localStorage.setItem(key, value) 4 return true 5 } catch (err) { 6 reportError('storage.write_failed', err) 7 return false 8 } 9}
如果保存草稿失败,要给用户明确提示,而不是静默丢数据。移动端浏览器、隐私模式、用户手动禁用存储,都会让写入在毫无征兆的情况下失败,这条防线不能省。
用 StorageManager 提前摸底配额,而不是等报错
与其等 QuotaExceededError 抛出来才处理,不如提前查一下大概还有多少空间可用。navigator.storage.estimate() 这个接口能拿到当前源的用量和配额估算,Chrome 和 Firefox 都已经支持,Safari 支持得晚一些、数值也不一定精确,只能当参考,不能当精确值用:
1if (navigator.storage && navigator.storage.estimate) { 2 navigator.storage.estimate().then(function (result) { 3 var usedMB = (result.usage / 1024 / 1024).toFixed(1) 4 var quotaMB = (result.quota / 1024 / 1024).toFixed(1) 5 console.log('已用 ' + usedMB + 'MB,配额约 ' + quotaMB + 'MB') 6 }) 7}
如果项目里要缓存大量离线数据,比较稳妥的做法是先估算一次配额,用量接近上限时主动清理最旧的缓存,而不是等写入失败了再手忙脚乱去删。另外还有个 navigator.storage.persist() 可以申请"持久化存储",申请成功后浏览器在存储压力下不会自动清理这个源的数据,但这个申请不一定会被批准,能不能拿到取决于浏览器自己的策略(比如站点是不是已经被加入书签、访问频率够不够),前端这边只能申请、不能强制。
敏感数据尽量少存
前端存储的安全原则很简单:能不存就不存,必须存就缩短生命周期。不要在本地长期存明文密码、长期 token、身份证银行卡这类敏感信息、以及权限的完整快照。
如果业务确实要保存草稿类数据,敏感字段可以考虑加密和过期清理,但前端的加密密钥如果也放在前端,安全收益非常有限——这只能挡住随手翻文件的人,挡不住真正想拿数据的攻击者,没必要自欺欺人式地"加了密就安全了"。
我的选择表
1主题、语言、小偏好: localStorage 2当前标签页临时状态: sessionStorage 3需要跨标签页联动的状态: localStorage + storage 事件 4服务端鉴权、SSR 需要读取的偏好: Cookie(高风险登录态用 HttpOnly) 5大量离线结构化数据、需要索引查询:IndexedDB 6静态资源和请求响应缓存: Cache Storage(配合 Service Worker) 7敏感数据: 尽量不存,必须存就加密+限期
数据大小、生命周期、是否需要跨标签页同步、是否参与请求、安全等级,这几个维度组合起来才能定下该用哪一个,单看"能不能用"是不够的——这也是这次帮组里排查表格缓存卡顿和 token 存放问题时,真正想清楚的地方。这几天 Vue 2.7 刚发布,把 Composition API 反向移植回了 Vue 2,团队里存量的 Vue 2 项目也在讨论要不要借这个机会,把散落在各个组件里的 localStorage 读写逻辑收敛成几个组合式函数。