{T}

客户端存储

随着 Web 应用程序的出现,也产生了对于能够直接在客户端上存储用户信息能力的要求。想法很合乎逻辑,属于某个特定用户的信息应该存在该用户的机器上。无论是登录信息、偏好设定或其他数据,Web 应用提供者发现他们在找各种方式将数据存在客户端上。这个问题的第一个方案是以 cookie 的形式出现的。今天,cookie 只是在客户端存储数据的其中一种选项

HTTP Cookie,通常直接叫做 cookie,最初是在客户端用于存储会话信息的。该标准要求服务器对任意 HTTP 请求发送 Set-Cookie HTTP 头作为响应的一部分,其中包含会话信息。例如,这种服务器响应的头可能如下:

javascript
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 头将信息发送回服务器,发送回服务器的额外信息可以用于唯一验证客户来自于发送的哪个请求。

javascript
GET /index.html HTTP/1.1 
Cookie: name=value 
Other-header: other-header-value

限制

cookie 是与特定域名绑定的。设置 cookie 后,它会与请求一起发送到创建它的域。这个限制能保证 cookie 中存储的信息只对被认可的接收者开放,不被其他域访问。

由于 cookie 是存在客户端计算机上的,还加入了一些限制确保 cookie 不会被恶意使用,同时不会占据太多磁盘空间。当超过单个域名限制之后还要再设置 cookie,浏览器就会清除以前设置的 cookie。

通常,只要遵守以下大致的限制,就不会在任何浏览器中碰到问题:

  • 不超过 300 个 cookie
  • 每个 cookie 不超过 4096 字节
  • 每个域不超过 20 个 cookie
  • 每个域不超过 81 920 字节

每个域能设置的 cookie 总数也是受限的,但不同浏览器的限制不同。例如:

  • 最新版 IE 和 Edge 限制每个域不超过 50 个 cookie
  • 最新版 Firefox 限制每个域不超过 150 个 cookie
  • 最新版 Opera 限制每个域不超过 180 个 cookie
  • Safari 和 Chrome 对每个域的 cookie 数没有硬性限制

浏览器中对于 cookie 的尺寸也有限制。大多数浏览器都有大约 4096B(加减 1)4kb 的长度限制。为了最佳的浏览器兼容性,最好将整个 cookie 长度限制在 4095B(含 4095)以内。尺寸限制影响到一个域下所有的 cookie,而并非每个 cookie 单独限制。

如果创建的 cookie 超过最大限制,则该 cookie 会被静默删除。注意,一个字符通常会占 1 字节。如果使用多字节字符(如 UTF-8 Unicode 字符),则每个字符最多可能占 4 字节

cookie 由浏览器保存的以下几块信息构成。

image-20240125113852448

  • 名称:唯一标识 cookie 的名称。cookie 名不区分大小写,因此 myCookie 和 MyCookie 是同一个名称。不过,实践中最好将 cookie 名当成区分大小写来对待,因为一些服务器软件可能这样对待它们。cookie 名必须经过 URL 编码

  • 值:储存在 cookie 中的字符串值。值必须被 URL 编码

  • 域:cookie 对于哪个域是有效的。所有向该域发送的请求中都会包含这个 cookie 信息

  • 路径:请求 URL 中包含这个路径才会把 cookie 发送到服务器。例如,可以指定 cookie 只能由 http://www.wrox.com/books/ 访问,因此访问 http://www.wrox.com/ 下的页面就不会发送 cookie,即使请求的是同一个域

  • 过期时间:表示 cookie 何时应该被删除的时间戳(也就是,何时应该停止向服务器发送这个cookie)。默认情况下,浏览器会话结束时即将所有 cookie 删除

  • 安全标志:设置之后,只在使用 SSL 安全连接的情况下才会把 cookie 发送到服务器。例如,请求 https://www.wrox.com 会发送 cookie,而请求 http://www.wrox.com 则不会

每一段信息都作为 Set-Cookie 头的一部分,使用分号加空格分隔每一段,如下例所示。

plain
HTTP/1.1 200 OK 
Content-type: text/html 
Set-Cookie: name=value; expires=Mon, 22-Jan-07 07:10:24 GMT; domain=.wrox.com 
Other-header: other-header-value

