浏览器存储方式简单梳理:Cookie、sessionStorage 和 localStorage 有什么区别

同一份筛选条件,存进 Cookie 和存进 localStorage,行为可以差出三件事:一个会让接口请求头莫名其妙变大,一个刷新页面就没了,还有一个是开两个标签页各改各的、谁也不知道对方存过什么。这三件事背后不是运气问题,是三种存储机制在生命周期、是否随请求发送、作用域上的设计差异,而这些差异恰恰是前端选存储方案时最容易被忽略的部分。

先说请求体积这件事。后台管理系统里有个筛选面板,最初图省事把选中的筛选项直接写进了 document.cookie。功能上没问题,直到有一次后端同事在排查接口耗时,发现同一个列表接口在某些账号上请求头明显偏大。查下来是这个账号筛选项攒了十几个字段,每一个都被拼进了 Cookie 字符串,而 Cookie 的特点是会跟着每一次同源请求自动带到服务端——不只是这个筛选接口,页面上所有请求都在背这几百字节。这不是 Cookie 的 bug,是它原本就该这么用:Cookie 生来是给服务端识别用的,不是给前端存业务状态用的。

这件事排查完之后,团队顺手把浏览器里几种存储方式又完整过了一遍,倒不是为了写一份规范挂在 wiki 上,而是发现平时大家对 Cookie、sessionStoragelocalStorage、IndexedDB 这几个名词已经混得太熟,熟到没人再去细想它们各自的规则有什么不一样——直到被具体的行为绊了一下才想起来查。

三种能力,从来不是同一件事的三种写法

Cookie、sessionStorage、localStorage 经常被并列讲,容易给人一种"都是存东西,选一个顺手的就行"的错觉。实际上它们解决的不是同一个问题。

Cookie 出现得最早,核心特点是可以随请求一起带到服务端,这也是它在登录态、会话标识这类场景里长期占主导地位的原因——因为这类数据本来就需要服务端在收到请求的第一时间就知道。但正因为它会跟请求走,体积就必须克制,各浏览器对单个 Cookie 大小和总量都有限制(通常单个 4KB 左右,总量几十个的量级),拿它存大段业务数据,代价是每次请求都要多传这部分字节。

写 Cookie 最原始的方式是直接操作 document.cookie,但这个字符串接口本身就是个容易踩坑的地方——赋值不是覆盖,而是逐条追加或更新:

1document.cookie = 'filter_status=active; path=/; max-age=3600';
2document.cookie = 'filter_type=order; path=/; max-age=3600';

上面这两行执行完之后,document.cookie 读出来的是两条都在的完整字符串,而不是第二行把第一行覆盖掉。第一次接手这段代码的时候容易理解成"每次赋值就是重新设置整个 Cookie",结果调试了半天发现值一直在变多、没有变少。要覆盖或删除某一条已有的 Cookie,本质上还是重新设置同名的一条:把值设成空字符串、同时把 Max-Age 设成 0 或者 Expires 设成过去的时间点,浏览器才会真正把它清掉;只是把值设成空字符串而不管过期时间,Cookie 依然存在,只是内容变成了空。真要解析出具体某个字段,通常还得自己写一段小函数按 ;= 拆开:

1function readCookie(name) {
2  const pairs = document.cookie.split('; ');
3  for (const pair of pairs) {
4    const [key, value] = pair.split('=');
5    if (key === name) {
6      return decodeURIComponent(value);
7    }
8  }
9  return null;
10}
11
12function removeCookie(name) {
13  document.cookie = name + '=; path=/; max-age=0';
14}

写入的值里如果带了分号、等号这类会跟 Cookie 语法本身冲突的字符,最好在设置前用 encodeURIComponent 转义一遍,读取时再用 decodeURIComponent 转回来,前面这段 readCookie 已经这么处理了——如果图省事跳过这一步,业务值里恰好出现分号,会被 Cookie 的分隔符规则拆成两截,读出来的内容和写进去的完全对不上,这类问题因为触发条件依赖具体值的内容,测试时不一定能覆盖到,容易留到生产环境才暴露。

