0%

JavaScript学习笔记(23)

《JavaScript高级程序设计》第二十三章,离线应用与客户端存储。


支持离线Web应用是HTML5的一个重点。开发离线Web应用需要几个步骤:首先应用要能够知道设备是否在线;其次,应用还必须能访问一定的资源(图像、JavaScript、CSS等),这样才能正常工作;最后,必须有一块用于保存数据的本地空间,无论是否上网都不妨碍读写。

离线检测

HTML5定义了navigator.onLine属性,true表示设备能上网,false表示设备离线;HTML5还定义了onlineoffline事件,分别在网络从离线变为在线和从在线变为离线时触发(在window对象上触发)。

1
2
3
4
5
6
7
8
9
10
11
12
13
if(navigator.onLine) {
// 正常工作
} else {
// 离线工作
}

window.addEventListener("online", function() {
alert("Online");
});

window.addEventListener("offline", function() {
alert("Offline");
});

应用缓存

HTML5的应用缓存(application cache, appcache)是从浏览器缓存中分出来的一块儿缓存区,专门为开发离线Web应用而设计的。

使用方法

用描述文件(manifest file)列出要下载和缓存的资源。

1
2
3
4
5
CACHE MANIFEST
#Comment

file.js
file.css

将描述文件与页面关联起来,这个文件的MIME类型必须是text/cache-manifest

1
<html manifest="./offline.manifest">

相关对象

应用缓存有相关的JavaScript API,它的核心是applicationCache对象,有下面的属性:

  • status:表示缓存的状态,有下面几个取值
    • 0:无缓存,没有与页面相关的appcache
    • 1:闲置,appcache未得到更新
    • 2:检查中,正在下载manifest文件并检查更新
    • 3:下载中,appcache正在下载manifest文件中指定资源
    • 4:更新完成,appcache已更新资源,所有资源已下载完毕,可通过swapCache()来使用
    • 5:废弃,appcache的manifest文件已不存在,页面无法再访问appcache

相关的事件:

  • checking:浏览器为appcache查找更新时触发
  • error:检查更新或下载资源期间发生错误时触发
  • noupdate:检查manifest文件发现文件无变化时触发
  • downloading:开始下载资源时触发
  • progress:文件下载appcache时持续触发
  • updateready:页面新的appcache下载完毕且可通过swapCache()使用时触发
  • cached:appcache完整可用时触发

一般来讲,上述事件会随着页面加载依次触发。当调用update()方法时,顺序有所不同:会先检查manifest文件是否更新(checking事件);再像页面刚刚加载那样继续执行后续操作;如果触发了cached事件,则说明appcache已就绪,不用发生其它操作了;如果触发了updateready事件,则说明新版本的appcache已可用,需要调用swapCache()来启用新应用缓存。

1
2
3
4
5
applicationCache.update();

applicationCache.addEventListener("updateready", function() {
applicationCache.swapCache();
});

数据存储

随着Web应用程序的出现,产生了对于能够直接在客户端上存储用户信息能力的要求。也就是让某个特定用户的信息存在该用户的机器上,包括登录信息、偏好设定或其它数据,下面就是现有的一些方法。

HTTP Cookie是Netscape公司提出的一种客户端存储数据的方式,用来存储会话信息。

使用方法

服务器对任意HTTP请求发送Set-Cookie响应头部,其中包含会话信息,类似下面:

1
2
3
4
HTTP/1.1 200 OK
Content-type: text/html
Set-Cookie: name=value
Other-header: other-header-value

浏览器会存储这样的会话信息,并在这之后为每个请求添加Cookie请求头部,类似下面:

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

构成

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

  • 名称
    唯一确定cookie的名称,不区分大小写(所以myCookieMyCookie被认为是同一个cookie),必须被URL编码。

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

  • cookie有效的那个域,如果没有明确设定,那么就是设置cookie的那个域。这个值可以包含子域(如www.wrox.com),也可不包含子域(如.wrox.com,这个对wrox.com的所有子域都有效)。
  • 路径
    cookie有效的那个路径。比如可以指定cookie只有从http://www.wrox.com/books/中才能访问,那么向http://www.wrox.com页面发送请求就不会发送cookie信息。
  • 失效时间
    表示cookie何时应该被从浏览器删除的时间戳,默认情况下,浏览器会在会话结束时将所有cookie删除。这个值是GMT格式的日期(Wdy, DD-Mon-YYYY HH:MM:SS GMT)。
  • 安全标志
    指定后,cookie只有在使用SSL连接的时候才发送到服务器。例如cookie信息只能发送给https://www.wrox.com,而发往http://www.wrox.com的请求不能带cookie。
