浏览器存储机制

中等 🟡浏览器原理
10 个标签
预计阅读时间:85 分钟
浏览器原理浏览器存储localStorageSessionStorageIndexedDBCookieService WorkerCache Storage离线应用存储配额

浏览器存储机制

浏览器就像一个带了好几种"抽屉"的工作台:有的抽屉很小但每次干活都会随手带上(Cookie),有的抽屉很大但只在当前房间里用(localStorage / sessionStorage),还有的干脆是一整间仓库,可以摆放结构复杂的货架(IndexedDB),甚至还有一个专门存"半成品网页"的库房(Cache Storage)。做前端开发,本质上就是要清楚每种抽屉能放多少东西、什么时候会被清空、谁能打开它、以及放东西的动作会不会影响干活的效率。这篇文章会把浏览器提供的主要存储机制逐一拆开讲清楚:是什么、为什么要用它、底层怎么实现、怎么写代码、真实项目里怎么落地、有哪些坑,以及最终应该怎么选。

为什么客户端需要"存储"

在没有客户端存储之前,网页只能靠服务器记住一切:你每次刷新页面,服务器都要重新告诉浏览器"你是谁""你上次选了什么语言""购物车里有什么"。这样做有两个明显问题:

服务器压力大:任何一点点状态都要靠数据库查询和网络往返来维持,哪怕只是"记住用户关闭过这条公告"这种小事。
离线完全不可用:一旦断网,页面就成了一张死图,用户之前填的表单、看到一半的文章统统消失。

客户端存储的意义就是让浏览器自己"记事",不用什么都问服务器要。它解决的核心问题可以归纳成三类:

1.状态保持:登录态、主题、语言偏好、未读消息数。
2.性能优化:把静态资源、接口响应缓存在本地,减少重复请求。
3.离线能力:断网时仍然可以浏览已缓存的内容,甚至可以离线操作,联网后再同步。

不同的存储机制就是为了在"容量、生命周期、是否随请求发送、读写方式(同步/异步)"这几个维度上做出不同取舍,从而适配不同场景。

存储机制全景对比

下面这张表是本文的"地图",后续每一节都会展开讲解表中的一行。

| 存储机制 | 容量上限(典型值) | 生命周期 | 作用域 | 是否随 HTTP 请求发送 | 读写方式 | 是否阻塞主线程 |

| --- | --- | --- | --- | --- | --- | --- |

| Cookie | 约 4KB / 条,单域名 20~50 条 | 可设置过期时间,也可为会话级 | 域名 + 路径(可跨子域) | 是,自动携带 | 同步(document.cookie) | 是(但操作量小,通常无感) |

| localStorage | 约 5MB~10MB(因浏览器而异) | 永久,直到手动清除 | 同源(协议+域名+端口) | 否 | 同步 | 是 |

| sessionStorage | 约 5MB~10MB | 会话期间,标签页关闭即清除 | 同源 + 同一浏览器标签页 | 否 | 同步 | 是 |

| IndexedDB | 一般为磁盘剩余空间的一定比例(数百 MB 到 GB 级) | 永久,直到手动清除 | 同源 | 否 | 异步 | 否 |

| Cache Storage(Service Worker) | 与 IndexedDB 类似,受同一配额池约束 | 永久,直到手动清除或被浏览器回收 | 同源 | 否 | 异步 | 否 |

看这张表时要注意两点:第一,Cookie 是唯一会自动随请求发送的机制,这既是它的核心用途(会话保持),也是它最大的性能隐患(每个请求都要多带几百字节甚至几 KB);第二,localStorage / sessionStorage 虽然 API 简单,但它们是同步阻塞的,数据量一大就会拖慢页面,而 IndexedDB 和 Cache Storage 天生异步,更适合存放"大而复杂"的数据。

接下来我们从最古老的 Cookie 讲起。

Cookie

概念

Cookie 是服务器通过 HTTP 响应头 `Set-Cookie` 写入浏览器、之后浏览器在每次请求里通过 `Cookie` 请求头自动带回给服务器的一小段文本。它是 1994 年就出现的技术,比 localStorage、IndexedDB 都要早得多,设计初衷就是为了解决"HTTP 是无状态协议,服务器分不清两次请求是不是同一个人发的"这个问题。

可以把 Cookie 想象成你去理发店办的一张"会员卡":你每次进门(每次发请求),前台都会看一眼你手里的卡(浏览器自动带上 Cookie),从而知道你是谁、有什么优惠。你自己一般不需要主动出示,浏览器替你做了这件事。

为什么用 Cookie

需要服务器端在每个请求里识别用户身份(会话保持、登录态)。
需要设置 `HttpOnly` 属性,让令牌完全不暴露给页面 JavaScript,从而降低 XSS 窃取风险。
需要按照域和路径做精细的访问范围控制(比如只在 `/api` 路径下发送)。

如果只是想在浏览器本地记点数据、不需要发给服务器,那就不该用 Cookie —— 这时候应该用 localStorage 或 IndexedDB,Cookie 会白白增加每个请求的体积。

原理

服务器通过响应头下发:

