张啸


世界上最快乐的事,莫过于为理想而奋斗。


JS(12) 客户端存储

Web应用允许使用浏览器提供的API实现将数据存储到用户的电脑上,这种客户端存储相当于赋予了Web浏览器记忆功能。比方说,Web应用就可以用这种方式来“记住”用户的偏好甚至是用户所有的状态信息,以便准确地“回忆”起用户上一次访问的位置。客户端存储遵循“同源策略”,因此不同站点的页面是无法互相读取对方存储的数据,而同一站点的不同页面之间是可以互相共享存储数据的,它为我们提供了一种通信机制,例如,一个页面上填写的表单数据可以显示在另外一个页面中。Web应用可以选择它们存储数据的有效期:比如采用临时存储可以让数据保存至当前窗口关闭或者浏览器退出;采用永久存储,可以将数据永久地存储到硬盘上,数年或者数月不失效。

客户端存储有以下几种形式:

  • Web存储

    Web存储最初作为HTML5的一部分被定义成API形式,但是后来被剥离出来作为独立的一份标准了。Web存储标准所描述的API包含localStorage对象和sessionStorage对象,这两个对象实际上是持久化关联数组,是名值对的映射表,“名”和“值”都是字符串。Web存储易于使用、支持大容量数据存储同时兼容当前所有的主流浏览器,但是不兼容早期浏览器。

  • cookie

    cookie是一种早期的客户端存储机制,起初是针对服务器端脚本设计使用的。尽管在客户端提供了非常繁琐的JavaScript API来操作cookie,但它们难用至极,而且只适合存储少量文本数据。不仅如此,任何以cookie形式存储的数据,不论服务器端是否需要,每一次HTTP请求都会把这些数据传输到服务器端。cookie目前仍然被客户端程序员大量使用的一个重要原因是:所有新旧浏览器都支持它。但是,随着Web Storage的普及,cookie终将会回归到最初的形态:作为一种被服务器脚本使用的客户端存储机制。

  • IE User Data

    微软在IE5及以后的IE浏览器中实现了它专属的客户端存储机制——“userData”。userData可以实现一定量的字符串数据存储,对于IE8以前的IE浏览器,可以将其用做是Web存储的替代方案。

  • 离线Web应用

    HTML5标准中定义了一组“离线Web应用”API,用以缓存Web页面以及相关资源(脚本、CSS文件、图像等)。它实现的是将Web应用整体存储在客户端,而不仅仅是存储数据。它能够让Web应用“安装”在客户端,这样一来,哪怕网络部可用的时候Web应用依然是可用的。

  • Web数据库

    为了能够让开发者像使用数据库那样来操作大量数据,很多主流的浏览器纷纷在其中开始集成客户端数据库功能。Safari、Chrome和Opera都内置了SQL数据库的客户端API。遗憾的是,这类API的标准化工作以失败告终,并且Firefox和IE也都不打算实现这种API。目前还有一种正在标准化的数据库API,称为“索引数据库API”(indexed database API)。调用该API返回的是一个不包含查询语句的简单数据库对象。这两种客户端数据库API都是异步的,都是用了事件处理机制。


一、localStorage和sessionStorage

实现了“Web存储”草案标准的浏览器在Window对象上定义了两个属性:localStorage和sessionStorage。这两个属性都代表同一个Storage对象——一个持久化关联数组,数组使用字符串来索引,存储的值也都是字符串形式的。Storage对象在使用上和一般的JavaScript对象没什么区别:设置对象的属性为字符串值,随后浏览器会将该值存储起来。localStorage和sessionStorage两者的区别在于存储的有效期和作用域的不同:数据可以存储多长时间以及谁拥有数据的访问权。

下面的代码使用的是localStorage,但是它对sessionStorage也同样适用

1
2
3
4
5
6
7
8
9
10
var name = localStorage.username;               // 查询一个存储的值
name = localStorage['username']; // 等价于数组表示法
if (!name) {
name = prompt('What is your name?'); // 询问客户一个问题
localStorage.username = name; // 存储用户的答案
}
// 迭代所有存储的name/value对
for (var name in localStorage) {
var value = localStorage[name]; // 查询每个名字对应的值
}

Storage对象还定义了一些诸如存储、获取、遍历和删除的方法。“Web存储”草案标准指出,我们既可以存储结构化的数据(对象和数组),也可以存储原始类型数据,还可以存储诸如日期、正则表达式甚至文件对象在内的内置类型的数据。

1
2
3
4
5
6
7
8
9
10
11
12
// 当存储一个数字的时候,会把它自动转换成一个字符串
// 但是,当获取该值的时候别忘记手动将其转换成数字类型
localStorage.x = 10;
var x = parseInt(localStorage.x);

// 同样地,存储一个日起类型数据的时候进行编码,获取的时候进行解码
localStorage.lastRead = (new Date()).toUTCString();
var lastRead = new Date(Date.parse(localStorage.lastRead));

// 是哦那个JSON可以使得对基本数据类型编码的工作变得很方便
localStorage.data = JSON.stringify(data);
var data = JSON.parse(localStorage.data);

1. 存储有效期和作用域

localStorage和sessionStorage的区别在于存储的有效期和作用域的不同。通过localStorage存储的数据是永久性的,除非Web应用可以删除存储的数据,或者用户通过设置浏览器配置来删除,否则数据将一直保存在用户的电脑上,永不过期。