1
2
3
4
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; path=/; secure
Other-header: other-header-value

上面的例子中,指定了一个叫做name的cookie,它会在格林威治时间2007年1月27如7:10:24失效,同时对于www.wrox.comwrox.com的任何子域,及这些域名下的所有页面(由path参数指定的)都有效。因为设置了secure,这个cookie只能通过SSL连接才能传输。

要注意的是,域、路径、失效时间、secure标志都是服务器给浏览器的指示,以指定何时应该发送cookie。这些参数并不会作为发送到服务器的cookie信息的一部分,只有键值对才会被发送。

限制

cookie在性质上是绑定在特定域名下的,这个限制确保了cookie中的信息只能让允许的接受者(服务器端)访问,而无法被其它域访问。还有一些额外的限制,用于防止cookie被恶意使用,同时不会占用太多磁盘空间,下面就是限制。

  • 每个域名下的cookie数限制
    • IE6前:20个
    • IE7后:50个
    • Firefox:50个
    • Opera:30个
    • Safari和Chrome:没有硬性规定
  • cookie尺寸限制:大多为4096B,最好将整个cookie长度限制在4095B内

当超过域名个数限制还设置cookie,浏览器就会清除以前设置的cookie;当尝试创建超过最大尺寸限制的cookie,该cookie会被悄无声息地丢掉。

API

在JavaScript中处理cookie比较复杂,因为接口比较蹩脚,即BOM的document.cookie。当用来获取属性值时,会返回所有cookie;当用于设置属性值时,会将新cookie添加到现有的cookie集合中,而不是覆盖cookie,除非设置的cookie名称已经存在。没有直接删除cookie的方法,一般都通过重置为""来实现。

1
2
3
4
5
6
7
// 获取cookie
// name1=value1;name2=value2
alert(decodeURIComponent(document.cookie));

// 设置cookie
// name1=value1;name2=value2;name=Nicholas;domain=.wrox.com;path=/
document.cookie = `${encodeURIComponent("name")}=${encodeURIComponent("Nicholas")}; domain=.wrox.com; path=/`;

子cookie

是为了绕开浏览器的单域名下cookie数限制而产生的方法。方法是使用一个cookie值来存储多个cookie键值对,一般会用query string格式来进行格式化。下面就是子cookie常见的格式:

1
name=name1=value1&name2=value2&name3=value3&name4=value4

* 关于cookie的思考
还有一类cookie被称为HTTP专有cookie,它可以从浏览器或服务器设置,但只能从服务器端读取,因为JavaScript无法获取HTTP专有cookie的值。由于所有cookie都放在请求头,所以cookie信息越大,请求的时间就越长,因此cookie不适合存储大量信息。

IE userData

IE5中,微软通过一个自定义行为引入了持久化用户数据的概念。userData默认可跨会话持久存在,不会过期。

使用方法

先使用CSS在某个元素上指定userData

1
<div stype="behavior:url(#default#userData)" id="dataStore"></div>

设置好后就可以使用setAttribute()来保存数据了,还要用save()来将数据提交到浏览器缓存中。

1
2
3
4
const dataStore = document.getElementById("dataStore");
dataStore.setAttribute("name", "Nicholas");
dataStore.setAttribute("book", "Professional JavaScript");
dataStore.save("BookInfo"); // 存入数据空间BookInfo

下一次页面载入之后,可以通过load()getAttribute()从中获取数据。

1
2
3
dataStore.load("BookInfo");
alert(dataStore.getAttribute("name")); // "Nicholas"
alert(dataStore.getAttribute("book")); // "Professional JavaScript"

当然,也可以用removeAttribute()来删除数据。删除之后,要调用save()来提交修改。

1
2
3
dataStore.removeAttribute("name");
dataStore.removeAttribute("book");
dataStore.save("BookInfo");

限制

要访问某个数据空间,脚本运行的页面必须来自同一个域、在同一个路径下、并使用与进行存储的脚本同样的协议。允许每个文档最多128KB、每个域名最多1MB数据。

WebStorage

主要是为了克服cookie带来的一些限制,当数据需要被严格控制在客户端上时,无须持续地将数据发回服务器。Web Storage的两个目标是:

  • 提供一种在cookie之外存储会话数据的途径
  • 提供一种存储大量可跨会话存在的数据的机制

最初的Web Storage规范包含了两种对象的定义:sessionStorageglobalStorage,它们都是window对象的属性。对它们存储空间的限制,都是以每个来源(协议、域、端口)为单位的,每个来源都有固定的空间,一般限制在2.5~5MB,因浏览器而异。

