离线web-ApplicationCache
https://www.html5rocks.com/en/tutorials/appcache/beginner/
http://diveintohtml5.info/offline.html#fallback
介绍
离线web程序变得非常重要。所有的浏览器都支持长期缓存页面资源,但浏览器会为了腾出缓存空间而清除掉一些之前的缓存。h5中通过ApplicationCache(后面简称appCache)来解决这个问题。appCache可以允许开发者指定某些文件应该保存到本地,用于离线访问。它有三个优点:
- 让用户可以离线访问页面
- 直接从本地硬盘而不是网络获取资源,速度更快
- 当我们的服务器因为某些原因宕机时,用户依然可以访问我们的页面,这使我们的服务更加健壮
清单文件
这是一个文本文件,用于列出浏览器应该缓存的资源,来离线访问。通过在页面中引用这个清单文件,来启用离线缓存:
<html manifest="example.appcache"> ... </html>
每个需要被离线访问的页面都需要像上面那样引用清单文件,如果html标签没有manifest属性,则浏览器不会缓存这个页面。页面地址后有不同参数,会被AppCache认为是不同的页面,会被分开缓存,所以AppCache最好用于无参数(或固定参数,唯一的)地址的页面。
通过在chrome中访问chrome://appcache-internals/来管理被appCache缓存的页面。
manifest所指向的清单文件地址必须与当前页面同源。清单文件可以使任意后缀,但被服务器输出时必须指定其媒体类型(content-type)为text/cache-manifest。这个规定在较新版本的chrome、ff、Safari被废弃了,但是对于IE11以及其他老浏览器,仍然有这个规定。
一个简单的清单文件结构如下:
CACHE MANIFEST
index.html
stylesheet.css
images/logo.png
scripts/main.js
http://cdn.example.com/scripts/main.js
以上清单文件会缓存4个文件,需要注意的是:
- CACHE MANIFEST必须在第一行
- 被缓存的文件可以来自其他域(CDN)
- 一些浏览器对于缓存的数量有限制,在chrome中,appCache使用共享缓存(与其他离线API共享这片区域)
- 如果manifest指向的地址返回了404或410,则缓存会被删除
- 如果清单文件或者指定被缓存的资源下载失败,则缓存会更新失败,浏览器会使用旧的缓存。
浏览器会下载所有清单文件中指定的文件,而不管这些文件有没有在当前页面中有用到。如果有多个页面,则每个页面都需要指向相同的、代表了整个应用程序的清单文件。如果app只有一个html,则保证这个html引用了清单文件即可,而不需要把这个html添加到清单文件中,因为浏览器会认为这个页面属于app的一部分,而自动对这个html进行缓存(但还是推荐显式地加上),利用html文件会被自动缓存的这特点可以实现一个“懒缓存”,H5说明书提供了一个例子:
CACHE MANIFEST
FALLBACK:
/ /offline.html
NETWORK:
*
对于一个有成千上万个页面的站点,我们不可能也不想去下载整个站点,但可以将其中一部分做成离线的,但怎么决定哪些页面应该被缓存呢?假设这个巨大的站点支持离线,我们访问的每个页面都会被下载和缓存。这就是以上清单文件所能做的事情。假设这个巨大的站点中每个html都引用了上面的清单文件,当访问里面的html时,浏览器会认为这个html是一个离线app的一部分,如果浏览器没有下载过这个清单文件,则会创建一个新的离线缓存,然后下载清单文件中所有的资源并且缓存到刚刚创建的离线缓存中,然后把当前页面也添加到缓存中。这样一来,任何被访问过的页面都会添加进同一个离线缓存中。换个说法就是所有html都引用同一个清单文件,但CACHE那一块不需要列举html文件,因为会自动缓存,但里面的资源需要列举,因为里面的资源不会被自动缓存。
以上的fallback仅仅只有一行,第一个斜杠是一个url匹配模式,会匹配所有页面。当我们离线访问一个页面时,这个页面在缓存中,则显示缓存的页面;如果不在缓存中,则显示错误信息,并且显示/offline.html这个页面。
下面来看一个更复杂的例子( # 用于注释一行):
CACHE MANIFEST
# 2010-06-18:v2
# Explicitly cached 'master entries'.
CACHE:
/favicon.ico
index.html
stylesheet.css
images/logo.png
scripts/main.js
# Resources that require the user to be online.
NETWORK:
*
# static.html will be served if main.py is inaccessible
# offline.jpg will be served in place of all images in images/large/
# offline.html will be served in place of all other .html files
FALLBACK:
/main.py /static.html
images/large/ images/offline.jpg
事件流
当我们访问了一个页面引用了清单文件,会有一系列的事件在window.applicationCache对象上触发(事件监听器要设置在这个对象上)。
当浏览器发现html标签上有一个manifest属性时,首先会触发一个checking事件,这个事件总会触发,不管浏览器之前有没有遇到过这个清单文件(再次访问当前页面或者访问过其他页面指向了相同的清单文件)
假如浏览器第一次遇到这个清单文件:
- 触发一个downloading事件,然后下载里面的资源
- 正在下载的时候,会持续触发progress事件,可以知道文件的下载情况
- 当所有文件都下载完,会触发一个cached事件,这就意味着资源已经缓存完成,可以离线了。
如果浏览器之前就遇到过这个清单文件,则可能有文件已经缓存好了。那问题就是自上次检查完后,这个清单文件本身有没有发生变化?
- 如果没有发生变化,则触发一个noupdate事件
- 如果有,则触发一个downloading事件,然后重新下载里面所有的资源。正在下载时,持续触发progress事件。当全部资源已经重新下载完毕了,触发updateready事件,这意味着一个全新版本的资源已经缓存完成,可以离线了。但新的资源并没有被应用上去,需要调用swapCache函数,然后手动刷新页面才会生效。
如果以上更新流程出现问题,则触发一个error事件并且停止更新。可能出错的地方:
- 可以联网时,以上的协商检查返回了404或410(清单文件找不到或者无权访问清单文件)
- 重新绑定关联时,新的清单下载失败
- 当更新过程中,服务器的清单文件发生变化
- 重新下载所有资源时,资源下载失败。
appCache暴露了一些事件让我们去观察appCache的状态:
function handleCacheEvent(e) { //... } function handleCacheError(e) { alert('Error: Cache failed to update!'); }; // Fired after the first cache of the manifest. appCache.addEventListener('cached', handleCacheEvent, false); // Checking for an update. Always the first event fired in the sequence. appCache.addEventListener('checking', handleCacheEvent, false); // An update was found. The browser is fetching resources. appCache.addEventListener('downloading', handleCacheEvent, false); // The manifest returns 404 or 410, the download failed, // or the manifest changed while the download was in progress. appCache.addEventListener('error', handleCacheError, false); // Fired after the first download of the manifest. appCache.addEventListener('noupdate', handleCacheEvent, false); // Fired if the manifest file returns a 404 or 410. // This results in the application cache being deleted. appCache.addEventListener('obsolete', handleCacheEvent, false); // Fired for each resource listed in the manifest as it is being fetched. appCache.addEventListener('progress', handleCacheEvent, false); // Fired when the manifest resources have been newly redownloaded. appCache.addEventListener('updateready', handleCacheEvent, false);
调试的艺术
以上提到清单文件中的资源下载失败时,会触发一个error事件,但我们无法得知具体的错误是什么,这使离线应用调试起来让人沮丧。
浏览器到底是怎么检查被缓存的清单文件是否发生了修改呢?分成三步:
- 通过http协议,类似于其他http资源,浏览器会检查缓存的清单文件是否过期,在http响应头中包含了文件的元信息,但不是强缓存(Expires、Cache-Control)。
- 假如缓存的清单文件过期了(通过http头检查),浏览器会问服务器是否有一个新的版本可以下载,是则下载。为了实现这一点浏览器会发起一个http请求,里面包含了缓存的清单文件的修改时间,这个时间也就是上次清单文件下载的时间。如果web服务器认为这个文件没有被修改,则返回304.
- 如果web服务器认为这个文件发生修改了,则返回200,浏览器从这个响应中读取新的清单文件内容.
总结以上三个步骤就是缓存的清单文件是协商缓存。
假如发布了一个清单文件,10分钟后,往上面加了一行,再次发布。会发生这样的事情:刷新页面,结果什么也没发生。这是因为浏览器始终认为这个缓存没有发生变化,这可能是因为服务器设置了强缓存(Cache-Control)。所以有一件事情绝对要去做:取消(不设置)清单文件的强缓存。
只要清单文件没有发生变化,则即使里面的资源发生了变化,浏览器也不会发现。如一个css文件已经重新发布了,但运行起来没有任何变化,因为清单文件没有发生变化。解决办法就是:只要离线资源发生了变化,就去修改清单文件,简单随便改里面的一个字符即可。最方便就是通过 # 注释往里面标记一个版本号,修改版本号即可
CACHE MANIFEST
# rev 43
clock.js
clock.css
html标签可以以相对路径引用清单文件,清单文件内以相对于清单文件的路径来引用其他资源。
缓存更新
可以理解为清单文件也会被缓存,是协商缓存。表现为
- 第一次访问页面时,下载并且把清单内容缓存到本地,浏览器再将这个清单文件的地址与页面的地址进行关联(绑定关联)
- 再次访问时,浏览器根据页面地址找到对应关联的清单地址(获取关联),然后检查这个地址是否与当前页面的清单地址一致(校验关联),不一致则重新下载新的清单(重新绑定关联)。最后根据这个地址检查本地的清单内容是否发生变化(协商检查),是则进入更新流程(重新下载所有资源),无法联网则使用缓存的清单文件。
仅当清单文件内容发生变化时,才会触发浏览器去更新缓存。假如清单文件无变化,而仅仅修改了图片或者修改了一个JS函数,这些变化不会被缓存(不会被浏览器发现)。
在一次更新中,清单文件会被检查(协商检查清单文件有没有发生变化)两次,开始的时候一次和所有缓存文件都更新完成后再检查一次。如果清单文件在更新的时候被修改,即两次检查的结果不一致,则更新失败。
就算缓存被更新了,浏览器不会使用这些缓存,直到页面被刷新,因为缓存的更新发生在页面重新加载之后(加载的是当前版本的缓存,而不是新版版的缓存)。
当清单文件或者清单文件内指定的资源下载失败时,整个更新过程就会失败。浏览器会继续使用旧的缓存
缓存会一直有效,直到:
- 用户手动清空浏览器缓存
- 清单文件被修改。更新清单文件中的缓存列表,并不意味着浏览器会对缓存进行更新,清单文件本身必须要修改
缓存的状态
通过window.applicationCache可以以编程方式来访问浏览器中的appCache,对象的status属性用于检查当前缓存的状态
var appCache = window.applicationCache; switch (appCache.status) { case appCache.UNCACHED: // UNCACHED == 0 return 'UNCACHED'; break; case appCache.IDLE: // IDLE == 1 return 'IDLE'; break; case appCache.CHECKING: // CHECKING == 2 return 'CHECKING'; break; case appCache.DOWNLOADING: // DOWNLOADING == 3 return 'DOWNLOADING'; break; case appCache.UPDATEREADY: // UPDATEREADY == 4 return 'UPDATEREADY'; break; case appCache.OBSOLETE: // OBSOLETE == 5 return 'OBSOLETE'; break; default: return 'UKNOWN CACHE STATUS'; break; };
编程方式来更新缓存首先要调用update方法,这会尝试去更新缓存(但需要清单文件已经发生变化),当status进行UPDATEREADY时,可以使用swapCache函数来将新的缓存替换旧的缓存:
var appCache = window.applicationCache; appCache.update(); // Attempt to update the user's cache. ... if (appCache.status == window.applicationCache.UPDATEREADY) { appCache.swapCache(); // The fetch was successful, swap in the new cache. }
以上缓存替换完成后,需要刷新页面,新替换进去的缓存才会生效,可以这么做:
window.addEventListener('load', function(e) { window.applicationCache.addEventListener('updateready', function(e) { if (window.applicationCache.status == window.applicationCache.UPDATEREADY) { // Browser downloaded a new app cache. if (confirm('A new version of this site is available. Load it?')) { window.location.reload(); } } else { // Manifest didn't changed. Nothing new to server. } }, false); }, false);
清单文件的结构
清单文件可以分成三个部分(书写顺序无影响,一个文件中可以同一个部分出现多次):CACHE、NETWORK和FALLBACK。
CACHE:
这是默认的部分,里面列出的文件(或者直接在CACHE MANIFEST底下的文件)会被下载后第一时间进行缓存
NETWORK:
里面列出的文件如果不在缓存中,则从网络上下载;否则如果某些资源地址不在这里,就算网络通畅,也不会去下载文件。所以一般指定一个 * 即可。
FALLBACK:
这是可选的,里面第一个url指定为资源,第二个url为当资源无法访问时就访问这个url。两个url必须与当前页面同源。这里指定资源url时,可以指定一个前缀来批量地指定多个url的访问失败时的反馈页面。
以下例子定义了一个全资源的错误页面(offline.html),当无网络时,用户访问站点根目录或者访问其他所有资源时,则这个页面就会显示出来
CACHE MANIFEST
# 2010-06-18:v3
# Explicitly cached entries
index.html
css/style.css
# offline.html will be displayed if the user is offline
FALLBACK:
/ /offline.html
# All other resources (e.g. sites) require the user to be online.
NETWORK:
*
# Additional resources to cache
CACHE:
images/logo1.png
images/logo2.png
images/logo3.png
appCache是一个逗比(douchebag)
http://alistapart.com/article/application-cache-is-a-douchebag
里面形容appCache就是一颗洋葱,当你一层一层剥开,你会流泪的。
1.文件总是来自于appCache,即使网络可用
当缓存更新完成,updateready事件会被触发,但这时候我们不能刷新页面,因为用户可能正在进行交互。这不是什么大问题,因为旧的缓存也可能足够好用了,如果实在需要新的缓存,则弹框提醒用户是否要刷新界面来启用新的缓存,是则刷新页面。
2.appCache缓存仅当清单文件本身发生变化时才更新
http协议是有缓存的,我们可以为每个文件定义缓存的方式(总是不缓存、协商缓存和强缓存)。假如清单文件中有50个html,每次我们访问里面的页面,浏览器就要创建50个请求来检查他们是否需要更新。
浏览器仅当清单文件本身发生变化了,才去检查内部的资源是否需要更新,只要清单文件的一个字节发生变化,就会触发检查更新。
这对一直不会变化的静态资源来说非常好。当资源的路径发生变化,就意味着清单文件的内容发生变化。最简单的改变清单文件的方式是修改里面的注释(如# v1 改成 # v2),这可以通过构建工具来完成,类似于为每个文件都创建一个唯一标识符ETAG,然后通过注释写到清单文件中,这样一来,文件内容发生变化,则清单文件发生变化
然而,清单文件被更新,不意味着里面的资源会更新。
3.appCache属于额外的缓存,不能替换http缓存
大部分appCache被更新时,浏览器会发出http请求。这符合一般的缓存流程:假如资源已经被强缓存下来了,则appCache更新,浏览器发出请求,发现这个强缓存没有到期,则不会去访问服务器了,更新就这样结束了。这是可以的,因为当清单文件发生变化时,我们降低了向服务器请求的次数。
所以所有资源都要正确地设置好http缓存头,这比清单文件更加重要
4.永远不要去强缓存清单文件
强缓存一个清单文件的话,页面每次获取到的清单文件总是旧的,根据清单文件获取的缓存也是旧的。
5.一个缓存页上不会加载没被缓存的资源
当缓存了index.html,却没有缓存cat.jpg,则这个图片不会显示在index.html上,即使网络可用。解决这个问题可以将NETWORK版块设置为一个 * 。这意味着浏览器在展示一个缓存页时,没有被缓存的资源就从网络中获取。
配合iframe使用
案例1:
在线访问一个普通页面A(没有引用清单文件),内嵌了一个隐藏的iframe,指向另一个页面B(引用了清单文件),可以离线访问B
案例2:
在案例1的基础上,B中的清单文件配置了Fallback,然后离线访问A,B中的fallback生效了。这是因为B在上次已经被缓存,然后离线访问A,A中通过一个隐藏的iframe访问了B,就相当于离线访问了B,B的fallback就生效了。
fallback:
CACHE MANIFEST FALLBACK: / fallback.html /assets/imgs/avatars/ assets/imgs/avatars/default-v1.png
以上当请求失败,就显示fallback.html。除非前缀为/assert/ims/avatars/,就返回一个默认的图片。
使用localstorage来动态离线管理
我们不可能缓存所有东西,因为内容太多了。我们希望用户自己去选择可离线访问的资源,保存到localstorage中。实现如下:
1.首先以地址映射页面内容,地址映射标题(标题都保存到index指向的json字符串中)
// Get the page content var pageHtml = document.body.innerHTML; var urlPath = location.pathName; // Save it in local storage localStorage.setItem( urlPath, pageHtml ); // Get our index var index = JSON.parse( localStorage.getItem( 'index' ) ); // Set the title and save it index[ urlPath ] = document.title; localStorage.setItem( 'index', JSON.stringify( index ) );
2.通过清单指定一个fallback页面,页面中读取localstorage:
var pageHtml = localStorage.getItem( urlPath ); if ( !pageHtml ) { document.body.innerHTML = 'Page not available'; }else { document.body.innerHTML = localStorage.getItem( urlPath ); document.title = localStorage.getItem( 'index' )[ urlPath ]; }
6.再见,条件下载
对于响应式下载(媒体查询加载不同的图片,如source标签),浏览器会根据清单文件的内容来下载,而不管其他地方是否有媒体查询,这会导致浏览器下载多套不同的资源。同样的道理也会作用于字体
7.我们不知道什么时候会显示fallback页面
根据说明(spec),当源请求被重定向到其他域、遇到4xx或5xx状态码、或网络错误时会显示fallback页面(显示的方式类似于服务器端跳转,即浏览器地址不变)。但是当用户没有网络的时候,fallback也没办法显示了。
8.重定向到其他域也会被认为是个错误
如果页面访问一个url,服务器对这个url的请求进行重定向,这会马上显示一个对应url的fallback页面,因为appCache不允许。
这个规则是好的,因为假设我们连了wifi上网,访问网站则重定向到了这个wifi的付费页面,相对于这点,显示fallback页面是好的。把这些需要重定向的url添加到NETWORK是无效的,但使用JS或者meta-redirect是可以的
其他阅读
- Gmail for mobile HTML5 series: using appcache to launch offline - part 1
- Gmail for mobile HTML5 series: using appcache to launch offline - part 2
- Gmail for mobile HTML5 series: using appcache to launch offline - part 3
- Debugging HTML5 offline application cache
- an HTML5 offline image editor and uploader application
- 20 Things I Learned About Browsers and the Web, an advanced demo that uses the application cache and other HTML5techniques
- https://github.com/jakearchibald/appcache-demo