localStorage的作用域是限定在文档源(document origin)级别的,文档源是通过协议、主机名以及端口三者来确定的,因此,下面每个url都拥有不同的文档源

1
2
3
4
http://www.example.com
https://www.example.com
http://static.example.com
http://www.example.com:8000

同源的文档间共享同样的localStorage数据(不论该源的脚本是否真正地访问localStorage)。它们可以互相读取对方的数据,甚至可以覆盖对方的数据。但是,非同源的文档间互相都不能读取或者覆盖对方的数据(即使它们运行的脚本是来自同一台第三方服务器也不行)。

需要注意的是localStorage的作用域也受浏览器供应商的限制。如果使用Firefox访问站点,那么下次用另一个浏览器(Chrome)再次访问的时候,那么本次是无法获取上次存储的数据的。

通过sessionStorage存储的数据和通过localStorage存储的数据的有效期也是不同的:前者的有效期和存储数据的脚本所在的最顶层的窗口或者是浏览器标签页是一样的。一旦窗口或者标签页被永久关闭了,那么所有通过sessionStorage存储的数据也都被删除了。(要注意的是,现代浏览器已经具备了重新打开最近关闭的标签页随后恢复上一次浏览的会话功能,因此,这些标签页以及与之相关的sessionStorage的有效期可能会更加长些)。

与localStorage一样,sessionStorage的作用域也是限定在文档中,因此非同源文档间都是无法共享sessionStorage的。不仅如此,sessionStorage的作用域还被限定在窗口中。如果同源的文档渲染在不同的浏览器标签页总,那么它们互相之间拥有的是各自的sessionStorage数据,无法共享;一个标签页中的脚本是无法读取或者覆盖由另一个标签页脚本写入的数据,哪怕这两个标签页渲染的是同一个页面,运行的是同一个脚本也不行。

要注意的是,这里提到的基于窗口作用域的sessionStorage指的窗口只是顶级窗口。如果一个浏览器标签页包含两个<iframe>元素,它们所包含的文档是同源的,那么这两者之间是可以共享sessionStorage的。

2. 存储API

localStorage和sessionStorage通常被当作普通的JavaScript对象使用:通过设置属性来存储字符串值,查询该属性来读取该值。除此之外,这两个对象还提供了更加正式的API。调用setItem()方法,将对应的名字和值传进去,可以实现数据存储。调用getItem()方法,将名字传进去,可以获取对应的值。调用removeItem()方法,将名字传进去,可以删除对应的数据。调用clear()方法,可以删除所有存储的数据。最后,使用length属性以及key()方法,传入0 ~ length-1的数字,可以枚举所有存储数据的名字。下面是一些使用localStorage的例子,这些代码对sessionStorage也适用

1
2
3
4
5
6
7
8
9
10
localStorage.setItem('x', 1);
localStorage.getItem('x');

for (var i = 0; i < localStorage.length; i++) {
var name = localStorage.key(i);
var name = localStorage.getItem(name);
}

localStorage.removeItem('x');
localStorage.clear();

尽管通过设置和查询属性能更加方便地存储和获取数据,但是有的时候还是不得不使用上面提到的这些方法的。比如说,其中clear()方法是唯一能删除存储对象中所有名值对的方法。同样的还有,removeItem()方法也是唯一通用的删除单个名值对的方式,因为IE8不支持delete操作符。

如果浏览器提供商完全实现了“Web存储”的标准,支持对象和数组类型的数据存储,那么就会又多了一个使用setItem()getItem()这类方法的理由。对象和数组类型的值通常是可变的,因此存储对象要求存储它们的副本,以确保之后任何对这类对象的改变都不影响到存储的对象。同样的,在获取该对象的时候也要求获取的是该对象的副本,以确保以获取对象的改动不会影响到存储的对象。而这类操作如果使用基于属性的API就会令人困惑。考虑下面这段代码(假设浏览器已经支持了结构化数据的存储)

1
2
3
localStorage.o = { x: 1 };              // 存储一个带有x属性的对象
localStorage.o.x = 2; // 试图去设置该对象的属性值
localStorage.o.x; // => 1: x没有变

上述第二行代码想要设置存储的对象的属性值,但事实上,它获取到的只是存储的对象的副本,随后设置了该对象的属性值,然后就将该副本废弃了。真正存储的对象保持不变。像这样的情况,使用getItem()就不会这么让人困惑了。

1
localStorage.getItem('o').x = 2;        // 我们并不想存储2

最后,还有另外一个使用显式的机遇方法的存储API的理由就是:在还不支持“Web存储”标准的浏览器中,其他的存储机制的顶层API对其也是兼容的。下面这段代码使用cookie和IE userData来实现存储API。如果使用基于方法的API,当localStorage可用的时候就可以使用它编写代码,而当它在其他浏览器上不可用的时候依然可以依赖其他的存储机制。

1
2
3
4
var memory = window.localStorage ||
(window.UserDataStorage && new UserDataStorage()) ||
new cookieStorage();
var username = memory.getItem('username');

3. 存储事件