Storage类型

也是存键值对,只能存字符串,有这么些属性方法。

  • length:键值对数量
  • clear():删除所有值
  • getItem(name):获取键为name的值
  • setItem(name):设置键为name的值
  • removeItem(name):删除键为name的键值对
  • key(index):获取index位置处的键

Storage事件

Storage对象有一个storage事件,当使用setItem()removeItem()clear()等操作时都会触发它。它的event有这么些属性:

  • domain:发生变化的存储空间的域名
  • key:设置或删除的键名
  • newValue:如果是设置值,则是新值;如果是删除键,则是null
  • oldValue:键被更改之前的值

sessionStorage对象

Storage的一个实例。特点是:不能跨会话存储,存储某个会话的数据直到浏览器关闭,不过刷新后依然存在。由于对象绑定于某个服务器会话,所以当文件在本地运行时不可用。其中数据只能由最初给对象存储数据的页面访问到,所以对多页面应用有限制。

1
2
3
4
5
6
7
8
9
10
11
// 设置(2种方法)
sessionStorage.setItem("name", "Nicholas");
sessionStorage.book = "Professional JavaScript";

// 获取(2种方法)
const name = sessionStorage.getItem("name");
const book = sessionStorage.book;

// 删除(2种方法)
delete sessionStorage.name;
sessionStorage.removeItem("book");

globalStorage对象

globalStorage自己不是Storage实例,不过它的每一个属性都是Storage的实例。特点是:可以跨越会话存储数据,不过有特定的访问限制(同源限制)。只要不手动删除,其中的数据会一直保留在磁盘上,适合在客户端存储文档或长期保存用户偏好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
globalStorage["wrox.com"].name = "Nicholas";
const name = globalStorage["wrox.com"].name;

// 存储数据,只有www.wrox.con页面可以访问,子域名也不行
globalStorage["www.wrox.com"] = "Nicholas";

// 存储数据,可以让任何人访问,不建议这样使用
globalStorage[""].name = "Nicholas";

// 存储数据,可以让任何以.net结尾的域名访问,也不建议使用
globalStorage["net"].name = "Nicholas";

// 如果事先不能确定域名,可以使用location.host
globalStorage[location.host].name = "Nicholas";

localStorage对象

在HTML5规范中取代了globalStorage,相当于globalStorage[location.host]。特点是:不能指定访问规则因为已事先设定好了,也有同源限制,也能够将数据保存直到通过JavaScript删除或用户清除浏览器缓存。

1
2
3
4
localStorage.setItem("name", "Nicholas");
localStorage.book = "Prefessional JavaScript";
const name = localStorage.getItem("name");
const book = localStorage.book;

IndexedDB

是浏览器中保存结构化数据的一种数据库。它的操作完全是异步进行的,所以一般需要注册onerroronsuccess事件处理程序来处理结果。

数据库

IndexedDB就是数据库,特色是使用对象而不是表来保存数据,常规操作如下。默认情况下无版本号,可以用setVersion()指定版本号。

1
2
3
4
5
6
7
8
9
let request, db;
request = window.indexedDB.open("databaseName");
request.onerror = function(event) {
alert(`Something bad happened while trying to open: ${event.target.errorCode}`);
};
request.onsuccess = function(event) {
db = event.target.result;
};
// event.target都指向request对象

错误码有下面的取值:

  • IDBDatabaseException.UNKNOWN_ERR(1):意外错误,无法归类
  • IDBDatabaseException.NON_TRANSIENT_ERR(2):操作不合法
  • IDBDatabaseException.NOT_FOUND_ERR(3):未发现数据库
  • IDBDatabaseException.CONSTRAINT_ERR(4):违反了数据库约束
  • IDBDatabaseException.DATA_ERR(5):提供给事务的数据不能满足要求
  • IDBDatabaseException.NOT_ALLOWED_ERR(6):操作不合法
  • IDBDatabaseException.TRANSACTION_INACTIVE_ERR(7):试图重用以完成的事务
  • IDBDatabaseException.ABORT_ERR(8):请求中断
  • IDBDatabaseException.READ_ONLY_ERR(9):试图在只读模式下写入或修改数据
  • IDBDatabaseException.TIMEOUT_ERR(10):在有效时间内未完成操作
  • IDBDatabaseException.QUOTA_ERR(11):磁盘空间不足

对象存储空间

连好数据库后,就可以使用对象存储空间(object storage)了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*
假设要保存的记录为:
var user = {
username: "007",
firstName: "James",
laseName: "Bond",
password: "foo"
}
显然应该用username作为键
*/

