前言

离线缓存应用,意思就是没有网络的情况下,被缓存的资源能从本地直接返回,让应用在一定范围内能正常运行。

离线应用有以下优点:

  1. 在没有网络的情况下也能打开网页。
  2. 由于部分被缓存的资源直接从本地加载,对用户来说可以加速网页加载速度,对网站运营者来说可以减少服务器压力以及传输流量费用。

实现离线缓存的技术有两种:

  1. AppCache 又叫 Application Cache,目前已经从 Web 标准中删除,请尽量不要使用它。
  2. Service Workers 是目前最新的离线缓存技术,是 Web Worker 的一部分。

Service Workers 通过拦截网络请求实现离线缓存,比 AppCache 更加灵活。它也是构建 PWA 应用的关键技术之一。

出于安全原因,Service Workers 要求必须在 HTTPS 下才能运行。

在本地开发中,我们可以在启动 chrome 时让本地应用设置为安全,可以不使用 https,而使用 http,否则会报错。
如启动的本地应用地址为http://localhost:8080/;

1
2
// 在终端启动chrome,chrome文件路径
"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --unsafely-treat-insecure-origin-as-secure=http://localhost:8080/

Service Workers 兼容性

目前 Chrome、Firefox、Opera 都已经全面支持 Service Workers,但对于移动端浏览器就不太乐观了,只有高版本的 Android 支持。
由于 Service Workers 无法通过注入 polyfill 去实现兼容,所以在你打算使用它前请先调查清楚你的网页的运行场景。

判断浏览器是否支持 Service Workers 的最简单的方法是通过以下代码:

1
2
3
4
// 如果 navigator 对象上存在 serviceWorker 对象,就表示支持
if (navigator.serviceWorker) {
// 通过 navigator.serviceWorker 使用
}

注册

要给网页接入 Service Workers,需要在网页加载后注册一个描述 Service Workers 逻辑的脚本。
所以编写一个 sw.js 专门包含 Service Workers 逻辑的脚本,比如缓存内容,sw.js 更新后重新加载缓存内容等等,而 sw.js 文件需要在应用入口文件加载注册。
如 main.js 为 webpack 的入口文件:

1
2
3
4
5
6
7
8
9
10
require('./main.css');

if (navigator.serviceWorker) {
window.addEventListener('DOMContentLoaded',function() {
// 调用 serviceWorker.register 注册,参数 /sw.js 为脚本文件所在的 URL 路径
navigator.serviceWorker.register('sw.js');
});
}

window.document.getElementById('app').innerText = 'Hello,Webpack';

一旦这个脚本文件被加载,Service Workers 的安装就开始了。这个脚本被安装到浏览器中后,就算用户关闭了当前网页,它仍会存在。
也就是说第一次打开该网页时 Service Workers 的逻辑不会生效,因为脚本还没有被加载和注册,但是以后再次打开该网页时脚本里的逻辑将会生效。

在 Chrome 中可以通过打开网址 chrome://inspect/#service-workers 来查看当前浏览器中所有注册了的 Service Workers。

使用 Service Workers 实现离线缓存

Service Workers 在注册成功后会在其生命周期中派发出一些事件,通过监听对应的事件在特点的时间节点上做一些事情。

在 Service Workers 脚本中,引入了新的关键字 self 代表当前的 Service Workers 实例。

在 Service Workers 安装成功后会派发出 install 事件,需要在这个事件中执行缓存资源的逻辑,实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 当前缓存版本的唯一标识符,用当前时间代替
var cacheKey = new Date().toISOString();

// 需要被缓存的文件的 URL 列表
var cacheFileList = [
'/index.html',
'/app.js',
'/app.css'
];

// 监听 install 事件
self.addEventListener('install', function (event) {
// 等待所有资源缓存完成时,才可以进行下一步
event.waitUntil(
caches.open(cacheKey).then(function (cache) {
// 要缓存的文件 URL 列表
return cache.addAll(cacheFileList);
})
);
});

接下来需要监听网络请求事件去拦截请求,复用缓存,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
self.addEventListener('fetch', function(event) {
event.respondWith(
// 去缓存中查询对应的请求
caches.match(event.request).then(function(response) {
// 如果命中本地缓存,就直接返回本地的资源
if (response) {
return response;
}
// 否则就去用 fetch 下载资源
return fetch(event.request);
}
)
);
});

以上就实现了离线缓存。

更新缓存

线上的代码有时需要更新和重新发布,如果这个文件被离线缓存了,那就需要 Service Workers 脚本中有对应的逻辑去更新缓存。
这可以通过更新 Service Workers 脚本文件做到。
浏览器针对 Service Workers 有如下机制:

  1. 每次打开接入了 Service Workers 的网页时,浏览器都会去重新下载 Service Workers 脚本文件(所以要注意该脚本文件不能太大),如果发现和当前已经注册过的文件存在字节差异,就将其视为“新服务工作线程”。
  2. 新 Service Workers 线程将会启动,且将会触发其 install 事件。
  3. 当网站上当前打开的页面关闭时,旧 Service Workers 线程将会被终止,新 Service Workers 线程将会取得控制权。
  4. 新 Service Workers 线程取得控制权后,将会触发其 activate 事件。

新 Service Workers 线程中的 activate 事件就是最佳的清理旧缓存的时间点,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 当前缓存白名单,在新脚本的 install 事件里将使用白名单里的 key
var cacheWhitelist = [cacheKey];