无论什么时候存储在localStorage和sessionStorage的数据发生改变,浏览器都会在其他对该数据可见的窗口对象上触发存储事件(但是,在对数据进行改变的窗口对象上是不会触发的)。如果浏览器有两个标签页都打开了来自同源的页面,其中一个页面在localStorage上存储了数据,那么另外一个标签页就会接收到一个存储事件。要记住的是sessionStorage的作用域是限制在顶层窗口的,因此对sessionStorage的改变只有当有相牵连的窗口的时候才会触发存储事件。还有要注意的是,只有当存储数据真正发生改变的时候才会触发存储事件。像给已经存在的存储项设置一个一模一样的值,亦或是删除一个本来就不存在的存储项都是不会触发存储事件的。

为存储事件注册处理程序可以通过addEventListener()方法(IE下使用attachEvent()方法)。在绝大多数浏览器下,还可以使用给window对象设置onstorage属性的方式。

与存储事件相关的事件对象有5个非常重要的属性(IE8不支持):

  • key

    被设置或者溢出的项的名字或者键名。如果调用的是clear()函数,那么该属性值为null。

  • newValue

    保存该项的新值;或者调用removeItem()时,该属性值为null。

  • oldValue

    改变或者删除该项前,保存该项原先的值;当插入一个新项的时候,该属性值为null。

  • storageArea

    这个属性值就好比是目标Window对象上的localStorage属性或者是sessionStorage属性。

  • url

    触发该存储变化脚本所在文档的url。

最后要注意的是:localStorage和存储事件都是采用广播机制的,浏览器会对目前正在访问同样站点的所有窗口发送消息。举个例子,如果一个用户要求网站停止动画效果,那么站点可能会在localStorage中存储该用户的首选项,这样一来,以后再访问该站点的时候就自动停止动画效果了。因为存储了该首选项,导致了触发一个存储事件让其他展现统一站点的窗口也获得了这样的一个用户请求。再比如,一个基于Web的图片编辑应用,通常允许在其他的窗口中展示工作条。当用户选择一个工具的时候,应用就可以使用localStorage来存储当前的状态,然后通知其他窗口用户选择了新的工具。


二、cookie

cookie是指Web浏览器存储的少量数据,同时它是与具体的Web页面或者站点相关的。cookie最早是设计为被服务端所用的,从底层来看,作为HTTP协议的一种扩展实现它。cookie数据会自动在Web浏览器和Web服务器之间传输的,因此服务端脚本就可以读写存储在客户端的cookie值。

操作cookie的API很早就已经定义和实现了,因此该API的兼容性很好。但是,该API几乎形同虚设,根本没有提供诸如查询、设置、删除cookie的方法,所有这些操作都要通过以特殊格式的字符串形式读写Document对象的cookie属性来完成。每个cookie的有效期和作用域都可以通过cookie属性来分别指定。这些属性也是通过同一个cookie属性上以特殊格式的字符串来设定的。

1. 有效期和作用域

除了名和值,cookie还有一些可选的属性来控制cookie的有效期和作用域。cookie默认的有效期很短暂:它只能持续在Web浏览器的会话期间,一旦用户关闭浏览器,cookie保存的数据就丢失了。要注意的是:这与sessionStorage的有效期还是有区别的。cookie的作用域并不是局限在浏览器的单个窗口中,它的有效期和整个浏览器进程而不是单个浏览器窗口的有效期一致。要想延长cookie的有效期,可以通过设置max-age属性,但是必须要明确告诉浏览器cookie的有效期是多长(单位是秒)。一旦设置了有效期,浏览器就会将cookie数据存储在一个文件中,并且直到过了指定的有效期才会删除该文件。

和localStorage以及sessionStorage类似,cookie的作用域是通过文档源和文档路径来确定的。该作用域通过cookie的path和domain属性也是可配置的。默认情况下,cookie和创建它的Web页面有关,并对该Web页面以及和该Web页面同目录或者子目录的其他Web页面可见。比如,Web页面http://www.example.com/catalog/index.html页面创建了一个cookie,那么该cookie对http://www.example.com/catalog/order.html页面和http://www.example.com/catalog/widgets/index.html页面都是可见的,但它对http://www.example.com/about.html页面不可见。

默认的cookie的可见性行为满足了最常见的需求。不过,有的时候,我们可能希望让整个网站都能够使用cookie的值,而不管是哪个页面创建它的。比方说,当用户在一个页面表单中输入了它的邮件地址,我们想要将它保存下来,为了下次该用户回到这个页面填写表单,或者在网站其他页面的任何地方要求输入账单地址的时候,将其作为默认的邮件地址。要满足这样的需求,可以设置cookie的路径(设置cookie的path属性)。

这样一类,来自同一个Web服务器的Web页面,只要其url是以指定的路径前缀开始的,都可以共享cookie。例如,如果http://www.example.com/catalog/widgets/index.html页面创建了一个cookie,并且将改路径设置成“/catalog”,那么该cookie对于http://www.example.com/catalog/order.html页面也是可见的。或者,如果把路径设置成“/”,那么该cookie对任何http://www.example.com这台Web服务器上的页面都是可见的。

将cookie的路径设置成“/”等于是让cookie和localStorage拥有同样的作用域,同时当它请求该站点上任何一个Web页面的时候,浏览器都必须将cookie的名字和值传递给服务器。但是,要注意的是,cookie的path属性不能被用做访问控制机制。如果一个Web页面想要读取同一站点其他页面的cookie,只要简单地将其他页面以隐藏<iframe>的形式加在进来,随后读取对应文档的cookie就可以了。同源策略限制了跨站的cookie窥探,但是对于同一站点的文档它是完全合法的。