设置 Cookie 时几个属性决定了它的安全属性和作用范围:

1Set-Cookie: session_id=abc; Path=/; HttpOnly; Secure; SameSite=Lax

HttpOnly 让这个 Cookie 无法被 document.cookie 读取,能挡掉一部分 XSS 窃取的路径;Secure 限定只在 HTTPS 下发送;PathDomain 控制作用范围;Max-AgeExpires 控制过期时间。这一年里比较值得留意的是 SameSite 的变化:Chrome 从 80 版本(2 月发布)开始,把没有显式声明 SameSite 的 Cookie 默认当作 Lax 处理,而不再是历史上默认的 None。这意味着跨站请求里如果依赖 Cookie 自动携带(比如某些第三方嵌入场景、跨域的接口鉴权),过去"不写就默认能带"的假设在 Chrome 80 之后不成立了,需要显式写 SameSite=None; Secure 才能保住老行为。团队里有个对接第三方支付回调的页面就因为这个提示在控制台报了警告,改成显式声明后才恢复。

DomainPath 这两个属性经常被搞混。Domain 不写的话默认只对当前精确域名生效,写了比如 Domain=.example.com,则当前域名及其所有子域都能读到这个 Cookie——中后台系统如果拆成了 admin.example.comapi.example.com 两个子域,登录态要在两边共享,就得显式把 Domain 提到父域名上,否则跳转到另一个子域时会发现"明明登录了却被当成未登录"。Path 则是按路径把 Cookie 分开,默认是设置这条 Cookie 时所在的路径,同一个域名下 /admin/report 两条路径完全可以各自持有互不影响的同名 Cookie,这在同一个大后台里拆分了多个子应用、又不想互相污染状态的场景里是刻意用到的做法。

登录态这类敏感信息,即便技术上能塞进 Cookie,也不该让它对 JavaScript 可读——该配 HttpOnly 的地方不要图方便省掉。

Cookie 数量和体积超限之后浏览器的处理方式也值得留意:不同浏览器策略不完全一致,有的是按写入时间淘汰最老的 Cookie,有的直接拒绝新的 Set-Cookie 生效。这意味着如果一个域名下 Cookie 堆得太多太杂——常见的原因是各种埋点脚本、第三方统计工具各自往同一个域名下写自己的标识 Cookie——业务自己设置的关键 Cookie 有可能在数量超限时被无声地挤掉,症状通常是"登录状态莫名其妙丢了,但看不出明显原因"。真遇到这类问题,第一步该做的是打开开发者工具的 Application 面板,把当前域名下所有 Cookie 逐条列出来看看到底有多少条、都是谁写的,而不是直接怀疑后端的 session 逻辑。

sessionStorage:会话级别的临时寄存

sessionStorage 解决的是另一类问题:数据只需要在当前这个标签页的会话周期里活着,页面关掉就该消失。它不会随请求发送,读写完全是前端自己的事,不涉及服务端和网络体积。

多步骤表单是典型场景,中间填写的内容不需要持久化,但页面刷新时不该丢:

1const draftKey = 'order-draft';
2
3function saveDraft(data) {
4  sessionStorage.setItem(draftKey, JSON.stringify(data));
5}
6
7function readDraft() {
8  const value = sessionStorage.getItem(draftKey);
9  return value ? JSON.parse(value) : null;
10}
11
12function clearDraft() {
13  sessionStorage.removeItem(draftKey);
14}

实际接入到表单页面时,一般是在每个步骤切换、或者输入框失焦时调用 saveDraft,页面初始化时调用 readDraft 把上次未提交完的内容灌回表单,提交成功之后再调用 clearDraft 把草稿清掉——如果提交成功后忘了清,用户下次填一个全新的表单时会莫名其妙看到上一次提交过的内容,这也是团队里遇到过的一个真实反馈,起因就是提交成功的分支里漏调了清除逻辑。