// 创建对象存储空间
let store = db.createObjectStore("users", { keyPath: "username" });

// 添加数据,返回的是这个更新请求,可以用事件处理程序判断成功与否
let request = store.add({
username: "007",
firstName: "James",
laseName: "Bond",
password: "foo"
});
request.onerror = function() { ... };
request.onsuccess = function() { ... };

事务

创建好对象存储空间后,接下来所有操作都通过事务来完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/* 创建事务 */

// 没有参数时,只能读取数据库中保存的对象
let transaction = db.transaction();

// 只加载users存储空间中的数据
let transaction = db.transaction("users");
// 只加载users、anotherStore存储空间的数据
let transaction = db.transaction(["users", "anotherStore"]);

let IDBTransaction = window.IDBTransaction || window.wbkitIDBTransaction;
// 访问模式为只读
let transaction = db.transaction("users", IDBTransaction.READ_ONLY);
// 访问模式为读写
let transaction = db.transaction("users", IDBTransaction.READ_WRITE);
// 访问模式为改变
let transaction = db.transaction("users", IDBTransaction.VERSION_CHANGE);

/*
访问特定的存储空间:objectStore()
进行操作:add()、put()、get()、delete()、clear()
*/
let request = db.transaction("users").objectStore("users").get("007");
request.onerror = function(event) {
alert("Did not get the object");
};
request.onsuccess = function(event) {
const result = event.target.result;
alert(result.firstName); // "James"
};

/* 取消事务、结束事务 */
transaction.onerror = function(event) { ... };
transaction.oncomplete = function(event) { ... };

游标

使用事务可以直接通过已知的键检索单个对象,检索多个对象则需要在事务内创建游标。

基本操作

IDBCursor实例有下面几个属性:

  • direction:游标移动的方向
    • IDBCursor.NEXT(0):表示下一项,为默认值
    • IDBCursor.NEXT_TO_DUPLICATE(1):表示下一个不重复的项
    • IDBCursor.PREV(2):表示前一项
    • IDBCursor.PREV_NO_DUPLICATE(3):表示前一个不重复的项
  • key:对象的键
  • value:实际的对象
  • primaryKey:游标使用的键,可能是对象键,可能是索引键
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 创建游标
let store = db.transaction("users").objectStore("users"),
request = store.openCursor();

// 获取结果
request.onsuccess = function(event) {
// result为存储空间的下一个对象,若存在则为IDBCursor实例,否则为null
let cursor = event.target.result;
if(cursor) {
alert(`Key: ${cursor.key},
Value: ${JSON.stringify(cursor.value)}`);
}
};

request.onerror = function(event) { ... }

使用游标可以通过update()更新值,通过delete()删除值,使用它们都会创建新请求并返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
request.onsuccess = function(event) {
let cursor = event.target.result,
value,
updateRequest,
deleteRequest;

if(cursor) {
if(cursor.key == "foo") {

cursor.value.password = "magic!"; // 更新密码
updateRequest = cursor.update(value); // 请求保存更新
updateRequest.onsuccess = function() { ... };
updateRequest.onerror = function() { ... };

deleteRequest = cursor.delete(); // 请求删除当前项
deleteRequest.onsuccess = function() { ... };
deleteRequest.onerror = function() { ... };
}
}
};

默认情况下,每个游标只发起一次请求。如果想发起另一次请求,必须调用下面的方法。

  • continue(key):参数可选。当不指定key时,游标移动到下一项;当指定key时,游标移动到指定键的位置
  • advance(count):向前移动count
1
2
3
4
5
6
7
8
9
10
11
request.onsuccess = function(event) {
let cursor = event.target.result;

if(cursor) {
alert(`Key: ${cursor.key},
Value: ${JSON.stringify(cursor.value)}`);
cursor.continue(); // 移动到下一项
} else {
alert("Done");
}
};
键范围

游标查找非常有限,所以提出以键来选定范围,由IDBKeyRange的实例来表示,有4种定义方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange;

/* 方法1:传入值作为返回对象的键名 */

// 返回键为"007"的对象
let onlyRange = IDBKeyRange.only("007");


/* 方法2:传入值作为结果集下界(游标开始的地方) */

// 游标从键为"007"的对象开始,向前移动直至最后一个对象,返回它们(含下界)
let lowerRange = IDBKeyRange.lowerBound("007");
// 游标从键为"007"的对象开始,向前移动直至最后一个对象,返回它们(不含下界)
let lowerRange = IDBKeyRange.lowerBound("007", true);


