第 25 章 客户端存储
25.1 cookie
HTTP cookie 通常也叫作 cookie,最初用于在客户端存储会话信息。这个规范要求服务器在响应 HTTP 请求时,通过发送 Set-Cookie HTTP 头部包含会话信息。
HTTP/1.1200 OK
Content-type: text/html
Set-Cookie: name=value
Other-header: other-header-value这个 HTTP 响应会设置一个名为"name",值为"value"的 cookie。名和值在发送时都会经过 URL 编码。浏览器会存储这些会话信息,并在之后的每个请求中都会通过 HTTP 头部 cookie 再将它们发回服务器,比如:
GET /index.jsl HTTP/1.1
Cookie: name=value
Other-header: other-header-value这些发送回服务器的额外信息可用于唯一标识发送请求的客户端。
25.1.1 限制
cookie 是与特定域绑定的。设置 cookie 后,它会与请求一起发送到创建它的域。这个限制能保证 cookie 中存储的信息只对被认可的接收者开放,不被其他域访问。
因为 cookie 存储在客户端机器上,所以为保证它不会被恶意利用,浏览器会施加限制。同时,cookie 也不会占用太多磁盘空间。
通常,只要遵守以下大致的限制,就不会在任何浏览器中碰到问题:
❑ 不超过 300 个 cookie;
❑ 每个 cookie 不超过 4096 字节;
❑ 每个域不超过 20 个 cookie;
❑ 每个域不超过 81920 字节。
每个域能设置的 cookie 总数也是受限的,但不同浏览器的限制不同。例如:
❑ 最新版 IE 和 Edge 限制每个域不超过 50 个 cookie;
❑ 最新版 Firefox 限制每个域不超过 150 个 cookie;
❑ 最新版 Opera 限制每个域不超过 180 个 cookie;
❑ Safari 和 Chrome 对每个域的 cookie 数没有硬性限制。
如果 cookie 总数超过了单个域的上限,浏览器就会删除之前设置的 cookie。IE 和 Opera 会按照最近最少使用(LRU, Least Recently Used)原则删除之前的 cookie,以便为新设置的 cookie 腾出空间。Firefox 好像会随机删除之前的 cookie,因此为避免不确定的结果,最好不要超出限制。
浏览器也会限制 cookie 的大小。大多数浏览器对 cookie 的限制是不超过 4096 字节,上下可以有一个字节的误差。为跨浏览器兼容,最好保证 cookie 的大小不超过 4095 字节。这个大小限制适用于一个域的所有 cookie,而不是单个 cookie。
如果创建的 cookie 超过最大限制,则该 cookie 会被静默删除。注意,一个字符通常会占 1 字节。如果使用多字节字符(如 UTF-8 Unicode 字符),则每个字符最多可能占 4 字节。
25.1.2 cookie 的构成
cookie 在浏览器中是由以下参数构成的。
❑ 名称:唯一标识 cookie 的名称。cookie 名不区分大小写,因此 myCookie 和 MyCookie 是同一个名称。不过,实践中最好将 cookie 名当成区分大小写来对待,因为一些服务器软件可能这样对待它们。cookie 名必须经过 URL 编码。
❑ 值:存储在 cookie 里的字符串值。这个值必须经过 URL 编码。
❑ 域:cookie 有效的域。发送到这个域的所有请求都会包含对应的 cookie。这个值可能包含子域(如www.wrox.com),也可以不包含(如.wrox.com表示对wrox.com的所有子域都有效)。如果不明确设置,则默认为设置cookie的域。
❑ 路径:请求 URL 中包含这个路径才会把 cookie 发送到服务器。例如,可以指定 cookie 只能由 http://www.wrox.com/books/ 访问,因此访问 http://www.wrox.com/ 下的页面就不会发送 cookie,即使请求的是同一个域。
❑ 过期时间:表示何时删除 cookie 的时间戳(即什么时间之后就不发送到服务器了)。默认情况下,浏览器会话结束后会删除所有 cookie。不过,也可以设置删除 cookie 的时间。这个值是 GMT 格式(Wdy, DD-Mon-YYYY HH: MM: SS GMT),用于指定删除 cookie 的具体时间。这样即使关闭浏览器 cookie 也会保留在用户机器上。把过期时间设置为过去的时间会立即删除 cookie。
❑ 安全标志:设置之后,只在使用 SSL 安全连接的情况下才会把 cookie 发送到服务器。例如,请求 https://www.wrox.com 会发送 cookie,而请求 http://www.wrox.com 则不会。
这些参数在 Set-Cookie 头部中使用分号加空格隔开,安全标志 secure 是 cookie 中唯一的非名/值对,只需一个 secure 就可以了。
HTTP/1.1200 OK
Content-type: text/html
Set-Cookie: name=value;domain=.wrox.com;path=/;secure
Other-header: other-header-value要知道,域、路径、过期时间和 secure 标志用于告诉浏览器什么情况下应该在请求中包含 cookie。这些参数并不会随请求发送给服务器,实际发送的只有 cookie 的名/值对。
25.1.3 JavaScript 中的 cookie
在 JavaScript 中处理 cookie 比较麻烦,因为接口过于简单,只有 BOM 的 document.cookie 属性。根据用法不同,该属性的表现迥异。要使用该属性获取值时,document.cookie 返回包含页面中所有有效 cookie 的字符串(根据域、路径、过期时间和安全设置),以分号分隔。所有名和值都是 URL 编码的,因此必须使用 decodeURIComponent()解码。
在设置值时,可以通过 document.cookie 属性设置新的 cookie 字符串。这个字符串在被解析后会添加到原有 cookie 中。设置 document.cookie 不会覆盖之前存在的任何 cookie,除非设置了已有的 cookie。在所有这些参数中,只有 cookie 的名称和值是必需的。
name=value; expires=expiration_time; path=domain_path; domain=domain_name; secureclass CookieUtil {
static get(name) {
let cookieName = `${encodeURIComponent(name)}=`,
cookieStart = document.cookie.indexOf(cookieName),
cookieValue = null
if (cookieStart > -1) {
let cookieEnd = document.cookie.indexOf('; ', cookieStart)
if (cookieEnd == -1) {
cookieEnd = document.cookie.length
}
cookieValue = decodeURIComponent(document.cookie.substring(cookieStart + cookieName.length, cookieEnd))
}
return cookieValue
}
static set(name, value, expires, path, domain, secure) {
let cookieText = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`
if (expires instanceof Date) {
cookieText += `; expires=${expires.toGMTString()}`
}
if (path) {
cookieText += `; path=${path}`
}
if (domain) {
cookieText += `; domain=${domain}`
}
if (secure) {
cookieText += '; secure'
}
document.cookie = cookieText
}
static unset(name, path, domain, secure) {
CookieUtil.set(name, '', new Date(0), path, domain, secure)
}
}
// 设置cookie
CookieUtil.set('name', 'Nicholas')
CookieUtil.set('book', 'Professional JavaScript')
// 读取cookie
console.log(CookieUtil.get('name')) // 'Nicholas'
console.log(CookieUtil.get('book')) // 'Professional JavaScript'
// 删除cookie
CookieUtil.unset('name')
CookieUtil.unset('book')
// 设置有路径、域和过期时间的cookie
CookieUtil.set('name', 'Nicholas', '/books/projs/', 'www.wrox.com', new Date('January 1, 2010'))
// 删除刚刚设置的cookie
CookieUtil.unset('name', '/books/projs/', 'www.wrox.com')
// 设置安全cookie
CookieUtil.set('name', 'Nicholas', null, null, null, true)25.1.4 子 cookie
为绕过浏览器对每个域 cookie 数的限制,有些开发者提出了子 cookie 的概念。子 cookie 是在单个 cookie 存储的小块数据,本质上是使用 cookie 的值在单个 cookie 中存储多个名/值对。
name=name1=value1&name2=value2&name3=value3&name4=value4&name5=value5class SubCookieUtil {
static get(name, subName) {
let subCookies = SubCookieUtil.getAll(name)
return subCookies ? subCookies[subName] : null
}
static getAll(name) {
let cookieName = encodeURIComponent(name) + '=',
cookieStart = document.cookie.indexOf(cookieName),
cookieValue = null,
cookieEnd,
subCookies,
parts,
result = {}
if (cookieStart > -1) {
cookieEnd = document.cookie.indexOf('; ', cookieStart)
if (cookieEnd == -1) {
cookieEnd = document.cookie.length
}
cookieValue = document.cookie.substring(cookieStart + cookieName.length, cookieEnd)
if (cookieValue.length > 0) {
subCookies = cookieValue.split('&')
for (let i = 0, len = subCookies.length; i < len; i++) {
parts = subCookies[i].split('=')
result[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1])
}
return result
}
}
return null
}
static set(name, subName, value, expires, path, domain, secure) {
let subcookies = SubCookieUtil.getAll(name) || {}
subcookies[subName] = value
SubCookieUtil.setAll(name, subcookies, expires, path, domain, secure)
}
static setAll(name, subcookies, expires, path, domain, secure) {
let cookieText = encodeURIComponent(name) + '=',
subcookieParts = new Array(),
subName
for (subName in subcookies) {
if (subName.length > 0 && subcookies.hasOwnProperty(subName)) {
subcookieParts.push('${encodeURIComponent(subName)}=${encodeURIComponent(subcookies[subName])}')
}
}
if (subcookieParts.length > 0) {
cookieText += subcookieParts.join('&')
if (expires instanceof Date) {
cookieText += `; expires=${expires.toGMTString()}`
}
if (path) {
cookieText += `; path=${path}`
}
if (domain) {
cookieText += `; domain=${domain}`
}
if (secure) {
cookieText += '; secure'
}
} else {
cookieText += `; expires=${new Date(0).toGMTString()}`
}
document.cookie = cookieText
}
static unset(name, subName, path, domain, secure) {
let subcookies = SubCookieUtil.getAll(name)
if (subcookies) {
delete subcookies[subName] // 删除
SubCookieUtil.setAll(name, subcookies, null, path, domain, secure)
}
}
static unsetAll(name, path, domain, secure) {
SubCookieUtil.setAll(name, null, new Date(0), path, domain, secure)
}
}
// 假设document.cookie=data=name=Nicholas&book=Professional%20JavaScript
document.cookie = 'data=name=Nicholas&book=Professional%20JavaScript'
// 取得所有子cookie
let data = SubCookieUtil.getAll('data')
console.log(data.name) // 'Nicholas'
console.log(data.book) // 'Professional JavaScript'
// 取得个别子cookie
console.log(SubCookieUtil.get('data', 'name')) // 'Nicholas'
console.log(SubCookieUtil.get('data', 'book')) // 'Professional JavaScript'
// 假设document.cookie=data=name=Nicholas&book=Professional%20JavaScript
// 设置两个子cookie
SubCookieUtil.set('data', 'name', 'Nicholas')
SubCookieUtil.set('data', 'book', 'Professional JavaScript')
// 设置所有子cookie并传入过期时间
SubCookieUtil.setAll('data', { name: 'Nicholas', book: 'Professional JavaScript' }, new Date('January 1, 2010'))
// 修改"name"的值并修改整个cookie的过期时间
SubCookieUtil.set('data', 'name', 'Michael', new Date('February 1, 2025'))
// 只删除"name"子cookie
SubCookieUtil.unset('data', 'name')
// 删除整个cookie
SubCookieUtil.unsetAll('data')25.1.5 使用 cookie 的注意事项
还有一种叫作 HTTP-only 的 cookie。HTTP-only 可以在浏览器设置,也可以在服务器设置,但只能在服务器上读取,这是因为 JavaScript 无法取得这种 cookie 的值。
因为所有 cookie 都会作为请求头部由浏览器发送给服务器,所以在 cookie 中保存大量信息可能会影响特定域浏览器请求的性能。保存的 cookie 越大,请求完成的时间就越长。即使浏览器对 cookie 大小有限制,最好还是尽可能只通过 cookie 保存必要信息,以避免性能问题。
对 cookie 的限制及其特性决定了 cookie 并不是存储大量数据的理想方式。因此,其他客户端存储技术出现了。
注意
不要在 cookie 中存储重要或敏感的信息。cookie 数据不是保存在安全的环境中,因此任何人都可能获得。应该避免把信用卡号或个人地址等信息保存在 cookie 中。
10 个开发者必须了解的 Cookie 知识点
- 所有请求都会自动带上 Cookie
浏览器会自动把 Cookie 附加到发往同一域的每个请求里(包括接口、图片、CSS、JS 等)。
建议:
- Cookie 不应该塞太多内容,否则每个请求都变大,影响性能。
- 只放必要的信息,比如 session_id,别放 JSON 字符串、token 列表等大数据。
- Cookie 最大 4KB,超过就被截断或忽略
主流浏览器对单个 Cookie 大小限制是 4096 字节(含 name、value 和其他属性)。
建议:
- 不要用 Cookie 存 JWT 或大对象。
- 如果数据超过 4KB,放到 localStorage、sessionStorage 或 IndexedDB 更合适。
- 设置 HttpOnly 防止 XSS 窃取 Cookie
开启 HttpOnly 后,JavaScript 无法通过 document.cookie 读取该 Cookie,即使存在 XSS 漏洞。
建议:
- 登录态、session ID、token 等敏感信息必须放到 HttpOnly 的 Cookie 里。
- 不要将敏感数据暴露给前端 JS。
- 设置 Secure 确保 Cookie 只通过 HTTPS 传输
带有 Secure 标记的 Cookie 只会在 HTTPS 请求中发送。HTTP 请求不会带。
建议:
- 所有认证相关 Cookie 一律加上 Secure。
- 你的站点应该强制使用 HTTPS,别再用 HTTP 开发调试。
- 使用 SameSite 属性防止 CSRF 攻击
SameSite 控制跨站请求时是否发送 Cookie,是防止 CSRF 的关键机制。
三个值:
- Strict:最安全,只有同站点请求才带 Cookie
- Lax:常用默认值,部分 GET 请求会带 Cookie
- None:允许跨域,但必须配合 Secure
建议:
- 认证 Cookie 至少设置 SameSite=Lax。
- 跨域场景(如主站+子站)用 SameSite=None; Secure。
- Cookie 会在子域名间共享(如果你没限制)
给 .example.com 设置的 Cookie,app.example.com、api.example.com 都能访问。
建议:
- 只对需要用到的子域设置 Cookie,比如只给 app.example.com 设置登录态。
- 避免跨子域 Cookie 泄露,防止不相关子域误用。
- Cookie 过期时间影响是否“持久”
没有设置 Expires 或 Max-Age 的 Cookie 是“会话 Cookie”,关掉浏览器就没了。
设置了就是“持久 Cookie”,会保留到指定时间。
建议:
- “记住我”这种功能必须设置过期时间。
- 临时状态信息可以用会话 Cookie。
- 第三方 Cookie 快没用了
主流浏览器(Chrome/Safari/Firefox)都在限制第三方 Cookie,跨站登录、广告跟踪都受到影响。
建议:
- 不要依赖第三方 Cookie 做认证或追踪。
- 改用服务端会话机制、一方存储或 token 方案。
- 在欧盟地区,特殊情况
在欧盟地区,必须征得用户同意才能设置非必要 Cookie 。
GDPR 和 ePrivacy 法规要求,设置追踪类 Cookie 必须用户明确同意。
建议:
- 实现清晰的 Cookie 通知和分类同意弹窗。
- 区分“必要”和“非必要”Cookie,非必要的要等用户同意后才设置。
- 删除 Cookie 的方法是设置一个“已过期”的值
Cookie 没法直接 delete,只能通过设置 Expires=过去时间 来使它失效。
示例代码:
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/;'注意:
- 删除 Cookie 时的 path 和 domain 必须和设置时一样,否则删除不成功。
- 很多人以为删了,其实还在。
25.2 Web Storage
Web Storage 规范最新的版本是第 2 版,这一版规范主要有两个目标:
❑ 提供在 cookie 之外的存储会话数据的途径;
❑ 提供跨会话持久化存储大量数据的机制。
Web Storage 的第 2 版定义了两个对象:localStorage 和 sessionStorage。localStorage 是永久存储机制,sessionStorage 是跨会话的存储机制。这两种浏览器存储 API 提供了在浏览器中不受页面刷新影响而存储数据的两种方式。2009 年之后所有主要供应商发布的浏览器版本在 window 对象上支持 localStorage 和 sessionStorage。
25.2.1 Storage 类型
Storage 类型用于保存名/值对数据,直至存储空间上限(由浏览器决定)。Storage 的实例与其他对象一样,但增加了以下方法。
❑ clear():删除所有值;不在 Firefox 中实现。
❑ getItem(name):取得给定 name 的值。
❑ key(index):取得给定数值位置的名称。
❑ removeItem(name):删除给定 name 的名/值对。
❑ setItem(name, value):设置给定 name 的值。
通过 length 属性可以确定 Storage 对象中保存了多少名/值对。我们无法确定对象中所有数据占用的空间大小,尽管 IE8 提供了 remainingSpace 属性,用于确定还有多少存储空间(以字节计)可用。
注意
Storage 类型只能存储字符串。非字符串数据在存储之前会自动转换为字符串。这种转换不能在获取数据时撤销。
25.2.2 sessionStorage 对象
sessionStorage 对象只存储会话数据,这意味着数据只会存储到浏览器关闭。这跟浏览器关闭时会消失的会话 cookie 类似。存储在 sessionStorage 中的数据不受页面刷新影响,可以在浏览器崩溃并重启后恢复。(取决于浏览器,Firefox 和 WebKit 支持,IE 不支持。)
因为 sessionStorage 对象与服务器会话紧密相关,所以在运行本地文件时不能使用。存储在 sessionStorage 对象中的数据只能由最初存储数据的页面使用,在多页应用程序中的用处有限。
// 使用方法存储数据
sessionStorage.setItem('name', 'Nicholas')
// 使用属性存储数据
sessionStorage.book = 'Professional JavaScript'
// 使用方法取得数据
let name = sessionStorage.getItem('name')
// 使用属性取得数据
let book = sessionStorage.book
// 可以结合sessionStorage的length属性和key()方法遍历所有的值
for (let i = 0, len = sessionStorage.length; i < len; i++) {
let key = sessionStorage.key(i)
let value = sessionStorage.getItem(key)
console.log(`${key}=${value}`)
}
// 也可以使用for-in循环迭代sessionStorage的值
for (let key in sessionStorage) {
let value = sessionStorage.getItem(key)
console.log(`${key}=${value}`)
}
// 使用delete删除值
delete sessionStorage.name
// 使用方法删除值
sessionStorage.removeItem('book')所有现代浏览器在实现存储写入时都使用了同步阻塞方式,因此数据会被立即提交到存储。具体 API 的实现可能不会立即把数据写入磁盘(而是使用某种不同的物理存储),但这个区别在 JavaScript 层面是不可见的。通过 Web Storage 写入的任何数据都可以立即被读取。
// 仅适用于IE8
sessionStorage.begin()
sessionStorage.name = 'Nicholas'
sessionStorage.book = 'Professional JavaScript'
sessionStorage.commit()sessionStorage 不能在多个窗口或标签页之间共享数据,但是当通过 window.open 或链接打开新页面时(不能是新窗口),新页面会复制前一页的 sessionStorage。
25.2.3 localStorage 对象
要访问同一个 localStorage 对象,页面必须来自同一个域(子域不可以)、在相同的端口上使用相同的协议。
// 使用方法存储数据
localStorage.setItem('name', 'Nicholas')
// 使用属性存储数据
localStorage.book = 'Professional JavaScript'
// 使用方法取得数据
let name = localStorage.getItem('name')
// 使用属性取得数据
let book = localStorage.book存储在 localStorage 中的数据会保留到通过 JavaScript 删除或者用户清除浏览器缓存。localStorage 数据不受页面刷新影响,也不会因关闭窗口、标签页或重新启动浏览器而丢失。
localStorage 与 sessionStorage 的区别
localStorage:
只读的 localStorage 属性允许你访问一个 Document 源(origin)的对象 Storage;存储的数据将保存在浏览器会话中。localStorage 类似 sessionStorage,但其区别在于:存储在 localStorage 的数据可以长期保留;而当页面会话结束——也就是说,当页面被关闭时,存储在 sessionStorage 的数据会被清除。
sessionStorage:
- 页面会话在浏览器打开期间一直保持,并且重新加载或恢复页面仍会保持原来的页面会话。
- 在新标签或窗口打开一个页面时会复制顶级浏览会话的上下文作为新会话的上下文,这点和
session cookie的运行方式不同。 - 打开多个相同的 URL 的 Tabs 页面,会创建各自的
sessionStorage。 - 关闭对应浏览器标签或窗口,会清除对应的
sessionStorage。
25.2.4 存储事件
每当 Storage 对象发生变化时,都会在文档上触发 storage 事件。使用属性或 setItem()设置值、使用 delete 或 removeItem()删除值,以及每次调用 clear()时都会触发这个事件。这个事件的事件对象有如下 4 个属性。
❑ domain:存储变化对应的域。
❑ key:被设置或删除的键。
❑ newValue:键被设置的新值,若键被删除则为 null。
❑ oldValue:键变化之前的值。
window.addEventListener('storage', (event) => console.log('Storage changed for ${event.domain}'))25.2.5 限制
与其他客户端数据存储方案一样,Web Storage 也有限制。具体的限制取决于特定的浏览器。一般来说,客户端数据的大小限制是按照每个源(协议、域和端口)来设置的,因此每个源有固定大小的数据存储空间。分析存储数据的页面的源可以加强这一限制。
不同浏览器给 localStorage 和 sessionStorage 设置了不同的空间限制,但大多数会限制为每个源 5MB。关于每种媒介的新配额限制信息表,可以参考 web.dev 网站上的文章“Storage for the Web”。
要了解关于 Web Storage 限制的更多信息,可以参考 dev-test.nemikor 网站的“Web Storage Support Test”页面。
25.3 IndexedDB
Indexed Database API 简称 IndexedDB,是浏览器中存储结构化数据的一个方案。IndexedDB 用于代替目前已废弃的 Web SQL Database API。IndexedDB 背后的思想是创造一套 API,方便 JavaScript 对象的存储和获取,同时也支持查询和搜索。
IndexedDB 的设计几乎完全是异步的。为此,大多数操作以请求的形式执行,这些请求会异步执行,产生成功的结果或错误。绝大多数 IndexedDB 操作要求添加 onerror 和 onsuccess 事件处理程序来确定输出。
25.3.1 数据库
IndexedDB 是类似于 MySQL 或 Web SQL Database 的数据库。与传统数据库最大的区别在于,IndexedDB 使用对象存储而不是表格保存数据。IndexedDB 数据库就是在一个公共命名空间下的一组对象存储,类似于 NoSQL 风格的实现。
使用 IndexedDB 数据库的第一步是调用 indexedDB.open()方法,并给它传入一个要打开的数据库名称。如果给定名称的数据库已存在,则会发送一个打开它的请求;如果不存在,则会发送创建并打开这个数据库的请求。这个方法会返回 IDBRequest 的实例,可以在这个实例上添加 onerror 和 onsuccess 事件处理程序。
let db,
request,
version = 1
request = indexedDB.open('admin', version)
request.onerror = (event) => alert(`Failed to open: ${event.target.errorCode}`)
request.onsuccess = (event) => {
db = event.target.result
}以前,IndexedDB 使用 setVersion()方法指定版本号。这个方法目前已废弃。如前面代码所示,要在打开数据库的时候指定版本。这个版本号会被转换为一个 unsigned long long 数值,因此不要使用小数,而要使用整数。
在两个事件处理程序中,event.target 都指向 request,因此使用哪个都可以。如果 onsuccess 事件处理程序被调用,说明可以通过 event.target.result 访问数据库(IDBDatabase)实例了,这个实例会保存到 db 变量中。之后,所有与数据库相关的操作都要通过 db 对象本身来进行。如果打开数据库期间发生错误,event.target.errorCode 中就会存储表示问题的错误码。
25.3.2 对象存储
数据库的版本决定了数据库模式,包括数据库中的对象存储和这些对象存储的结构。如果数据库还不存在,open()操作会创建一个新数据库,然后触发 upgradeneeded 事件。可以为这个事件设置处理程序,并在处理程序中创建数据库模式。如果数据库存在,而你指定了一个升级版的版本号,则会立即触发 upgradeneeded 事件,因而可以在事件处理程序中更新数据库模式。
request.onupgradeneeded = (event) => {
const db = event.target.result
// 如果存在则删除当前objectStore。测试的时候可以这样做
// 但这样会在每次执行事件处理程序时删除已有数据
if (db.objectStoreNames.contains('users')) {
db.deleteObjectStore('users')
}
// keyPath属性表示应该用作键的存储对象的属性名
db.createObjectStore('users', { keyPath: 'username' })
}25.3.3 事务
创建了对象存储之后,剩下的所有操作都是通过事务完成的。事务要通过调用数据库对象的 transaction()方法创建。任何时候,只要想要读取或修改数据,都要通过事务把所有修改操作组织起来。
let transaction1 = db.transaction()
let transaction2 = db.transaction('users')
let transaction3 = db.transaction(['users', 'anotherStore'])
let transaction4 = db.transaction('users', 'readwrite')如果不指定参数,则对数据库中所有的对象存储有只读权限。要修改访问模式,可以传入第二个参数。
有了事务的引用,就可以使用 objectStore()方法并传入对象存储的名称以访问特定的对象存储。然后,可以使用 add()和 put()方法添加和更新对象,使用 get()取得对象,使用 delete()删除对象,使用 clear()删除所有对象。其中,get()和 delete()方法都接收对象键作为参数,这 5 个方法都创建新的请求对象。
const transaction = db.transaction('users'),
store = transaction.objectStore('users'),
request = store.get('007')
request.onerror = (event) => alert('Did not get the object! ')
request.onsuccess = (event) => alert(event.target.result.firstName)因为一个事务可以完成任意多个请求,所以事务对象本身也有事件处理程序:onerror 和 oncomplete。这两个事件可以用来获取事务级的状态信息:
transaction.onerror = (event) => {
// 整个事务被取消
}
transaction.oncomplete = (event) => {
// 整个事务成功完成
}注意
不能通过 oncomplete 事件处理程序的 event 对象访问 get()请求返回的任何数据。因此,仍然需要通过这些请求的 onsuccess 事件处理程序来获取数据。
25.3.4 插入对象
拿到了对象存储的引用后,就可以使用 add()或 put()写入数据了。这两个方法都接收一个参数,即要存储的对象,并把对象保存到对象存储。这两个方法只在对象存储中已存在同名的键时有区别。这种情况下,add()会导致错误,而 put()会简单地重写该对象。更简单地说,可以把 add()想象成插入新值,而把 put()想象为更新值。
// users是一个用户数据的数组
let request,
requests = []
for (let user of users) {
request = store.add(user)
request.onerror = () => {
// 处理错误
}
request.onsuccess = () => {
// 处理成功
}
requests.push(request)
}25.3.5 通过游标查询
使用事务可以通过一个已知键取得一条记录。如果想取得多条数据,则需要在事务中创建一个游标。游标是一个指向结果集的指针。与传统数据库查询不同,游标不会事先收集所有结果。相反,游标指向第一个结果,并在接到指令前不会主动查找下一条数据。
需要在对象存储上调用 openCursor()方法创建游标。与其他 IndexedDB 操作一样,openCursor()方法也返回一个请求,因此必须为它添加 onsuccess 和 onerror 事件处理程序。
const transaction = db.transaction('users'),
store = transaction.objectStore('users'),
request = store.openCursor()
request.onsuccess = (event) => {
// 处理成功
const cursor = event.target.result
if (cursor) {
// 永远要检查
console.log(`Key: ${cursor.key}, Value: ${JSON.stringify(cursor.value)}`)
}
}
request.onerror = (event) => {
// 处理错误
}在调用 onsuccess 事件处理程序时,可以通过 event.target.result 访问对象存储中的下一条记录,这个属性中保存着 IDBCursor 的实例(有下一条记录时)或 null(没有记录时)。这个 IDBCursor 实例有几个属性。
❑ direction:字符串常量,表示游标的前进方向以及是否应该遍历所有重复的值。可能的值包括:NEXT("next")、NEXTUNIQUE("nextunique")、PREV("prev")、PREVUNIQUE("prevunique")。
❑ key:对象的键。
❑ value:实际的对象。
❑ primaryKey:游标使用的键。可能是对象键或索引键 。
游标可用于更新个别记录。update()方法使用指定的对象更新当前游标对应的值。与其他类似操作一样,调用 update()会创建一个新请求,因此如果想知道结果,需要为 onsuccess 和 onerror 赋值:
request.onsuccess = (event) => {
const cursor = event.target.result
let value, updateRequest
if (cursor) {
// 永远要检查
if (cursor.key == 'foo') {
value = cursor.value //取得当前对象
value.password = 'magic!' //更新密码
updateRequest = cursor.update(value) //请求保存更新后的对象
updateRequest.onsuccess = () => {
//处理成功
}
updateRequest.onerror = () => {
//处理错误
}
}
}
}也可以调用 delelte()来删除游标位置的记录,与 update()一样,这也会创建一个请求:
request.onsuccess = (event) => {
const cursor = event.target.result
let value, deleteRequest
if (cursor) {
// 永远要检查
if (cursor.key == 'foo') {
deleteRequest = cursor.delete() //请求删除对象
deleteRequest.onsuccess = () => {
//处理成功
}
deleteRequest.onerror = () => {
//处理错误
}
}
}
}如果事务没有修改对象存储的权限,update()和 delete()都会抛出错误。
默认情况下,每个游标只会创建一个请求。要创建另一个请求,必须调用下列中的一个方法。
❑ continue(key):移动到结果集中的下一条记录。参数 key 是可选的。如果没有指定 key,游标就移动到下一条记录;如果指定了,则游标移动到指定的键。
❑ advance(count):游标向前移动指定的 count 条记录。
这两个方法都会让游标重用相同的请求,因此也会重用 onsuccess 和 onerror 处理程序,直至不再需要。
request.onsuccess = (event) => {
const cursor = event.target.result
if (cursor) {
// 永远要检查
console.log(`Key: ${cursor.key}, Value: ${JSON.stringify(cursor.value)}`)
cursor.continue() //移动到下一条记录
} else {
console.log('Done!')
}
}调用 cursor.continue()会触发另一个请求并再次调用 onsuccess 事件处理程序。在没有更多记录时,onsuccess 事件处理程序最后一次被调用,此时 event.target.result 等于 null。
25.3.6 键范围
使用游标会给人一种不太理想的感觉,因为获取数据的方式受到了限制。使用键范围(key range)可以让游标更容易管理。键范围对应 IDBKeyRange 的实例。有四种方式指定键范围,第一种是使用 only()方法并传入想要获取的键:
const onlyRange = IDBKeyRange.only('007')第二种键范围可以定义结果集的下限。下限表示游标开始的位置。
// 从"007"记录开始,直到最后
const lowerRange = IDBKeyRange.lowerBound('007')
// 从"007"的下一条记录开始,直到最后
const lowerRange = IDBKeyRange.lowerBound('007', true)第三种键范围可以定义结果集的上限,通过调用 upperBound()方法可以指定游标不会越过的记录。
// 从头开始,到"ace"记录为止
const upperRange = IDBKeyRange.upperBound('ace')
// 从头开始,到"ace"的前一条记录为止
const upperRange = IDBKeyRange.upperBound('ace', true)要同时指定下限和上限,可以使用 bound()方法。这个方法接收四个参数:下限的键、上限的键、可选的布尔值表示是否跳过下限和可选的布尔值表示是否跳过上限。
// 从"007"记录开始,到"ace"记录停止
const boundRange = IDBKeyRange.bound('007', 'ace')
// 从"007"的下一条记录开始,到"ace"记录停止
const boundRange = IDBKeyRange.bound('007', 'ace', true)
// 从"007"的下一条记录开始,到"ace"的前一条记录停止
const boundRange = IDBKeyRange.bound('007', 'ace', true, true)
// 从"007"记录开始,到"ace"的前一条记录停止
const boundRange = IDBKeyRange.bound('007', 'ace', false, true)定义了范围之后,把它传给 openCursor()方法,就可以得到位于该范围内的游标:
const store = db.transaction('users').objectStore('users'),
range = IDBKeyRange.bound('007', 'ace')
request = store.openCursor(range)
request.onsuccess = function (event) {
const cursor = event.target.result
if (cursor) {
// 永远要检查
console.log(`Key: ${cursor.key}, Value: ${JSON.stringify(cursor.value)}`)
cursor.continue() //移动到下一条记录
} else {
console.log('Done!')
}
}25.3.7 设置游标方向
openCursor()方法实际上可以接收两个参数,第一个是 IDBKeyRange 的实例,第二个是表示方向的字符串。通常,游标都是从对象存储的第一条记录开始,每次调用 continue()或 advance()都会向最后一条记录前进。这样的游标其默认方向为"next"。如果对象存储中有重复的记录,可能需要游标跳过那些重复的项。为此,可以给 openCursor()的第二个参数传入"nextunique":
const transaction = db.transaction('users'),
store = transaction.objectStore('users'),
request = store.openCursor(null, 'nextunique')另外,也可以创建在对象存储中反向移动的游标,从最后一项开始向第一项移动。此时需要给 openCursor()传入"prev"或"prevunique"作为第二个参数(后者的意思当然是避免重复)。
25.3.8 索引
对某些数据集,可能需要为对象存储指定多个键。例如,如果同时记录了用户 ID 和用户名,那可能需要通过任何一种方式来获取用户数据。为此,可以考虑将用户 ID 作为主键,然后在用户名上创建索引。
const transaction = db.transaction('users'),
store = transaction.objectStore('users'),
index = store.createIndex('username', 'username', { unique: true })createIndex()的第一个参数是索引的名称,第二个参数是索引属性的名称,第三个参数是包含键 unique 的 options 对象。这个选项中的 unique 应该必须指定,表示这个键是否在所有记录中唯一。
createIndex()返回的是 IDBIndex 实例。在对象存储上调用 index()方法也可以得到同一个实例。
const transaction = db.transaction('users'),
store = transaction.objectStore('users'),
index = store.index('username')可以在索引上使用 openCursor()方法创建新游标,这个游标与在对象存储上调用 openCursor()创建的游标完全一样。只是其 result.key 属性中保存的是索引键,而不是主键。
const transaction = db.transaction('users'),
store = transaction.objectStore('users'),
index = store.index('username'),
request = index.openCursor()
request.onsuccess = (event) => {
// 处理成功
}使用 openKeyCursor()方法也可以在索引上创建特殊游标,只返回每条记录的主键。这个方法接收的参数与 openCursor()一样。最大的不同在于,event.result.key 是索引键,且 event.result.value 是主键而不是整个记录。
const transaction = db.transaction('users'),
store = transaction.objectStore('users'),
index = store.index('username'),
request = index.openKeyCursor()
request.onsuccess = (event) => {
// 处理成功
// event.result.key是索引键,event.result.value是主键
}可以使用 get()方法并传入索引键通过索引取得单条记录,这会创建一个新请求:
const transaction = db.transaction('users'),
store = transaction.objectStore('users'),
index = store.index('username'),
request = index.get('007')
request.onsuccess = (event) => {
// 处理成功
}
request.onerror = (event) => {
// 处理错误
}如果想只取得给定索引键的主键,可以使用 getKey()方法。这样也会创建一个新请求,但 result.value 等于主键而不是整个记录:
const transaction = db.transaction('users'),
store = transaction.objectStore('users'),
index = store.index('username'),
request = index.getKey('007')
request.onsuccess = (event) => {
// 处理成功
// event.target.result.key是索引键,event.target.result.value是主键
}在这个 onsuccess 事件处理程序中,event.target.result.value 中应该是用户 ID。
任何时候,都可以使用 IDBIndex 对象的下列属性取得索引的相关信息。
❑ name:索引的名称。
❑ keyPath:调用 createIndex()时传入的属性路径。
❑ objectStore:索引对应的对象存储。
❑ unique:表示索引键是否唯一的布尔值。
对象存储自身也有一个 indexNames 属性,保存着与之相关索引的名称。使用如下代码可以方便地了解对象存储上已存在哪些索引:
const transaction = db.transaction('users'),
store = transaction.objectStore('users'),
indexNames = store.indexNames
for (let indexName in indexNames) {
const index = store.index(indexName)
console.log(`Indexname: ${index.name}
KeyPath: ${index.keyPath}
Unique: ${index.unique}`)
}在对象存储上调用 deleteIndex()方法并传入索引的名称可以删除索引:
const transaction = db.transaction("users"),
store = transaction.objectStore("users"),
store.deleteIndex("username")25.3.9 并发问题
第一次打开数据库时,添加 onversionchange 事件处理程序非常重要。另一个同源标签页将数据库打开到新版本时,将执行此回调。对这个事件最好的回应是立即关闭数据库,以便完成版本升级。
let request, database
request = indexedDB.open('admin', 1)
request.onsuccess = (event) => {
database = event.target.result
database.onversionchange = () => database.close()
}应该在每次成功打开数据库后都指定 onversionchange 事件处理程序。记住,onversionchange 有可能会被其他标签页触发。
25.3.10 限制
IndexedDB 的很多限制实际上与 Web Storage 一样。首先,IndexedDB 数据库是与页面源(协议、域和端口)绑定的,因此信息不能跨域共享。这意味着www.wrox.com和p2p.wrox.com会对应不同的数据存储。
其次,每个源都有可以存储的空间限制。当前 Firefox 的限制是每个源 50MB,而 Chrome 是 5MB。移动版 Firefox 有 5MB 限制,如果用度超出配额则会请求用户许可。
Firefox 还有一个限制——本地文本不能访问 IndexedDB 数据库。Chrome 没有这个限制。因此在本地运行本书示例时,要使用 Chrome。