该头信息指定了一个叫做 name 的 cookie,它会在格林威治时间 2007 年 1 月 22 日 7:10:24 失效,同时对于 www.wrox.com 和 wrox.com 的任何子域(如 p2p.wrox.com)都有效

secure 标志是 cookie 中唯一一个非名值对儿的部分,直接包含一个 secure 单词。如下:

javascript
HTTP/1.1 200 OK 
Content-type: text/html 
Set-Cookie: name=value; domain=.wrox.com; path=/; secure 
Other-header: other-header-value

这里创建的 cookie 对所有 wrox.com 的子域及该域中的所有页面有效(通过 path=/指定)。不过,这个 cookie 只能在 SSL 连接上发送,因为设置了 secure 标志。

要知道,域、路径、过期时间和 secure 标志用于告诉浏览器什么情况下应该在请求中包含 cookie。这些参数并不会随请求发送给服务器,实际发送的只有 cookie 的名/值对。

在 JavaScript 中处理 cookie 有些复杂,只有 BOM的 document. cookie 属性。document.cookie 返回当前页面可用的(根据 cookie 的域、路径、失效时间和安全设置)所有 cookie 的字符串,一系列由分号隔开的名值对儿

javascript
name1=value1;name2=value2;name3=value3

所有名字和值都是经过 URL 编码的,所以必须使用 decodeURIComponent()来解码。

当用于设置值的时候,document.cookie 属性可以设置为一个新的 cookie 字符串。这个 cookie 字符串会被解释并添加到现有的 cookie 集合中。设置 document.cookie 并不会覆盖 cookie,除非设置的 cookie 的名称已经存在。设置 cookie 的格式如下,和 Set-Cookie 头中使用的格式一样。

javascript
name=value; expires=expiration_time; path=domain_path; domain=domain_name; secure

这些参数中,只有 cookie 的名字和值是必需的

javascript
document.cookie = "name=Nicholas";

当客户端每次向服务器端发送请求的时候,都会发送这个 cookie;当浏览器关闭的时候,它就会被删除。虽然这段代码没问题,但因为这里正好名称和值都无需编码,所以最好每次设置 cookie 时都像下面这个例子中一样使用 encodeURIComponent()

javascript
document.cookie = encodeURIComponent("name") + "=" + encodeURIComponent("Nicholas");

要给被创建的 cookie 指定额外的信息,只要将参数追加到该字符串,和 Set-Cookie 头中的格式一样,如下所示

javascript
document.cookie = encodeURIComponent("name") + "=" + encodeURIComponent("Nicholas") + ";domain=.wrox.com; path=/";

由于 JavaScript 中读写 cookie 不是非常直观,常常需要写一些函数来简化 cookie 的功能。基本的 cookie 操作有三种:读取、写入和删除。它们在 CookieUtil 对象中如下表示

javascript
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); 
  } 
};

可以像下面这样使用上述方法。

javascript
// 设置 cookie
CookieUtil.set("name", "Nicholas");
CookieUtil.set("book", "Professional JavaScript");

// 读取 cookie
alert(CookieUtil.get("name")); // "Nicholas"
alert(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);

这些方法通过处理解析、构造 cookie 字符串的任务令在客户端利用 cookie 存储数据更加简单。

为绕过浏览器对每个域 cookie 数的限制,有些开发者提出了子 cookie 的概念。子 cookie 是在单个 cookie 存储的小块数据,本质上是使用 cookie 的值在单个 cookie 中存储多个名/值对

javascript
name=name1=value1&name2=value2&name3=value3&name4=value4&name5=value5 

// 假设 document.cookie=data=name=Nicholas&book=Professional%20JavaScript

cookie 的格式类似于查询字符串。这些值可以存储为单个 cookie,而不用单独存储为自己的名/值对。结果就是网站或 Web 应用程序能够在单域 cookie 数限制下存储更多的结构化数据。

要操作子 cookie,就需要再添加一些辅助方法。解析和序列化子 cookie 的方式不一样,且因为对子 cookie 的使用而变得更复杂。比如,要取得某个子 cookie,就需要先取得 cookie,然后在解码值之前需要先像下面这样找到子 cookie:

javascript
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; 
  } 
  // 省略其他代码
};

这两个项目都要使用 decodeURIComponent()来解码,然后放入 result 对象中,最后作为方法的返回值。如果 cookie 不存在,则返回 null。可以像下面这样使用上述方法:

