使用 jQuery Mobile 与 HTML5 开发 Web App —— HTML5 离线缓存

本文要介绍的,是 HTML5 离线网络应用程序的特性,离线网络应用程序在 W3C 中的实际名称是 "Offline Web applications" ,也称离线缓存。当用户打开浏览器时,浏览器会将一个列表中指定的资源都下载并储存在本地。下次当用户再访问这个网络程序时,浏览器会自动引用本地缓存中相应的文件,而不会再从网络下载这些资源。不管离线网络应用程序是否专为 Web Apps 而设,但这对于 Web Apps 来说无疑是个非常实用的特性,它使到 Web Apps 相对于原生 Apps 的一个重要劣势 —— 高度依赖网络,得以大大减缓。开发者可以利用这个特性把 Web Apps 中的元素缓存到本地端,使到 Web Apps 可以脱机工作,即使是需要联网工作的 Apps ,也可以缓存部分文件到本地端,减少带宽占用,这样 Web Apps 相对于原生 Apps 就更加具有优势了。下面正式开始介绍这个特性。

一. 离线网络应用程序基础

离线网络应用程序的核心是一个 content type (内容类型) 为 cache-manifest 的文本文件。这个文件保存了应用程序中需要离线存储的文件(HTML, CSS, JavaScript, 图片等)。举一个简单的例子,若一个应用程序由以下文件组成:

  • index.html
  • demo.css
  • demo.js
  • logo.png

index.html 为主页,其他文件是主页中引用的资源,如果我们需要离线缓存这个应用程序,需要在 index.html 的同级目录下增加一个 manifest 文件,并命名为 .manifest 后缀文件,而文件中的内容可以这样编写:

CACHE MANIFEST
./index.html
./demo.css
./demo.js
./logo.png

可以看出,以上文件的路径是相对路径,这里是相对于 manifest 文件而言的。当然,你也可以使用绝对路径,这并不影响 manifest 的使用。

manifest 文件的编写很简单,但启用离线缓存还需一些步骤。首先是需要把 manifest 文件和应用程序关联起来,即把页面指向缓存名单,方法是为 html 标签指定 manifest 值,例如:

<html manifest="demo.manifest">

其中 demo.manifest 是例子中编写的 manifest 列表文件。

小提示:若应用程序中有多个页面,则每个页面都需要与 manifest 关联起来。

接着,开发者必须给 manifest 文件指定 text/cache-manifest 内容类型,这样浏览器才能识别它。关于具体的方法,如果你的服务器支持 .htaccess ,可以在 .htaccess 中写入以下语句:

AddType text/cache-manifest .manifest

这样做可以把 text/cache-manifest 的 MIME 类型和 .manifest 文件关联起来。当然,可能开发者并没有 .htaccess 的编写权限,但实际上,开发者可以使用另外一些更实用的方法达到目的。因为在实际的项目开发中,应用程序中的文件数量不会只像上例中仅有的 4 个,如果有很多的文件需要缓存,每个文件都需要手动写入 manifest 文件实在比较费时,更麻烦的是,每次改动这些文件都必须相应地改动 manifest 文件,因此 Kayo 更加建议开发者使用后台脚本直接获取需要缓存的文件并写入一个 manifest 列表,Kayo 熟悉的后台脚本是 PHP ,在 PHP 中,可以直接在代码中设置 MIME 类型,这样就不需要配置 Web 服务来完成对 manifest 的支持了。至于具体如何使用 PHP 编写 manifest ,会在下面详细介绍。

二. 浏览器支持

关于离线网络应用程序在现代浏览器中都已经实现完整的支持,IE 则完全不支持。具体如下:

Chrome 4+ , Firefox 3.5+ , Safari 4+ 和 Opera 10.6+

三. 白名单

默认情况下,开发者会为应用程序中所有文件指定缓存,但实际上,仍有一些资源可能需要强制取消缓存,即必须访问网络资源,离线时该资源不可用。为元素强制取消缓存可以在 manifest 文件中使用关键字 NETWORK: 。在 NETWORK: 关键字下添加文件的列表,这些文件会强制取消缓存,而这个文件列表就称为白名单 (Whitelist) 。实际上,对于需要缓存的资源也是有关键字的,这个关键字是 EXPLICIT: ,但所有资源列表的开头如果没有添加关键字,默认都会被认为在 EXPLICIT: 关键字的列表下,因此需要缓存的资源列表开头可以忽略不写关键字(如上例)。

