第25章 客户端存储
第25章 客户端存储
随着Web应用程序的出现,人们要求能够直接在客户端上存储用户信息。这个想法是合乎逻辑的:与用户有关的信息应该存在于该用户的机器上。无论是登录信息、首选项还是其他数据,Web应用程序提供商发现自己正在寻找在客户端上存储数据的方法。这个问题的第一个解决方案是以 cookie 的形式出现的,它是旧的 Netscape Communications Corporation 的产物,并在名为 Persistent Client State: HTTP Cookies 的规范中进行了描述(HTTP Cookies规范链接)。今天,cookie 只是一种可用于在客户端存储数据的选项。
Cookies
HTTP cookie,通常简称为cookie,最初用于在客户端存储会话信息。该规范要求服务器发送包含会话信息的 Set-Cookie HTTP 头,作为对 HTTP 请求的任何响应的一部分。例如,服务器响应头可能为:
HTTP/1.1 200 OK
Content-type: text/html
Set-Cookie: name=value
Other-header: other-header-value
此 HTTP 响应设置了一个名为“name”且值为“value”的 cookie。名称和值在发送时都是 URL 编码的。浏览器存储此类会话信息,并通过 Cookie HTTP 头为该点之后的每个请求将其发送回服务器,例如:
GET /index.jsl HTTP/1.1
Cookie: name=value
Other-header: other-header-value
限制
发送回服务器的额外信息可用于识别发送请求的客户端。Cookie 本质上与特定域(domain)相关联。设置 cookie 后,它会与请求一起发送到创建它的同一个域。此限制可确保存储在 cookie 中的信息仅可供批准的域访问。
由于 cookie 存储在客户端计算机上,因此设置限制以确保 cookie 不会被恶意使用并且不会占用太多磁盘空间。一般来说,如果使用以下近似限制,在所有浏览器传输中都不会遇到任何问题:
- 总共 300 个 cookies
- 每个 cookie 4096 字节
- 每个域 20 个 cookie
- 每个域 81920 字节
每个域的 cookie 总数是有限的,尽管它因浏览器而异。例如:
- 最新版本的 IE 和 Edge 将 cookie 限制为每个域 50 个。
- 最新版本的 Firefox 将 cookie 限制为每个域 150 个。
- Opera 的最新版本将 cookie 限制为每个域 180 个。
- Safari 和 Chrome 对每个域的 cookie 数量没有硬性限制。
当 cookie 设置高于每个域的限制时,浏览器开始清除先前设置的 cookie。IE 和 Opera 首先删除最近最少使用(LRU: least recently used)cookie,以便为新设置的 cookie 留出空间。Firefox 似乎随机决定要消除哪些 cookie,因此注意 cookie 限制以避免意想不到的后果非常重要。
浏览器中 cookie 的大小也有限制。大多数浏览器都有大约 4096 字节的字节数限制,多或少一个字节。为了获得最佳的跨浏览器兼容性,最好将总 cookie 大小保持在 4095 字节或更少。大小限制适用于域的所有 cookie,而不是每个 cookie。
如果尝试创建超过最大 cookie 大小的 cookie,该 cookie 将被静默删除。请注意,一个字符通常占用一个字节,除非使用多字节字符,例如某些 UTF-8 Unicode 字符,每个字符最多可占用 4 个字节。
Cookie组成
Cookie由浏览器存储以下信息:
Name(名称) :用于标识cookie的唯一名称。Cookie名称不区分大小写,因此
myCookie
和MyCookie
被视为相同。然而,在实践中最好将cookie名称视为区分大小写,因为某些服务器软件可能会区分它们。Cookie名称必须进行URL编码。Value(数值) :存储在cookie中的字符串值。该值也必须进行URL编码。
Domain(域) :可以访问该cookie的域。所有从该域的资源发送的请求都将包含cookie信息。该值可以包含子域或不包含(例如,
.wrox.com
,表示以.wrox.com
结尾的所有域都可以访问)。如果未明确设置,浏览器会假定域是设置cookie的域。Path(路径) :在域内的路径。例如,可以指定cookie只能从
http://www.wrox.com/books/
访问,这样即使请求来自同一个域,http://www.wrox.com
上的页面也不会发送cookie信息。Expiration(过期时间) :指示何时应删除cookie(即何时应停止将其发送到服务器)的时间戳。默认情况下,所有cookie在浏览器会话结束时会被删除。但是,可以设置另一个过期时间。此值设置为GMT格式(
Wdy, DD-Mon-YYYY HH:MM:SS GMT
)的日期,并指定应删除cookie的确切时间。因此,即使在浏览器关闭后,cookie也可以保留在用户的机器上。Secure flag(安全标志) :当设置时,cookie信息仅在使用SSL连接时才会发送到服务器。例如,对
https://www.wrox.com
的请求应该发送cookie信息,而对http://www.wrox.com
的请求则不发送。
每条信息都被指定为Set-Cookie
头的一部分,使用分号和空格来分隔每个部分。例如:
HTTP/1.1 200 OK
Content-type: text/html
Set-Cookie: name=value; expires=Mon, 8-Oct-2021 07:10:24 GMT; domain=.wrox.com
Other-header: other-header-value
在上述示例中,指定了一个名为“name”的cookie,该cookie将在 GMT 时间2021年10月8日星期一7:10:24之前对.wrox.com
和该域的任何其他子域(例如p2p.wrox.com
)有效。
Secure
标志是cookie中唯一不是名称-值对的部分,简单地包括了"secure"一词。
另外,需要注意的是,domain
、path
、expiration
和secure
这些参数实际上并不作为cookie信息的一部分发送到服务器,而只有名称-值对会被发送。
JavaScript中的Cookie
由于接口很糟糕,BOM的 document.cookie
属性在 JavaScript 中处理 Cookie 有点复杂。此属性的独特之处在于它的行为取决于它的使用方式。当用于获取属性值时,document.cookie
返回页面可用的所有 Cookie 的字符串(基于 Cookie 的域、路径、到期时间和安全设置)作为一系列由分号分隔的名称-值对,如下所示:
name1=value1; name2=value2; name3=value3
所有名称和值都是 URL 编码的,因此必须通过 decodeURIComponent()
进行解码。
当用于设置值时,document.cookie
属性可以设置为新的 Cookie 字符串。该 Cookie 字符串被解释并添加到现有的 Cookie 集中。设置 document.cookie
不会覆盖任何 Cookie,除非设置的 Cookie 名称已被使用。设置 Cookie 的格式如下,与 Set-Cookie
头使用的格式相同:
name=value; expires=expiration_time; path=domain_path; domain=domain_name; secure
在这些参数中,只需要 Cookie 的名称和值:
document.cookie = "name=Nicholas";
此代码创建一个名为“name”的会话 Cookie,其值为“Nicholas”。每次客户端向服务器发出请求时都会发送这个 Cookie;当浏览器关闭时,它将被删除。由于名称或值中都不需要编码字符,所以能正常发送。但是在设置 Cookie 时始终使用 encodeURIComponent()
是最好的,如下所示:
document.cookie = encodeURIComponent("name") + "=" + encodeURIComponent("Nicholas");
要指定有关创建的 Cookie 的额外信息,只需将其添加到与 Set-Cookie
头格式相同的字符串中,如下所示:
document.cookie = encodeURIComponent("name") + "=" + encodeURIComponent("Nicholas") + "; domain=.wrox.com; path=/";
由于 JavaScript 中 Cookie 的读取和写入不是很简单,因此通常使用函数来简化 Cookie 功能。Cookie 的基本操作分为三种:读取、写入和删除。如下工具:
class 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);
};
}
Subcookies
为了绕开浏览器对每个域的 Cookie 限制,一些开发人员使用了一个叫做 subcookies 的概念。Subcookies 是存储在单个 Cookie 中更小的数据块。这个想法是使用 Cookie 的值在单个 Cookie 中存储多个名称-值对。Subcookies 最常见的格式如下:
name=name1=value1&name2=value2&name3=value3&name4=value4&name5=value5
Subcookies 倾向于以查询字符串格式进行格式化。然后可以使用单个 Cookie 存储和访问这些值,而不是为每个名称-值对使用不同的 Cookie。结果是网站或 Web 应用程序可以存储更多结构化数据,而不会达到每个域的 Cookie 限制。
要使用 subcookies,需要一组新方法。Subcookies 的解析和序列化略有不同,并且有点复杂。以获取 subcookies 为例,获取 Cookie 的基本步骤与获取 Cookie 相同,但在解码值之前,需要按如下方式查找 subcookies 信息:
class 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 (cookieParts.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;
}
};
Cookie注意事项
另一种类型的 Cookie 是称为 HTTP-only Cookie。HTTP-only Cookie 可以由浏览器或服务器设置,但只能从服务器读取,因为 JavaScript 无法获取 HTTP-only Cookie 的值。
由于所有 Cookie 都是作为请求头从浏览器发送的,因此在 Cookie 中存储大量信息会影响浏览器对特定域的请求的整体性能。Cookie 信息越大,完成对服务器的请求所需的时间就越长。即使浏览器对 Cookie 设置了大小限制,最好在 Cookie 中存储尽可能少的信息,以避免影响性能。
Cookie 的限制和性质使它们不太适合存储大量信息,这就是其他方法出现的原因。
注意:避免在 Cookie 中存储重要或敏感数据。Cookie 数据未存储在安全环境中,因此其他人可以访问其中包含的任何数据。应该避免在 Cookie 中存储信用卡号或个人地址等数据。
网络存储
Web Storage规范的两个主要目标是:
- 提供一种在cookie之外存储会话数据的方法。
- 提供一种机制,用于存储跨会话持续存在的大量数据。
Web Storage规范的第二版包括两个对象的定义:localStorage,永久存储机制和sessionStorage,会话范围的存储机制。这两种浏览器存储API都提供了在浏览器中存储数据的方式,这些数据可以在页面重新加载后继续存在。localStorage和sessionStorage都可用作自2009年以来发布的所有主要浏览器版本中的window属性。
存储类型
Storage类型旨在将名称-值对保存到最大尺寸(由浏览器确定),并具有以下额外方法:
- clear(): 删除所有值,未在Firefox中实现。
- getItem(name): 获取给定name的值。
- key(index): 获取给定数字位置中值的名称。
- removeItem(name): 删除由name标识的名称-值对。
- setItem(name, value): 设置给定name的值。
getItem()、removeItem()和setItem()方法可以通过操作Storage对象直接或间接调用。由于每个项目都作为属性存储在对象上,因此可以通过使用点或括号表示法访问属性来简单地读取值,通过执行相同操作设置值,或使用delete运算符删除它。即便如此,通常还是建议使用方法而不是属性访问,以确保不会用键覆盖已经可用的对象成员之一。
可以使用length属性来确定Storage对象中有多少名称-值对。虽然IE8提供了一个remainingSpace属性来获取仍可用于存储的空间量(以字节为单位),但无法确定对象中所有数据的大小。
注意: Storage类型只能存储字符串。非字符串数据在存储之前会自动转换为字符串。
sessionStorage对象
sessionStorage对象仅存储会话的数据,这意味着数据会一直存储到浏览器关闭为止。这相当于在浏览器关闭时消失的会话cookie。存储在sessionStorage上的数据会在页面刷新后持续存在,并且在浏览器崩溃并重新启动时也可能可用,具体取决于浏览器供应商(Firefox和WebKit支持这一点,但IE不支持)。
因为sessionStorage对象与服务器会话相关联,所以当文件在本地运行时它不可用。存储在sessionStorage上的数据只能从最初将数据放置到对象上的页面访问,这使得它在多页面应用程序中的用途有限。因为sessionStorage对象是Storage的一个实例,可以通过使用setItem()或通过直接分配新属性将数据分配到它上面:
// 使用方法
sessionStorage.setItem("name", "Nicholas");
// 使用属性
sessionStorage.book = "Professional JavaScript";
所有现代浏览器都将存储写入实现为阻塞同步操作,因此添加到存储中的数据会立即提交。API实现可能不会立即将值写入磁盘(并且首次使用时更喜欢使用不同的物理存储),但是这种差异在JavaScript级别是不可见的,并且可以立即读取使用某种形式的Web存储写入的内容。
当sessionStorage上存在数据时,可以使用getItem()或直接访问属性名称来获取它:
let name = sessionStorage.getItem("name");
let book = sessionStorage.book;
可以使用length属性和key()方法的组合来迭代sessionStorage中的值:
for (let i = 0, len = sessionStorage.length; i < len; i++) {
let key = sessionStorage.key(i);
let value = sessionStorage.getItem(key);
console.log(`${key}=${value}`);
}
sessionStorage中的名称-值对可以通过先使用key()获取给定位置的数据名称,然后使用该名称通过getItem()获取值来顺序访问。也可以使用for-in循环迭代sessionStorage中的值:
for (let key in sessionStorage) {
let value = sessionStorage.getItem(key);
console.log(`${key}=${value}`);
}
每次循环时,key都会在sessionStorage中填充一个名称,不会返回任何内置方法或长度属性。要从sessionStorage中删除数据,可以使用对象属性上的delete运算符或removeItem()方法:
delete sessionStorage.name;
sessionStorage.removeItem("book");
sessionStorage对象主要用于仅对会话有效的小块数据。如果需要跨会话保留数据,那么globalStorage或localStorage更合适。
localStorage对象
在修订的 HTML5 规范中,localStorage 对象取代了 globalStorage 作为存储持久客户端数据的一种方式。为了访问同一个 localStorage 对象,页面必须来自同一个域(子域无效)、使用相同的协议和相同的端口提供服务。因为 localStorage 是 Storage 的一个实例,所以它的使用方式与 sessionStorage 完全相同:
localStorage.setItem("name", "Nicholas");
localStorage.book = "Professional JavaScript";
let name = localStorage.getItem("name");
let book = localStorage.book;
两种存储方式的区别在于,localStorage 中存储的数据会被持久化,直到通过 JavaScript 专门删除或用户清除浏览器的缓存。localStorage 数据在页面重新加载、关闭窗口和选项卡以及重新启动浏览器时保留。
存储事件
每当对 Storage 对象进行更改时,都会在文档上触发 storage 事件。对于使用属性或 setItem() 设置的每个值、使用 delete 或 removeItem() 删除每个值以及每次调用 clear() 都会触发。事件对象具有以下四个属性:
- domain:存储发生更改的域。
- key:设置或删除的键。
- newValue:被设置的键的值,如果键被删除,则为 null。
- oldValue:键更改之前的值。
可以使用以下代码侦听 storage 事件:
window.addEventListener("storage", (event) => alert(`Storage changed for ${event.domain}`));
对 sessionStorage 和 localStorage 的所有更改都会触发 storage 事件。
限制和约束
与其他客户端数据存储解决方案一样,Web Storage 也有局限性。一般来说,客户端数据的大小限制是基于每个源(协议、域和端口)设置的,因此每个源都有固定的空间来存储其数据。分析存储数据的页面的来源会强制执行此限制。
localStorage 和 sessionStorage 的存储限制在浏览器之间不一致,但大多数将每个源存储限制为 5MB。
IndexedDB
Indexed Database API,简称 IndexedDB,是浏览器中的结构化数据存储。IndexedDB 是作为现已弃用的 Web SQL 数据库 API 的替代方案而出现的。IndexedDB 背后的想法是创建一个 API,它可以轻松地存储和获取 JavaScript 对象,同时仍然允许查询和搜索。
IndexedDB 被设计为几乎完全异步。因此,大多数操作都作为稍后执行的请求执行,并产生成功或错误的结果。几乎每个 IndexedDB 操作都需要添加 onerror
和 onsuccess
事件处理程序来确定结果。
数据库
IndexedDB 是一个类似于 MySQL 或 Web SQL 的数据库。最大的不同在于 IndexedDB 使用对象仓库而不是表来跟踪数据。IndexedDB 数据库只是一组以通用名称(NoSQL 风格的实现)分组的对象仓库集合。使用数据库的第一步是使用 indexedDB.open()
打开它并传入要打开的数据库的名称。如果具有给定名称的数据库已经存在,则请求打开它;如果数据库不存在,则请求创建并打开它。调用 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
事件处理程序,则数据库实例对象(IDBDatabase
)在 event.target.result
中可用并存储在 database
变量中。从现在开始,所有使用数据库的请求都是通过数据库对象本身发出的。如果发生错误,则存储在 event.target.errorCode
中的错误码将指示问题的本质。
注意: 以前,IDBDatabaseException
用于指示 IndexedDB 遇到的错误。这已被标准 DOMExceptions
取代。
对象仓库
一旦与数据库建立连接,下一步就是与对象仓库进行交互。如果数据库版本与期望的版本不匹配,可能需要创建一个对象仓库。然而,在创建对象仓库之前,重要的是要考虑要存储的数据类型。
假设要存储包含用户名、密码等用户记录。保存单个记录的对象可能如下所示:
let user = {
username: "007",
firstName: "James",
lastName: "Bond",
password: "foo"
};
数据库的版本决定了数据库模式,它由数据库中的对象仓库和这些对象仓库的结构组成。如果数据库尚不存在,则 open()
操作会创建它;然后会触发 upgradeneeded
事件。可以为此事件设置处理程序,并在处理程序中创建数据库计划。如果数据库存在但又指定了升级的版本号,则会立即触发 upgradeneeded
事件,从而允许在事件处理程序中提供更新的计划。
以下是为这些用户创建对象仓库的方法:
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 如果存在,则删除当前 objectStore。这对测试很有用,但每次执行此事件处理程序时都会擦除现有数据。
if (db.objectStoreNames.contains("users")) {
db.deleteObjectStore("users");
}
db.createObjectStore("users", {
keyPath: "username"
});
};
第二个参数的 keyPath
属性指示应该用作键的存储对象的属性名称。
事务
在创建对象仓库之后,所有进一步的操作都通过事务(transactions)完成。要创建事务,需要使用数据库对象上的 transaction()
方法。每当需要读取或更改数据时,都会使用事务将所有更改组合在一起。创建一个新的事务最简单的形式如下:
let transaction = db.transaction();
如果没有指定参数,将对数据库中的所有对象仓库具有只读访问权限。更有针对性的策略是指定一个或多个要访问的对象仓库名称:
let transaction = db.transaction("users");
这样可以确保在事务期间仅加载有关 "users" 对象仓库的信息并可用。如果想访问多个对象仓库,第一个参数也可以是一个字符串数组:
let transaction = db.transaction(["users", "anotherStore"]);
如前所述,这些事务中的每一个都以只读方式访问数据。要更改数据,必须传入指示访问模式的第二个参数。此参数应该是三个字符串之一:"readonly"、"readwrite" 或 "versionchange":
let transaction = db.transaction("users", "readwrite");
这样的事务能够读取和写入 "users" 对象仓库。
一旦获得了对事务的引用,就可以使用 objectStore()
方法并传入要使用的存储名称来访问特定的对象仓库。然后,可以像以前一样使用 add()
和 put()
,以及 get()
来检索值,使用 delete()
删除一个对象,并使用 clear()
删除所有对象。get()
和 delete()
方法都接受一个对象键作为它们的参数,所有这五个方法都会创建一个新的请求对象:
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) => {
// entire transaction was cancelled
};
transaction.oncomplete = (event) => {
// entire transaction completed successfully
};
oncomplete
的 event 对象不允许访问 get()
请求返回的任何数据,因此对于这些类型的请求,仍然需要一个 onsuccess
事件处理程序。
插入
由于拥有对对象仓库的引用,因此可以使用 add()
或 put()
方法来向对象仓库中添加数据。这两种方法都接受一个参数,即要存储的对象,并将对象保存到对象仓库中。只有当对象仓库中已经存在具有相同键的对象时,才会出现这两者之间的差异。在这种情况下,add()
方法将导致错误,而 put()
方法将简单地覆盖对象。更简单地说,将 add()
视为用于插入新值,而 put()
用于更新值。因此,首次初始化对象仓库,可能需要执行以下操作:
// users 是新用户的数组
for (let user of users) {
store.add(user);
}
每次调用 add()
或 put()
都会为对象仓库创建一个新的更新请求。如果要验证请求是否成功完成,可以将请求对象仓库在变量中并分配 onerror
和 onsuccess
事件处理程序:
// where users is an array of new users
let request,
requests = [];
for (let user of users) {
request = store.add(user);
request.onerror = () => {
// 处理错误
};
request.onsuccess = () => {
// 处理成功
};
requests.push(request);
}
创建对象仓库并填充数据后,就可以开始查询了。
用游标查询
IndexedDB中的transaction可以直接用于获取具有已知键的单个项。当要获取多个项时,需要在transaction内创建一个游标。游标是指向结果集的指针。与传统的数据库查询不同,游标不会收集预先设置的所有结果。相反,游标指向第一个结果,并且在收到指示之前不会尝试查找下一个结果。
游标是使用对象仓库上的openCursor()方法创建的。与IndexedDB的其他操作一样,openCursor()的返回值是一个请求,因此必须分配onsuccess和onerror事件处理程序:
const transaction = db.transaction("users"),
store = transaction.objectStore("users"),
request = store.openCursor();
request.onsuccess = (event) => {
// 处理成功
};
request.onerror = (event) => {
// 处理失败
};
当onsuccess事件处理程序被调用时,对象仓库中的下一项可以通过event.target.result访问。当有下一个项时,它保存一个IDBCursor的实例,当没有其他项目时,它保存为null。IDBCursor实例有几个属性:
- direction:一个数值,指示游标应该行进的方向以及它是否应该遍历所有重复值。有四种可能的字符串值:"next"、"nextunique"、"prev"和"prevunique"。
- key:对象的键。
- value:实际对象。
- primaryKey:游标使用的键。可以是对象键或索引键。
可以使用以下方法获取有关单个结果的信息:
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) { // 始终检查
console.log(`Key: ${cursor.key}, Value: ${JSON.stringify(cursor.value)}`);
}
};
本示例中的cursor.value是一个对象,这就是为什么在显示之前对其进行JSON编码的原因。
游标可用于更新单个记录。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 = () => {
// 处理失败
};
}
}
};
还可以通过调用delete()删除该位置的项,与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 = () => {
// 处理失败
};
}
}
};
如果transaction没有修改对象仓库的权限,update()和delete()都会抛出错误。
默认情况下,每个游标仅发出一个请求。要发出另一个请求,必须调用以下方法之一:
- continue(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("完成!");
}
};
对continue()的调用会触发另一个请求并再次调用onsuccess。当没有更多的项要迭代时,最后一次调用onsuccess并且event.target.result等于null。
键范围
考虑到数据获取方式的限制,使用游标可能并非最佳选择。Key ranges使得管理游标变得更加容易。键范围由IDBKeyRange的一个实例表示,有四种不同的方式来指定键范围。
第一种方式是使用only()
方法并传入要获取的key:
const onlyRange = IDBKeyRange.only("007");
此范围确保仅获取键为“007”的值。使用此范围创建的游标类似于直接访问对象仓库并调用get("007")
。
第二种类型的范围定义了结果集的下限。下限表示游标应该从哪个项开始。例如,以下键范围确保游标从键“007”开始并一直持续到结束:
// 从项目 "007" 开始,一直到结束
const lowerRange = IDBKeyRange.lowerBound("007");
如果想从紧跟在“007”处的值开始,可以传入第二个参数true:
// 从 "007" 后面的项目开始,一直到结束
const lowerRange = IDBKeyRange.lowerBound("007", true);
第三种范围是上限,通过使用upperBound()
方法指示不想越过的键。以下键可确保游标从开头开始,并在到达带有键“ace”的值时停止:
// 从开头开始,到 "ace" 结束
const upperRange = IDBKeyRange.upperBound("ace");
如果不想包含给定的键,则传入true作为第二个参数:
// 从开头开始,到 "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");
const range = IDBKeyRange.bound("007", "ace");
const 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("完成!");
}
};
此示例仅输出键“007”和“ace”之间的值,比上一节示例少了一些。
设置游标方向
openCursor()
实际上有两个参数。第一个是IDBKeyRange
的实例,第二个是指示方向的字符串。通常,游标从对象仓库中的第一项开始,并随着每次调用continue()
或advance()
向最后一项前进。这些游标的默认方向值为“next”。如果对象仓库中存在重复项,可能需要一个跳过重复项的游标。可以通过将“nextunique”作为第二个参数传递给openCursor()
来实现:
const transaction = db.transaction("users");
const store = transaction.objectStore("users");
const request = store.openCursor(null, "nextunique");
请注意,openCursor()
的第一个参数为null
,这表示应使用所有值的默认键范围。该游标将遍历对象仓库中的项,从第一个项开始并移向最后一个项,同时跳过任何重复项。
也可以创建一个在对象仓库中向后移动的游标,从最后一个项开始并通过传入“prev”或“prevunique”(当然,后者是为了避免重复)向第一个移动:
const transaction = db.transaction("users");
const store = transaction.objectStore("users");
const request = store.openCursor(null, "prevunique");
当使用“prev”或“prevunique”打开游标时,每次调用continue()
或advance()
都会将游标向后移动而不是向前移动。
索引
对于某些数据集,可能希望为对象仓库指定多个键。例如,如果同时通过用户ID和用户名跟踪用户,可能希望使用任一数据访问记录。为此,可能会将用户ID视为主键并在用户名上创建索引。
要创建新索引,首先获取对对象仓库的引用,然后调用createIndex()
,如下例所示:
const transaction = db.transaction("users");
const store = transaction.objectStore("users");
const index = store.createIndex("username", "username", { unique: true });
createIndex()
的第一个参数是索引的名称,第二个是要索引的属性的名称,第三个是包含unique
键的option
对象。应始终指定此option
以指示键在所有记录中是否唯一。因为username
不能重复,所以这个索引是唯一的。
createIndex()
的返回值是IDBIndex
的一个实例。还可以通过对象仓库上的index()
方法获取相同的实例。例如,要使用名为“username”的现有索引:
const transaction = db.transaction("users");
const store = transaction.objectStore("users");
const index = store.index("username");
索引的作用很像对象仓库。可以使用openCursor()
方法在索引上创建一个新游标,该方法与对象仓库上的openCursor()
方法完全相同,除了event.result.key
属性用索引键而不是主键填充:
const transaction = db.transaction("users");
const store = transaction.objectStore("users");
const index = store.index("username");
const request = index.openCursor();
request.onsuccess = (event) => {
// handle success
};
索引还可以创建一个特殊的游标,使用openKeyCursor()
方法只返回每个记录的主键,该方法接受与openCursor()
相同的参数。最大的区别在于event.result.key
是索引键,而event.result.value
是主键而不是整个记录:
const transaction = db.transaction("users");
const store = transaction.objectStore("users");
const index = store.index("username");
const request = index.openKeyCursor();
request.onsuccess = (event) => {
// handle success
// event.result.key is the index key, event.result.value is the primary key
};
还可以通过使用get()
并传入索引键从索引中获取单个值,这会创建一个新请求:
const transaction = db.transaction("users");
const store = transaction.objectStore("users");
const index = store.index("username");
const request = index.get("007");
request.onsuccess = (event) => {
// handle success
};
request.onerror = (event) => {
// handle failure
};
要仅获取给定索引键的主键,请使用getKey()
方法。这也会创建一个新请求,但event.result.value
等于主键值而不是整个记录:
const transaction = db.transaction("users");
const store = transaction.objectStore("users");
const index = store.index("username");
const request = index.getKey("007");
request.onsuccess = (event) => {
// handle success
// event.result.key is the index key, event.result.value is the primary key
};
在此例的onsuccess
事件处理程序中,event.result.value
将是用户ID。
任何时候都可以使用IDBIndex
对象上的属性获取有关索引的信息:
name
: 索引的名称。keyPath
: 传递到createIndex()
的属性路径。objectStore
: 此索引作用于的对象仓库。unique
: 指示索引键是否唯一的布尔值。
对象仓库本身也在indexNames
属性中按名称跟踪索引。这使得使用以下代码很容易找出对象上已经存在哪些索引:
const transaction = db.transaction("users");
const store = transaction.objectStore("users");
const indexNames = store.indexNames;
for (let indexName of indexNames) {
const index = store.index(indexName);
console.log(`Index name: ${index.name} KeyPath: ${index.keyPath} Unique: ${index.unique}`);
}
此代码迭代每个索引并将其信息输出到控制台。
可以通过在对象仓库上调用deleteIndex()
方法并传入索引名称来删除索引:
const transaction = db.transaction("users");
const store = transaction.objectStore("users");
store.deleteIndex("username");
由于删除索引不会触及对象仓库中的数据,因此该操作无需任何回调即可发生。
并发问题
虽然IndexedDB是网页内的异步API,但仍然存在并发问题。如果同时在两个不同的浏览器选项卡中打开同一个网页,则一个可能会在另一个准备就绪之前尝试升级数据库。有问题的操作是将数据库设置为新版本,因此只有在浏览器中只有一个选项卡使用数据库时才能完成版本更改。当第一次打开一个数据库时,分配一个onversionchange事件处理程序很重要。当来自同一来源的另一个选项卡将数据库打开到新版本时,将执行此回调。对这个事件最好的反应是立即关闭数据库,以便完成版本升级。例如:
let request, database;
request = indexedDB.open("admin", 1);
request.onsuccess = (event) => {
database = event.target.result;
database.onversionchange = () => database.close();
};
应该在每次成功打开数据库后分配onversionchange。请记住,在其他选项卡中也会调用onversionchange。通过始终分配这些事件处理程序,将确保Web应用程序能够更好地处理与IndexedDB相关的并发问题。
限制和约束
IndexedDB的许多限制与Web Storage的限制完全相同。首先,IndexedDB数据库与页面的来源(协议、域和端口)相关联,因此信息不能跨域共享。这意味着www.wrox.com和p2p.wrox.com有完全独立的数据存储。其次,每个源可以存储的数据量是有限制的。Firefox中的当前限制是每个源50MB,而Chrome的限制是5MB。Firefox移动版有5MB的限制,如果超过配额,将要求用户允许存储更多。Firefox施加了一个额外的限制,即本地文件无法访问IndexedDB数据库。Chrome没有这个限制。在本地运行本书中的示例时,请务必使用Chrome。