浏览器存储机制
浏览器存储机制
浏览器就像一个带了好几种"抽屉"的工作台:有的抽屉很小但每次干活都会随手带上(Cookie),有的抽屉很大但只在当前房间里用(localStorage / sessionStorage),还有的干脆是一整间仓库,可以摆放结构复杂的货架(IndexedDB),甚至还有一个专门存"半成品网页"的库房(Cache Storage)。做前端开发,本质上就是要清楚每种抽屉能放多少东西、什么时候会被清空、谁能打开它、以及放东西的动作会不会影响干活的效率。这篇文章会把浏览器提供的主要存储机制逐一拆开讲清楚:是什么、为什么要用它、底层怎么实现、怎么写代码、真实项目里怎么落地、有哪些坑,以及最终应该怎么选。
为什么客户端需要"存储"
在没有客户端存储之前,网页只能靠服务器记住一切:你每次刷新页面,服务器都要重新告诉浏览器"你是谁""你上次选了什么语言""购物车里有什么"。这样做有两个明显问题:
客户端存储的意义就是让浏览器自己"记事",不用什么都问服务器要。它解决的核心问题可以归纳成三类:
不同的存储机制就是为了在"容量、生命周期、是否随请求发送、读写方式(同步/异步)"这几个维度上做出不同取舍,从而适配不同场景。
存储机制全景对比
下面这张表是本文的"地图",后续每一节都会展开讲解表中的一行。
| 存储机制 | 容量上限(典型值) | 生命周期 | 作用域 | 是否随 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
如果只是想在浏览器本地记点数据、不需要发给服务器,那就不该用 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 中间件的项目)都采用如下模式:
这个模式的好处是令牌完全对前端 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 | 控制过期时间 | 会话永不失效,增加被盗用后的危害窗口 |
常见坑
最佳实践
小结
Cookie 的核心价值是"自动随请求发送",这既是它存在的理由,也是它必须被小心对待的原因。凡是需要服务器识别身份的场景,Cookie(尤其是 HttpOnly Cookie)仍然是最主流、最安全的方案;凡是纯前端本地记忆的场景,都应该让位给下面要讲的 localStorage 和 sessionStorage。
localStorage
概念
localStorage 是浏览器提供的一个同源、持久化的键值对存储区,API 极其简单:\`setItem\`、\`getItem\`、\`removeItem\`、\`clear\`。它不会随请求发送给服务器,数据只保存在用户本地磁盘上,除非用户清理浏览器数据或代码主动删除,否则会一直存在——即使关闭浏览器、重启电脑也不会丢失。
可以把 localStorage 想象成你在家里书桌抽屉里存的便签纸:只有你自己(同一个源的页面)能打开这个抽屉,纸条不会自动寄给任何人(不随请求发送),你不整理它就会一直堆在那里。
为什么用 localStorage
原理
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 |
| 适合场景 | 用户偏好、长期缓存 | 表单临时数据、单次会话状态 |
常见坑
最佳实践
小结
localStorage 的优势是简单、持久、同步 API 上手快,最适合"体积小、跨会话保留、非敏感"的数据。它的短板同样明显:容量有限、同步阻塞、没有过期机制、安全性弱。理解了这些边界,才能判断什么时候该往下一节的 sessionStorage 或再下一节的 IndexedDB 走。
sessionStorage
概念
sessionStorage 和 localStorage 的 API 几乎一模一样(同样是 \`setItem\`/\`getItem\`/\`removeItem\`/\`clear\`),唯一的核心区别在生命周期和作用域:sessionStorage 的数据只在当前这一个标签页存在,标签页一关闭数据就被清空;即使你打开同一个网址的另一个新标签页,也是一份全新的、独立的 sessionStorage,彼此不共享。
可以把它理解成"草稿纸":写完这一次,用完这张纸的这次会话,纸就被扔掉了;就算你在另一张桌子(另一个标签页)上重新拿了一张同样的纸,那也是另一张纸,跟之前那张没有任何关系。
为什么用 sessionStorage 而不是 localStorage
原理
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 是 localStorage 的"限定版":API 相同,但生命周期被收窄到单个标签页的这一次会话。选择它的判断标准很简单——如果数据在标签页关闭之后就不再有意义,用 sessionStorage;如果还需要保留,用 localStorage。
IndexedDB
概念
IndexedDB 是浏览器内置的一个事务型 NoSQL 数据库,支持存储结构化数据(不仅是字符串,还可以是对象、数组、二进制 Blob/File),支持按索引查询、支持游标遍历、支持事务保证一致性,容量也远大于 localStorage。可以把它理解成"浏览器自带的一个小型 SQLite/MongoDB",专门为需要存大量数据、复杂查询的前端场景准备。
如果说 localStorage 像书桌抽屉里的便签纸,那 IndexedDB 就像一整间带索引卡片柜的档案室:东西可以分门别类地摆放(对象仓库 Object Store),可以按某个字段快速检索(索引 Index),操作过程有严格的登记流程保证不出错(事务 Transaction)。
为什么用 IndexedDB
原理
IndexedDB 的核心概念有四个:
数据库结构的变更(新增对象仓库、新增索引)只能在 \`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 级(视磁盘可用空间而定) |
| 适合数据规模 | 几十到几百条小记录 | 成千上万条记录甚至更多 |
常见坑
最佳实践
小结
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
原理
Cache Storage 的核心 API:
这些 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 等 |
常见坑
最佳实践
小结
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 |
常见坑
最佳实践
小结
跨标签页通信不需要额外引入 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()\`,避免用户好不容易下载的离线内容被浏览器在设备存储紧张时悄悄清空。
常见坑
最佳实践
小结
配额查询不是一个"炫技"的 API,而是所有严肃对待离线能力的应用都应该配备的基础设施:写入前预估、写入后监控、必要时申请持久化,能显著减少"数据莫名其妙丢失"或"下载到一半失败"这类让用户抓狂的问题。
存储安全
安全风险
防护措施
存储最佳实践总览
数据分类与选型
| 数据类型 | 推荐存储 | 理由 |
| --- | --- | --- |
| 会话令牌 / 认证信息 | HttpOnly Cookie | 不暴露给 JavaScript,降低 XSS 窃取风险 |
| 用户偏好(主题、语言) | localStorage | 需要跨会话、跨标签页保留,数据量小 |
| 表单草稿、分步骤进度 | sessionStorage | 只在当前会话有意义,标签页关闭自动清理 |
| 大量结构化业务数据(笔记、邮件、离线记录) | IndexedDB | 容量大、支持索引查询、支持事务 |
| 静态资源、接口响应的离线缓存 | Cache Storage(配合 Service Worker) | 专为离线场景设计,可编程控制缓存策略 |
| 跨标签页广播通知 | storage 事件 或 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() | 在写入前后了解配额与持久化状态 | 大批量下载、长期保留数据的应用 |
选型的核心思路始终是同一句话:先看数据的生命周期和访问模式,再决定用哪个"抽屉"去装它——装错抽屉,轻则性能打折,重则数据莫名其妙消失或者被别人翻看。