例如,对上例进行扩展,把 logo.png 添加到白名单,可以这样编写 manifest 文件。

CACHE MANIFEST
./index.html
./demo.css
./demo.js

NETWORK:
./logo.png

四. 备选名单

备选名单是对于白名单的补充,在白名单中,没有联网时,资源不能加载,这样会导致页面出现错误,如上例中,"logo.png" 被设置为白名单,如果用户离线浏览该应用程序,会出现一个损坏的图片链接,为了避免这种情况,可以使用 FALLBACK: 指定一个备选名单,备选名单中需要为一个资源准备两个文件,若能正常联网,会引用第一个文件,离线时则引用第二个文件,关于这个特点,有一个很实用的应用 —— 表明程序是离线工作还是联网工作,在不同的状态下引用不同的图片即可方便地表明状态。接下来 Kayo 继续扩展上例,为 logo.png 指定一个离线时的替换图片 backup.png 。

CACHE MANIFEST
./index.html
./demo.css
./demo.js

FALLBACK:
./logo.png ./backup.png

五. 更新 manifest

似乎,经过上面三个步骤后,应用程序的离线缓存已经很好的工作了。但有一个很重要的问题仍需注意 —— 根据离线缓存的工作原理,当用户第一次使用应用程序时,浏览器会根据 manifest 文件里的列表下载指定的资源,在下次使用该应用程序时,浏览器会自动加载这些资源的副本,那么假如开发者修改了程序,浏览器仍旧会加载本地的资源,因此我们需要给浏览器一个提示,程序已经更新。

那么什么时候浏览器会更新本地缓存呢?

只有当 manifest 修改后浏览器才会重新下载 manifest 中所有指定需要离线缓存的文件。而检测 manifest 是否修改过是对 manifest 文件的内容逐个字符进行比较,包括注释和空行。当然,大多情况下,修改程序时并不会影响主要的文件,所以 manifest 中的文件列表也不会有改动,因此需要改动 manifest 以通知浏览器程序已更新的最好办法是修改注释,开发者可以在 manifest 文件中添加一行注释,比如说是程序的版本号,当程序更新后同时修改 manifest 文件中这个版本号,浏览器就会判定程序已经更新,自动重新下载所有需要离线缓存的资源。例如,把上例改成:

CACHE MANIFEST
# version 1.0
./index.html
./demo.css
./demo.js

FALLBACK:
./logo.png ./backup.png

需要注意的是,"CACHE MANIFEST"是必要行,并且必须在 manifest 文件中的第一行。这样下次更改程序后同时修改 manifest 的程序版本号,浏览器就可以判定程序更新了。

手动更新缓存

当然,为了能更精确地控制程序更新,最好是使用手动方法更新缓存,离线网络应用程序规范中也提供了相应的手动更新缓存的方法。开发者可以使用 window.applicationCache.update() 方法手动更新缓存,为了更准确地判断是否需要更新,开发者可以先检测 window.applicationCache.status 的值,若其值为 "UPDATEREADY" (即浏览器检测到 manifest 已被修改), 可以调用 window.applicationCache.update() 方法更新缓存。例如:

if ( window.applicationCache.status == window.applicationCache.UPDATEREADY ){
	window.applicationCache.update();
}

六. 使用 PHP 脚本编写 manifest 文件

如上面所说,使用脚本编写 manifest 文件可以同时解决 manifest 文件扩展名与 MIME 类型关联起来,还可以自动列出缓存文件。具体的编写如下:

header('Content-Type: text/cache-manifest');
echo "CACHE MANIFEST\n";

$allHashes = ""; // 创建一个字符串保存文件的 md5 值