cookie的作用域默认由文档源限制。但是,有的大型网站想要子域之间能够互相共享cookie。比如,order.example.com域下的服务器想要读取catalog.example.com域下设置的cookie值。这个时候就需要通过设置cookie的domain属性来达到目的。如果catalog.example.com域下的一个页面创建了一个cookie,并将其path属性设置成“/”,其domain设置成“.example.com”,那么该cookie就对所有catalog.example.comorders.example.com以及任何其他example.com域下的任何其他服务器都可见。如果没有为一个cookie设置域属性,那么domain属性的默认值是当前Web服务器的主机名。要注意的是,cookie的域只能设置为当前服务器的域。

最后要介绍的cookie属性是secure,它是一个布尔类型的属性,用来表明cookie的值以何种形式通过网络传递。cookie默认是以不安全的形式(通过普通的、不安全的HTTP连接)传递的。而一旦cookie被标识为“安全的”,那就只能当浏览器和服务器通过HTTPS或者其他的安全协议连接的时候才能传递它。

2. 保存cookie

要给当前文档设置默认有效期的cookie值,只须将cookie属性设置为一个字符串形式的值

1
document.cookie = "version=" + encodeURIComponent(document.lastModified);

下次读取cookie属性的时候,之前存储的名值对的数据就在文档的cookie列表中。由于cookie的名值中的值不允许包含分号、逗号和空白负,因此,在存储前一般可以采用encodeURIComponent()对值进行编码。相应的,读取cookie值的时候需要采用decodeURIComponent()函数解码。

以简单的名值对形式存储的cookie数据有效期只在当前Web浏览器的会话内,一旦用户关闭浏览器,cookie数据就丢失了。如果想要延长cookie的有效期,就需要设置max-age属性来指定cookie的有效期(单位是秒)。按照如下的字符串形式设置cookie属性即可。

1
name=value; max-age=seconds
1
2
3
4
5
6
function setcookie(name, value, daysToLive) {
var cookie = name + '=' + encodeURIComponent(value);
if (typeof daysToLive === 'number')
cookie += "; max-age=" + (daysToLive * 60 * 60 * 24);
document.cookie = cookie;
}

同样的,如果要设置cookie的path、domain和secure属性,只须在存储cookie值前,以如下字符串形式追加在cookie值的后面

1
2
3
; path=path
; domain=domain
; secure

要改变cookie的值,就需要使用相同的名字、路径和域,但是新的值重新设置cookie的值。同样的,设置新的max-age属性就可以改变原来的cookie的有效期。

要删除一个cookie,需要使用相同的名字、路径和域,然后指定一个任意(非空)的值,并且将max-age属性指定为0,再次设置cookie。

3. 读取cookie

使用JavaScript表达式来读取cookie属性的时候,其返回的值是一个字符串,该字符串都是由一系列名值对组成,不同的名值对之间通过“分号和空格”分开,其内容包含了所有作用在当前文档的cookie。但是,它并不包含其他设置的cookie属性。通过document.cookie属性可以获取cookie的值,但是为了更好地查看cookie的值,一般会采用split()方法将cookie值中的名值对都分离出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function getcookie() {
var cookie = {};
var all = document.cookie;
if (all === '')
return cookie;
var list = all.split('; ');
for (var i = 0; i < list.length; i++) {
var cookie = list[i];
var p = cookie.indexOf('=');
var name = cookie.substring(0, p);
var value = cookie.substring(p + 1);
value = decodeURIComponent(value);
cookie[name] = value;
}
return cookie;
}

4. cookie的局限性

cookie的设计初衷是给服务端脚本用来存储少量数据的,该数据会在每次请求一个相关的URL时传递到服务器中。RFC2965鼓励浏览器供应商对cookie的数目和大小不做限制。可是,要知道,该标准不允许浏览器保存超过300个cookie,为每个Web服务器保存的cookie数不能超过20个,而且,每个cookie保存的数据不能超过4KB。实际上,现代浏览器允许cookie总数超过300个,但是部分浏览器对单个cookie大小仍然有4KB的限制。

5. cookie相关的存储

下例展示了如何实现基于cookie的一系列存储API方法。该例定义了一个cookieStorage函数,通过将max-agepath属性传递给该构造函数,就会返回一个对象,然后就可以像使用localStorage和sessionStorage一样来使用这个对象了。但是要注意的是,该例并没有实现存储事件,因此,当设置和查询cookieStorage对象的属性的时候,不会实现自动保存和获取对应的值。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/*
cookieStorage.js
本类实现像localStorage和sessionStorage一样的存储API,不同的是,基于HTTP cookie实现它
*/
function cookieStorage(maxage, path) {
var cookie = (function() {
var cookie = {};
var all = document.cookie;
if (all === '')
return cookie;
var list = all.split('; ');
for (var i = 0; i < list.length; i++) {
var cookie = list[i];
var p = cookie.indexOf('=');
var name = cookie.substring(0, p);
var value = cookie.substring(p + 1);
value = decodeURIComponent(value);
cookie[name] = value;
}
return cookie;
} ());

var keys = [];
for (var key in cookie)
keys.push(key);

this.length = keys.length;

this.key = function(n) {
if (n < 0 || n >= keys.length)
return null;
return keys[n];
};

this.getItem = function(name) {
return cookie[name] || null;
};

this.setItem = function(key, value) {
if (!(key in cookie)) {
keys.push(key);
this.length++;
}

cookie[key] = value;

var cookie = key + '=' + encodeURIComponent(value);

if (maxage)
cookie += '; max-age=' + maxage;

if (path)
cookie += '; path=' + path;

document.cookie = cookie;
};

this.removeItem = function(key) {
if (!(key in cookie))
return;

delete cookie[key];

for (var i = 0; i < keys.length; i++) {
if (keys[i] === key) {
keys.splice(i, 1);
break;
}
}
this.length--;

document.cookie = key + '=; max-age=0';
};

this.clear = function() {
for (var i = 0; i < keys.length; i++)
document.cookie = keys[i] + '=; max-age=0';

cookies = {};
keys = [];
this.length = 0;
};
}

