通过 Service workers 让 PWA 离线工作

我们已经看到了 js13kPWA 的结构,并且看到了基本的 shell 启动和运行,那么让我们看看如何使用 Service Worker 实现离线功能。 在本文中,我们将看看它是如何在 js13kPWA example 中使用的(另请参阅源代码)。 我们将研究如何添加脱机功能。

Service workers 解释

Service Workers是浏览器和网络之间的虚拟代理。 他们最终解决了前端开发人员多年来一直在努力解决的问题 - 最值得注意的是解决了如何正确缓存网站资源并使其在用户设备离线时可用。

它们的运行在一个与我们页面的 JavaScript 主线程独立的线程上,并且没有对 DOM 结构的任何访问权限。 这引入了与传统 Web 编程不同的方法 - API 是非阻塞的,并且可以在不同的上下文之间发送和接收信息。 您可分配给 Service Worker 一些任务,并在使用基于 Promise 的方法当任务完成时收到结果。

他们不仅仅提供离线功能,还提供包括处理通知,在单独的线程上执行繁重的计算等。Service workers 非常强大,因为他们可以控制网络请求,修改网络请求,返回缓存的自定义响应,或合成响应。

安全

因为它们非常强大,所以 Service Workers 只能在安全的上下文中执行(即 HTTPS )。 如果您想在将代码推送到生产环境之前先进行实验,则可以始终在本地主机上进行测试或设置 GitHub 页面 - 两者都支持HTTPS。

离线优先

“离线优先”或“缓存优先”模式是向用户提供内容的最流行策略。 如果资源已缓存且可脱机使用,请在尝试从服务器下载资源之前先将其返回。 如果它已经不在缓存中,请下载并缓存以备将来使用。

PWA 渐进增强

当作为渐进增强功能正确实施时,service workers 可以通过提供离线支持使支持 Service​Worker API 的现代浏览器的用户受益,但不会为使用旧版浏览器的用户破坏任何内容。

js13kPWA应用程序中的 Service workers

理论足够了,让我们看一些源代码!

注册 Service Worker

首先在 app.js 文件中查看注册新 Service Worker 的代码:

if('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/pwa-examples/js13kpwa/sw.js');
};

如果浏览器支持 service worker API,则使用 ServiceWorkerContainer.register() 方法对该站点进行注册。 其内容在 sw.js 文件中,可以在注册成功后执行。 它是 app.js 文件中唯一的Service Worker代码; 其他关于 Service Worker 的内容都写在 sw.js 文件中。

Service Worker 的生命周期

注册完成后,sw.js 文件会自动下载,然后安装,最后激活。

安装

API 允许我们为我们感兴趣的关键事件添加事件监听器 - 第一个是 install 事件:

self.addEventListener('install', function(e) {
    console.log('[Service Worker] Install');
});

install 的监听函数中, 我们可以初始化缓存以及添加离线应用时所需的文件,我们的 js13kPWA 应用确实也是这么做的。

首先,创建一个作为缓存的名字的变量,app shell所需的文件被记录在一个数组上

var cacheName = 'js13kPWA-v1';
var appShellFiles = [
  '/pwa-examples/js13kpwa/',
  '/pwa-examples/js13kpwa/index.html',
  '/pwa-examples/js13kpwa/app.js',
  '/pwa-examples/js13kpwa/style.css',
  '/pwa-examples/js13kpwa/fonts/graduate.eot',
  '/pwa-examples/js13kpwa/fonts/graduate.ttf',
  '/pwa-examples/js13kpwa/fonts/graduate.woff',
  '/pwa-examples/js13kpwa/favicon.ico',
  '/pwa-examples/js13kpwa/img/js13kgames.png',
  '/pwa-examples/js13kpwa/img/bg.png',
  '/pwa-examples/js13kpwa/icons/icon-32.png',
  '/pwa-examples/js13kpwa/icons/icon-64.png',
  '/pwa-examples/js13kpwa/icons/icon-96.png',
  '/pwa-examples/js13kpwa/icons/icon-128.png',
  '/pwa-examples/js13kpwa/icons/icon-168.png',
  '/pwa-examples/js13kpwa/icons/icon-192.png',
  '/pwa-examples/js13kpwa/icons/icon-256.png',
  '/pwa-examples/js13kpwa/icons/icon-512.png'
];

接下来,从data/games.js的内容中解析出来的图片链接被赋值到另一个数组上,之后,两个数组会用Array.prototype.concat()方法合并起来。

var gamesImages = [];
for(var i=0; i<games.length; i++) {
  gamesImages.push('data/img/'+games[i].slug+'.jpg');
}
var contentToCache = appShellFiles.concat(gamesImages);

接着我们可以监听install事件:

self.addEventListener('install', function(e) {
  console.log('[Service Worker] Install');
  e.waitUntil(
    caches.open(cacheName).then(function(cache) {
          console.log('[Service Worker] Caching all: app shell and content');
      return cache.addAll(contentToCache);
    })
  );
});

有两件事需要在这里解释一下:ExtendableEvent.waitUntil做了什么 ?caches 对象是什么东西?