这里有个容易踩的点:sessionStorage 是各标签页各管各的,即便是同一个页面、同一个用户,新开一个标签页访问同一个地址,读到的 sessionStorage 也是空的——它不共享。这跟 localStorage 的同源共享是完全相反的行为,如果搞混了两者的作用域,容易在测试时"在这个标签页明明存进去了,怎么另一个标签页看不到"这类问题上卡住。

这条各管各的规则里还有一个更细的分支,是 QA 在验收多标签页场景时碰到的:用浏览器"复制标签页"或者在同一个标签页里通过 window.open 不带 noopener 打开新窗口,新页面的 sessionStorage 是会继承当前标签页的内容的,而如果是用户自己手动新开一个空白标签页再输入地址访问,sessionStorage 才是真正的空白。这个继承规则容易在测试和线上环境之间造成误判——测试时习惯用"复制标签页"验证多页签场景,看到数据是同步的,就以为 sessionStorage 也能跨标签页共享,等真到用户那边用最普通的方式新开标签页,才发现完全对不上。判断一个页面到底是"继承"还是"独立",本质上看它是不是由已有标签页衍生出来的,而不是简单地看地址栏里的 URL 是否相同。

关闭标签页会清空对应的 sessionStorage,但浏览器的标签页恢复功能(比如异常崩溃后重新打开、或者主动选择"重新打开关闭的标签页")是个例外——多数浏览器会把 sessionStorage 一并恢复,这一点在设计"离开页面前提醒用户保存"这类交互时值得留意,不能简单假设"标签页关掉数据就一定没了"。

sessionStorage 还有一个经常被拿来跟"页面刷新会不会丢数据"混着问的场景,就是通过 <a target="_blank"> 或者代码里 window.open 打开的新标签页——这种情况下新标签页和原标签页是不同的浏览上下文,sessionStorage 按刚才说的继承规则处理:如果新标签页是由当前标签页的脚本触发打开的(无论是链接点击还是 window.open),并且没有显式加 rel="noopener" 切断关联,新标签页会继承一份当前的 sessionStorage 快照;后续两个标签页各自独立写入,互不影响彼此,这份继承只发生在打开的那一刻,不是持续同步。

localStorage:默认长期存在,直到手动清除

localStorage 没有内置的过期机制,只要用户不手动清除、不换设备,数据会一直留在那儿。它适合"丢了可以恢复、不丢也没关系"的信息,比如主题偏好:

1const themeKey = 'theme-mode';
2
3export function saveThemeMode(mode) {
4  localStorage.setItem(themeKey, mode);
5}
6
7export function readThemeMode() {
8  return localStorage.getItem(themeKey) || 'system';
9}

主题偏好这类需要在页面渲染之前就决定视觉状态的数据,还有一个容易被忽视的时序问题:如果读取 localStorage 并应用主题的逻辑写在框架初始化之后、组件挂载完成才执行,页面会先按默认主题渲染一次,再在读到本地存储的值之后切换一次,肉眼能看到明显的"先亮后暗"闪烁。团队里后台系统的深色模式刚上线时就是这个问题,后来把读取和应用主题的这一小段逻辑挪到了 index.html 里最早执行的一段内联脚本中,在框架接管页面之前就把对应的 class 打到根节点上,才把这个闪烁去掉:

1<script>
2  (function () {
3    var mode = localStorage.getItem('theme-mode') || 'system';
4    if (mode === 'dark') {
5      document.documentElement.classList.add('dark-mode');
6    }
7  })();
8</script>

这段内联脚本执行得足够早,能在样式表和框架脚本加载完成之前就把 class 定下来,本质上是拿"提前同步读一次 localStorage"换取视觉上没有闪烁,代价是这几行代码脱离了框架的构建流程,需要手动维护,改动主题逻辑时容易忘记这里也要同步改。

前面提到的筛选条件问题,后来就是从 Cookie 挪到了 localStorage——但挪过去之后暴露了另一个坑:字段改名、枚举值调整之后,老用户浏览器里存的还是旧结构,页面加载时直接把这些过时值原样灌回筛选表单,出现一堆界面上找不到对应选项的"幽灵条件"。这提醒我们 localStorage 存的是没有 schema 校验的纯字符串,读取时必须对结构做兼容判断,不能假设存进去的和现在预期的结构一致;简单的做法是在存储的 value 里带上一个版本号字段,读取时版本不匹配就直接丢弃重置,而不是硬灌。