三、应用程序存储和离线Web应用

HTML5中新增了“应用程序缓存”,允许Web应用将应用程序自身本地保存到用户的浏览器中。不像localStorage和sessionStorage只是保存Web应用程序相关的数据,它是将应用程序自身保存起来——应用程序所需要运行的所有文件(HTML、CSS、JavaScript、图片等)。“应用程序缓存”和一般的浏览器缓存不同:它不会随着用户清除浏览器缓存而被清除。同时,换存起来的应用程序也不会像一般固定大小的缓存那样,老数据会被最近一次访问的新数据代替掉。它其实不是临时存储在缓存中:应用程序更像是被“安装”在那里,除非被用户“卸载”或者“删除”它们,否则它们就会一直“驻扎”在那里。所以,总的来说,“应用程序缓存”在真正意义上不是缓存,更好地说法应该称之为“应用程序存储”。

让Web应用能够实现“本地安装”的目的是要保证它们能够在离线状态(比如,当在飞机上或者手机没信号的时候)下依然可以访问。将自己“安装”到应用程序缓存中的Web应用,在离线状态下使用localStorage来保存应用相关的数据,同时还具备一套同步机制,在再次回到在线状态的时候,能够将存储的数据传输给服务器。我们先来结合扫下应用程序是如何将自己“安装”到应用程序缓存中的。

1. 应用程序缓存清单

想要将应用程序“安装”到应用程序缓存中,首先要创建一个清单:包含了所有应用程序依赖的所有URL列表。然后,通过在应用程序主HTML页面的<html>标签中设置manifest属性,指向该清单文件就可以了

1
2
3
4
5
<!DOCTYPE HTML>
<html manifest="myapp.appcache">
<head>...</head>
<body>...</body>
</html>

清单文件中的首行内容必须以“CACHE MANIFEST”字符串开始。其余就是要缓存的文件URL列表,一行一个URL。相对路径的URL都相对于清单文件的URL。会忽略内容中的空行,会作为注释而忽略以“#”开始的行。注释前面可以有空格,但是在同一行注视后面是不允许有非空字符的

1
2
3
4
5
6
CACHE MANIFEST

myapp.html
myapp.js
myapp.css
images/background.png

缓存清单的MIME类型

应用程序缓存清单文件约定以.appcache作为文件扩展名。但是,这也仅仅只是个约定而已,Web服务器真正识别清单文件的方式是通过“text/cache-manifest”这个MIME类型的一个清单。如果服务器将清单文件的Content-Type的头信息设置成其他MIME类型,那么就不会缓存应用程序了。因此,可能需要对Web服务器做一定的配置来使用这个MIME类型,比如,在Web应用目录下创建Apache服务器的一个.htaccess文件。

清单文件包含要缓存的应用的标识。如果一个Web应用有很多Web页面(用户可以访问多个HTML页面),那么每个HTML页面就需要设置<html manifest=>属性来指向清单文件。事实上,将这些不同的页面都指向同一个清单文件,可以很清楚地表达它们都是需要换存起来的,同时它们又是来自同一个Web应用的。如果一个应用只有少量的HTML页面,那么一般会把这些页面都显式地列在清单文件中。

像之前提到的,一个简单的清单必须列出Web应用依赖的所有资源。一旦一个Web应用首次下载下来并缓存,之后的任何加在请求就都来自缓存。从缓存中去载入一个应用资源的时候,就要求它请求的任何资源务必要在清单中。不会载入不在清单中的资源。这种政策有点离线的味道。如果一个简单的缓存起来的应用能够从缓存中载入并运行,那么它也可以在浏览器的离线状态下运行。通常情况下,很多复杂的Web应用无法将它们依赖的所有资源都缓存起来。但是,如果它们同时也有一个复杂的清单的话,它们仍然可以使用应用程序缓存。

2. 复杂的清单

一个应用从应用程序缓存中载入的时候,只有其清单文件中列举出来的资源文件会载入。前面例子中的清单文件一次列举一个资源的URL。事实上,清单文件还有比这更复杂的语法,例句资源的方式也还有另外两种。在清单文件中可以使用特殊的区域头来标识该头信息之后清单项的类型。像该例中列举的简单缓存项事实上都属于“CACHE”区域,这也是默认的区域。另外两种区域是以“NETWORK”和“FALLBACK”头信息开始的。