service worker会等到 waitUntil 里面的代码执行完毕之后才开始安装。它返回一个promise —— 这个过程时必须的,因为安装过程需要一些时间,我们必须等待它完成 。

caches 是一个特殊的 CacheStorage 对象,它能在Service Worker指定的范围内提供数据存储的能力(service worker在注册时,第二个参数是选填的,可以被用来指定你想让 service worker 控制的内容的子目录,译者注),在service worker中使用web storage 将不会有效果,因为web storage的执行是同步的(此处理解为web storage并不返回一个promise,译者注),所以我们使用Cache API作为替代。

这里,我们使用给定的名字开启了一个缓存,并且将我们的应用所需要缓存的文件全部添加进去,当我们再次加载这些资源时,对应的缓存就是可用的(通过请求的url确定缓存是否命中)。

Activation

还有一个activate 事件,它的用法跟install 事件相同。这个事件通常用来删除那些我们已经不需要的文件或者做一些清理工作。因为在我们的app里面没有使用到,这里我们暂时跳过它。

响应请求

每次当我们的应用发起一个http请求时,我们还有一个fetch 事件可以使用。这个事件对我们来说非常有用,它允许我们拦截请求并对请求作出自定义的响应,下面是一个简单的例子

self.addEventListener('fetch', function(e) {
    console.log('[Service Worker] Fetched resource '+e.request.url);
});

请求的响应可以是任何我们想要的东西:请求过的文件缓存下来的副本,或者一段做了具体操作的JavaScript代码,拥有无限的可能。

在我们的示例代码中,当缓存存在时,我们使用缓存来提供服务而不是重新请求数据。不管当前应用是在线还是离线,我们都这么做。当请求的文件不在缓存中时,我们会在响应之前将数据添加到缓存中。

self.addEventListener('fetch', function(e) {
  e.respondWith(
    caches.match(e.request).then(function(r) {
          console.log('[Service Worker] Fetching resource: '+e.request.url);
      return r || fetch(e.request).then(function(response) {
                return caches.open(cacheName).then(function(cache) {
          console.log('[Service Worker] Caching new resource: '+e.request.url);
          cache.put(e.request, response.clone());
          return response;
        });
      });
    })
  );
});

上述代码中,对于请求我们我们首先会在缓存中查找资源是否被缓存,如果有,将会返回缓存的资源,如果不存在,会转而从网络中请求数据,然后将它缓存起来,这样下次有相同的请求发生时,我们就可以直接使用缓存。

FetchEvent.respondWith 方法将会接管响应控制,它会作为服务器和应用之间的代理服务。它允许我们对每一个请求作出我们想要的任何响应:Service Worker会处理这一切,从缓存中获取这些数据,并在需要的情况下对它们进行修改。

就是这样,我们的应用会在install触发时缓存资源,并且在fetch事件触发时返回缓存中的资源,这就是为什么它甚至在离线状态下也能使用的原因。当我们添加新的内容时,他也会随时被缓存下来。

更新

还有一点需要考虑:当我们的应用有了一个新版本,并且它包含了一些可用的新资源 时,我们应该如何去更新它的Service Worker?我们存放在缓存名称中的版本号是这个问题的关键:

var cacheName = 'js13kPWA-v1';

当我们把版本号更新到v2,service worker会将我们所有的文件(包括那些新的文件)添加到一个新的缓存中。

contentToCache.push('/pwa-examples/js13kpwa/icons/icon-32.png');

// ...

self.addEventListener('install', function(e) {
  e.waitUntil(
    caches.open('js13kPWA-v2').then(function(cache) {
      return cache.addAll(contentToCache);
    })
  );
});

这个时候一个新的service worker会在后台被安装,而旧的service worker仍然会正确的运行,直到没有任何页面使用到它为止,这时候新的service worker将会被激活,然后接管所有的页面。

缓存的清理

还记得我们前面跳过的那个activate 事件吗?它可以用来清理那些我们不再需要的缓存:

self.addEventListener('activate', function(e) {
  e.waitUntil(
    caches.keys().then(function(keyList) {
          return Promise.all(keyList.map(function(key) {
        if(cacheName.indexOf(key) === -1) {//此处应该是contentToCache.indexOf(key)===-1,使用cacheName明显不对,译者注 
          return caches.delete(key);
        }
      }));
    })
  );
});

这样能确保只有那些我们需要的文件会保留在缓存中,我们不需要留下任何的垃圾,毕竟浏览器的缓存空间是有限的,手动清理掉这些不需要的缓存是一个不错的主意。

其他用途

从缓存中加载资源并不是Service Worker的唯一能力,如果你有比较耗时的计算,你可以把它们从主线程中抽离出来,在Service Worker中进行计算,最后在它们计算完毕的时候从Service Worker中取得计算结果。性能方面,你可以在Service Worker中对即将使用到的资源进行预加载,这样当你使用到这些资源的时候,应用的加载速度会更快。

总结

在这篇文章中我们简单的了解了如何使用service workers让你的PWA实现离线功能。如果你想要学习更多关于Service Worker API的概念以及使用service worker方面的相关细节,你可以进一步查阅我们的文档。

Service Workers在处理消息推送 的时候也会经常被用到,这一部分我们将会在后面的章节进行讨论。