1const FILTER_SCHEMA_VERSION = 2;
2
3function saveFilter(filter) {
4  const payload = {
5    version: FILTER_SCHEMA_VERSION,
6    data: filter,
7  };
8  localStorage.setItem('list-filter', JSON.stringify(payload));
9}
10
11function loadFilter() {
12  const raw = localStorage.getItem('list-filter');
13  if (!raw) {
14    return null;
15  }
16
17  try {
18    const payload = JSON.parse(raw);
19    if (payload.version !== FILTER_SCHEMA_VERSION) {
20      localStorage.removeItem('list-filter');
21      return null;
22    }
23    return payload.data;
24  } catch (error) {
25    localStorage.removeItem('list-filter');
26    return null;
27  }
28}

JSON.stringify/JSON.parse 这一对搭配本身也有几个容易忽略的陷阱。stringify 会悄悄丢掉值为 undefined 的字段、函数类型的字段,以及 Symbol 类型的键,这些字段序列化之后直接从结果里消失,而不是报错——如果筛选对象里恰好有一个字段因为业务逻辑赋值成了 undefined,存储前后对象的字段数量就会不一致,排查起来容易摸不着头脑。日期对象序列化后会变成 ISO 字符串,读回来的时候如果直接当 Date 用会出错,必须手动 new Date(value) 转换回去。NaNInfinity 这类数字会被 stringifynull,反序列化后类型和语义都变了,如果筛选条件里有需要保留这些特殊数值的场景,得在存之前自己做一层转换,不能指望 JSON 帮你保真。

访问令牌、身份证号、银行卡号这类敏感信息不应该放进 localStorage:一旦页面存在 XSS,攻击脚本可以直接用 localStorage.getItem 读走,没有任何门槛。

行为差异汇总:不只是"存的时间长短不同"

生命周期上,Cookie 可以显式设置过期时间,sessionStorage 跟随标签页会话,localStorage 默认持久。是否自动参与请求这一点最关键:Cookie 会跟着每次同源请求走,sessionStoragelocalStorage 都不会,数据要不要传给服务端得自己在业务代码里显式做。

JavaScript 可读性上,普通 Cookie 能被 document.cookie 读到,HttpOnly Cookie 读不到,sessionStoragelocalStorage 都能被同源页面的脚本直接读写。作用范围上,Cookie 受 Domain、Path、SameSite 共同影响,sessionStorage 限制在单个标签页会话内,localStorage 在同源的所有标签页、所有窗口之间是共享的——这也是它能用来做跨标签页同步的前提。

同源是这几种机制共同遵守的规则:协议、域名、端口三者都一致才算同源,任何一个不同,存储就各管各的、互不相通。这一点上 Cookie 是个例外——它划分作用范围靠的是 Domain 加 Path,而不是严格的协议+域名+端口三件套,http://https:// 站点在没有 Secure 限制的情况下,只要域名和路径匹配是可以共享同一批 Cookie 的,这跟 sessionStoragelocalStorage 严格按源分开的行为不一样,混着理解容易在排查"为什么切了 HTTPS 之后有些数据看不到了"这类问题时找错方向。

容量量级上把几种机制放在一起比会更直观:Cookie 单条大致 4KB、同域总量几十条的量级,属于几种机制里最抠门的一个;sessionStoragelocalStorage 通常都在 5MB 上下(不同浏览器有出入,sessionStorage 有的实现会给到更大一些);IndexedDB 和 Cache API 则不再是固定的 MB 级别,而是跟当前设备可用磁盘空间挂钩的一个比例,量级上能到几百 MB 甚至更高。选型时如果已经明确知道要存的数据量级——是几个开关状态、一份多字段的筛选条件,还是成百上千条要支持查询的记录——容量差出的不是一星半点,基本上能直接排除掉不合适的选项,不需要纠结太久。

localStorage 的同步 API 与容量差异