self.addEventListener('activate', function(event) {
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.map(function(cacheName) {
// 不在白名单的缓存全部清理掉
if (cacheWhitelist.indexOf(cacheName) === -1) {
// 删除缓存
return caches.delete(cacheName);
}
})
);
})
);
});

sw.js 完整代码:

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
// 当前缓存版本的唯一标识符,用当前时间代替
var cacheKey = new Date().toISOString();

// 当前缓存白名单,在新脚本的 install 事件里将使用白名单里的 key
var cacheWhitelist = [cacheKey];

// 需要被缓存的文件的 URL 列表
var cacheFileList = [
'/index.html',
'app.js',
'app.css'
];

// 监听 install 事件
self.addEventListener('install', function (event) {
// 等待所有资源缓存完成时,才可以进行下一步
event.waitUntil(
caches.open(cacheKey).then(function (cache) {
// 要缓存的文件 URL 列表
return cache.addAll(cacheFileList);
})
);
});

// 拦截网络请求
self.addEventListener('fetch', function (event) {
event.respondWith(
// 去缓存中查询对应的请求
caches.match(event.request).then(function (response) {
// 如果命中本地缓存,就直接返回本地的资源
if (response) {
return response;
}
// 否则就去用 fetch 下载资源
return fetch(event.request);
}
)
);
});

// 新 Service Workers 线程取得控制权后,将会触发其 activate 事件
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames.map(function (cacheName) {
// 不在白名单的缓存全部清理掉
if (cacheWhitelist.indexOf(cacheName) === -1) {
// 删除缓存
return caches.delete(cacheName);
}
})
);
})
);
});

z 这些都是没有接入 webpack 时实现的。

接入 Webpack

使用 webpack 要使用serviceworker-webpack-plugin插件。

1
npm i -D serviceworker-webpack-plugin webpack-dev-server

webpack 例子:

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
const path = require("path");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const { WebPlugin } = require("web-webpack-plugin");
const ServiceWorkerWebpackPlugin = require("serviceworker-webpack-plugin");

module.exports = {
entry: {
app: "./main.js" // Chunk app 的 JS 执行入口文件
},
output: {
filename: "[name].js",
publicPath: ""
},
module: {
rules: [
{
test: /\.css/, // 增加对 CSS 文件的支持
// 提取出 Chunk 中的 CSS 代码到单独的文件中
use: ExtractTextPlugin.extract({
use: ["css-loader"] // 压缩 CSS 代码
})
}
]
},
plugins: [
// 一个 WebPlugin 对应一个 HTML 文件
new WebPlugin({
template: "./template.html", // HTML 模版文件所在的文件路径
filename: "index.html" // 输出的 HTML 的文件名称
}),
new ExtractTextPlugin({
filename: `[name].css` // 给输出的 CSS 文件名称加上 hash 值
}),
new ServiceWorkerWebpackPlugin({
// 自定义的 sw.js 文件所在路径
// ServiceWorkerWebpackPlugin 会把文件列表注入到生成的 sw.js 中
entry: path.join(__dirname, "sw.js")
})
],
devServer: {
// Service Workers 依赖 HTTPS,使用 DevServer 提供的 HTTPS 功能。
// https: true,
}
};

devServer 的 https 可以不开启,如果 chrome 启动设置不禁止 http;
使用了 ServiceWorkerWebpackPlugin 后可以在 sw.js 里 global.serviceWorkerOption 下获取 assets,里面包含了 webpack 输出的所有静态文件。

1
2
3
4
5
6
7
8
9
Version: webpack 3.12.0
Time: 16ms
Asset Size Chunks Chunk Names
app.css 21 bytes 0 [emitted] app
index.html 332 bytes [emitted]
sw.js 7.13 kB [emitted]


 global.serviceWorkerOption.assets --- ["/app.js", "/app.css", "/index.html"]

而 ServiceWorkerWebpackPlugin 内置了注册启动

1
2
3
import runtime from "serviceworker-webpack-plugin/lib/runtime";
// 调用 serviceWorker.register 注册,参数 /sw.js 为脚本文件所在的 URL 路径
var registration = runtime.register();

ServiceWorkerWebpackPlugin 事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import registerEvents from "serviceworker-webpack-plugin/lib/browser/registerEvents";
registerEvents(registration, {
onInstalled: () => {
pushLog("onInstalled");
},
onUpdateReady: () => {
pushLog("onUpdateReady", true);
},

onUpdating: () => {
pushLog("onUpdating");
},
onUpdateFailed: () => {
pushLog("onUpdateFailed");
},
onUpdated: () => {
pushLog("onUpdated");
}
});

ServiceWorkerWebpackPlugin 更新后手动更新

1
2
3
4
5
import applyUpdate from "serviceworker-webpack-plugin/lib/browser/applyUpdate";
// 当为onUpdateReady时,可以使用applyUpdate触发更新操作
applyUpdate().then(() => {
window.location.reload();
});

验证

通过打开开发者工具的 Application-Service Workers 一栏,就能看到当前页面注册的 Service Workers,正常的效果如图:

01
01

通过打开开发者工具的 Application-Cache-Cache Storage 一栏,能看到当前页面缓存的资源列表,正常的效果如图:

02
02

为了验证网页在离线时能访问的能力,需要在开发者工具中的 Network 一栏中通过 Offline 选项禁用掉网络,再刷新页面能正常访问,并且网络请求的响应都来自 Service Workers,正常的效果如图:

03
03

从图上可以看到红字的为没有缓存的,而有 ServiceWorker 的为有缓存的。

完整项目例子