\`\`\`

Set-Cookie: session_id=abc123; Max-Age=3600; Path=/; Domain=example.com; Secure; HttpOnly; SameSite=Lax

\`\`\`

浏览器收到后会把这条 Cookie 存进本地的 Cookie 仓库,并在之后所有匹配 \`Domain\` 和 \`Path\` 的请求中自动加上请求头:

\`\`\`

Cookie: session_id=abc123

\`\`\`

前端 JavaScript 也可以通过 \`document.cookie\` 读写(除非设置了 \`HttpOnly\`)。需要特别注意的是,\`document.cookie\` 是一个非常"奇怪"的 API:给它赋值并不会覆盖整个 Cookie 字符串,而是新增或更新其中一条;读取时返回的是当前域下所有可读 Cookie 拼成的一整条分号分隔字符串。

代码示例:完整的 Cookie 读写删封装

\`\`\`javascript

/**

* 设置 Cookie

* @param {string} name 键

* @param {string} value 值

* @param {object} options 可选项:days(过期天数)、path、domain、secure、sameSite

*/

function setCookie(name, value, options = {}) {

const { days, path = '/', domain, secure = true, sameSite = 'Lax' } = options;

let cookieStr = \`\${encodeURIComponent(name)}=\${encodeURIComponent(value)}\`;

if (days) {

const expires = new Date();

expires.setTime(expires.getTime() + days 24 60 60 1000);

cookieStr += \`; expires=\${expires.toUTCString()}\`;

}

cookieStr += \`; path=\${path}\`;

if (domain) cookieStr += \`; domain=\${domain}\`;

if (secure) cookieStr += '; Secure';

cookieStr += \`; SameSite=\${sameSite}\`;

document.cookie = cookieStr;

}

/**

* 读取单个 Cookie

*/

function getCookie(name) {

const target = encodeURIComponent(name) + '=';

const parts = document.cookie.split(';');

for (let part of parts) {

part = part.trim();

if (part.indexOf(target) === 0) {

return decodeURIComponent(part.substring(target.length));

}

}

return null;

}

/**

* 读取全部 Cookie,返回对象形式,便于遍历和调试

*/

function getAllCookies() {

return document.cookie

.split(';')

.filter(Boolean)

.reduce((acc, pair) => {

const [key, ...rest] = pair.trim().split('=');

acc[decodeURIComponent(key)] = decodeURIComponent(rest.join('='));

return acc;

}, {});

}

/**

* 删除 Cookie:把过期时间设置为过去即可

* 注意:path 和 domain 必须和当初写入时完全一致,否则删不掉

*/

function deleteCookie(name, path = '/', domain) {

let cookieStr = \`\${encodeURIComponent(name)}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=\${path}\`;

if (domain) cookieStr += \`; domain=\${domain}\`;

document.cookie = cookieStr;

}

// 使用示例

setCookie('theme', 'dark', { days: 30 });

console.log(getCookie('theme')); // 'dark'

console.log(getAllCookies()); // { theme: 'dark', ... }

deleteCookie('theme');

\`\`\`

上面的封装解决了原生 API 三个常见麻烦:手动编码/解码特殊字符、拼接过期时间字符串、以及删除时容易忘记带上一致的 \`path\`。

真实案例:登录会话保持

绝大多数传统 Web 应用(包括 Rails、Django、Express + Session 中间件的项目)都采用如下模式:

1.用户提交登录表单,服务器验证成功后生成一个随机的 \`session_id\`,存入 Redis 或数据库,同时通过 \`Set-Cookie\` 下发给浏览器,并设置 \`HttpOnly; Secure; SameSite=Lax\`。
2.浏览器此后每次请求都会自动带上这个 Cookie。
3.服务器根据 \`session_id\` 去 Redis 查找对应的用户信息,从而识别"这是谁"。

这个模式的好处是令牌完全对前端 JavaScript 不可见(因为 \`HttpOnly\`),即使页面存在 XSS 漏洞,攻击者的注入脚本也读不到 Cookie,只能靠"借刀杀人"(诱导用户在被攻击页面上发起请求,利用浏览器自动带 Cookie 的特性)达成 CSRF 攻击 —— 这也是为什么 \`SameSite\` 属性如此重要。

数据与对比

| SameSite 取值 | 行为 | 典型使用场景 |

| --- | --- | --- |

| Strict | 完全不在跨站请求中发送 | 银行、后台管理系统等安全性要求极高的场景 |

| Lax(默认值) | 允许顶级导航(如点击链接跳转)携带,但 iframe、表单 POST 等不携带 | 绝大多数普通网站的默认选择 |

| None | 允许任意跨站请求携带,但必须同时设置 Secure | 第三方登录、跨站内嵌 iframe、支付网关回调 |

| 属性 | 作用 | 不设置的风险 |

| --- | --- | --- |

| HttpOnly | 禁止 JavaScript 通过 document.cookie 读取 | XSS 攻击可直接窃取会话 Cookie |

| Secure | 仅通过 HTTPS 传输 | 中间人可在 HTTP 明文阶段截获 Cookie |

| SameSite | 限制跨站请求是否携带 | 容易被 CSRF 攻击利用 |

| Max-Age / Expires | 控制过期时间 | 会话永不失效,增加被盗用后的危害窗口 |

常见坑

4KB 限制被忽略:把 JWT、用户完整信息塞进 Cookie,超出限制后浏览器会静默丢弃或截断,排查起来非常隐蔽。
忘记同步 path/domain 删除:设置时用了 \`domain=.example.com\`,删除时却没带 domain,导致"删除"其实是新建了一条只在当前子域生效的空 Cookie,原来的 Cookie 仍然存在。
中文或特殊字符未编码:不用 \`encodeURIComponent\` 直接拼接值,遇到分号、等号、中文会破坏 Cookie 字符串结构。
过度依赖 Cookie 传输大数据:每个请求都要带上这些字节,尤其是图片、静态资源域名如果和主站共享 Cookie,会显著拖慢加载速度(这也是为什么很多网站把静态资源放在无 Cookie 的独立域名下,如 \`static.example.com\`)。
SameSite=None 忘记加 Secure:现代浏览器会直接拒绝这种组合的 Cookie,导致第三方场景下登录莫名其妙失败。

最佳实践

会话令牌一律加 \`HttpOnly + Secure + SameSite\`,绝不允许 JavaScript 读取。
非敏感的小型偏好设置(比如 A/B 实验分组、语言选择)可以用普通 Cookie,但优先考虑体积。
静态资源尽量放在与主站不同的域名,避免携带无用 Cookie。
需要在前端频繁读写、且不需要随请求发送的数据,坚决不要用 Cookie,改用 localStorage 或 IndexedDB。

小结

Cookie 的核心价值是"自动随请求发送",这既是它存在的理由,也是它必须被小心对待的原因。凡是需要服务器识别身份的场景,Cookie(尤其是 HttpOnly Cookie)仍然是最主流、最安全的方案;凡是纯前端本地记忆的场景,都应该让位给下面要讲的 localStorage 和 sessionStorage。

localStorage

概念

localStorage 是浏览器提供的一个同源、持久化的键值对存储区,API 极其简单:\`setItem\`、\`getItem\`、\`removeItem\`、\`clear\`。它不会随请求发送给服务器,数据只保存在用户本地磁盘上,除非用户清理浏览器数据或代码主动删除,否则会一直存在——即使关闭浏览器、重启电脑也不会丢失。

可以把 localStorage 想象成你在家里书桌抽屉里存的便签纸:只有你自己(同一个源的页面)能打开这个抽屉,纸条不会自动寄给任何人(不随请求发送),你不整理它就会一直堆在那里。

为什么用 localStorage

需要跨会话保留的用户偏好:主题、语言、是否看过引导页。
需要在本地缓存接口返回的数据,减少重复请求(注意要配合过期策略)。
数据量不大(通常几 KB 到几百 KB),结构简单(字符串、可 JSON 序列化的对象)。

原理

localStorage 底层由浏览器按"源"(协议 + 域名 + 端口)维护一份独立的存储空间,通常落地为浏览器 profile 目录下的 SQLite 文件(Chrome 用 LevelDB,具体实现因浏览器而异,但对外表现一致)。所有操作都是同步的,也就是说 \`localStorage.setItem\` 执行期间会阻塞主线程直到写入完成——这也是为什么不建议在 localStorage 里存大量数据或频繁写入。

代码示例:基础操作与错误处理

\`\`\`javascript

// 存储对象需要手动序列化

function saveToLocalStorage(key, data) {

try {

const serialized = JSON.stringify(data);

localStorage.setItem(key, serialized);

return true;

} catch (error) {

if (error.name === 'QuotaExceededError') {

console.error('localStorage 空间已满:', key);

// 可以尝试清理最旧的数据后重试

} else {

console.error('localStorage 写入失败:', error);

}

return false;

}

}

function getFromLocalStorage(key, fallback = null) {

try {

const raw = localStorage.getItem(key);

return raw ? JSON.parse(raw) : fallback;

} catch (error) {

console.error('localStorage 解析失败,数据可能已损坏:', error);

return fallback;

}

}

saveToLocalStorage('userPrefs', { theme: 'dark', lang: 'zh-CN' });

console.log(getFromLocalStorage('userPrefs'));

\`\`\`

代码示例:带过期时间的 localStorage 封装

原生 localStorage 没有过期机制,很多"缓存接口数据"的场景需要自己实现一个 TTL(存活时间)包装层:

\`\`\`javascript

/**

* 写入带过期时间的数据

* @param {string} key

@param {} value

* @param {number} ttlMs 存活时间,单位毫秒

*/

function setWithExpiry(key, value, ttlMs) {

const payload = {

value,

expiry: Date.now() + ttlMs,

};

localStorage.setItem(key, JSON.stringify(payload));

}

/**

* 读取数据,若已过期则自动删除并返回 null

*/

function getWithExpiry(key) {

const raw = localStorage.getItem(key);

if (!raw) return null;

let payload;

try {

payload = JSON.parse(raw);

} catch {

localStorage.removeItem(key);

return null;

}

if (Date.now() > payload.expiry) {

localStorage.removeItem(key);

return null;

}

return payload.value;

}

// 缓存接口响应 10 分钟

setWithExpiry('apiCache:userProfile', { id: 1, name: '张三' }, 10 60 1000);

const cached = getWithExpiry('apiCache:userProfile');

if (cached) {

console.log('命中缓存', cached);

} else {

console.log('缓存已过期或不存在,需要重新请求接口');

}

\`\`\`

代码示例:命名空间封装,避免 key 冲突

大型项目里多个模块共用 localStorage 很容易互相覆盖 key,可以封装一个带前缀的命名空间实例:

\`\`\`javascript

class NamespacedStorage {

constructor(namespace) {

this.namespace = namespace;

}

_key(key) {

return \`\${this.namespace}:\${key}\`;

}

set(key, value) {

localStorage.setItem(this._key(key), JSON.stringify(value));

}

get(key, fallback = null) {

const raw = localStorage.getItem(this._key(key));

return raw ? JSON.parse(raw) : fallback;

}

remove(key) {

localStorage.removeItem(this._key(key));

}

// 只清空属于当前命名空间的 key,而不是整个 localStorage

clearNamespace() {

const prefix = \`\${this.namespace}:\`;

Object.keys(localStorage)

.filter((k) => k.startsWith(prefix))

.forEach((k) => localStorage.removeItem(k));

}

}

const cartStorage = new NamespacedStorage('cart');

cartStorage.set('items', [{ sku: 'A001', qty: 2 }]);

console.log(cartStorage.get('items'));

\`\`\`

代码示例:检测剩余容量、优雅降级

\`\`\`javascript

function getLocalStorageUsage() {

let totalBytes = 0;

for (const key in localStorage) {

if (Object.prototype.hasOwnProperty.call(localStorage, key)) {

// 简单估算:key + value 的 UTF-16 字节数

totalBytes += (key.length + localStorage.getItem(key).length) * 2;

}

}

return totalBytes;

}

function safeSetItem(key, value) {

try {

localStorage.setItem(key, value);

return { ok: true };

} catch (error) {

if (error.name === 'QuotaExceededError' || error.code === 22) {

// 容量已满:尝试清理策略,比如删除最旧的缓存

console.warn('localStorage 已满,当前占用约', getLocalStorageUsage(), '字节');

return { ok: false, reason: 'quota-exceeded' };

}

return { ok: false, reason: 'unknown', error };

}

}

\`\`\`

跨标签页同步:storage 事件

localStorage 有一个经常被忽略但非常实用的特性:当另一个同源标签页修改了 localStorage,当前标签页会收到一个 \`storage\` 事件(当前标签页自己修改不会触发自己的事件,只会通知其他标签页)。这让"多标签页数据同步"变得非常简单,不需要引入 WebSocket 或轮询。

\`\`\`javascript

// 标签页 A 和标签页 B 都执行了这段监听代码

window.addEventListener('storage', (event) => {

// event.key:发生变化的键,clear() 触发时为 null

// event.oldValue / event.newValue:变化前后的值

// event.url:触发变化的页面地址

// event.storageArea:具体是 localStorage 还是 sessionStorage

if (event.key === 'cart:items') {

console.log('购物车在其他标签页发生了变化,同步 UI');

const newItems = event.newValue ? JSON.parse(event.newValue) : [];

renderCartBadge(newItems.length);

}

});

function renderCartBadge(count) {

const badge = document.getElementById('cart-badge');

if (badge) badge.textContent = String(count);

}

\`\`\`

真实案例:电商购物车持久化

很多电商网站的购物车逻辑是这样设计的:用户加购商品时,前端把购物车状态写入 localStorage(同时异步同步给服务器,用于登录用户的多端同步);用户刷新页面或重新打开标签页时,直接从 localStorage 读取购物车渲染,无需等待接口返回,体验上"秒开";如果用户同时开着"商品详情页"和"购物车页"两个标签页,通过 \`storage\` 事件监听,加购之后购物车页的数量角标可以实时更新,不需要用户手动刷新。

数据与对比

| 项目 | localStorage | sessionStorage |

| --- | --- | --- |

| 生命周期 | 永久(直到手动清除) | 标签页关闭即清除 |

| 作用域 | 同源所有标签页共享 | 同源 + 同一标签页(新开标签页不共享,即使是同一网址) |

| 是否触发 storage 事件通知其他标签页 | 是 | 否(sessionStorage 变化不会跨标签页广播,因为本来就互相隔离) |

| 典型容量 | 5MB~10MB | 5MB~10MB |

| 适合场景 | 用户偏好、长期缓存 | 表单临时数据、单次会话状态 |

常见坑

只能存字符串:直接 \`setItem('user', {name: 'a'})\` 会被隐式转换成字符串 \`"[object Object]"\`,必须手动 \`JSON.stringify\`。
QuotaExceededError 未处理:容量写满后抛异常,如果没有 try/catch,会直接中断后续代码执行。
同步阻塞主线程:在渲染热路径里频繁读写 localStorage(比如每次 \`onScroll\` 都存一次状态)会造成明显卡顿。
隐私模式限制:Safari 无痕模式下 localStorage 容量极小甚至写入会直接抛错,需要做兼容检测。
以为是安全存储:localStorage 对同源的任何脚本都完全可读,一旦发生 XSS,攻击者可以直接读走里面所有内容,绝不能存 Token、密码等敏感信息。
误以为可以跨子域共享:\`a.example.com\` 和 \`b.example.com\` 是不同的源,localStorage 完全隔离,不像 Cookie 可以设置 \`domain=.example.com\` 共享。

最佳实践

存储前判断 \`typeof window !== 'undefined' && window.localStorage\` 是否可用(尤其是 Next.js 这类支持 SSR 的框架,服务端渲染阶段没有 \`window\`)。
所有读写都包 try/catch,对 \`QuotaExceededError\` 做专门处理。
需要过期机制的数据一律用上面的 \`setWithExpiry\` 封装,不要让缓存"长生不老"。
用命名空间前缀隔离不同模块的 key,防止相互覆盖。
避免存储敏感信息;确需要存储 Token 时优先考虑内存变量 + HttpOnly Cookie 的组合方案。

小结

localStorage 的优势是简单、持久、同步 API 上手快,最适合"体积小、跨会话保留、非敏感"的数据。它的短板同样明显:容量有限、同步阻塞、没有过期机制、安全性弱。理解了这些边界,才能判断什么时候该往下一节的 sessionStorage 或再下一节的 IndexedDB 走。

sessionStorage

概念

sessionStorage 和 localStorage 的 API 几乎一模一样(同样是 \`setItem\`/\`getItem\`/\`removeItem\`/\`clear\`),唯一的核心区别在生命周期和作用域:sessionStorage 的数据只在当前这一个标签页存在,标签页一关闭数据就被清空;即使你打开同一个网址的另一个新标签页,也是一份全新的、独立的 sessionStorage,彼此不共享。

可以把它理解成"草稿纸":写完这一次,用完这张纸的这次会话,纸就被扔掉了;就算你在另一张桌子(另一个标签页)上重新拿了一张同样的纸,那也是另一张纸,跟之前那张没有任何关系。

为什么用 sessionStorage 而不是 localStorage

数据本质上只对当前这次浏览有意义,关闭标签页后就该被丢弃(比如"用户在多步骤表单里填到第几步了")。
需要不同标签页互相隔离的场景:比如同一个网站在两个标签页里分别以不同身份登录进行调试和对比(虽然更严谨的隔离通常用无痕窗口,但 sessionStorage 至少能避免状态串台)。
想利用"关闭即清除"这个特性自动做垃圾回收,不需要额外写清理逻辑。

原理

sessionStorage 由浏览器为每个"浏览会话"(准确说是每个标签页 / 窗口的每次导航历史)单独维护一份存储空间。需要注意:如果你用 \`window.open\` 打开的新标签页继承自父标签页(部分浏览器行为),sessionStorage 会被复制一份初始快照,但之后两者互相独立、互不影响;如果用户直接在地址栏输入网址新开标签页,则是全新空白的 sessionStorage。

代码示例:多步骤表单草稿自动保存

这是 sessionStorage 最典型的落地场景:用户填写一个分步骤的长表单(比如注册资料、报销申请),中途误刷新页面也不希望内容丢失,但也不需要"关闭浏览器后还记得",用 sessionStorage 正合适。

\`\`\`javascript

const FORM_DRAFT_KEY = 'form:registration:draft';

function saveDraft(formData) {

try {

sessionStorage.setItem(FORM_DRAFT_KEY, JSON.stringify({

data: formData,

savedAt: Date.now(),

}));

} catch (error) {

console.warn('草稿保存失败(可能是隐私模式限制):', error);

}

}

function loadDraft() {

const raw = sessionStorage.getItem(FORM_DRAFT_KEY);

if (!raw) return null;

try {

const parsed = JSON.parse(raw);

return parsed.data;

} catch {

return null;

}

}

function clearDraft() {

sessionStorage.removeItem(FORM_DRAFT_KEY);

}

// 监听表单输入,做防抖后自动保存

function bindAutoSave(form) {

let timer = null;

form.addEventListener('input', () => {

clearTimeout(timer);

timer = setTimeout(() => {

const formData = Object.fromEntries(new FormData(form).entries());

saveDraft(formData);

}, 500);

});

// 页面加载时恢复草稿

const draft = loadDraft();

if (draft) {

Object.entries(draft).forEach(([name, value]) => {

const field = form.elements.namedItem(name);

if (field) field.value = value;

});

}

// 提交成功后清除草稿

form.addEventListener('submit', () => {

clearDraft();

});

}

\`\`\`

代码示例:记录多步骤表单当前进度

\`\`\`javascript

const STEP_KEY = 'wizard:currentStep';

function goToStep(stepIndex) {

sessionStorage.setItem(STEP_KEY, String(stepIndex));

renderStep(stepIndex);

}

function restoreStep() {

const saved = sessionStorage.getItem(STEP_KEY);

return saved ? Number(saved) : 0;

}

// 页面初始化

renderStep(restoreStep());

\`\`\`

真实案例:报销/申请类长表单

企业内部系统(报销审批、请假申请、入职资料填写)经常是十几个字段分好几屏。这类系统普遍采用"sessionStorage 自动存草稿 + 提交成功后清空"的模式:用户填到一半不小心刷新了页面,或者被同事叫走再回来,表单内容都还在;但只要关闭标签页重新打开,就默认认为是"重新开始一次填写",不会把三天前填了一半的旧草稿莫名其妙地翻出来吓到用户。这正是 sessionStorage"会话级"生命周期最契合的场景。

数据与对比

| 场景 | 推荐存储 | 原因 |

| --- | --- | --- |

| 记住用户是否看过新手引导 | localStorage | 需要跨会话长期记住 |

| 记录多步骤表单当前进度 | sessionStorage | 只在这一次填写中有意义 |

| 缓存 Token 用于请求头 | 都不推荐 | 应使用内存变量 + HttpOnly Cookie |

| 保存用户主题选择 | localStorage | 需要长期保留、跨标签页共享 |

| 保存搜索页的临时筛选条件 | sessionStorage | 关闭页面后没必要保留 |

常见坑

误以为多个标签页共享:这是最常见的误解,同一网址开两个标签页,sessionStorage 是两份独立数据,不会同步。
依赖它做"记住登录状态":一旦用户关闭标签页重新打开就会丢失,体验非常糟糕,这类需求应该用 Cookie 或 localStorage。
不会触发 storage 事件用于跨标签页通信:因为本来就是隔离的,写监听代码时容易White费时间调试为什么收不到事件。
隐私模式下同样可能受限:和 localStorage 一样会受浏览器隐私策略影响,需要兜底处理。

最佳实践

明确"只在这次会话内有效"的数据才用 sessionStorage,其余一律考虑 localStorage 或服务器端。
表单类场景做防抖保存,避免每次按键都写一次,减少不必要的同步阻塞。
提交成功、放弃编辑等"结束"节点要主动清理,不要依赖用户关闭标签页这个隐式时机。

小结

sessionStorage 是 localStorage 的"限定版":API 相同,但生命周期被收窄到单个标签页的这一次会话。选择它的判断标准很简单——如果数据在标签页关闭之后就不再有意义,用 sessionStorage;如果还需要保留,用 localStorage。

IndexedDB

概念

IndexedDB 是浏览器内置的一个事务型 NoSQL 数据库,支持存储结构化数据(不仅是字符串,还可以是对象、数组、二进制 Blob/File),支持按索引查询、支持游标遍历、支持事务保证一致性,容量也远大于 localStorage。可以把它理解成"浏览器自带的一个小型 SQLite/MongoDB",专门为需要存大量数据、复杂查询的前端场景准备。

如果说 localStorage 像书桌抽屉里的便签纸,那 IndexedDB 就像一整间带索引卡片柜的档案室:东西可以分门别类地摆放(对象仓库 Object Store),可以按某个字段快速检索(索引 Index),操作过程有严格的登记流程保证不出错(事务 Transaction)。

为什么用 IndexedDB

数据量大(几百条到几十万条记录),localStorage 的 5MB 容量和同步 API 完全不够用。
需要按字段查询、排序、范围检索,而不只是按 key 精确匹配。
需要离线可用的完整业务数据(笔记、邮件、待办事项、聊天记录)。
需要存储二进制数据,比如图片、音频、离线下载的文件。

原理

IndexedDB 的核心概念有四个:

数据库(Database):一个源下可以有多个数据库,每个数据库有一个名称和版本号。
对象仓库(Object Store):相当于关系型数据库里的"表",每条记录通过 \`keyPath\`(主键字段)或自增 key 标识。
索引(Index):在对象仓库的某个字段上建立索引,从而支持按该字段快速查询,而不必全表扫描。
事务(Transaction):所有的读写操作都必须在事务里进行,事务保证一组操作要么全部成功要么全部失败,同时决定了并发访问时的锁行为。

数据库结构的变更(新增对象仓库、新增索引)只能在 \`onupgradeneeded\` 回调里进行,这个回调只在"打开一个比当前已存版本号更高的版本"时触发——这也是 IndexedDB 版本管理的核心机制。

代码示例:完整的增删改查 + 索引 + 游标 + 事务

\`\`\`javascript

const DB_NAME = 'NotesAppDB';

const DB_VERSION = 1;

const STORE_NAME = 'notes';

// 1. 打开数据库,处理版本升级

function openDatabase() {

return new Promise((resolve, reject) => {

const request = indexedDB.open(DB_NAME, DB_VERSION);

request.onupgradeneeded = (event) => {

const db = event.target.result;

if (!db.objectStoreNames.contains(STORE_NAME)) {

// keyPath 指定主键字段,autoIncrement 让 id 自动递增

const store = db.createObjectStore(STORE_NAME, {

keyPath: 'id',

autoIncrement: true,

});

// 建立索引,方便按 category 查询、按 updatedAt 排序

store.createIndex('by_category', 'category', { unique: false });

store.createIndex('by_updatedAt', 'updatedAt', { unique: false });

}

};

request.onsuccess = (event) => resolve(event.target.result);

request.onerror = (event) => reject(event.target.error);

});

}

// 2. 新增一条记录

function addNote(db, note) {

return new Promise((resolve, reject) => {

const tx = db.transaction(STORE_NAME, 'readwrite');

const store = tx.objectStore(STORE_NAME);

const request = store.add({

...note,

updatedAt: Date.now(),

});

request.onsuccess = () => resolve(request.result); // 返回自增 id

request.onerror = () => reject(request.error);

});

}

// 3. 按主键获取一条记录

function getNote(db, id) {

return new Promise((resolve, reject) => {

const tx = db.transaction(STORE_NAME, 'readonly');

const store = tx.objectStore(STORE_NAME);

const request = store.get(id);

request.onsuccess = () => resolve(request.result);

request.onerror = () => reject(request.error);

});

}

// 4. 更新记录(put 是"存在则覆盖,不存在则新增")

function updateNote(db, note) {

return new Promise((resolve, reject) => {

const tx = db.transaction(STORE_NAME, 'readwrite');

const store = tx.objectStore(STORE_NAME);

const request = store.put({ ...note, updatedAt: Date.now() });

request.onsuccess = () => resolve(request.result);

request.onerror = () => reject(request.error);

});

}

// 5. 删除记录

function deleteNote(db, id) {

return new Promise((resolve, reject) => {

const tx = db.transaction(STORE_NAME, 'readwrite');

const store = tx.objectStore(STORE_NAME);

const request = store.delete(id);

request.onsuccess = () => resolve();

request.onerror = () => reject(request.error);

});

}

// 6. 使用索引查询:按分类查找所有笔记

function getNotesByCategory(db, category) {

return new Promise((resolve, reject) => {

const tx = db.transaction(STORE_NAME, 'readonly');

const store = tx.objectStore(STORE_NAME);

const index = store.index('by_category');

const request = index.getAll(category);

request.onsuccess = () => resolve(request.result);

request.onerror = () => reject(request.error);

});

}

// 7. 用游标逐条遍历(适合处理大量数据、需要中途判断的场景)

function iterateAllNotes(db, onEachNote) {

return new Promise((resolve, reject) => {

const tx = db.transaction(STORE_NAME, 'readonly');

const store = tx.objectStore(STORE_NAME);

const request = store.openCursor();

request.onsuccess = (event) => {

const cursor = event.target.result;

if (cursor) {

onEachNote(cursor.value);

cursor.continue(); // 移动到下一条记录

} else {

resolve(); // 游标遍历结束

}

};

request.onerror = () => reject(request.error);

});

}

// 使用示例

(async () => {

const db = await openDatabase();

const id = await addNote(db, { title: '学习 IndexedDB', category: 'tech' });

console.log('新增笔记 id:', id);

const note = await getNote(db, id);

console.log('读取笔记:', note);

await updateNote(db, { ...note, title: '学习 IndexedDB(已更新)' });

const techNotes = await getNotesByCategory(db, 'tech');

console.log('tech 分类下的笔记:', techNotes);

await iterateAllNotes(db, (n) => console.log('遍历到:', n.title));

await deleteNote(db, id);

})();

\`\`\`

代码示例:范围查询与排序

IndexedDB 的索引支持 \`IDBKeyRange\`,可以做区间查询,比如"最近 7 天更新过的笔记":

\`\`\`javascript

function getRecentNotes(db, sinceTimestamp) {

return new Promise((resolve, reject) => {

const tx = db.transaction(STORE_NAME, 'readonly');

const store = tx.objectStore(STORE_NAME);

const index = store.index('by_updatedAt');

// lowerBound(sinceTimestamp) 表示 >= sinceTimestamp

const range = IDBKeyRange.lowerBound(sinceTimestamp);

const results = [];

const request = index.openCursor(range, 'prev'); // 'prev' 表示按索引倒序

request.onsuccess = (event) => {

const cursor = event.target.result;

if (cursor) {

results.push(cursor.value);

cursor.continue();

} else {

resolve(results);

}

};

request.onerror = () => reject(request.error);

});

}

// 查询最近 7 天更新过的笔记

const sevenDaysAgo = Date.now() - 7 24 60 60 1000;

getRecentNotes(db, sevenDaysAgo).then((notes) => console.log(notes));

\`\`\`

代码示例:用 idb 库简化原生 API

原生 IndexedDB 的回调式写法非常繁琐,社区里 Jake Archibald 写的 \`idb\` 库把它包装成了 Promise 风格,代码量能减少一半以上:

\`\`\`javascript

import { openDB } from 'idb';

async function createDb() {

return openDB('NotesAppDB', 1, {

upgrade(db) {

const store = db.createObjectStore('notes', {

keyPath: 'id',

autoIncrement: true,

});

store.createIndex('by_category', 'category');

},

});

}

async function demo() {

const db = await createDb();

// 新增

const id = await db.add('notes', { title: '用 idb 库', category: 'tech', updatedAt: Date.now() });

// 读取

const note = await db.get('notes', id);

// 更新

await db.put('notes', { ...note, title: '用 idb 库(已更新)' });

// 按索引查询

const techNotes = await db.getAllFromIndex('notes', 'by_category', 'tech');

// 删除

await db.delete('notes', id);

// 事务:一次性执行多个操作,保证原子性

const tx = db.transaction('notes', 'readwrite');

await Promise.all([

tx.store.add({ title: 'A', category: 'life', updatedAt: Date.now() }),

tx.store.add({ title: 'B', category: 'life', updatedAt: Date.now() }),

tx.done,

]);

console.log(techNotes);

}

\`\`\`

真实案例:离线笔记应用与邮件客户端

Google Keep、Notion 的本地缓存层、以及许多 PWA 邮件客户端(如 Gmail 的离线模式)都大量使用 IndexedDB:邮件正文、附件元数据、联系人列表全部先落地到 IndexedDB,用户在地铁隧道等无网络环境下依然可以查看已同步的邮件、撰写草稿;一旦恢复网络,后台再把这些改动通过 Service Worker 的 Background Sync 同步回服务器。这种"本地优先(Local-first)"的架构如果没有 IndexedDB 这种支持复杂查询、容量足够大的存储方式,几乎无法实现。

数据与对比

| 维度 | localStorage | IndexedDB |

| --- | --- | --- |

| 数据类型 | 仅字符串 | 对象、数组、Blob、File 等结构化类型 |

| 查询能力 | 仅按 key 精确匹配 | 支持索引、范围查询、游标遍历 |

| 并发安全 | 无事务概念,多标签页写入可能互相覆盖 | 有事务机制,保证一组操作的原子性 |

| API 风格 | 同步,简单直接 | 异步(基于事件,或 Promise 封装),学习曲线较陡 |

| 容量 | 约 5MB~10MB | 数百 MB 到 GB 级(视磁盘可用空间而定) |

| 适合数据规模 | 几十到几百条小记录 | 成千上万条记录甚至更多 |

常见坑

版本升级处理不当:忘记在 \`onupgradeneeded\` 里做兼容判断(比如判断 \`db.objectStoreNames.contains\`),导致老用户升级新版本时报错。
事务自动提交:IndexedDB 的事务会在当前"微任务队列清空"后自动提交,如果在事务回调中间插入了 \`await fetch(...)\` 这种跨事件循环的异步操作,事务会提前关闭并抛出 \`TransactionInactiveError\`。正确做法是把所有需要跨网络的操作放在事务之外,先准备好数据,再进入事务只做纯粹的数据库读写。
回调地狱:原生 API 全部基于 \`onsuccess\`/\`onerror\` 事件回调,嵌套层级一深代码就难以维护,建议使用 \`idb\` 库或自行封装 Promise。
对象存储的 keyPath 设计不合理:主键选错字段会导致后续大量查询都要走全表扫描的游标遍历,而不能命中索引。
过早关闭数据库连接:多个页面/标签页共用同一个数据库时,升级版本可能因为旧连接未关闭而被阻塞(\`onblocked\` 事件),需要监听并提示用户关闭其他标签页。

最佳实践

数据库设计阶段先想清楚需要哪些查询维度,再决定建哪些索引,索引不是越多越好(会增加写入开销)。
所有写操作放在同一个事务里批量执行,减少事务开销,同时保证原子性。
优先使用 \`idb\` 或 \`Dexie.js\` 这类成熟封装库,除非有非常定制化的需求才手写原生 API。
版本升级逻辑要做到"幂等",即多次执行 \`onupgradeneeded\` 也不会出错。
大批量导入数据时考虑分批次写入,避免单个事务过大导致长时间阻塞。

小结

IndexedDB 是浏览器里"重型选手",专门用来对付 localStorage 应付不了的大数据量、复杂查询、离线优先场景。它的学习成本比其他存储机制都高,但一旦掌握,几乎可以把浏览器当成一个功能完备的本地数据库来用。

Service Worker Cache(Cache Storage API)

概念

Cache Storage API(俗称 Service Worker Cache)是浏览器提供的一套用来存储 \`Request\`/\`Response\` 键值对的存储机制,专门为离线优先的 Web 应用(PWA)设计。它通常配合 Service Worker 使用:Service Worker 是运行在页面主线程之外的一个独立脚本,可以拦截页面发出的所有网络请求,决定"这个请求要不要走缓存、要不要走网络、要不要两者结合"。

可以把 Service Worker 想象成一个装在小区门口的"代收快递点":所有快递(网络请求)都要先经过它,它可以选择直接把之前收到的包裹(缓存的响应)交给你,也可以帮你重新联系快递员(发起真实网络请求),甚至可以先给你一个旧包裹应急,同时偷偷去催新包裹送到。

为什么用 Cache Storage / Service Worker

需要让 Web 应用在完全离线的情况下依然可以打开、可以浏览已访问过的内容。
需要精细控制"哪些资源优先用缓存、哪些资源必须用最新网络数据",而不是依赖浏览器 HTTP 缓存的黑盒策略。
需要缓存整站的静态资源(HTML/CSS/JS/图片),实现"秒开"的应用外壳(App Shell)体验。

原理

Cache Storage 的核心 API:

\`caches.open(cacheName)\`:打开(或创建)一个命名的缓存空间,返回 \`Cache\` 对象。
\`cache.put(request, response)\`:把一个请求-响应对存入缓存。
\`cache.match(request)\`:根据请求查找匹配的缓存响应。
\`cache.addAll(urls)\`:批量拉取并缓存一组 URL。
\`cache.delete(request)\`:删除某条缓存。
\`caches.delete(cacheName)\`:删除整个缓存空间(常用于版本升级时清理旧缓存)。

这些 API 本身不会自动生效,真正让"网页可以离线打开"的是 Service Worker 里的 \`fetch\` 事件监听——浏览器发出的每个请求都会先经过这里,由开发者代码决定如何响应。

代码示例:注册 Service Worker

\`\`\`javascript

// 在页面脚本中注册

if ('serviceWorker' in navigator) {

window.addEventListener('load', () => {

navigator.serviceWorker

.register('/sw.js')

.then((registration) => {

console.log('Service Worker 注册成功,作用域:', registration.scope);

})

.catch((error) => {

console.error('Service Worker 注册失败:', error);

});

});

}

\`\`\`

代码示例:预缓存静态资源(安装阶段)

\`\`\`javascript

// sw.js

const CACHE_NAME = 'app-shell-v3';

const PRECACHE_URLS = [

'/',

'/index.html',

'/styles/main.css',

'/scripts/main.js',

'/offline.html',

];

self.addEventListener('install', (event) => {

event.waitUntil(

caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))

);

self.skipWaiting(); // 让新 Service Worker 立即进入激活流程,不等旧标签页全部关闭

});

self.addEventListener('activate', (event) => {

event.waitUntil(

caches.keys().then((cacheNames) =>

Promise.all(

cacheNames

.filter((name) => name !== CACHE_NAME) // 清理旧版本缓存

.map((name) => caches.delete(name))

)

)

);

self.clients.claim(); // 立即接管所有已打开的页面

});

\`\`\`

代码示例:Cache First(缓存优先)策略

适合几乎不变的静态资源,比如字体、图标、打包出的带 hash 文件名的 JS/CSS。

\`\`\`javascript

self.addEventListener('fetch', (event) => {

if (event.request.destination === 'font' || event.request.url.includes('/static/')) {

event.respondWith(cacheFirst(event.request));

}

});

async function cacheFirst(request) {

const cached = await caches.match(request);

if (cached) {

return cached; // 命中缓存,直接返回,不发网络请求

}

try {

const response = await fetch(request);

const cache = await caches.open(CACHE_NAME);

cache.put(request, response.clone()); // 缓存一份供下次使用

return response;

} catch (error) {

// 网络也失败了,返回离线兜底页面

return caches.match('/offline.html');

}

}

\`\`\`

代码示例:Network First(网络优先)策略

适合会频繁变化、但也希望离线时能看到"上一次成功获取的数据"的内容,比如新闻列表、接口数据。

\`\`\`javascript

self.addEventListener('fetch', (event) => {

if (event.request.url.includes('/api/articles')) {

event.respondWith(networkFirst(event.request));

}

});

async function networkFirst(request) {

const cache = await caches.open(CACHE_NAME);

try {

const response = await fetch(request);

cache.put(request, response.clone()); // 请求成功,更新缓存

return response;

} catch (error) {

// 网络失败,退回缓存

const cached = await cache.match(request);

if (cached) return cached;

return new Response(JSON.stringify({ error: '离线且无缓存数据' }), {

status: 503,

headers: { 'Content-Type': 'application/json' },

});

}

}

\`\`\`

代码示例:Stale-While-Revalidate(旧值优先,后台更新)策略

这是体验最平衡的策略:立即返回缓存里的旧数据(不让用户等待),同时在后台悄悄发起网络请求更新缓存,供下次使用。

\`\`\`javascript

self.addEventListener('fetch', (event) => {

if (event.request.url.includes('/api/feed')) {

event.respondWith(staleWhileRevalidate(event.request));

}

});

async function staleWhileRevalidate(request) {

const cache = await caches.open(CACHE_NAME);

const cached = await cache.match(request);

const networkFetch = fetch(request)

.then((response) => {

cache.put(request, response.clone());

return response;

})

.catch(() => null); // 网络失败也不影响已经返回的缓存内容

// 有缓存就立刻返回缓存,同时后台更新;没有缓存则等待网络结果

return cached || networkFetch;

}

\`\`\`

三种策略对比

| 策略 | 首次响应来源 | 数据新鲜度 | 适合场景 |

| --- | --- | --- | --- |

| Cache First | 缓存(有则用,无则请求网络并缓存) | 可能过时,需要配合版本号更新 | 静态资源:字体、图标、带 hash 的构建产物 |

| Network First | 网络(失败才退回缓存) | 最新,但弱网下有明显延迟 | 对实时性要求高、又想保留基础离线能力的接口 |

| Stale-While-Revalidate | 缓存(立即返回),网络结果用于下次 | 当前展示的可能是上一次的数据 | 新闻流、Feed 列表等"先展示,后台悄悄更新"的场景 |

使用 Workbox 简化策略配置

手写 \`fetch\` 事件判断逻辑在真实项目里容易写得又长又难维护,Google 出的 \`Workbox\` 库把上述三种策略封装成了几行配置:

\`\`\`javascript

import { registerRoute } from 'workbox-routing';

import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';

import { ExpirationPlugin } from 'workbox-expiration';

// 静态资源:缓存优先,最多缓存 60 天

registerRoute(

({ request }) => request.destination === 'font' || request.destination === 'image',

new CacheFirst({

cacheName: 'static-assets',

plugins: [new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 24 60 * 60 })],

})

);

// 接口数据:网络优先,超时 3 秒退回缓存

registerRoute(

({ url }) => url.pathname.startsWith('/api/articles'),

new NetworkFirst({ cacheName: 'api-articles', networkTimeoutSeconds: 3 })

);

// Feed 流:旧值优先,后台更新

registerRoute(

({ url }) => url.pathname.startsWith('/api/feed'),

new StaleWhileRevalidate({ cacheName: 'api-feed' })

);

\`\`\`

真实案例:新闻类 App 离线阅读

主流新闻类 PWA(比如 Twitter Lite 的早期版本、许多新闻门户的移动端 Web)普遍采用这样的组合策略:App Shell(导航栏、底部 Tab、基础布局的 HTML/CSS/JS)用 Cache First,保证秒开;文章列表用 Stale-While-Revalidate,用户点开 App 立刻看到上次缓存的内容,同时后台悄悄拉取最新列表,等下次进入时呈现最新数据;已经点开阅读过的文章详情页会被单独缓存,即便进电梯断网也能继续阅读刚才没读完的文章。这套组合让弱网、断网环境下的可用性有质的提升。

数据与对比:HTTP 缓存 vs Cache Storage API

| 维度 | 浏览器 HTTP 缓存(Cache-Control 等) | Cache Storage API |

| --- | --- | --- |

| 控制方 | 服务器通过响应头声明策略 | 前端 JavaScript 完全编程控制 |

| 是否可编程判断 | 否,规则固定 | 是,可以写任意逻辑(比如按用户身份区分缓存) |

| 离线能力 | 有限(且策略不透明,各浏览器实现细节不同) | 完整可控,是 PWA 离线能力的基础 |

| 典型 API | 无直接 JS API,靠响应头协商 | caches.open / match / put / delete 等 |

常见坑

忘记清理旧版本缓存:每次发版都用新的 \`CACHE_NAME\`(如加版本号后缀),却忘了在 \`activate\` 事件里删除旧缓存,日积月累占满配额。
Service Worker 更新不生效:浏览器默认要等所有旧标签页关闭后新 Service Worker 才会激活,开发时容易误以为"改了代码却没生效",需要用 \`self.skipWaiting()\` 和 \`self.clients.claim()\` 主动接管。
缓存了不该缓存的响应:比如把 \`POST\` 请求或者带用户敏感信息的接口响应缓存下来,被其他用户或场景复用导致数据泄露或错乱。
作用域(scope)设置错误:\`sw.js\` 放在子目录下会导致它只能控制该子目录下的页面,很多项目因此把 \`sw.js\` 强制放在根目录。
HTTPS 限制:Service Worker 只能在 HTTPS(或 localhost)环境下注册,本地用 HTTP 起服务调试时会发现注册直接失败。

最佳实践

缓存名称带上版本号(如 \`app-shell-v3\`),每次发版更新版本号,并在 \`activate\` 阶段清理旧版本。
只缓存 \`GET\` 请求的响应,且优先用状态码为 200 的成功响应。
针对不同类型的资源选用不同策略,而不是一刀切用同一种缓存策略。
生产项目优先考虑 Workbox,而不是从零手写 \`fetch\` 拦截逻辑,减少边界情况的疏漏。
给用户提供"检测到新版本,点击刷新"的提示,而不是静默更新导致用户长期停留在旧版本页面。

小结

Service Worker + Cache Storage API 是让 Web 应用具备"类原生 App"离线体验的核心技术,配合 Cache First / Network First / Stale-While-Revalidate 三种策略,几乎可以覆盖所有资源类型的缓存需求。它的门槛比其他存储机制高,但对于强调离线可用性的产品(新闻、社交 Feed、内容管理后台)是绕不开的一环。

storage 事件与跨标签页通信

概念

前面在 localStorage 一节提到过:当一个标签页修改了 localStorage,浏览器会向其他同源标签页派发 \`storage\` 事件。这一节把这个特性单独拿出来,和另一个专门用于跨标签页通信的 API——\`BroadcastChannel\`——做对比,帮助你在实际项目里选对工具。

为什么需要跨标签页通信

用户经常会同时打开同一个网站的多个标签页:一个看商品详情、一个看购物车;一个写文章、一个预览效果;一个登录状态过期后,其他标签页也应该同步感知并跳转登录页。这些场景都需要"标签页之间互相通知"的能力,而不是各自为政。

代码示例:用 storage 事件实现登出同步

\`\`\`javascript

// 任意标签页执行登出操作时,写一个"信号" key

function logoutEverywhere() {

localStorage.setItem('auth:logout-signal', String(Date.now()));

// 实际登出逻辑(清 Cookie、清 Token、跳转登录页)

performLocalLogout();

}

// 所有标签页都监听这个信号

window.addEventListener('storage', (event) => {

if (event.key === 'auth:logout-signal') {

console.log('检测到其他标签页登出,同步登出当前标签页');

performLocalLogout();

}

});

function performLocalLogout() {

// 清理本地状态、跳转登录页等

window.location.href = '/login';

}

\`\`\`

代码示例:用 BroadcastChannel 实现更直观的消息通信

\`\`\`javascript

// 创建(或加入)一个命名频道,所有同源页面都可以监听

const channel = new BroadcastChannel('app-events');

// 发送消息

function notifyCartUpdated(items) {

channel.postMessage({ type: 'cart-updated', items });

}

// 接收消息

channel.onmessage = (event) => {

const { type, items } = event.data;

if (type === 'cart-updated') {

console.log('购物车更新,同步 UI', items);

}

};

// 页面关闭前记得关闭频道,释放资源

window.addEventListener('beforeunload', () => channel.close());

\`\`\`

数据与对比

| 方式 | 触发条件 | 传递内容 | 是否需要真实写入存储 | 兼容性 |

| --- | --- | --- | --- | --- |

| storage 事件 | localStorage/sessionStorage 发生变化 | 只能是字符串(key/oldValue/newValue) | 是,必须真实调用 setItem/removeItem/clear | 兼容性极好,几乎所有现代浏览器都支持 |

| BroadcastChannel | 主动调用 postMessage | 任意可结构化克隆的数据(对象、数组等) | 否,纯内存通信,不占用存储配额 | 现代浏览器广泛支持,个别老旧浏览器需 polyfill |

常见坑

当前标签页监听不到自己触发的 storage 事件:这是规范规定的行为(只通知其他标签页),如果需要当前标签页也执行同样逻辑,要手动调用一次,不能只依赖事件监听。
用 storage 事件传递大对象:因为值只能是字符串,传大对象要先 \`JSON.stringify\`,事件里还要再 \`parse\` 一次,性能不如 BroadcastChannel 直接传结构化数据。
忘记清理"信号" key:像上面登出同步的例子,如果不清理这个 key,可能会在下次不相关的场景里被误判为"又要登出"。

最佳实践

简单的"广播一个信号,其他标签页做出反应"场景,直接复用 localStorage + storage 事件即可,不需要引入新概念。
需要频繁通信、传递复杂数据结构的场景,优先用 \`BroadcastChannel\`,语义更清晰,也不会污染 localStorage。
两者都只能在同源页面间通信,如果需要跨域通信,应该用 \`window.postMessage\`(配合 iframe 或弹出窗口)。

小结

跨标签页通信不需要额外引入 WebSocket 或轮询,浏览器原生就提供了 storage 事件和 BroadcastChannel 两套方案:前者复用已有的本地存储机制,后者是专门为通信设计的轻量消息总线,按场景选择即可。

存储配额与 navigator.storage.estimate()

概念

前面每种存储机制都提到了"容量限制",但这些限制在不同浏览器、不同设备上并不是一个固定数字,而是由浏览器根据磁盘剩余空间动态计算的一个配额(Quota)。现代浏览器提供了 Storage Manager API,可以让页面主动查询"我现在用了多少、还能用多少",而不是靠盲猜或者等到写满了报错才知道。

为什么需要查询配额

在写入大量数据(比如离线下载整本电子书、缓存大量图片)之前,先判断空间是否足够,给用户合理提示。
判断当前数据是被临时性存储(可能被浏览器在空间紧张时自动清除)还是持久化存储(不会被自动清除)。
排查"为什么用户反馈数据莫名其妙丢失"这类问题时,配额和持久化状态是重要线索。

代码示例:查询当前存储使用情况

\`\`\`javascript

async function checkStorageQuota() {

if (!navigator.storage || !navigator.storage.estimate) {

console.warn('当前浏览器不支持 Storage Manager API');

return null;

}

const estimate = await navigator.storage.estimate();

// estimate.usage:已使用字节数(涵盖 localStorage、IndexedDB、Cache Storage 等总和)

// estimate.quota:预计可用的总配额字节数

const usedMB = (estimate.usage / 1024 / 1024).toFixed(2);

const quotaMB = (estimate.quota / 1024 / 1024).toFixed(2);

const percent = ((estimate.usage / estimate.quota) * 100).toFixed(1);

console.log(\`已使用 \${usedMB}MB / 共 \${quotaMB}MB(\${percent}%)\`);

return { usedMB, quotaMB, percent };

}

checkStorageQuota();

\`\`\`

代码示例:请求持久化存储,避免被自动清理

浏览器在磁盘空间紧张时,可能会按照"最近最少使用"的策略自动清除某些源的数据(尤其是用户很少访问的网站)。对于确实需要长期保留数据的应用(比如离线笔记、PWA),可以主动申请"持久化存储"权限:

\`\`\`javascript

async function requestPersistentStorage() {

if (!navigator.storage || !navigator.storage.persist) {

return false;

}

const alreadyPersisted = await navigator.storage.persisted();

if (alreadyPersisted) {

console.log('已经是持久化存储,不会被自动清理');

return true;

}

const granted = await navigator.storage.persist();

console.log(granted ? '持久化存储申请成功' : '持久化存储申请被拒绝');

return granted;

}

requestPersistentStorage();

\`\`\`

代码示例:容量不足时的降级提示

\`\`\`javascript

async function ensureEnoughSpace(requiredBytes) {

const estimate = await navigator.storage.estimate();

const remaining = estimate.quota - estimate.usage;

if (remaining < requiredBytes) {

const remainingMB = (remaining / 1024 / 1024).toFixed(1);

const requiredMB = (requiredBytes / 1024 / 1024).toFixed(1);

alert(\`存储空间不足:剩余约 \${remainingMB}MB,本次操作需要约 \${requiredMB}MB,请清理浏览器缓存后重试\`);

return false;

}

return true;

}

// 下载一本约 50MB 的离线电子书前先检查空间

ensureEnoughSpace(50 1024 1024).then((ok) => {

if (ok) startDownloadOfflineBook();

});

\`\`\`

数据与对比:不同浏览器的配额策略(典型情况,实际以浏览器行为为准)

| 浏览器 | 典型配额策略 | 是否会自动清理 | 持久化存储申请 |

| --- | --- | --- | --- |

| Chrome / Edge(Chromium 系) | 通常为磁盘剩余空间的 60% 左右,多个源共享 | 空间紧张时按最近最少使用策略清理"临时"来源 | 支持 navigator.storage.persist() |

| Firefox | 每个源可用磁盘剩余空间的一定比例,组限额与全局限额分层管理 | 达到组限额会清理最近最少使用的源 | 支持,但策略与 Chromium 系略有差异 |

| Safari | 历史上配额相对保守,且曾有"7 天未访问清理网站数据"的策略(ITP 相关) | 是,非活跃网站的数据更容易被清理 | 支持较晚,实际效果因版本而异 |

真实案例:离线视频/电子书下载类应用

在线教育、电子书阅读类 PWA 在用户点击"下载离线观看/离线阅读"之前,普遍会调用 \`navigator.storage.estimate()\` 检查剩余空间是否足够容纳这个课程或书籍的体积,如果不够,会提示用户"请清理部分已下载内容后重试",而不是让下载过程中途因为 \`QuotaExceededError\` 失败导致用户看到一半下载中断、体验极差。同时,这类应用几乎都会调用 \`navigator.storage.persist()\`,避免用户好不容易下载的离线内容被浏览器在设备存储紧张时悄悄清空。

常见坑

把 estimate() 的结果当成精确值:规范里明确说这是一个"估算值",不同浏览器返回的口径、误差都不同,不能拿来做严格的容量计算,只能作为参考。
申请持久化存储被静默拒绝:\`persist()\` 的授予与否由浏览器根据用户与网站的互动程度(比如是否加入主屏幕、访问频率)综合判断,不是调用了就一定成功,代码里要处理"被拒绝"的情况。
不同存储机制共享同一个配额池:localStorage、IndexedDB、Cache Storage 通常算在同一个配额里,只清理 IndexedDB 而不管 Cache Storage 里堆积的旧缓存,可能仍然会撞到配额上限。

最佳实践

大批量写入前先 \`estimate()\` 打个招呼,给用户一个"是否继续"的选择,而不是等失败了才提示。
需要长期保留数据的应用主动申请持久化存储,并且引导用户"添加到主屏幕"以提高授权概率。
定期清理不再需要的旧缓存(尤其是 Cache Storage 里的旧版本静态资源),把配额留给真正有价值的数据。

小结

配额查询不是一个"炫技"的 API,而是所有严肃对待离线能力的应用都应该配备的基础设施:写入前预估、写入后监控、必要时申请持久化,能显著减少"数据莫名其妙丢失"或"下载到一半失败"这类让用户抓狂的问题。

存储安全

安全风险

XSS 攻击窃取存储数据:localStorage、sessionStorage、非 HttpOnly 的 Cookie 对同源的任何脚本完全开放,一旦页面存在 XSS 漏洞,这些数据可以被攻击者的注入脚本直接读走。
CSRF 攻击利用 Cookie 的自动发送特性:攻击者诱导用户在恶意页面上触发一个对目标网站的请求,浏览器会自动带上目标网站的 Cookie,从而以用户身份执行未授权操作。
信息泄露:把身份证号、银行卡号、完整手机号等敏感信息明文存进 localStorage 或 IndexedDB,一旦设备被他人使用或数据被导出,就是直接的隐私泄露。
数据篡改:由于所有客户端存储都可以被用户自己(通过 DevTools)或恶意脚本任意修改,任何"信任客户端存储的数据做安全判断"(比如把 \`isAdmin: true\` 存在 localStorage 里当作权限依据)都是极其危险的做法。

防护措施

会话令牌一律使用 \`HttpOnly + Secure + SameSite\` Cookie,绝不放入 JavaScript 可读的存储。
对用户输入进行严格转义、使用 \`textContent\` 而非 \`innerHTML\`,从根源上降低 XSS 风险,从而间接保护所有本地存储的数据。
实施内容安全策略(CSP),限制脚本来源,即使出现漏洞也能降低被利用的概率。
任何安全相关的判断(权限、身份)必须以服务器端校验为准,客户端存储的数据只能作为"展示用的缓存",不能作为信任依据。
定期清理不再需要的本地数据,减少一旦发生泄露时的影响面。

存储最佳实践总览

数据分类与选型

| 数据类型 | 推荐存储 | 理由 |

| --- | --- | --- |

| 会话令牌 / 认证信息 | HttpOnly Cookie | 不暴露给 JavaScript,降低 XSS 窃取风险 |

| 用户偏好(主题、语言) | localStorage | 需要跨会话、跨标签页保留,数据量小 |

| 表单草稿、分步骤进度 | sessionStorage | 只在当前会话有意义,标签页关闭自动清理 |

| 大量结构化业务数据(笔记、邮件、离线记录) | IndexedDB | 容量大、支持索引查询、支持事务 |

| 静态资源、接口响应的离线缓存 | Cache Storage(配合 Service Worker) | 专为离线场景设计,可编程控制缓存策略 |

| 跨标签页广播通知 | storage 事件 或 BroadcastChannel | 前者复用现有存储,后者语义更清晰、支持复杂数据 |

存储策略

先明确数据的生命周期需求(永久 / 会话级 / 可随时丢弃),再选存储机制,而不是反过来"先用着再说"。
对所有客户端存储的读写都做防御性编程:try/catch、类型校验、过期判断,不要假设数据永远合法。
定期清理过期或不再需要的数据,避免配额被无用数据占满。
监控存储使用情况(结合 \`navigator.storage.estimate()\`),在关键写入操作前做容量预判。

性能优化

减少 localStorage / sessionStorage 的读写频率,尤其避免在滚动、输入等高频事件里直接同步读写,配合防抖/节流。
批量操作优先:IndexedDB 的多次写入尽量放进同一个事务;Cache Storage 的多个资源用 \`addAll\` 一次性写入。
大数据量、复杂查询坚决用 IndexedDB,不要硬塞进 localStorage 拖慢主线程。
Service Worker 的缓存策略要按资源类型区分对待,不要用一刀切的策略牺牲数据新鲜度或加载速度。

跨浏览器兼容性

使用前做特性检测(如 \`'indexedDB' in window\`、\`'serviceWorker' in navigator\`),为不支持的浏览器提供降级方案。
注意隐私模式(Safari 无痕、Chrome 访客模式)下部分存储 API 容量极小甚至直接报错,代码要能优雅处理。
关注不同浏览器在配额策略、持久化存储授权上的差异,不要假设所有浏览器行为一致。

工具与资源

存储库

localforage:统一 localStorage 风格的 API,底层自动选择 IndexedDB / WebSQL / localStorage 中最优的实现,适合想要"IndexedDB 的容量、localStorage 的简单 API"的场景。
Dexie.js:对 IndexedDB 的高级封装,提供链式查询、响应式查询(\`liveQuery\`)等能力,社区生态成熟,适合复杂业务的离线数据层。
idb:Jake Archibald 编写的轻量封装,几乎是原生 API 的 Promise 化,体积小、贴近原生,适合不需要太多额外功能的场景。
PouchDB:兼容 CouchDB 协议的客户端数据库,内置与远程数据库的双向同步能力,适合需要"离线优先 + 自动同步"的应用。
Workbox:Google 出品的 Service Worker 工具集,把 Cache First / Network First / Stale-While-Revalidate 等策略封装成简洁的路由配置。

开发工具

Chrome DevTools Application 面板:可以查看 Cookie、localStorage、sessionStorage、IndexedDB 各个对象仓库的具体数据,也能查看 Service Worker 状态和 Cache Storage 内容,还能模拟离线状态测试离线能力。
Firefox Storage Inspector:Firefox 开发者工具中专门的存储检查面板,功能与 Chrome Application 面板类似。
Edge DevTools Storage 面板:基于 Chromium 内核,界面和能力与 Chrome 高度一致。
Safari Web Inspector Storage 面板:调试 Safari 和 iOS Web 应用存储行为的重要工具,尤其是排查 Safari 特有的存储清理策略问题时必备。

学习资源

MDN Web Storage API 文档:localStorage / sessionStorage 的权威参考。
MDN IndexedDB 文档:详细讲解事务、索引、游标等核心概念,附带完整示例。
MDN Service Worker 文档:Service Worker 生命周期、Cache Storage API 的官方权威说明。
Google Web Fundamentals / web.dev:涵盖离线优先架构、PWA 最佳实践、存储配额管理等实战向内容。

案例分析

存储方案选择

金融应用:极度谨慎地限制客户端存储,不在前端保留任何敏感信息,会话令牌全部用 HttpOnly Cookie,且严格设置较短的过期时间,同时禁用或严格限制 localStorage 中可能残留的业务数据。
电商应用:购物车、浏览历史、筛选偏好使用 localStorage 持久化,并通过 storage 事件在多个标签页间保持一致;商品图片、静态资源通过 Service Worker 缓存提高二次访问速度。
新闻应用:App Shell 用 Cache First 缓存,保证秒开;文章列表用 Stale-While-Revalidate,兼顾"立刻可见"和"保持新鲜";已读文章详情单独缓存,支持断网续读。
社交媒体:会话状态使用 Cookie + 服务器端 Session;未读消息数、草稿箱等使用 IndexedDB 存储,支持复杂查询和大数据量;多标签页之间用 BroadcastChannel 同步消息通知。

实施效果

综合运用这些存储机制之后,典型的收益包括:断网或弱网环境下核心功能仍然可用,用户等待白屏或错误页的情况大幅减少;重复访问的静态资源和接口数据可以直接命中本地缓存,首屏与二次访问速度明显提升;用户在多标签页间操作时状态能够及时同步,减少"这边改了那边没变"的困惑;同时,通过合理的安全边界划分(哪些数据能放在前端、哪些必须留在服务器),显著降低了因为存储不当引发的数据泄露风险。

总结

浏览器存储机制不是"选一个最强的就够了",而是一套需要按场景组合使用的工具箱:Cookie 负责需要自动随请求发送的身份信息;localStorage / sessionStorage 负责小体积、结构简单的本地状态,区别在于是否需要跨会话保留;IndexedDB 负责大数据量、复杂查询、离线优先的业务数据;Cache Storage 配合 Service Worker 负责让整个应用具备离线可用的能力;storage 事件和 BroadcastChannel 负责让这些数据在多个标签页之间保持同步;而 \`navigator.storage.estimate()\` 则是让这一切在配额有限的现实条件下稳定运行的"安全阀"。

| 存储机制 | 一句话定位 | 典型选型信号 |

| --- | --- | --- |

| Cookie | 服务器需要在每个请求里识别你的身份 | 需要随请求自动发送、需要 HttpOnly 保护 |

| localStorage | 小体积数据,要跨会话、跨标签页长期保留 | 用户偏好、长期本地缓存 |

| sessionStorage | 小体积数据,只在这一次浏览中有意义 | 表单草稿、分步骤进度 |

| IndexedDB | 数据量大、结构复杂,需要查询能力 | 离线业务数据、成千上万条记录 |

| Cache Storage + Service Worker | 让整个应用具备离线可用能力 | PWA、弱网/断网场景下的核心体验保障 |

| storage 事件 / BroadcastChannel | 多标签页之间需要互相感知状态变化 | 登出同步、购物车角标同步、消息通知 |

| navigator.storage.estimate() | 在写入前后了解配额与持久化状态 | 大批量下载、长期保留数据的应用 |

选型的核心思路始终是同一句话:先看数据的生命周期和访问模式,再决定用哪个"抽屉"去装它——装错抽屉,轻则性能打折,重则数据莫名其妙消失或者被别人翻看。