localStorage 的读写是同步阻塞的,读写几个字符串级别的偏好设置没有问题,但如果习惯性地把它当成一个小型数据库频繁塞大对象,每次读写都会占用主线程时间。它也不保证一定能写成功——隐私模式、存储配额耗尽、部分浏览器的用户策略都可能让 setItem 抛异常,写入侧最好兜一层:

1function safeSetStorage(key, value) {
2  try {
3    localStorage.setItem(key, JSON.stringify(value));
4    return { ok: true };
5  } catch (error) {
6    return { ok: false, message: '本地存储不可用' };
7  }
8}
9
10function safeGetStorage(key, fallback = null) {
11  try {
12    const value = localStorage.getItem(key);
13    return value ? JSON.parse(value) : fallback;
14  } catch (error) {
15    return fallback;
16  }
17}

封装这一层不是多此一举,是把解析失败、容量异常、存储被禁用这几种情况都挡在业务代码之外,避免一次 JSON.parse 抛错就打断整个页面渲染。

容量上各浏览器实现不完全一致,主流桌面浏览器给同源的 localStorage 大致是 5MB 上下的量级(Chrome、Firefox 都在这个区间),移动端 WebView 有的会更保守。这个数字不是标准强制的,不同浏览器、不同版本会有出入,业务上不该依赖一个精确值,超量时 setItem 会直接抛 QuotaExceededError,前面那层 try/catch 就是用来接住它的。

隐私模式(Safari 的"无痕浏览"、Chrome 的"隐身模式")是另一类容易漏掉的写入失败场景。不同浏览器处理方式不完全一样:有的允许写入但把配额压得极小(几百 KB 甚至更低,写几条数据就报满),有的干脆把 localStoragesessionStorage 直接禁用,调用 setItem 直接抛异常。团队的后台系统本身面向内部员工,隐私模式不算高频场景,但客服团队反馈过个别同事习惯用无痕窗口登录避免和自己的常用账号冲突,页面上偏好设置保存失败的提示就是从这类反馈里来的——真正的修复方式还是前面那层 safeSetStorage 的封装,把异常挡住之后至少不让整个页面因为一次存储失败而白屏。

多标签页同时写同一个 key 也是同步 API 容易被忽略的一个副作用:localStorage 本身没有真正的事务保证,两个标签页几乎同时执行"读出旧值、修改、写回"这种读改写模式,后写入的会直接覆盖先写入的,中间不存在锁机制。如果业务上确实需要在同一个 key 上做累加或者合并式更新,光靠 localStorage 是保证不了正确性的,得退回到以服务端为准的方案,或者把这类计数、累加类需求从本地存储里挪出去。

跨标签页同步:storage 事件

localStorage 在同源页面间共享这一点,配合 storage 事件可以做成简单的跨标签页通知机制。中后台系统里习惯多开页签对照数据的用户不少——同时开着列表页和详情页各一个标签,或者同一个页面开两份对比不同时间段的数据,如果主题切换、登录状态这类全局状态在标签页之间不同步,观感上很容易让人怀疑是不是哪个页签数据不对,进而怀疑是不是接口返回错了。这类问题定位起来最费时间的地方在于现象具有欺骗性:明明数据是对的,只是显示状态没同步,却常常被误判成数据问题去查接口。正好能用 storage 事件解决:

1window.addEventListener('storage', (event) => {
2  if (event.key !== 'theme-mode') {
3    return;
4  }
5
6  applyTheme(event.newValue || 'system');
7});

需要注意的是这个事件只会在其他标签页触发,当前触发写入的标签页自己收不到;而且它只对 localStorage 的变化生效,sessionStorage 由于本身就不跨标签页共享,自然也没有对应的跨页通知。

storage 事件对象里带的信息比想象中丰富,除了 keynewValue,还有 oldValueurl(触发写入的页面地址)和 storageArea(对应哪个存储对象)。有个细节容易被忽略:setItem 写入了和当前值完全相同的内容,并不会触发其他标签页的 storage 事件——浏览器内部会先比较新旧值,值没变就认为没有变化需要广播。如果业务逻辑依赖"写一次就一定通知一次",这个判断得自己在业务层再包一层,比如带上时间戳或者一个自增序号,确保每次写入的字符串本身有变化。

