localStorage 与 sessionStorage 与 IndexedDB 与 Cookies — 浏览器存储,无需凌晨三点的后悔
选择合适浏览器存储的实用指南——包含对比表格、清晰解释JWT争议,以及每种方案如何破坏你夜晚的详细说明。
It’s 3am 😬. A user filed a bug: their shopping cart is empty. You open DevTools, click the Application tab, and stare at the sidebar. 你把它放在哪里了? 正确的答案取决于一系列问题,大多数开发者只有在错误被提交后才会去思考这些问题。
这不是一个定义文章。你可以在任何地方找到这些内容。这里是讨论什么会出错、应该使用什么,以及为什么JWT存储在本地存储中的争论大多忽略了重点的地方。
简要版本(也就是你可能会收藏的表格)
| localStorage | sessionStorage | IndexedDB | 饼干 | |
|---|---|---|---|---|
| 持久性 | 永久 | 仅限标签页会话 | 永久 | 可配置(会话或过期日期) |
| 最大大小 | 5–10 MB | 5–10 MB | GB(浏览器配额) | 每个cookie约4 KB |
| 服务器访问 | 不 | 不 | 不 | 是的——随每个请求发送 |
| 异步API | 否(阻塞主线程) | 否(阻塞主线程) | 是(基于Promise/事件机制) | 不 |
| JavaScript可读 | 是的 | 是的 | 是的 | 仅在没有HttpOnly标志时可读 |
| Web Worker访问 | 不 | 不 | 是的 | 不 |
| 在标签页间共享 | 是的 | 否——每个标签页是隔离的 | 是的 | 是的 |
| Safari ITP驱逐 | 7天内无交互后 | 标签页关闭时 | 7天内无交互后 | 取决于Expires属性 |
localStorage
持久、同步、基于源的。这是每个人都会用到的“工作马”,但事实上一半时间它并不应该被使用。
它实际上是什么
localStorage存储字符串键值对。仅此而已。大多数浏览器的存储限制为5MB,部分浏览器为10MB(Chrome提供更多的空间)。它基于源——协议+域名+端口——因此 http://example.com 且 https://example.com 拥有独立的存储。在标签页关闭、浏览器重启后仍然存在,除非用户清除了浏览器数据或你显式调用 localStorage.clear().
// Read/write is synchronous — it happens right now, on the main thread
localStorage.setItem('theme', 'dark');
const theme = localStorage.getItem('theme'); // 'dark'
// Storing objects? You're serializing manually.
localStorage.setItem('user', JSON.stringify({ id: 1, name: 'Alex' }));
const user = JSON.parse(localStorage.getItem('user'));
它会出错的地方
- 配额超限 ——抛出一个同步的
DOMException: QuotaExceededError。如果你没有在写入操作中使用try/catch,你将通过用户提交的错误报告发现这个问题。 - 私密/无痕浏览 ——浏览器会给你一个全新的、隔离的localStorage,配额更严格(或为零)。Firefox历史上会立即抛出配额错误。永远不要依赖localStorage在未进行特征检测的情况下可用。
- Safari ITP ——如果用户在7天内没有访问你的网站,Safari可能会清除localStorage。这是文档中明确说明的行为。它会在最糟糕的时候让你感到惊讶。
- XSS ——localStorage中的任何内容都可以被运行在你源上的JavaScript读取。如果攻击者可以注入脚本,他们就能获取所有内容。
用于
用户界面偏好(深色模式、侧边栏状态、语言)、非敏感的缓存(最后获取时间戳、静态配置)、任何在页面刷新后应保留但不包含认证令牌或个人身份信息的内容。如果你正在考虑将JWT或API密钥存入这里——请继续阅读。
sessionStorage
localStorage能做到的一切,但寿命更短,并且有一个关键的隔离行为会不断困扰用户。
标签页隔离陷阱
sessionStorage是按标签页而非浏览器划分的。在新标签页中打开同一页面会得到一个完全独立的sessionStorage。这对用户来说是显而易见的,但直到有人提交一个关于多步骤表单数据在“不小心”打开第二个标签页时消失的错误报告,你才会意识到这一点。 不是 唯一的例外:如果用户通过
或中间点击链接打开新标签页,新标签页 window.open() 会获得打开时父标签页的sessionStorage副本。之后,两个标签页将被隔离。这种边缘情况会产生一个在凌晨2点的Stack Overflow问题。 多步骤表单状态、一次性向导数据、任何应在单个会话中存在并在用户关闭标签页时消失的内容。它在结账流程中确实是有用的——你不想让部分结账状态在之前的会话中残留。如果你需要数据在多个标签页或独立打开的页面之间共享,请不要使用它。 成熟的选项。异步、事务性,并能存储真正的JavaScript对象——而不仅仅是序列化的字符串。它还拥有三种中最为痛苦的原生API,这也是几乎没有人直接使用它的原因。
// Perfect for checkout flows — step data lives until the tab closes
sessionStorage.setItem('checkoutStep', '2');
sessionStorage.setItem('cartSnapshot', JSON.stringify(cart));
// Cleared automatically when the tab closes — no cleanup code needed
用于
IndexedDB是一个完整的键值存储,支持索引和基于游标的查询。存储限制非常宽松——浏览器允许使用一定比例的磁盘空间,实际中通常为几GB。你可以存储结构化对象、Blobs、ArrayBuffers和Files,而无需手动序列化。它在Web Workers中可用。它是PWA和离线应用用来存储不适合保留在内存中的数据的首选。
IndexedDB
——与localStorage相同:在7天内无交互后,Safari可以驱逐IndexedDB数据。这曾导致多个PWA在Apple为新版本iOS修复该行为之前崩溃。如果你的目标用户包括iOS Safari用户,请考虑这一点。
它实际上是什么
iOS存储空间不足
// Native IDB API — nobody writes this directly in production
const request = indexedDB.open('myDB', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
db.createObjectStore('users', { keyPath: 'id' });
};
request.onsuccess = (event) => {
const db = event.target.result;
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
store.put({ id: 1, name: 'Alex', avatar: someBlob });
};
// What you actually use — Dexie.js or the idb wrapper
import Dexie from 'dexie';
const db = new Dexie('myApp');
db.version(1).stores({ users: '++id, name, email' });
await db.users.add({ name: 'Alex', email: 'alex@example.com' });
它会出错的地方
- Safari ITP ——在iOS上,操作系统在存储空间不足时可能会删除IndexedDB数据。它不会询问,数据直接就消失了。
- 私密浏览 ——Chrome允许在无痕模式下使用IndexedDB(带有会话级别的配额)。Safari在私密浏览模式下过去会抛出错误;行为因版本而异。
- 原生API ——如果你编写原始的IDB代码而没有包装器,基于回调的事件模型会产生你花费半天才能调试的错误。使用
- idb Dexie.js 离线应用、大型数据集(会滥用localStorage)、任何你原本会基于localStorage构建但不断碰到5MB限制的情况、PWA中的文件缓存。如果你本地存储了50个用户生成的文档,IndexedDB是答案。如果你只是存储用户的主题偏好,那就过度了。 或 四种中最古老的。服务器能看到它。唯一一个有4KB限制,如果你尝试将JWT存入其中,它一定会咬你一口。.
用于
Cookies与其它不同的地方
饼干
Cookies会自动随每个匹配的HTTP请求发送。这是功能也是问题。这意味着你的会话Cookie在没有JavaScript参与的情况下就能到达服务器——同时这也意味着每个对
的请求都会携带Cookie开销,无论你是否喜欢。
真正重要的属性: api.example.com ——JavaScript无法读取此Cookie。XSS攻击无法窃取它。这是会话Cookie的基本要求。
——仅通过HTTPS发送。在生产站点上如果没有这个属性,你就是在通过HTTP发送认证Cookie。不要这样做。
- 防止JavaScript通过 SameSite=Strict
- 安全的 ——Cookie仅在请求来自你自己的域名时发送。有效防止CSRF攻击,但会破坏OAuth重定向流程。SameSite=Lax对大多数应用来说是一个合理的折中。
- Expires / Max-Age ——如果没有这些属性,它就是一个会话Cookie,在浏览器关闭时就会失效。为“记住我”功能设置明确的过期时间。
- 如果你正在调试一个复杂的Cookie字符串, IO Tools’ Cookie Parser
// Setting a cookie from JavaScript (no HttpOnly, obviously)
document.cookie = "theme=dark; Path=/; Max-Age=31536000; Secure; SameSite=Lax";
// Server-side (Node.js + Express) — where the real power is
res.cookie('sessionId', token, {
httpOnly: true, // JS cannot read this
secure: true, // HTTPS only
sameSite: 'lax', // balanced CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days in ms
});
会将其分解为可读的键值对——当你盯着一个400字符的 头信息并试图弄清楚哪个属性出错时非常有用。 4KB限制 Set-Cookie ——这是包括名称、值和所有属性在内的总大小。一个典型的JWT是400–800字节。一个包含大量声明的大型JWT是1–2KB。你几乎没有空间。
它会出错的地方
- SameSite=Strict会破坏OAuth ——当你的身份提供商在登录后重定向回你的应用时,这是一个跨站请求。SameSite=Strict意味着你的会话Cookie不会被发送。对于任何经过OAuth流程的情况,使用Lax。
- Cookie顺序和路径匹配 ——当多个Cookie具有重叠的路径时,浏览器以未定义的顺序发送它们。不要依赖优先级。
- 第三方Cookie的废弃 ——Chrome正在移除第三方Cookie。跨站Cookie依赖是中期的隐患。
- JWT存储在本地存储中的争论(真正的攻击面) 这个话题在每次身份验证讨论中都会出现,而大多数争论的人实际上是在互相误解。
将JWT存储在localStorage中的担忧:如果网站存在XSS漏洞,攻击者的脚本可以直接读取令牌并窃取它。攻击者现在拥有一个有效的认证令牌,可以在任何设备、任何地方使用,直到它过期。
使用HttpOnly Cookie的论点:JavaScript无法读取该Cookie,因此XSS无法窃取它。会话仍然可以在请求中使用(攻击者可以通过XSS从受害者浏览器发起请求),但他们无法将令牌用于其他地方。这限制了攻击范围。
真实情况:
根本问题是XSS,而不是存储位置
。如果你存在XSS,使用HttpOnly Cookie是真正有帮助的——攻击者无法将令牌带离系统。但解决XSS才是更关键的目标,可以通过严格的Content Security Policy、不使用不受控的第三方脚本以及正确的输出转义来实现。 如果你正在构建一个具有严格CSP且没有你无法控制的第三方JavaScript的SPA,localStorage可能适合JWT。如果你运行一个包含Google Tag Manager、广告像素和数十个npm依赖的网站,使用HttpOnly Cookie会更安全,因为你的XSS攻击面比你想象的要大得多。在调试JWT问题时,
JWT Decoder on IO Tools
很有用——粘贴令牌并查看其负载和过期时间,无需编写代码。该工具在你追踪401错误链路时,有助于确认令牌是否仍然有效。 决策流程图 在你凌晨3点打开Stack Overflow页面之前,请先走一遍这个流程: JWT Expiry Checker 服务器是否需要读取它?
→ Cookie。讨论结束。
它是否大于5MB或是否需要查询能力?
- → IndexedDB。 它是否在关闭标签页时应消失?
- → sessionStorage。 其余情况
- → localStorage,但要谨慎考虑你存储的内容。 每个人都会搞错的一点
- 存储API在所有用户和所有情况下都不可靠。它们可能被浏览器、操作系统、隐私设置或用户清除。任何将客户端存储视为真实数据来源(而非缓存)的架构最终都会在某人身上失败。 经得起考验的模式:将浏览器存储视为对真实数据来源(服务器)的性能优化。积极缓存,但设计应用程序在缓存为空时能够优雅恢复。凌晨3点的调试会话几乎从来不是关于你使用了哪个存储API——它们都是关于假设数据会存在而实际上并不存在的问题。
人们普遍误解的一点
存储API在不同用户和不同时间并不可靠。它们可能被浏览器、操作系统、隐私设置或用户清除。任何将客户端存储视为真实数据源(而非缓存)的架构,最终都会在某些情况下失败。
经久不衰的模式是:将浏览器存储视为对真实数据源(服务器)的性能优化手段。应积极进行缓存,但必须设计应用程序,使其在缓存为空时能够优雅地恢复。凌晨三点的调试几乎从来不是关于使用了哪个存储API,而是关于错误地假设数据始终存在。
