浏览器中存储数据

直接存储在浏览器中的小数据,通常由Web 服务器使用响应 Set-Cookie HTTP-header 设置,然后浏览器使用 Cookie HTTP-header 将它们自动添加到(几乎)每个对相同域的请求中。

作用:

  • 登录后,服务器在响应中使用 Set-Cookie HTTP-header 来设置具有唯一“会话标识符(session identifier)”的 cookie
  • 下次当请求被发送到同一个域时,浏览器会使用 Cookie HTTP-header 通过网络发送 cookie
  • 帮助服务器知道是谁发起了请求

读取

从文档中获取cookie

// _ym_uid=1759117014973370134; _ym_d=1759117014; __stripe_mid=96461b27-f155-4efd-9179-f6555e80e6611896f5
// 格式为:name1=value1;name2=value2;
console.info( document.cookie ); 

写入

虽然可以对cookie进行写入,但本质并非数据属性而是访问器,赋值操作会进行内部处理(即仅允许进行追加,不允许覆盖写)

document.cookie = "user=John"; // 只会更新或者新增名称为 user 的 cookie
console.info(document.cookie); // 展示所有 cookie
// _ym_uid=1759117014973370134; _ym_d=1759117014; __stripe_mid=96461b27-f155-4efd-9179-f6555e80e6611896f5;user=John

cookie允许任何字符串,因此为避免异常情况,应当进行编码处理

// 特殊字符(空格),需要编码
let name = "my name";
let value = "John Smith"

// 将 cookie 编码为 my%20name=John%20Smith
document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value);
// 同时设置多个cookie,使用 ; 进行分隔
document.cookie = "user=John; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT"

注意:

  • encodeURIComponent 编码后的 name=value 对,大小不能超过 4KB
  • 每个域的 cookie 总数不得超过 20+ 左右,具体限制取决于浏览器

额外属性

path

path=/mypath

路径前缀必须为绝对路径,只有path指定的路径以及子路径才能访问对应的cookie。通常设置为根目录/

如果一个 cookie 带有 path=/admin 设置,那么该 cookie 在 /admin/admin/something 下都是可见的,但是在 /home/adminpage 下不可见

domain

domain=site.com

此属性控制哪些域可以访问这些cookie,实际中存在限制,无法手动设置任何域。
因此每个域的cookie都是隔离的。

默认下子域与主域的cookie也是隔离的,但允许进行设置:

// 在 site.com
// 使 cookie 可以被在任何子域 *.site.com 访问:
document.cookie = "user=John; domain=site.com"

// 之后
// 在 forum.site.com即可访问主域的cookie
console.info(document.cookie); 

expires、max-age

session cookie:未设置expiresmax-age两个参数的cookie,关闭浏览器后就会消失。

仅设置其中一个即可使cookie在特定时间失效:

  1. expires:定义了浏览器会自动清除该 cookie 的时间,日期必须完全采用 GMT 时区的这种格式,如果设置为过去的时间,则 cookie 会被删除
    // 当前时间 +1 天
    let date = new Date(Date.now() + 86400e3);
    date = date.toUTCString();
    document.cookie = "user=John; expires=" + date;
    
  2. max-ageexpires 的替代选项,指明了 cookie 的过期时间距离当前时间的秒数,将其设置为 0 或负数,则 cookie 会被删除
    // cookie 会在一小时后失效
    document.cookie = "user=John; max-age=3600";
    
    // 删除 cookie(让它立即过期)
    document.cookie = "user=John; max-age=0";
    

secure

标记此cookie只能通过HTTPS传输,只在https://site.com下可见,在http://site.com下不可见,但默认情况下cookie仅基于域而言,只要同在site.com即可见

// 设置 cookie secure(只在 HTTPS 环境下可访问)
document.cookie = "user=John; secure";

samesite

本意是为了防止 XSRF(跨网站请求伪造)攻击。