它适合主题、登录状态提示这类低频、简单的状态广播,真要做多个页签之间的实时协作或复杂消息传递,storage 事件的粒度和时延都不够用,应该考虑 BroadcastChannel 这类专门为跨上下文通信设计的机制,或者退回到服务端的长连接方案。BroadcastChannel 用起来比监听 storage 事件直接得多,不需要借助存储写入这个"副作用"来触发通知,本身就是一个消息通道:

1const channel = new BroadcastChannel('theme-sync');
2
3channel.postMessage({ mode: 'dark' });
4
5channel.onmessage = (event) => {
6  applyTheme(event.data.mode);
7};

不过这一年 BroadcastChannel 在 Safari 里还没有实现,只能在 Chrome、Firefox 这类浏览器里放心用,如果后台系统对 Safari 也要兼容,storage 事件依然是更稳妥的兜底方案。

IndexedDB:另一个量级的场景

前面三种能存的都是字符串,容量也都是 KB 到几 MB 的量级。如果业务需要存结构化的大量数据——比如离线场景下缓存整张列表、支持索引查询、需要事务保证——就已经超出了 Cookie、sessionStorage、localStorage 的设计初衷,这时候该看的是 IndexedDB。

IndexedDB 是浏览器内置的对象存储数据库,异步 API、支持索引和事务,容量上限也远高于 localStorage(通常是可用磁盘空间的一个比例,而不是固定的几 MB)。代价是 API 本身偏底层,直接手写游标和事务代码啰嗦,团队目前的中后台项目还没有直接触碰的必要——现有的分页加载、按需请求已经能覆盖大部分列表场景,真要上 IndexedDB,更可能出现在需要离线可用、或者本地要缓存大量结构化数据支持复杂查询的场景,比如某些工具型页面把用户上传的原始数据整份缓存在本地做二次处理。

出于好奇,找了个周末在本地起了个小 demo 验证一下最基础的用法。打开数据库、建对象仓库这一步是通过 onupgradeneeded 事件完成的,只有在数据库版本号变化(比如首次创建,或者显式升级版本号)时才会触发:

1const request = indexedDB.open('local-cache-db', 1);
2
3request.onupgradeneeded = (event) => {
4  const db = event.target.result;
5  if (!db.objectStoreNames.contains('records')) {
6    const store = db.createObjectStore('records', { keyPath: 'id' });
7    store.createIndex('by-status', 'status', { unique: false });
8  }
9};
10
11request.onsuccess = (event) => {
12  const db = event.target.result;
13  writeRecord(db, { id: 1, status: 'active', payload: { name: '示例数据' } });
14};
15
16request.onerror = (event) => {
17  console.error('数据库打开失败', event.target.error);
18};

写入和读取都要通过事务(transaction)进行,事务的读写模式要显式声明:

1function writeRecord(db, record) {
2  const tx = db.transaction('records', 'readwrite');
3  const store = tx.objectStore('records');
4  store.put(record);
5
6  tx.oncomplete = () => {
7    console.log('写入完成');
8  };
9  tx.onerror = () => {
10    console.error('写入失败', tx.error);
11  };
12}
13
14function readByStatus(db, status) {
15  const tx = db.transaction('records', 'readonly');
16  const index = tx.objectStore('records').index('by-status');
17  const range = IDBKeyRange.only(status);
18  const cursorRequest = index.openCursor(range);
19  const results = [];
20
21  cursorRequest.onsuccess = (event) => {
22    const cursor = event.target.result;
23    if (cursor) {
24      results.push(cursor.value);
25      cursor.continue();
26    } else {
27      console.log('查完了,共', results.length, '条');
28    }
29  };
30}

这套 API 用下来最不习惯的地方是"全事件回调"的风格——不管是打开数据库、读写记录还是移动游标,都得挂 onsuccess/onerror,写起来比 localStorage 那种一行 getItem 繁琐得多,稍不注意漏挂一个 onerror 就可能让某次写入静默失败都不知道。