$dir = new RecursiveDirectoryIterator(".");
foreach(new RecursiveIteratorIterator($dir) as $file){ // 获取当前目录并遍历文件
	if( $file->IsFile() && // 判断获取内容为文件
		$file->getFilename() != "manifest.php" && // "manifest.php" 不缓存
		$file->getFilename() != "logo.png" && // 备选资源不缓存
		$file->getFilename() != "offline.png" &&
		!strpos( $file, '/.' ) &&
		substr( $file->getFilename(), 0, 1 ) != "."){

		echo "./" . $file->getFilename() . "\n";
		$allHashes .= md5_file($file); // 把每一个缓存的文件的 md5 值连接起来
	}
 
}
 
echo "FALLBACK:\n"; // 输出备选名单
echo "./logo.png ./offline.png\n";

echo "# " . md5($allHases) ."\n"; // 把连接起来的 md5 值重新计算一个 md5(因为连接所得的字符串过于冗长)

在这个脚本中,获取了应用程序的目录并把目录中的文件逐个添加到缓存列表,同时在这个过程中排除如 "manifest.php" 和备选资源等文件,并且,对每一个缓存的文件计算 md5 值,把这些值连接起来后重新计算一个 md5 并输出到一行注释中,这样缓存的文件只要有一个发生了改变,都会影响这行注释,从而使到整个 manifest 文件发生改变,这样就并不需要在更新程序后手动修改 manifest 文件。可以看出,如果需要缓存的文件很多时,该方法将会十分方便,并且更新程序后 manifest 文件也会被自动修改。当然,这只是使用脚本编写 manifest 的其中一种方式,开发者应视具体情况而制定相应的脚本。

七. 关于离线网络应用程序的属性、方法和事件

在 “手动更新缓存” 中,Kayo 提到了一个对象 window.applicationCache ,这个对象中包含了与离线网络应用程序相关的属性、方法和事件,除了上面涉及到的 status 属性和 update() 方法外,还有其他的相关内容,下面开始对它们进行详细介绍。

1. 属性

window.applicationCache 对象中具有一个属性 status ,该状态会根据当前的缓存状态显示不同的值和状态编号,具体的属性值如下:

  • UNCACHED 未开始缓存状态。 状态编号:0
  • IDLE 空闭状态。 状态编号:1
  • CHECKING 正在检查 manifest 文件是否存在。 状态编号:2
  • DOWNLOADING 正在下载缓存文件。 状态编号:3
  • UPDATEREADY 更新下载已完成,等待更新状态。 状态编号:4
  • OBSOLETE 废弃状态。 状态编号:5

2. 方法

接着,是相关的方法,具体如下:

  • update() 检测与文档关联的 manifest 文件是否有修改,如果有修改,则更新缓存。若 manifest 文件不存在或该缓存已废弃,则会抛出一个 InvalidStateError 错误。
  • abort() user agent 会发出一个信号给当前的缓存对象,中止应用缓存的下载,如果页面没有使用离线网络应用程序,那么在尝试发出信号后该方法不会再执行任何操作。
  • swapCache() 手动执行本地缓存更新,并且只能在 updataReady 事件触发时在事件回调函数中调用。但需要注意该方法不会立即更新缓存文件,仍需要刷新页面才生效。若文档关联的 manifest 文档不存在,会抛出一个 InvalidStateError 错误。

3. 事件

除了属性和方法,window.applicationCache 对象还会根据一些情况在其身上触发一些事件,开发者应该了解这些事件,根据所触发的事件,可以了解一个正在运作的离线缓存的工作情况,然后根据不同的情况作出适当的处理。

  • checking 浏览器在检测到 html 标签上有 manifest 属性后,会检查相关联的 manifest 文件是否存在,同时触发 checking 事件。
  • noupdate 在检测到 manifest 没有更新(即没有修改)时触发。
  • downloading 缓存下载时触发。
  • progress 下载进度(阶段性事件)。
  • cached 已根据 manifest 文件中指定的要求下载相应的资源。
  • updateready 更新时,已根据 manifest 文件中指定的要求重新下载相应的资源。
  • obsolete manifest 文件请求发生 404 或 410 错误时触发,发生此事件表明该缓存已被删除。
  • error 以下四种情况都会触发 error 事件:1. manifest 文件请求发生 404 或 410 错误; 2. manifest 文件没有修改,但页面无法正确引用 manifest ;3. 根据 manifest 文件更新资源时发生致命错误(如上面提到的 InvalidStateError 错误);4. 当资源正在更新时 manifest 文件被修改。