XSRF:在site1.com(恶意网站)上存在一个表单请求指向site2.com(正常网站),由于表单设置的请求为site2.com,因此会携带此域名下的cookie,从而完成请求提交,为避免此情况,site2.com的表单提交都会携带XSRF token进行服务器校验,而site1.com无法获取到或者生成此token,因此请求无法获取数据。

  1. samesite=strict:非当前域下的任何操作都不会发送cookie

    等同于直接设置 samesite

    但也就意味着如何用户自己从笔记中访问网址则也不会发送cookie,因此设计时可以通过cookie1进行身份认证,cookie2进行数据更改操作的认证

  2. samesite=lax:当从外部来到网站,则禁止浏览器发送 cookie,但是增加了一个例外
    • HTTP 方法是“安全的”(例如 GET 方法,而不是 POST,仅进行数据获取,不进行修改)
    • 该操作执行顶级导航(更改浏览器地址栏中的 URL),即top==window,意味着此页面并非运行在iframe中

httpOnly

Web 服务器使用 Set-Cookie header 来设置 cookie。并且,它可以设置 httpOnly 选项,这个选项禁止任何 JavaScript 访问 cookie。使用 document.cookie 看不到此类 cookie,也无法对此类 cookie 进行操作。

Cookie自定义函数

  1. 使用正则获取
    // 返回具有给定 name 的 cookie,
    // 如果没找到,则返回 undefined
    function getCookie(name) {
    let matches = document.cookie.match(new RegExp(
        "(?:^|; )" + name.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, '\\$1') + "=([^;]*)"
    ));
    return matches ? decodeURIComponent(matches[1]) : undefined;
    }
    
  2. 添加方法,可以自定义默认值属性,如默认path=/
    function setCookie(name, value, options = {}) {
    
    options = {
        path: '/',
        // 如果需要,可以在这里添加其他默认值
        ...options
    };
    
    if (options.expires instanceof Date) {
        options.expires = options.expires.toUTCString();
    }
    
    let updatedCookie = encodeURIComponent(name) + "=" + encodeURIComponent(value);
    
    for (let optionKey in options) {
        updatedCookie += "; " + optionKey;
        let optionValue = options[optionKey];
        if (optionValue !== true) {
        updatedCookie += "=" + optionValue;
        }
    }
    
    document.cookie = updatedCookie;
    }
    
  3. 删除方法:设置一个负的过期时间来调用它
    function deleteCookie(name) {
    setCookie(name, "", {
        'max-age': -1
    })
    }
    

第三方cookie

如果 cookie 是由用户所访问的页面的域以外的域通过 HTTP 响应头(如 Set-Cookie)设置的,则称为第三方 cookie。

典型场景

  • 用户访问 site.com,该页面加载了来自 ads.com 的资源(如 <img src="https://ads.com/banner.png">)。
  • ads.com 在响应中通过 Set-Cookie: id=1234 设置 cookie。
  • 此 cookie 属于 ads.com 域,仅在向 ads.com 发起请求时自动携带。

跟踪机制
当用户随后访问另一个也嵌入了 ads.com 资源的网站(如 other.com),浏览器仍会向 ads.com 发送之前设置的 cookie,使 ads.com 能跨站识别同一用户,实现跨站跟踪。

用途与争议
第三方 cookie 常用于广告定向和用户行为分析,但由于隐私问题,越来越多的浏览器限制或默认阻止其使用:

  • Safari:完全禁止第三方 cookie。
  • Firefox:内置黑名单,阻止已知跟踪器的第三方 cookie。
  • Chrome 等:提供隐私设置选项,允许用户禁用第三方 cookie。

重要澄清
通过 <script> 加载的第三方脚本(如 Google Analytics)若使用 document.cookie 设置 cookie,该 cookie 仍属于当前页面的主域,不是第三方 cookie。只有通过第三方域的 HTTP 响应头(如图片、iframe、XHR/fetch 到第三方域并接收 Set-Cookie)设置的 cookie 才被视为第三方 cookie。

Storage

localStorage

localStorage 是一种持久化的客户端存储机制,具有以下特点:

  • 持久性:数据不会因页面刷新、关闭标签页、重启浏览器甚至操作系统而丢失。
  • 作用域:绑定到(协议 + 域名 + 端口),同源的所有窗口和标签页共享同一个 localStorage
  • 容量大:通常支持至少 5MB 的存储空间(远大于 cookie 的 4KB),且不会随 HTTP 请求自动发送到服务器,避免带宽浪费。
  • 仅限客户端操作:完全通过 JavaScript 控制,服务器无法直接读写。

方法示例

setItem(key, value):存储键/值对(值会被自动转换为字符串)