版本号这一块也有个容易踩的坑:indexedDB.open 第二个参数如果不传,默认是 1;后续要新增对象仓库或者索引,必须显式把版本号调大,onupgradeneeded 才会再次触发。如果本地已经打开过一个旧版本的连接(比如另一个标签页还开着这个页面),新的连接尝试升级版本时会被阻塞,触发的是 onblocked 而不是 onupgradeneeded,这也是多标签页场景下调试 IndexedDB 时常见的困惑来源——升级逻辑迟迟不执行,查下去才发现是别的标签页占着旧连接没放。

1request.onblocked = () => {
2  console.warn('有其他标签页占用着旧版本连接,升级被阻塞');
3};

删除整个数据库用 indexedDB.deleteDatabase,同样是异步的,也一样要挂事件:

1const deleteRequest = indexedDB.deleteDatabase('local-cache-db');
2deleteRequest.onsuccess = () => {
3  console.log('数据库已删除');
4};

demo 跑通之后没有立刻往生产代码里搬,主要是当前中后台的列表数据本身走的是服务端分页,本地并没有"需要离线可查、需要按字段建索引查询"这类硬需求,贸然引入一套新的异步 API 和事务模型,维护成本换不回明显的收益。这条判断标准先记下来,等真正遇到量级和查询复杂度都撑不住前三种方案的需求时再深入。

Cache API:另一条不太一样的存储路径

跟 Service Worker 配套的 Cache API 严格说不算"给业务数据用的存储",但它确实也是浏览器提供的一种本地持久化能力,容易被拿来跟前面几种混着比较,值得单独说清楚它解决的是什么问题。Cache API 存的是请求-响应对,也就是完整的 HTTP 响应,而不是任意结构的 JSON 或字符串:

1self.addEventListener('fetch', (event) => {
2  event.respondWith(
3    caches.open('static-v1').then((cache) => {
4      return cache.match(event.request).then((cached) => {
5        if (cached) {
6          return cached;
7        }
8        return fetch(event.request).then((response) => {
9          cache.put(event.request, response.clone());
10          return response;
11        });
12      });
13    })
14  );
15});

这段代码只能跑在 Service Worker 的作用域里,不是普通页面脚本能直接用的(普通页面脚本可以读写 caches,但拦截请求本身依赖 Service Worker 注册成功)。它的定位跟前面几种存储机制完全不同:localStorage、IndexedDB 存的是业务需要的数据,Cache API 存的是网络请求的副本,目的是离线可用和加速二次访问,语义上更接近"给资源加了一层可编程的本地缓存",而不是给业务状态找一个存放的地方。团队内部一个需要弱网环境下也能打开的移动端页面就是靠 Service Worker 加 Cache API 把关键静态资源和几个只读接口缓存下来,跟当前这批中后台系统要解决的"筛选条件往哪存"完全是两个问题,但因为都叫"存储",容易被人混在一次讨论里,提前把这两件事分清楚能少绕不少弯路。

那个弱网页面用到的缓存名里带了版本号(static-v1),这不是随手起的名字,是刻意为之:静态资源更新之后,Service Worker 激活新版本时会对比缓存名,把不匹配当前版本号的旧缓存清掉,避免新旧资源混杂在同一个缓存里越积越多:

1self.addEventListener('activate', (event) => {
2  const currentCaches = ['static-v1'];
3  event.waitUntil(
4    caches.keys().then((cacheNames) => {
5      return Promise.all(
6        cacheNames
7          .filter((name) => !currentCaches.includes(name))
8          .map((name) => caches.delete(name))
9      );
10    })
11  );
12});

没有这一步清理逻辑的话,每次发版本如果都换一个新的缓存名,旧缓存会一直占着磁盘空间不释放,时间长了同样会撞上前面提到的存储配额上限。这算是 Cache API 用起来比 localStorage 更需要主动维护生命周期的地方——localStorage 的键值对基本是"设置了就一直在",而缓存这类东西,如果没有配套的版本管理和清理策略,很容易变成只增不减的存量。