“NETWORK”区域标识了该URL中的资源从不缓存,总要通过网络获取。通常,会将一些服务端的脚本资源放在“NETWORK”区域中,而实际上该区域中的资源的URL都只是URL前缀,用来表示以此URL前缀开头的资源都应该要通过网络加载。当然,如果浏览器处于离线状态,那么这些资源都将获取失败。“NETWORK”区域中的URL还支持“*”通配符。该通配符表示对任何不在清单中的资源,浏览器都将通过网络加载。这实际上违背了这样一条规则:缓存应用程序必须要在清单中列举的所有应用相关的资源!

“FALLBACK”区域中的清单项每行都包含两个URL。第二个URL是指需要加载和存储在缓存中的资源,第一个URL是一个前缀。任何能够匹配到该前缀的URL都不会换存起来,但是可能的话,它们会从网络中载入。如果从网络中载入这样一个URL失败的话,就会使用第二个URL指定的缓存资源来代替,从缓存中获取。想象一个Web应用包含一定数量的视频教程。这些视频都很大,显然把它们缓存到本地是不合适的。因此,在离线状态下,通过清单文件中的fallback区域,就可以使用一些基于文本的帮助文件来代替了。

1
2
3
4
5
6
7
8
9
10
11
12
CACHE MANIFEST

CACHE:
myapp.html
myapp.css
myapp.js

FALLBACK:
videos/ offline_help.html

NETWORK:
cgi/

3. 缓存的更新

当一个Web应用从缓存中载入的时候,所有与之相关的文件也是直接从缓存中获取。在线状态下,浏览器会异步地检查清单文件是否有更新。如果有更新,新的清单文件以及清单中列举的所有文件都会下载下来重新保存到应用程序缓存中。但是,要注意的是,浏览器只是检查清单文件,而不会去检查缓存的文件是否有更新:只检查清单文件。比如,如果修改了一个缓存的JavaScript文件,并且要想让该文件生效,就必须去更新下清单文件。由于应用程序依赖的文件列表其实并没有变化,因此最简单的方式就是更新版本号

1
2
3
4
CACHE MANIFEST
# myapp version 1
myapp.html
myapp.js

同样,如果想要让Web应用从缓存中“卸载”,就要在服务器端删除清单文件,使得请求该文件的时候返回HTTP404无法找到的错误,同时,修改HTML文件以便它们与该清单列表断开链接。

要注意的是,浏览器检查清单文件以及更新缓存的操作是异步的,可能是在从缓存中载入应用之前,也有可能同时进行。因此,对于简单的Web应用而言,在更新清单文件之后,用户必须载入应用两次才能保证最新的版本生效:第一次是从缓存中载入老版本随后更新缓存;第二次才从缓存中载入最新的版本。

浏览器在更新缓存过程中会触发一系列事件,可以通过注册处理程序来跟踪这个过程同时提供反馈给用户。

1
2
3
4
5
6
7
applicationCache.onupdateready = function() {
var reload = confirm("A new version of this application is available\n" +
"and will be used the next time you reload.\n" +
"Do you want to reload now?");
if (reload)
location.reload();
}

要注意的是,该事件处理程序是注册在ApplicationCache对象上的,该对象是Window的applicationCache属性的值。支持应用程序缓存的浏览器会定义该属性。此外,除了上面例子中的updateready事件之外,还有其他7种应用程序缓存事件可以监控。下面展示了一个简单的处理程序通过显示对应的消息来通知用户缓存更新的进度,以及当前缓存的状态。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 下面所有的事件处理程序都是用此函数来显示状态消息
// 由于都是通过调用status函数来显示状态,因此所有处理程序都返回false来阻止浏览器
// 显示其默认状态消息
function status(msg) {
// 将消息输出到id为“statusline”的文档元素中
document.getElementById('statusline').innerHTML = msg;
console.log(msg);
}

// 每当应用程序载入的时候,都会检查该清单文件
// 也总会首先触发“checking”事件
window.applicationCache.onchecking = function() {
status('Check for a new version.');
return false;
};

// 如果清单文件没有改动,同时应用程序也已经缓存了
// “noupdate”事件会被触发,整个过程结束
window.applicationCache.onnoupdate = function() {
status('This version is up-to-date.');
return false;
};

// 如果还未缓存应用程序,或者清单文件有改动
// 那么浏览器会下载并缓存清单中的所有资源
// 触发“downloading”事件,同时意味着下载过程开始
window.applicationCache.ondownloading = function() {
status('Downloading new version.');
window.progresscount = 0;
return false;
};

// 在下载过程中会间断性地触发“progress”事件
// 通常是在每个文件下载完毕的时候
window.applicationCache.onprogress = function(e) {
// 事件对象应当是“process”事件
// 通过该对象可以计算出下载完成比例,但是,如果它不是“process”事件,
// 我们统计调用的次数
var progress = '';
if (e && e.lengthComputable)
progress = ' ' + Math.round(100 * e.loaded / e.total) + '%';
else
progress = ' (' + ++progresscount + ')';

status('Downloading new version' + progress);
return false;
};

// 当下载完成并且首次将应用程序下载到缓存中时,
// 浏览器会触发“cached”事件
window.applicationCache.oncached = function() {
status('This application is now cached locally');
return false;
};

// 当下载完成并将缓存中的应用程序更新后,浏览器会触发“updateready”事件
// 要注意的是:触发此事件的时候,用户仍然可以看到老版本的应用程序
window.applicationCache.onupdateready = function() {
status('A new version has been download. Reload to run it');
return false;
};