localStorage.setItem('theme', 'dark');
localStorage.setItem('count', 42); // 数字转为 "42"

getItem(key):根据键获取对应的值,若不存在则返回 null

const theme = localStorage.getItem('theme'); // "dark"
const count = localStorage.getItem('count'); // "42"

removeItem(key):删除指定键及其对应的值

localStorage.removeItem('theme'); // 删除 theme 键

clear():清空所有存储的数据

localStorage.clear(); // 所有数据被移除

key(index):返回指定索引位置的键名(索引从 0 开始)

// 假设 localStorage 中有两项:['name', 'age']
const firstKey = localStorage.key(0); // "name"

length:返回当前存储中键值对的数量(只读属性)

console.log(localStorage.length); // 例如:2

可遍历性:虽然 localStorage 不是可迭代对象,但可通过 lengthkey()Object.keys() 遍历

// 方法一:使用 length + key()
for (let i = 0; i < localStorage.length; i++) {
  const key = localStorage.key(i);
  console.log(key, localStorage.getItem(key));
}

// 方法二:使用 Object.keys()
Object.keys(localStorage).forEach(key => {
  console.log(key, localStorage.getItem(key));
});

注意:键和值必须是字符串。存储对象应使用 JSON.stringify(),读取时用 JSON.parse()

sessionStorage

sessionStorage 提供临时的会话级存储,特性如下:

  • 生命周期:仅在当前浏览器标签页有效。页面刷新后数据保留,但关闭标签页后数据被清除。
  • 作用域:绑定到源 + 浏览上下文(标签页),不同标签页即使访问同一 URL 也拥有独立的 sessionStorage
  • API 与 localStorage 完全一致,仅生命周期和作用范围不同。

方法示例

setItem(key, value):在当前会话中保存数据

sessionStorage.setItem('tempToken', 'abc123');
sessionStorage.setItem('step', '3');

getItem(key):读取当前会话中的数据

const token = sessionStorage.getItem('tempToken'); // "abc123"
const step = sessionStorage.getItem('step');       // "3"

removeItem(key):删除某项会话数据

sessionStorage.removeItem('tempToken');

clear():清除当前标签页的所有会话数据

sessionStorage.clear();

key(index):获取指定索引的键名

const firstKey = sessionStorage.key(0); // 例如 "step"

length:返回当前会话中存储项的数量

console.log(sessionStorage.length); // 例如:1

典型使用场景

// 表单草稿自动保存(刷新后恢复)
document.getElementById('message').addEventListener('input', (e) => {
  sessionStorage.setItem('draft', e.target.value);
});

// 页面加载时恢复草稿
window.addEventListener('load', () => {
  const draft = sessionStorage.getItem('draft');
  if (draft) {
    document.getElementById('message').value = draft;
  }
});

注意:在新标签页中打开同一页面时,sessionStorage 是全新的,不会继承原标签页的数据。

Storage事件

localStoragesessionStorage 中的数据被修改时,会触发 storage 事件。该事件不会在执行修改的当前窗口中触发,而是在其他同源且能访问该存储的窗口或标签页中触发,常用于跨标签页通信。