排查存储问题时常用的几个入口

前面这几种机制混用久了,出问题时该去哪里看也值得记一下,省得每次都临时想。Chrome DevTools 的 Application 面板是最直接的入口:左侧能分别展开 Cookies、Local Storage、Session Storage、IndexedDB、Cache Storage 几个分类,每一类下面按源(origin)分组,点开就能看到具体的键值对,也能直接在面板里手动删除某一条或者清空整个分类,验证"清空之后页面是否恢复正常"是排查这类问题最快的手段之一。Cookie 那一栏还会显示 HttpOnlySecureSameSite 这几列,比读文档更直观。

Network 面板里选中某个请求,Headers 里的 CookieSet-Cookie 能看到实际随请求发送和响应回来的 Cookie 内容,前面提到的"请求头莫名变大"就是从这里发现的:把某一次请求的 Cookie 请求头整段复制出来数长度,比靠猜测方便得多。控制台里直接执行 document.cookielocalStoragesessionStorage 也能快速看一眼当前的存储内容,只是 document.cookie 拿到的是一整段拼接字符串,不如 Application 面板里分行显示看得清楚。

navigator.storage.estimate() 这个 API 能拿到当前源大致的存储配额使用情况,返回一个包含 usagequota 的 Promise,可以用来在业务代码里主动判断"是不是快要写满了",而不是等 QuotaExceededError 抛出来才处理:

1navigator.storage.estimate().then((estimate) => {
2  const percentUsed = (estimate.usage / estimate.quota) * 100;
3  console.log('已使用', percentUsed.toFixed(2), '%');
4});

这个 API 覆盖的是包括 IndexedDB、Cache Storage 在内的整个源级别配额,不单独区分 localStorage 占了多少,用来做一个大致的健康度判断足够,精确定位到某一类存储占用多少还是得回到 Application 面板里手动核对。

这一年这个 API 在 Chrome、Firefox 里都已经能用,Safari 还没跟上,如果页面要兼容 Safari,得先判断 navigator.storage 是否存在再调用,不能假设所有浏览器都支持:

1if (navigator.storage && navigator.storage.estimate) {
2  navigator.storage.estimate().then((estimate) => {
3    console.log(estimate.usage, estimate.quota);
4  });
5} else {
6  console.log('当前浏览器不支持存储配额查询');
7}

怎么选,落到几个具体问题上

选存储方案不是先问"能不能存",而是先问几个问题:这份数据服务端要不要在请求时就知道?丢了会怎样,泄露会怎样?结构以后会不会变,旧数据怎么兼容?需不需要在多个标签页之间保持一致?数据量级和查询复杂度是字符串级别还是需要索引?

服务端每次请求都要识别的,交给 Cookie,同时把 HttpOnlySecureSameSiteDomainPath 这几个属性配置到位,不要图省事全部留空;只在当前标签页这一次会话里有意义的临时数据,比如多步骤表单的草稿、当前页面内的临时筛选,用 sessionStorage,同时清楚它在新开标签页和继承标签页之间的行为差异;长期本地偏好、丢了也能恢复的,用 localStorage,但要处理好结构升级(版本号字段)、序列化陷阱(undefined、日期、NaN 这几类值)和写入异常(隐私模式、配额耗尽的 try/catch 兜底);需要跨标签页保持状态一致的,localStorage 配合 storage 事件是最省事的方案,实时性要求更高就上 BroadcastChannel;数据量大、需要索引查询或离线能力的,才考虑 IndexedDB,权衡它偏底层的事务和回调写法带来的维护成本;需要离线访问网络资源、给页面加速的,是 Service Worker 配合 Cache API 的问题,跟前面几种业务数据存储不是一回事;任何本质上敏感的数据,优先考虑不要交给前端这几种可读存储长期保存,该放服务端就放服务端。

真正容易出问题的,往往不是 API 调用写错了,是没在存之前想清楚这份数据的生命周期、传输方式和暴露出去的风险——先存了再说,往往就是后续那些"部分用户页面不对""接口体积莫名变大""标签页之间对不上"问题的起点。