javascript
//假设 document.cookie=data=name=Nicholas&book=Professional%20JavaScript 

//取得全部子 cookie 
var data = SubCookieUtil.getAll("data"); 
alert(data.name); //"Nicholas" 
alert(data.book); //"Professional JavaScript" 

//逐个获取子 cookie 
alert(SubCookieUtil.get("data", "name")); //"Nicholas" 
alert(SubCookieUtil.get("data", "book")); //"Professional JavaScript"

要写入子 cookie,可以使用另外两个方法:set() 和 setAll() 。这两个方法的实现如下:

javascript
class SubCookieUtil {

  // 省略之前的代码
  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; 
  } 
  // 省略其他代码
};

可以按如下方式使用这些方法。

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, 2010"));

最后一组子 cookie 相关的方法是要删除子 cookie 的。常规 cookie 可以通过直接设置过期时间为某个过去的时间删除,但删除子 cookie 没有这么简单。为了删除子 cookie,需要先取得所有子 cookie,把要删除的那个删掉,然后再把剩下的子 cookie 设置回去。下面是相关方法的实现:

javascript
class SubCookieUtil { 
  // 省略之前的代码
  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); 
  } 
}

unset() 方法用于从 cookie 中删除一个子 cookie,其他子 cookie 不受影响;而 unsetAll()方法与 CookieUtil.unset()一样,会删除整个 cookie。与 set() 和 setAll() 一样,路径、域和 secure 标志必须与创建 cookie 时使用的一样。可以像下面这样使用这两个方法:

javascript
// 只删除"name"子 cookie 
SubCookieUtil.unset("data", "name");

// 删除整个 cookie 
SubCookieUtil.unsetAll("data");

如果你担心开发中可能会达到单域名的 cookie 上限,那么子 cookie 可是一个非常有吸引力的备选方案。不过,你需要更加密切关注 cookie 的长度,以防超过单个 cookie 的长度限制

还有一种叫作 HTTP-only 的 cookie。HTTP-only 可以在浏览器设置,也可以在服务器设置,但只能在服务器上读取,这是因为 JavaScript 无法取得这种 cookie 的值。

因为所有 cookie 都会作为请求头部由浏览器发送给服务器,所以在 cookie 中保存大量信息可能会影响特定域浏览器请求的性能。保存的 cookie 越大,请求完成的时间就越长。即使浏览器对 cookie 大小有限制,最好还是尽可能只通过 cookie 保存必要信息,以避免性能问题。对 cookie 的限制及其特性决定了 cookie 并不是存储大量数据的理想方式。因此,其他客户端存储技术出现了

一定不要在 cookie 中存储重要和敏感的数据。cookie 数据并非存储在一个安全环境中,其中包含的任何数据都可以被他人访问。所以不要在 cookie 中存储诸如信用卡号或者个人地址之类的数据。

Web存储机制

Web Storage 最早是网页超文本应用技术工作组(WHATWG,Web Hypertext Application Technical Working Group)在 Web Applications 1.0 规范中提出的。这个规范中的草案最终成为了 HTML5 的一部分,后来又独立成为自己的规范。Web Storage 的目的是解决通过客户端存储不需要频繁发送回服务器的数据时使用 cookie 的问题。

Web Storage 规范最新的版本是第 2 版,这一版规范主要有两个目标:

  • 提供在 cookie 之外的存储会话数据的途径
  • 提供跨会话持久化存储大量数据的机制

Web Storage 的第 2 版定义了两个对象:localStorage 和 sessionStorage。localStorage 是永久存储机制,sessionStorage 是跨会话的存储机制。这两种浏览器存储 API 提供了在浏览器中不受页面刷新影响而存储数据的两种方式。2009 年之后所有主要供应商发布的浏览器版本在 window 对象上支持 localStorage 和 sessionStorage

Storage 类型

Storage 类型用于保存名/值对数据,直至存储空间上限(由浏览器决定)。Storage 的实例与其他对象一样,但增加了以下方法

  • clear():删除所有值;Firefox 中没有实现

  • getItem(name):根据指定的名字 name 获取对应的值

  • key(index):获得 index 位置处的值的名字

  • removeItem(name):删除由 name 指定的名值对儿

  • setItem(name, value):为指定的 name 设置一个对应的值