// 如果浏览器处于离线状态,检查清单列表失败,则会触发“error”事件
// 当一个未缓存的应用程序引用一个不存在的清单文件,也会触发此事件
window.applicationCache.onerror = function() {
status("Coundn't load manifest or cache application");
return false;
};

// 如果一个缓存的应用程序引用一个不存在的清单文件
// 会触发“obsolete”事件,同时会将应用从缓存中移除
// 之后都不会从缓存而是通过网络来加载资源
window.applicationCache.onobsolete = function() {
status('This application is no longer cached. ' +
'Reload to get the latest version from the network.');
return false;
};

每次载入了一个设置了manifest属性的HTML文件,浏览器都会触发“checking”事件,并通过网络载入该清单文件。不过之后,会随着不同的情况触发不同的事件。

  • 没有可用的更新

    如果应用程序已经缓存并且清单文件没有改动,则浏览器会触发“noupdate”事件。

  • 有可用的更新

    如果应用程序已经缓存了并且清单文件发生了改动,则浏览器会触发“downloading”事件,并开始下载和缓存清单文件中列举的所有资源。随着下载过程的进行,浏览器还会触发“progress”事件,在下载完成后,会触发“updateready”事件。

  • 首次载入新的应用程序

    如果还未缓存应用程序,“downloading”事件和“progress”事件都会触发。但是,当下载完成后,浏览器会触发“cached”事件而不是“updateready”事件。

  • 浏览器处于离线状态

    如果浏览器处于离线状态,它无法检查清单文件,同时它会触发“error”事件。如果一个未缓存的应用程序引用一个不存在的清单文件,浏览器也会触发该事件。

  • 清单文件不存在

    如果浏览器处于在线状态,应用程序也已经换存起来了,但是清单文件不存在(返回404无法找到错误),浏览器会触发“obsolete”事件,并将该应用程序从缓存中移除。

除了使用事件处理程序之外,还可以使用applicationCache.status属性来查看当前缓存状态,该属性有6个可能的属性值:

  • ApplicationCache.UNCACHED (0)

    应用程序没有设置manifest属性,未缓存

  • ApplicationCache.IDLE (1)

    清单文件已经检查完毕,并且已经缓存了最新的应用程序

  • ApplicationCache.CHECKING (2)

    浏览器正在检查清单文件

  • ApplicationCache.DOWNLOADING (3)

    浏览器正在下载并缓存清单中列举的所有文件

  • ApplicationCache.UPDATEREADY (4)

    已经下载和缓存了最新版的应用程序

  • ApplicationCache.OBSOLETE (5)

    清单文件不存在,缓存将被清除

ApplicationCache对象还定义了两个方法:update()方法显式调用了更新缓存算法以检测是否有最新版本的应用程序。这导致浏览器检测同一个清单文件(并触发相同的事件),这和第一次载入应用程序时的效果是一样的。

还有一个方法是swapCache(),该方法更加巧妙。还记得当浏览器下载并缓存更新版本的应用时,用户仍然在运行老版本的应用吧。只有当用户再次载入应用时,才会访问到最新版本。但是如果用户没有重新载入,就必须要保证老版本的应用也要工作正常。同时要注意的时,老版本应用程序的相关资源可能是从缓存中加载的:比如,应用程序可能使用XMLHttpRequest去获取文件,而这些请求也务必要保证能够从老版本缓存中的文件获取到。因此,浏览器在用户再次载入应用前必须在缓存中保留老版本的应用。

swapCache()方法告诉浏览器它可以弃用老的缓存,所有的请求都从新缓存中获取。要注意的是,这并不会重新载入应用程序:所有已经载入的HTML文件、图片、脚本等资源都不会改变。但是,之后的请求都将从最新的缓存中获取。这会导致“版本错乱”的问题,因此,一般不推荐使用,除非应用程序设计得很好,确保这样的方式没有问题。想象下,比方说,有这么个应用程序,它什么也不做,就只是在浏览器检查清单文件的整个过程中,显示过度画面。触发“noupdate”事件时,它继续“前进”并载入应用程序的首页。触发“downloading”事件,并且更新缓存后,它显示合适的反馈给用户。触发“updateready”事件时,它调用swapCache()方法,然后从最新的缓存中载入更新过的首页。

要注意的是,只有当状态属性是ApplicationCache.UPDATEREADY或者ApplicationCache.OBSOLETE时,调用swapCache()方法才有意义(当状态是OBSOLETE时,调用swapCache()方法可以立即弃用废弃的缓存,让之后所有的请求都通过网络获取)。如果在状态属性是其他数值的时候调用swapCache()方法,它就会抛出异常。

4. 离线Web应用

离线Web应用指的是将自己“安装”在应用程序缓存中的程序,使得哪怕在浏览器处于离线状态时候依然可访问它。举个最简单的例子——类似时钟和万花筒生成器这样的应用——Web应用要离线可用需要做的事情。但是,大多数重要的Web应用也需要像服务器上传数据:哪怕是简单的游戏应用都有可能需要把用户的最高得分上传到服务器上。这类应用也可以成为离线应用。它们可以使用localStorage来存储应用数据,然后当在线的时候再将数据上传到服务器。在本地存储和服务器端同步数据是将Web应用转变为离线应用最巧妙的缓解,特别是当用户需要从多台设备获取数据的时候。