这里 Kayo 需要指出一点,以上这些事件并不会在一个缓存过程中全部触发,如 error(前三种情况) , updateready , obsolete , noupdate , cached 均为一个缓存过程中的最终事件,在一次缓存过程中只会出现其中一个。又如 cached 事件,必须要有下载资源并且在完成后才触发,即第二次打开页面并且 manifest 没有修改是不会触发 cached 事件(没有下载资源),开发者应仔细注意每个事件的触发时刻,判断在不同情况下应该利用何种事件。

这个例举一个例子,使用 addEventListener 监听 cached 事件,检测到 cached 事件发生时弹出提示,即代表离线资源已经下载完成。

if(window.applicationCache) {

	window.applicationCache.addEventListener('cached', function(){
		alert('cached');
	}, true);

}

八. 完整实例 Demo

为了使读者更好的理解离线网络应用程序的具体使用,这里会把上面逐步扩展的例子写成完整 Demo (为了简化例子结构,这里只使用 addEventListener 监听事件,请使用 Chrome, Firefox 等现代浏览器浏览 Demo )。

完整 Demo

本文由 Kayo Lee 发表,本文链接:https://kayosite.com/web-app-by-jquery-mobile-and-html5-offline-web-applications.html

评论列表

  • 评论者头像
    回复

    RecursiveDirectoryIterator
    呵呵,难得看到有用spl标准库的代码

    • 评论者头像
      回复

      @傅小黑 哈哈,最喜欢统一标准的调用,这样的代码才更有爱!不过对于 spl 我也不是很熟悉,略知一二罢了!

  • 评论者头像
    回复

    这个要慢慢学习下

  • 评论者头像
    回复

    表示膜拜

  • 评论者头像
    回复

    這個很實際耶,但是緩存的東西也不能太大太多吧?

    • 评论者头像
      回复

      @班森 嗯,是的,虽然没有准确的数据,但缓存的量大概是5M到10M左右,只适合做辅助数据储存了!
      班森不用那个gmail邮箱了吗?

  • 评论者头像
    回复

    楼主,膜拜下,最近在学web app开发,以后有问题多指教!

  • 评论者头像
    回复

    有个问题我想问一下!HTML5中 localstorage中 如果我放置一个JS对象在里面,如何取出来呢,他只支持字符串吗?

    • 评论者头像
      回复

      @天下霸唱 不可以的,localstorage是Key-value储存,只能放置值,不能放入对象!

      • 评论者头像
        回复

        @Kayo 谢谢,我就说Value里面我放了一个对象,是可以放置到里面的,也可以用alert弹出来,但是就是拿不到里面的对象的属性

        • 评论者头像
          回复

          An inlieltgent answer – no BS – which makes a pleasant change

  • 评论者头像
    回复

    请教一个问题,在jqm中如何清楚CSS缓存,目前我遇到了一个问题:在A页面中存在img{}的CSS样式,B页面中存在img标签,当从A页面跳转到B页面时,A页面的img{}样式在B页面中出现了,刷新页面后正常,我想清楚A页面的CSS缓存。。。。。

  • 评论者头像
    回复

    对于上面 这一块 为什么w3c中告诉扩展名是.appcache

  • 评论者头像
    回复

    能不能缓存动态页面
    ajax调取后台数据这样的页面

  • 评论者头像
    回复

    这个方案很不错,终于不用在服务里生成太多碎片缓存 导致inode用完了

  • 评论者头像
    回复

    在微信不弹出窗口,是不是微信内置浏览器不支持?

  • 评论者头像
    回复

    第六中最后一行$allHases应该是$allHashes

  • 评论者头像
    回复

    使用RecursiveDirectoryIterator会造成每次输出的文件名顺序不是一定的,不知道该怎么办,所以改用blob了

回复

你正在以游客身份访问网站,请输入你的昵称和 E-mail