getItem()、removeItem() 和 setItem() 方法可以直接调用,也可通过 Storage 对象间接调用。因为每个项目都是作为属性存储在该对象上的,所以可以通过点语法或者方括号语法访问属性来读取值,设置也一样,或者通过 delete 操作符进行删除。不过,我们还建议读者使用方法而不是属性来访问数据,以免某个键会意外重写该对象上已经存在的成员

还可以使用 length 属性来判断有多少名值对儿存放在 Storage 对象中。Storage 类型只能存储字符串。非字符串的数据在存储之前会被转换成字符串。

sessionStorage 对象

sessionStorage 对象存储特定于某个会话的数据,也就是该数据只保持到浏览器关闭。这个对象就像会话 cookie,也会在浏览器关闭后消失。存储在 sessionStorage 中的数据可以跨越页面刷新而存在。

因为 seesionStorage 对象绑定于某个服务器会话,所以当文件在本地运行的时候是不可用的。存储在 sessionStorage 中的数据只能由最初给对象存储数据的页面访问到,所以对多页面应用有限制。

由于 sessionStorage 对象其实是 Storage 的一个实例,所以可以使用 setItem()或者直接设置新的属性来存储数据。

javascript
//使用方法存储数据
sessionStorage.setItem("name", "Nicholas"); 

//使用属性存储数据
sessionStorage.book = "Professional JavaScript";

sessionStorage 中有数据时,可以使用 getItem()或者通过直接访问属性名来获取数据。

javascript
//使用方法读取数据
var name = sessionStorage.getItem("name"); 

//使用属性读取数据
var book = sessionStorage.book;

还可以通过结合 length 属性和 key() 方法来迭代 sessionStorage 中的值