为了在离线状态可用,Web应用需要可以告知别人自己是离线还是在线,同时当网络连接的状态发生改变时候也能“感知”到。通过navigator.onLine属性,可以检测浏览器是否在线,同时,在Window对象上注册在线和离线事件的处理程序,可以检测网络连接状态的改变。

下面以一个简单的离线Web应用结束,该应用使用了这些技术。该应用名叫“PermaNote”——一个简单的记事本程序,它将用户的文本保存到localStorage中,并且在网络连接可用的时候,将其上传到服务器。PermaNote只允许用户编辑单个笔记,而且不考虑任何授权和身份验证的问题——它假设服务端有区分用户的方式,但是不包括任何登陆界面。PermaNode应用包含三个文件。其中appcache文件是一个缓存清单文件,它列出了另外两个文件,同时指定不需要缓存“note”这个url:我们使用此url来实现在服务端读写笔记数据。

1
2
3
4
5
6
7
8
9
10
<!-- permanote.appcache -->

CACHE MANIFEST

# PermaNote v8
permanote.html
permanote.js

NETWORK:
note
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- permanote.html -->

<!DOCTYPE HTML>
<html manifest="permanote.appcache">
<head>
<title>PermaNote Editor</title>
<script src="permanote.js"></script>
<style>
#editor { width: 100%; height: 250px; }
#statusline { width: 100%; }
</style>
</head>
<body>
<div id="toolbar">
<button id="savebutton" onclick="save()">Save</button>
<button onclick="sync()">Sync Note</button>
<button onclick="applicationCache.update()">Update Application</button>
</div>
<textarea id="editor"></textarea>
<div id="statusline"></div>
</body>
</html>
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
// permanote.js

var editor, statusline, savebutton, idletimer;

window.onload = function() {
// 第一次载入时,初始化本地存储
if (localStorage.note == null)
localStorage.note = '';
if (localStorage.lastModified == null)
localStorage.lastModified = 0;
if (localStorage.lastSaved == null)
localStorage.lastSaved = 0;

// 查找编辑器UI元素,并初始化全局变量
editor = document.getElementById('editor');
statusline = document.getElementById('statusline');
savebutton = document.getElementById('savebutton');

// 初始化编辑器,将保存的笔记数据填充为其内容
editor.value = localStorage.note;
// 同步前禁止编辑
editor.disabled = true;

// 一旦文本区有内容输入
editor.addEventListener('input', function(e) {
// 将新的值保存到localStorage中
localStorage.note = editor.value;
localStorage.lastModified = Date.now();
// 重置闲置计时器
if (idletimer)
clearTimeout(idletime);
idletimer = setTimeout(save, 5000);
// 启用保存按钮
savebutton.disabled = false;
}, false);

// 每次载入应用程序时,尝试同步服务器
sync();
};

// 离开页面前保存数据到服务器
window.onbeforeunload = function() {
if (localStorage.lastModified > localStorage.lastSaved)
save();
};

// 离线时,通知用户
window.onoffline = function() {
status('Offline');
};

// 再次返回在线状态时,进行同步
window.ononline = function() {
sync();
};

// 当有新版本应用的时候,提醒用户
// 这里我们也可以采用location.reload()方法来强制重新载入应用
window.applicationCache.onupdateready = function() {
status('A new version of this application is available. Reload to run it.');
};

// 当没有新版本的时候也通知用户
window.applicationCache.onnoupdate = function() {
status('You are running the latest version of the application');
};

// 用于在状态栏中显示状态消息的函数
function status(msg) {
statusline.innerHTML = msg;
}

// 每当笔记内容更新后,如果用户停止编辑超过5分钟
// 就会自动将笔记文本上传到服务器(在线状态下)
function save() {
if (idletimer)
clearTimeout(idletimer);

idletimer = null;
if (navigator.onLine) {
var xhr = new XMLHttpRequest();
xhr.open('PUT', '/note');
xhr.send(editor.value);
xhr.onload = function() {
localStorage.lastSaved = Date.now();
savebutton.disabled = true;
};
}
}

// 检查服务端是否有新版本的笔记,
// 如果没有,则将当前版本保存到服务器端
function sync() {
if (navigator.onLine) {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/note');
xhr.send();
xhr.onload = function() {
var remoteModTime = 0;
if (xhr.status == 200) {
var remoteModTime = xhr.getResponseHeader('Last-Modified');
remoteModTime = new Date(remoteModTime).getTime();
}

if (remoteModTime > localStorage.lastModified) {
status('Newer note found on server.');
var useit = confirm('There is a newer version of the note\n' +
'on the server. Click OK to use that version\n' +
'or click Cancel to continue editing this\n' +
'version and overwrite the server');
var now = Date.now();
if (useit) {
editor.value = localStorage.note = xhr.responseText;
localStorage.lastSaved = now;
status('Newest version downloaded');
} else {
status('Ignoring newer version of the note.');
}
localStorage.lastModified = now;
} else {
status('You are editing the current version of the note');
}

if (localStorage.lastModified > localStorage.lastSaved) {
save();
}

editor.disabled = false;
editor.focus();
}
} else {
// 离线状态下,不能同步
status("Can't sync while offline");
editor.disabled = false;
editor.focus();
}
}

参考文献

  1. 《JavaScript权威指南(第6版)》