事件对象属性

  • key:被修改的键名(若调用 clear(),则为 null
  • oldValue:修改前的值(新增项时为 null
  • newValue:修改后的值(删除项时为 null
  • url:执行修改操作的文档 URL
  • storageArea:被修改的存储对象(即 localStoragesessionStorage

使用示例

// 在所有同源窗口中监听 storage 事件(除自身外)
window.addEventListener('storage', (event) => {
  console.log('存储发生变化:');
  console.log('key:', event.key);
  console.log('old value:', event.oldValue);
  console.log('new value:', event.newValue);
  console.log('from URL:', event.url);
  console.log('storage area:', event.storageArea === localStorage ? 'localStorage' : 'sessionStorage');
});

// 在另一个同源窗口中执行以下代码,将触发上述监听器
localStorage.setItem('message', 'Hello from another tab!');

典型应用场景

  • 用户在标签页 A 登出,通知其他标签页清除 UI 状态
  • 多标签页协同编辑时同步状态
  • 实现简单的跨标签页消息广播

注意

  • sessionStoragestorage 事件仅在同一标签页内的同源 iframe之间可能触发(因不同标签页的 sessionStorage 完全隔离)。
  • storage 事件是浏览器原生机制,兼容性良好;对于更复杂的通信需求,可考虑 BroadcastChannel API(但需注意其浏览器支持情况)。

IndexedDB

IndexedDB 是一个浏览器内建的数据库,比 localStorage 强大得多。它支持多种类型的键,可以存储几乎任何类型的值(包括对象和二进制数据),具备事务可靠性、键范围查询、索引等功能,并能存储远超 localStorage 的数据量(通常数百 MB 甚至更多)。它主要适用于离线应用,常与 Service Worker 等技术结合使用。

IndexedDB 原生基于事件模型,但也可通过 Promise 包装器(如 idb)使用 async/await,提升开发体验。

打开数据库

使用 indexedDB.open(name, version) 打开或创建数据库:

let openRequest = indexedDB.open("booksDb", 1);
  • name:数据库名称(字符串)
  • version:正整数版本号(默认为 1)

返回一个请求对象,需监听以下事件:

success:数据库打开成功,openRequest.result 为数据库对象(IDBDatabase

openRequest.onsuccess = function(event) {
  let db = openRequest.result;
  console.log("数据库已打开:", db);
  // 可在此执行后续操作
};

error:打开失败

openRequest.onerror = function(event) {
  console.error("打开数据库失败:", openRequest.error);
};

upgradeneeded:数据库不存在(版本为 0)或本地版本低于指定版本,用于初始化或升级结构

openRequest.onupgradeneeded = function(event) {
  let db = openRequest.result;
  let oldVersion = event.oldVersion;
  let newVersion = event.newVersion ?? 1;

  if (oldVersion < 1) {
    // 初始化数据库结构
    db.createObjectStore('books', { keyPath: 'id' });
  }
  if (oldVersion < 2) {
    // 升级到版本 2:添加索引
    let books = transaction.objectStore('books');
    books.createIndex('price_idx', 'price', { unique: false });
  }
};

注意:upgradeneeded 是唯一允许修改数据库结构(如创建对象库、索引)的时机。

版本控制机制
当新版本号高于当前本地版本时,触发 upgradeneeded。若多个标签页同时访问,旧连接应监听 versionchange 并主动关闭:

db.onversionchange = function() {
  db.close();
  alert("数据库已更新,请刷新页面");
};

对象库(Object Store)

对象库是 IndexedDB 中存储数据的基本单位,类似于关系型数据库中的“表”。

  • 每个值必须有唯一键(key),类型可为数字、字符串、日期、二进制或数组
  • 键可通过以下方式指定:
    • 显式提供(调用 add/put 时传入)
    • 使用对象属性作为键(keyPath
    • 自动生成递增键(autoIncrement: true

创建对象库(仅在 upgradeneeded 中允许):

db.createObjectStore('books', {keyPath: 'id'});
// 或
db.createObjectStore('books', {autoIncrement: true});

删除对象库

db.deleteObjectStore('books');

事务(Transaction)

所有数据操作必须在事务中进行。事务保证操作的原子性(全部成功或全部回滚)。

启动事务

let transaction = db.transaction("books", "readwrite");
  • 第一参数:对象库名(或数组)
  • 第二参数:事务类型
    • "readonly"(默认):允许多个并发读
    • "readwrite":独占写锁,阻塞其他写事务
    • "versionchange":仅在 upgradeneeded 中自动创建,可修改结构

执行操作

let books = transaction.objectStore("books");
let book = { id: 'js', price: 10 };
let request = books.add(book);

request.onsuccess = () => console.log("添加成功", request.result); // 返回键
request.onerror = () => console.error("错误", request.error);

方法对比

  • add(value, [key]):插入新记录,若键已存在则失败(ConstraintError
  • put(value, [key]):插入或覆盖现有记录

事务自动提交
事务在所有请求完成且微任务队列清空后自动提交。不能在事务中插入宏任务(如 fetchsetTimeout),否则后续操作会因事务已关闭而失败。

可监听 transaction.oncompletetransaction.onabort 跟踪事务状态。

搜索

通过键搜索

支持精确键或键范围(IDBKeyRange):

// 精确查询
books.get('js');

// 范围查询
books.getAll(IDBKeyRange.bound('css', 'html'));     // 'css' ≤ id ≤ 'html'
books.getAll(IDBKeyRange.upperBound('html', true)); // id < 'html'
books.getAll();                                     // 获取所有

常用搜索方法示例

get(query):获取第一个匹配项的值

books.get('js').onsuccess = (e) => {
  console.log(e.target.result); // { id: 'js', price: 10 }
};

getAll([query], [count]):获取所有匹配项的值(可限制数量)

books.getAll(IDBKeyRange.lowerBound(5), 2).onsuccess = (e) => {
  console.log(e.target.result); // 最多 2 本价格 ≥5 的书
};

getKey(query):获取第一个匹配项的主键

books.getKey(IDBKeyRange.only('js')).onsuccess = (e) => {
  console.log(e.target.result); // 'js'
};

getAllKeys([query], [count]):获取所有匹配项的主键

books.getAllKeys().onsuccess = (e) => {
  console.log(e.target.result); // ['js', 'css', 'html']
};

count([query]):统计匹配项数量

books.count(IDBKeyRange.bound('a', 'z')).onsuccess = (e) => {
  console.log("字母开头的书籍数量:", e.target.result);
};

结果按键排序(因内部有序存储)。

通过字段搜索(使用索引)

若需按非键字段(如 price)查询,需先创建索引:

// 在 upgradeneeded 中
let books = db.createObjectStore('books', {keyPath: 'id'});
books.createIndex('price_idx', 'price', {unique: false});
  • name:索引名
  • keyPath:要索引的对象属性路径
  • options
    • unique: true:禁止重复值
    • multiEntry: true:若字段是数组,为每个元素建索引项

使用索引查询

let priceIndex = books.index("price_idx");
priceIndex.getAll(10);                          // 价格为 10 的所有书
priceIndex.getAll(IDBKeyRange.upperBound(5));   // 价格 ≤ 5 的书

索引结果按索引字段值排序。

从存储中删除

  • 按主键删除books.delete('js')
  • 按索引字段删除:先查主键,再删除
priceIndex.getKey(5).onsuccess = (e) => {
  let id = e.target.result;
  if (id !== undefined) books.delete(id);
};
  • 清空整个对象库books.clear()

光标(Cursors)

当数据量过大无法一次性加载(如 getAll 内存不足)时,使用光标逐条遍历。

基本用法

let request = books.openCursor();

request.onsuccess = function() {
  let cursor = request.result;
  if (cursor) {
    console.log(cursor.key, cursor.value); // 键和值
    cursor.continue(); // 移至下一条
  } else {
    console.log("遍历完成");
  }
};

方向选项

  • "next"(默认):升序
  • "prev":降序
  • "nextunique" / "prevunique":跳过重复键(仅用于索引光标)

索引光标
在索引上打开光标时,cursor.key 为索引键(如价格),cursor.primaryKey 为主键(如书 ID):

let request = priceIndex.openCursor(IDBKeyRange.upperBound(5));
request.onsuccess = function() {
  let cursor = request.result;
  if (cursor) {
    console.log("价格:", cursor.key);
    console.log("书 ID:", cursor.primaryKey);
    cursor.continue();
  }
};

Promise 包装器

推荐使用 idb 库简化异步操作:

import { openDB } from 'https://cdn.jsdelivr.net/npm/idb@7/+esm';

const db = await openDB('booksDb', 1, {
  upgrade(db) {
    db.createObjectStore('books', { keyPath: 'id' });
  }
});

// 添加数据
const tx = db.transaction('books', 'readwrite');
await tx.store.add({ id: 'js', price: 10 });
await tx.done; // 等待事务完成

优势:

  • 使用 async/await 避免回调嵌套
  • 自动处理事务生命周期(通过 tx.done
  • 错误通过 try...catch 捕获

注意:即使使用包装器,仍需遵守“事务中不能插入宏任务”的规则。

总结

IndexedDB 是功能强大的客户端数据库,适用于需要存储大量结构化数据的场景。核心概念包括:

  1. 数据库:通过版本控制管理结构变更
  2. 对象库:存储数据的容器,需在 upgradeneeded 中定义
  3. 事务:所有读写操作的载体,分 readonly/readwrite 类型
  4. 索引:支持高效字段查询
  5. 光标:处理大数据集的流式遍历
  6. Promise 包装器:显著提升开发体验

虽然 API 较复杂,但结合现代工具(如 idb),可高效构建离线优先的 Web 应用。