javascript
for (let i = 0, len = sessionStorage.length; i < len; i++){ 
  let key = sessionStorage.key(i); 
  let value = sessionStorage.getItem(key); 
  alert(`${key}=`${value}`);
}

还可以使用 for-in 循环来迭代 sessionStorage 中的值:

javascript
for (var key in sessionStorage){ 
  var value = sessionStorage.getItem(key); 
  alert(key + "=" + value); 
}

要从 sessionStorage 中删除数据,可以使用 delete 操作符删除对象属性,也可调用 removeItem() 方法。以下是这些方法的例子。

javascript
//使用 delete 删除一个值——在 WebKit 中无效
delete sessionStorage.name; 

// 使用方法删除一个值
sessionStorage.removeItem("book");

sessionStorage 对象应该主要用于存储只在会话期间有效的小块数据。如果需要跨会话持久存储数据,可以使用 globalStorage 或 localStorage

localStorage 对象

localStorage 对象在修订过的 HTML 5 规范中作为持久保存客户端数据的方案取代了 globalStorage。由于 localStorage 是 Storage 的实例,所以可以像使用 sessionStorage 一样来使用它

javascript
// 使用方法存储数据
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()时都会触发这个事件。这个事件的事件对象有如下 4 个属性

  • domain 发生变化的存储空间的域名

  • key 设置或者删除的键名

  • newValue 如果是设置值,则是新值;如果是删除键,则是 null

  • oldValue 键被更改之前的值

javascript
window.addEventListener("storage", (event) => alert('Storage changed for ${event.domain}'));

对于 sessionStorage 和 localStorage 上的任何更改都会触发 storage 事件,但 storage 事件不会区分这两者

限制

Web Storage 也有限制。具体的限制取决于特定的浏览器。一般来说,客户端数据的大小限制是按照每个源(协议、域和端口)来设置的,因此每个源有固定大小的数据存储空间。分析存储数据的页面的源可以加强这一限制。

不同浏览器给 localStorage 和 sessionStorage 设置了不同的空间限制,但大多数会限制为每个源 5MB

IndexedDB

Indexed Database API,或者简称为 IndexedDB,是在浏览器中保存结构化数据的一种数据库。IndexedDB 是为了替代目前已被废弃的 Web SQL Database API(因为已废弃,所以本书未介绍)而出现的。IndexedDB 的思想是创建一套 API,方便保存和读取 JavaScript 对象,同时还支持查询及搜索

IndexedDB 设计的操作完全是异步进行的。因此,大多数操作会以请求方式进行,但这些操作会在后期执行,然后如果成功则返回结果,如果失败则返回错误。差不多每一次 IndexedDB 操作,都需要你注册 onerror 或 onsuccess 事件处理程序,以确保适当地处理结果

数据库

IndexedDB 是类似于 MySQL 或 Web SQL Database 的数据库。与传统数据库最大的区别在于,IndexedDB 使用对象存储而不是表格保存数据。IndexedDB 数据库就是在一个公共命名空间下的一组对象存储,类似于 NoSQL 风格的实现。

使用 IndexedDB 数据库的第一步是调用 indexedDB.open() 方法,并给它传入一个要打开的数据库名称。如果给定名称的数据库已存在,则会发送一个打开它的请求;如果不存在,则会发送创建并打开这个数据库的请求。这个方法会返回 IDBRequest 的实例,可以在这个实例上添加 onerror 和 onsuccess 事件处理程序

js
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; 
};

要在打开数据库的时候指定版本。这个版本号会被转换为一个 unsigned long long 数值,因此不要使用小数,而要使用整数。

在两个事件处理程序中,event.target 都指向 request,因此使用哪个都可以。如果 onsuccess 事件处理程序被调用,说明可以通过 event.target.result 访问数据库(IDBDatabase)实例了,这个实例会保存到 db 变量中。之后,所有与数据库相关的操作都要通过 db 对象本身来进行。如果打开数据库期间发生错误,event.target.errorCode 中就会存储表示问题的错误码

以前,出错时会使用 IDBDatabaseException 表示 IndexedDB 发生的错误。目前它已被标准的 DOMExceptions 取代。

对象存储

建立了数据库连接之后,下一步就是使用对象存储。如果数据库版本与期待的不一致,那可能需要创建对象存储。不过,在创建对象存储前,有必要想一想要存储什么类型的数据。

假设要存储包含用户名、密码等内容的用户记录。可以用如下对象来表示一条记录:

js
let user = { 
  username: "007", 
  firstName: "James", 
  lastName: "Bond", 
  password: "foo" 
}; 

观察这个对象,可以很容易看出最适合作为对象存储键的 username 属性。用户名必须全局唯一,它也是大多数情况下访问数据的凭据。这个键很重要,因为创建对象存储时必须指定一个键。

数据库的版本决定了数据库模式,包括数据库中的对象存储和这些对象存储的结构。如果数据库还不存在,open() 操作会创建一个新数据库,然后触发 upgradeneeded 事件。可以为这个事件设置处理程序,并在处理程序中创建数据库模式。如果数据库存在,而你指定了一个升级版的版本号,则会立即触发 upgradeneeded 事件,因而可以在事件处理程序中更新数据库模式。

下面的代码演示了为存储上述用户信息如何创建对象存储:

js
request.onupgradeneeded = (event) => { 
  const db = event.target.result; 
  // 如果存在则删除当前 objectStore。测试的时候可以这样做
  // 但这样会在每次执行事件处理程序时删除已有数据

  if (db.objectStoreNames.contains("users")) { 
    db.deleteObjectStore("users"); 
  } 
  db.createObjectStore("users", { keyPath: "username" }); 
}; 

这里第二个参数的 keyPath 属性表示应该用作键的存储对象的属性名。

事务

创建了对象存储之后,剩下的所有操作都是通过事务完成的。事务要通过调用数据库对象的 transaction() 方法创建。任何时候,只要想要读取或修改数据,都要通过事务把所有修改操作组织起来。最简单的情况下,可以像下面这样创建事务:

js
let transaction = db.transaction();

如果不指定参数,则对数据库中所有的对象存储有只读权限。更具体的方式是指定一个或多个要访问的对象存储的名称:

js
let transaction = db.transaction("users"); 

这样可以确保在事务期间只加载 users 对象存储的信息。如果想要访问多个对象存储,可以给第一个参数传入一个字符串数组:

js
let transaction = db.transaction(["users", "anotherStore"]); 

如前所述,每个事务都以只读方式访问数据。要修改访问模式,可以传入第二个参数。"readonly"、"readwrite" 或 "versionchange"。

js
let transaction = db.transaction("users", "readwrite"); 

这样事务就可以对 users 对象存储读写了。

有了事务的引用,就可以使用 objectStore() 方法并传入对象存储的名称以访问特定的对象存储。然后,可以使用 add() 和 put() 方法添加和更新对象,使用 get() 取得对象,使用 delete() 删除对象,使用 clear()删除所有对象。其中,get() 和 delete() 方法都接收对象键作为参数,这 5 个方法都创建新的请求对象。来看下面的例子:

js
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。这两个事件可以用来获取事务级的状态信息:

js
transaction.onerror = (event) => { 
  // 整个事务被取消
}; 

transaction.oncomplete = (event) => { 
  // 整个事务成功完成
}; 

注意,不能通过 oncomplete 事件处理程序的 event 对象访问 get() 请求返回的任何数据。因此,仍然需要通过这些请求的 onsuccess 事件处理程序来获取数据

插入对象

拿到了对象存储的引用后,就可以使用 add() 或 put() 写入数据了。这两个方法都接收一个参数,即要存储的对象,并把对象保存到对象存储。

这两个方法只在对象存储中已存在同名的键时有区别。这种情况下,add() 会导致错误,而 put() 会简单地重写该对象。更简单地说,可以把 add() 想象成插入新值,而把 put()想象为更新值。因此第一次初始化对象存储时,可以这样做:

js
// users 是一个用户数据的数组
for (let user of users) { 
  store.add(user); 
} 

每次调用 add()或 put() 都会创建对象存储的新更新请求。如果想验证请求成功与否,可以把请求对象保存到一个变量,然后为它添加 onerror 和 onsuccess 事件处理程序:

js
// users 是一个用户数据的数组
let request, requests = []; 

for (let user of users) { 
  request = store.add(user); 
  request.onerror = () => { 
    // 处理错误
  }; 

  request.onsuccess = () => { 
    // 处理成功
  }; 
  requests.push(request); 
} 

创建并填充了数据后,就可以查询对象存储了

通过游标查询

使用事务可以通过一个已知键取得一条记录。如果想取得多条数据,则需要在事务中创建一个游标。游标是一个指向结果集的指针。与传统数据库查询不同,游标不会事先收集所有结果。相反,游标指向第一个结果,并在接到指令前不会主动查找下一条数据。

需要在对象存储上调用 openCursor()方法创建游标。与其他 IndexedDB操作一样,openCursor() 方法也返回一个请求,因此必须为它添加 onsuccess 和 onerror 事件处理程序。例如:

js
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("next")、NEXTUNIQUE("nextunique")、PREV("prev")、PREVUNIQUE("prevunique")
  • key:对象的键
  • value:实际的对象
  • primaryKey:游标使用的键。可能是对象键或索引键(稍后讨论)

可以像下面这样取得一个结果:

js
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 赋值:

js
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()一样,这也会创建一个请求:

js
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 处理程序,直至不再需要。例如,下面的代码迭代了一个对象存储中的所有记录:

js
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

键范围

使用游标会给人一种不太理想的感觉,因为获取数据的方式受到了限制。使用键范围 (key range) 可以让游标更容易管理。键范围对应 IDBKeyRange 的实例。有四种方式指定键范围,第一种是使用 only() 方法并传入想要获取的键:

js
const onlyRange = IDBKeyRange.only("007"); 

这个范围保证只获取键为"007"的值。使用这个范围创建的游标类似于直接访问对象存储并调用 get("007")。

第二种键范围可以定义结果集的下限。下限表示游标开始的位置。例如,下面的键范围保证游标从 "007" 这个键开始,直到最后:

js
// 从"007"记录开始,直到最后
const lowerRange = IDBKeyRange.lowerBound("007"); 

如果想从"007"后面的记录开始,可以再传入第二个参数 true:

js
// 从"007"的下一条记录开始,直到最后
const lowerRange = IDBKeyRange.lowerBound("007", true); 

第三种键范围可以定义结果集的上限,通过调用 upperBound() 方法可以指定游标不会越过的记录。下面的键范围保证游标从头开始并在到达键为"ace"的记录停止:

js
// 从头开始,到"ace"记录为止
const upperRange = IDBKeyRange.upperBound("ace"); 

如果不想包含指定的键,可以在第二个参数传入 true:

js
// 从头开始,到"ace"的前一条记录为止
const upperRange = IDBKeyRange.upperBound("ace", true); 

要同时指定下限和上限,可以使用 bound()方法。这个方法接收四个参数:下限的键、上限的键、可选的布尔值表示是否跳过下限和可选的布尔值表示是否跳过上限。下面是几个例子:

js
// 从"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() 方法,就可以得到位于该范围内的游标:

js
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!"); 
  } 
};

这个例子只会输出从键为"007"的记录开始到键为"ace"的记录结束的对象,比上一节的例子要少

设置游标方向

openCursor() 方法实际上可以接收两个参数,第一个是 IDBKeyRange 的实例,第二个是表示方向的字符串。通常,游标都是从对象存储的第一条记录开始,每次调用 continue() 或 advance()都会向最后一条记录前进。这样的游标其默认方向为"next"。如果对象存储中有重复的记录,可能需要游标跳过那些重复的项。为此,可以给 openCursor() 的第二个参数传入"nextunique":

js
const transaction = db.transaction("users"), 
      store = transaction.objectStore("users"), 
      request = store.openCursor(null, "nextunique");

注意,openCursor() 的第一个参数是 null,表示默认的键范围是所有值。此游标会遍历对象存储中的记录,从第一条记录开始迭代,到最后一条记录,但会跳过重复的记录

另外,也可以创建在对象存储中反向移动的游标,从最后一项开始向第一项移动。此时需要给 openCursor() 传入"prev"或 "prevunique" 作为第二个参数(后者的意思当然是避免重复)。例如:

js
const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      request = store.openCursor(null, "prevunique");

在使用 "prev" 或 "prevunique" 打开游标时,每次调用 continue() 或 advance() 都会在对象存储中反向移动游标

索引

对某些数据集,可能需要为对象存储指定多个键。例如,如果同时记录了用户 ID 和用户名,那可能需要通过任何一种方式来获取用户数据。为此,可以考虑将用户 ID 作为主键,然后在用户名上创建索引。

要创建新索引,首先要取得对象存储的引用,然后像下面的例子一样调用 createIndex():

js
const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      index = store.createIndex("username", "username", { unique: true });

createIndex() 的第一个参数是索引的名称,第二个参数是索引属性的名称,第三个参数是包含键 unique 的 options 对象。这个选项中的 unique 应该必须指定,表示这个键是否在所有记录中唯一。因为 username 可能不会重复,所以这个键是唯一的。

createIndex()返回的是 IDBIndex 实例。在对象存储上调用 index()方法也可以得到同一个实例。例如,要使用一个已存在的名为"username"的索引,可以像下面这样:

js
const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      index = store.index("username");
request.onsuccess = (event) => { 
  // 处理成功
};

使用 openKeyCursor()方法也可以在索引上创建特殊游标,只返回每条记录的主键。这个方法接收的参数与 openCursor()一样。最大的不同在于,event.result.key 是索引键,且 event.result.value 是主键而不是整个记录

js
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()方法并传入索引键通过索引取得单条记录,这会创建一个新请求

js
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 等于主键而不是整个记录:

js
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 属性,保存着与之相关索引的名称。使用如下代码可以方便地了解对象存储上已存在哪些索引:

js
const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      indexNames = store.indexNames

for (let indexName in indexNames) { 
  const index = store.index(indexName); 
  console.log(`Index name: ${index.name} KeyPath: ${index.keyPath} Unique: ${index.unique}`); 
}

以上代码迭代了每个索引并在控制台中输出了它们的信息。

在对象存储上调用 deleteIndex()方法并传入索引的名称可以删除索引:

js
const transaction = db.transaction("users"), 
 store = transaction.objectStore("users"), 
 store.deleteIndex("username");

因为删除索引不会影响对象存储中的数据,所以这个操作没有回调。

并发问题

IndexedDB 虽然是网页中的异步 API,但仍存在并发问题。如果两个不同的浏览器标签页同时打开。了同一个网页,则有可能出现一个网页尝试升级数据库而另一个尚未就绪的情形。有问题的操作是设置数据库为新版本,而版本变化只能在浏览器只有一个标签页使用数据库时才能完成。

第一次打开数据库时,添加 onversionchange 事件处理程序非常重要。另一个同源标签页将数据库打开到新版本时,将执行此回调。对这个事件最好的回应是立即关闭数据库,以便完成版本升级。例如

js
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