/* 方法3:传入值作为结果集上界(游标不能超过的地方) */

// 游标从头开始,向后移动直至键为"007"的对象,返回它们(含上界)
let upperRange = IDBKeyRange.upperBound("007");
// 游标从头开始,向后移动直至键为"007"的对象,返回它们(不含上界)
let upperRange = IDBKeyRange.upperBound("007", true);


/* 方法4:同时指定上下界,接收参数(下界的键、上界的键、可选的是否忽略下界、可选的是否忽略上界) */

// 从键为"007"的对象开始(含),到键为"ace"的对象结束(含)
let boundRange = IDBKeyRange.bound("007", "ace");
// 从键为"007"的对象开始(不含),到键为"ace"的对象结束(含)
let boundRange = IDBKeyRange.bound("007", "ace", true);
// 从键为"007"的对象开始(不含),到键为"ace"的对象结束(不含)
let boundRange = IDBKeyRange.bound("007", "ace", true, true);
// 从键为"007"的对象开始(含),到键为"ace"的对象结束(不含)
let boundRange = IDBKeyRange.bound("007", "ace", false, true);

定义完键范围后,把它传给openCursor(),就可以得到符合约束条件的游标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let store = db.transaction("users").objectStore("users"),
range = IDBKeyRange.bound("007", "ace"),
request = store.openCursor(range);

request.onsuccess = function(event) {
let cursor = event.target.result;
if(cursor) {
alert(`Key: ${cursor.key},
Value: ${JSON.stringify(cursor.value)}`);
cursor.continue();
} else {
alert("Done");
}
};

上面这个例子就可以输出从键"007"到键"ace"的对象。

设定方向

openCursor()可以接收2个参数:第一个是键范围(如上),第二个是方向(之前提到过几个取值)。

1
2
3
4
5
6
7
8
const IDBCursor = window.IDBCursor || window.webkitIDBCursor;
const store = db.transaction("users").objectStore("users");

// 从第一个对象迭代到最后一个对象,跳过重复对象
let request = store.openCursor(null, IDBCursor.NEXT_TO_DUPLICATE);

// 从最后一个对象迭代到第一个对象
let request = store.openCursor(null, IDBCursor.PREV);

索引

需要用多个键存取的情况下,可以考虑将一个作为主键,另一个作为索引。用IDBIndex实例来表示,它有下面的属性。

  • name:索引的名字
  • keyPath:传入createIndex()中的属性路径
  • objectStore:索引的对象存储空间
  • unique:索引键是否唯一
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 创建索引,参数为:索引名、索引的属性名、选项对象
const store = db.transaction("users").objectStore("users");
let index = store.createIndex("username", "username", { unique: false });

// 获取索引
let index = store.index("username");

// 获取所有索引
const indexNames = store.indexNames;
for(let i = 0; i < indexNames.length; i++) {
let idx = store.index(indexNames[i++]);
alert(`IndexName: ${idx.name}, KeyPath: ${idx.keyPath}, Unique: ${idx.unique}`);
}

// 删除索引
store.deleteIndex("username");

索引和对象存储空间相似,相似的操作如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const store = db.transaction("users").objectStore("users");
const index = store.index("username");

const openCursorReq = index.openCursor();
openCursorReq.onsuccess = function(event) {
// event.result.key存索引键而非主键
};

const openKeyCursorReq = index.openKeyCursor();
openKeyCursorReq.onsuccess = function(event) {
// event.result.key存索引键,event.result.value存主键
};

// 从索引中取得对应对象
const getReq = index.get("007");
getReq.onsuccess = function(event) { ... };

// 根据索引键取得主键
const getKeyReq = index.getKey("007");
getKeyReq.onsuccess = function(event) {
// event.result.key存索引键,event.result.value存主键
};

并发问题

虽然IndexedDB提供的是异步API,但仍然存在并发操作的问题。

比如,浏览器两个不同的标签页打开了同一个页面,页面a有可能试图用setVersion()更新页面b尚未准备就绪的数据库,解决办法是b监听事件,当onversionchange的时候用db.close()关闭数据库以保证版本顺利更新完成。

再比如,页面a有可能试图用setVersion()更新页面b上已经打开的数据库,解决办法是a监听事件,当onblocked的时候弹框提醒用户关闭其它标签页。

限制

IndexedDB数据库有如下限制。

  • 只能通过同源页面操作,不能跨域共享信息
  • 每个来源的数据库有空间限制,Chrome是5MB,Firefox是50MB
  • Firefox限制不允许本地文件访问IndexedDB