w3ctech

[译]我的第一个Service Worker

原文链接

2015年11月7日

我从来不会掩饰我对Service Workers的兴趣,而且我也不是一个人。在哥本哈根举行的Coldfront会议上,几乎每一个谈话都提到了Service Workers.

显然,我对与Service Worker的功能感到十分的兴奋——离线缓存、后台进程、通知推送与其他能够让web应用与本地应用相竞争的好点子。但更令我兴奋的是Service Worker规范已经被设计了。这让它不再是那种可有可无的技术。相反,它已经被精雕细琢来用于改进现有的网站功能。(啊,假如web组件也是这样就好了。)

我有很多种想法来让Service Workers改进我们的社区网站如The session,或者那些我们在Clearleft生成的新闻网站。但首先,将我的个人网站作为一个试验场会更有意义。

在开始前,我就已经攻克了第一个难关——使用https协议。因为Service Workers要求一个安全的链接。如果你在本地有一个你的网站的副本,你也可以在本地尝试Service Worker。

那我是怎么开始体验Service Workers的呢?首先,在本地运行Service Worker的服务,然后通过命令行的apachectl stop和apachectl start指令控制我的本地Apache服务器。

这让我想起了另外一个关于Service Worker的使用场景。它不仅仅是当用户的网络连接失效的时候使用(例如火车进入隧道),也可以在你的服务器并不总是可用的时候提供服务。

要不是已经有慷慨的开发者分享了他们现在的工作,我或者永远都不会去尝试:

  • 卫报的开发者在博客上记录了他们如何提供一个离线的页面。
  • Nicolas记录了他是怎样为他的网站开启Service Worker的。
  • Jake把多种使用Service Workers的方法放在了一本离线教程中。

另外,我知道Jake会参加FF会议,所以要是我被难住了,我会纠缠他的。这也正式最终发生的事情。(谢谢,Jake)

所以,如果你决定尝试使用Service Workers,请一定,一定要把你的经验分享出来。

其实这完全取决于你怎样使用Service Workers。我发现,如果是像我现在这样的一个个人网站,你可以尝试这样子:

  1. 明确你的缓存资源例如像CSS,JavaScript和一些图片。
  2. 缓存你的主页,所以他能够在网络断开的时候进行显示。
  3. 对于其他页面,准备好一个备用的“脱机”页面,当网络连接失败的时候进行展示。

所以现在我已经在adactio.com上面启用了Service Worker。虽然他只适用于Chrome、Android、Opera和即将到来的Firefox新版本。不过这毕竟是一种进步。随着越来越多的浏览器对他进行支持,Service Worker会变得越来越有用。

代码

如果你对Service Worker是干什么的很有兴趣,请接着看。而另一方面,如果你并不擅长代码,你可以停止阅读了。如果你是想直接跳到完成的代码处,只需要注意一点,随意去拷贝、打破、重构、改进吧,只要你想,怎么做都行。

首先,让我们定义一下什么是Service Worker。我十分喜欢Matt Gaunt的定义——Service Worker是一个让你的浏览器在后台运行的脚本,它是从页面分离出来的的,负责为那些不需要页面或者用户交互的功能开启通道。

register(注册)

首先我需要做个一个快速的功能检测看是否支持Service Workers,我可能会在我的全局JavsScript文件中进行又或者我会在我的页面内联的脚本中进行。如果浏览器支持的话,我会通过指向另一个JavaScript文件来注册我的Service Worker,这个文件一般在我的网站的根目录。

if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/serviceworker.js', {
    scope: '/'
  });
}

之所以将serviceworker.js放在根目录,是因为它要对所有关于我的域名的请求都要作出反应。如果我把它放在其他地方,例如/js/serviceworker.js,那么他就只能对/js目录下的请求作出反应。

一旦这个文件被加载,Service Woker的安装就开始了。这意味着这个脚本会被安装到用户的浏览器中,哪怕在用户离开了我的网站后,他仍会存在。

install(安装)

我是通过updateStaticCache函数来进行Service Worker安装的。这个函数把我想要存储的文件的填充进缓存中。

self.addEventListener('install', function (event) {
  event.waitUntil(updateStaticCache());
});

updateStaticCache函数会被用于在缓存中存储东西。我要确保缓存中的文件的文件名包含了其版本号,特别是那些被用于作为守护脚本的文件。通过这种方式,当我需要更新缓存的时候,我只需要更新版本号即可。

var staticCacheName = 'static';
var version = 'v1::';

以下就是我使用的updateStaticCache函数。我通过它存储了我的JavaScript、CSS、一些CSS需要的图片、网站主页,和一些离线的时候展示的脱机页。

function updateStaticCache() {
  return caches.open(version + staticCacheName)
    .then(function (cache) {
      return cache.addAll([
        '/path/to/javascript.js',
        '/path/to/stylesheet.css',
        '/path/to/someimage.png',
        '/path/to/someotherimage.png',
        '/',
        '/offline'
      ]);
    });
};

因为那些文件都是由caches.open创造的promise的返回值中的一部分,所以Service Worker会知道那些文件在缓存中才被安装。所以这些文件应该是越少越好。

当然你可以不让他们成为返回至的一部分来把文件放置在缓存中。这样子,当他们加载完成了他们就会被放置在缓存中,而Service Worker的安装将不会被延迟。

function updateStaticCache() {
  return caches.open(version + staticCacheName)
    .then(function (cache) {
      cache.addAll([
        '/path/to/somefile',
        '/path/to/someotherfile'
      ]);
      return cache.addAll([
        '/path/to/javascript.js',
        '/path/to/stylesheet.css',
        '/path/to/someimage.png',
        '/path/to/someotherimage.png',
        '/',
        '/offline'
      ]);
    });
}

另一个方法是使用两个完全不同的缓存,但是我现在依然决定是使用同一个缓存。

activate(激活)

当激活事件被触发的时候,这是一个好机会去清楚过时的缓存(通过查询不适配当前版本号的文件的方法)。我在Nicolas的代码中复制出来这种方法。

self.addEventListener('activate', function (event) {
  event.waitUntil(
    caches.keys()
      .then(function (keys) {
        return Promise.all(keys
          .filter(function (key) {
            return key.indexOf(version) !== 0;
          })
          .map(function (key) {
            return caches.delete(key);
          })
        );
      })
  );
});

fetch(提取)

每次浏览器向我的网站请求文件的时候,请求事件就会被触发。而Service Worker的魔幻之处在于他能在请求发出前中断并且对其进行处理。

self.addEventListener('fetch', function (event) {
  var request = event.request;
  ...
});

POST requests(Post请求)

一开始,我将那些不是GET的请求返回

if (request.method !== 'GET') {
  event.respondWith(
      fetch(request)
  );
  return;
}

这基本上只是在重复浏览器无论如何都会做的事。但是在这里,如果我的请求不成功,我可以回退到我的备用离线页。我是通过把catch语句添加到fetch上做到的。

if (request.method !== 'GET') {
  event.respondWith(
      fetch(request)
          .catch(function () {
              return caches.match('/offline');
          })
  );
  return;
}

HTML requests(HTML请求)

对于页面请求和文件请求,我的处理方式是不同的,如果浏览器请求的是页面,我决定这么做:

  1. 首先尝试从网络上获取页面。
  2. 假如那不成功,尝试在缓存中查找页面。

首先,我需要检测这是不是一个HTML请求。我是通过嗅探accept headers来进行判断的,这或许不是最安全的策略。

if (request.headers.get('Accept').indexOf('text/html') !== -1) {

现在我从网络提取我的页面。

event.respondWith(
  fetch(request)
);

如果网络运行良好,这将返回网站的响应,并且我会通过它。

但是要是网络运行的不好,我将会在缓存中寻找匹配的文件。是时候使用一下catch了。

.catch(function () {
  return caches.match(request);
})

所以现在整个event.respondWith表达式会是这个样子。

event.respondWith(
  fetch(request)
    .catch(function () {
      return caches.match(request)
    })
);

最后,我需要注意此一下当网络不能返回页面而我在缓存中又找不到相应的页面的情况。

现在,我首次尝试通过在caches.match表达式上添加catch语句来达成这一目的。

return caches.match(request)
  .catch(function () {
    return caches.match('/offline');
  })

但是,这好像不生效,我也不知道为什么。然后Jake帮我找到了问题所在。因为caches.match总是会返回一个响应,哪怕这个响应是为定义的。所以,我的catch表达式永远都不会被触发。鉴于此,我利用返回的响应是假这一方法取而代之。

return caches.match(request)
  .then(function (response) {
    return response || caches.match('/offline');
  })

当这个问题解决后,我用于处理HTML请求的代码变成这样子。

event.respondWith(
  fetch(request, { credentials: 'include' })
    .catch(function () {
      return caches.match(request)
        .then(function (response) {
          return response || caches.match('/offline');
        })
    })
);

事实上,我还需要做一件事。假如网络请求成功,我需要把缓存的响应藏起来。事实上,这并不完全正确。我藏的是缓存的响应的备份。这是因为你只能读出响应值一次。所以如果我想做任何事,我必须要克隆他。

var copy = response.clone();
caches.open(version + staticCacheName)
  .then(function (cache) {
    cache.put(request, copy);
  });

我在真实响应返回前就完成了拷贝,然后下面是如何把它们组合到一起。

if (request.headers.get('Accept').indexOf('text/html') !== -1) {
  event.respondWith(
    fetch(request, { credentials: 'include' })
      .then(function (response) {
        var copy = response.clone();
        caches.open(version + staticCacheName)
          .then(function (cache) {
            cache.put(request, copy);
          });
        return response;
      })
      .catch(function () {
        return caches.match(request)
          .then(function (response) {
            return response || caches.match('/offline');
          })
      })
  );
  return;
}

好了,这就是用于页面请求的方法。

File requests(文件请求)

我想用与页面请求不同的方法来处理文件请求。下面是我的优先级列表:

  1. 首先在缓存中寻找相应的文件
  2. 如果这不成功,发起一个网络请求。
  3. 如果网络请求也失败了,并且请求的是一个图片,显示一个占位符。

步骤一:尝试从缓存中获得文件。

event.respondWith(
  caches.match(request)
);

步骤二:如果那样子不起作用,联网请求。切记,现在我不能用catch表达式,因为caches.match无论如何都会返回一些东西,响应或者undefined。所以我这么做:

event.respondWith(
  caches.match(request)
    .then(function (response) {
      return response || fetch(request);
    })
);

现在我来处理fetch,我可以使用catch来处理第三步:假如网络请求失败,检测请求的是否是图片,如果是,显示一个占位符。

.catch(function () {
  if (request.headers.get('Accept').indexOf('image') !== -1) {
    return new Response('<svg>...</svg>',  { headers: { 'Content-Type': 'image/svg+xml' }});
  }
})

我可以指向缓存中的一个占位符图片,但是我决定利用一个新的响应对象来发送一个关于苍蝇的SVG。

这就是他整个处理的样子。

event.respondWith(
  caches.match(request)
    .then(function (response) {
      return response || fetch(request)
        .catch(function () {
          if (request.headers.get('Accept').indexOf('image') !== -1) {
            return new Response('<svg>...</svg>', { headers: { 'Content-Type': 'image/svg+xml' }});
          }
        })
    })
);

我用于处理fetch事件的代码整体造型是这样子的。

self.addEventListener('fetch', function (event) {
  var request = event.request;
  // Non-GET requests
  if (request.method !== 'GET') {
    event.respondWith(
      ... 
    );
    return;
  }
  // HTML requests
  if (request.headers.get('Accept').indexOf('text/html') !== -1) {
    event.respondWith(
      ...
    );
    return;
  }
  // Non-HTML requests
  event.respondWith(
    ...
  );
});

点击随意细读代码。

下一步

我现在运行的代码是第一步,仍然有许多改进的余地。

现在,我把用户需要到访问的页面在缓存中积攒起来。我不认为这会失控——想象一下,大多数人永远只会访问我的网站的一小部分页面。但是,这还是会有机会让缓存变得十分臃肿。理想情况下,我必须要有方法让缓存变得高效。

我觉得:或者我应该对HTML页面进行单独缓存,然后限制一下缓存的数量,例如20或者30个。每次我把新的页面假如缓存,我就把最老的页面去掉。

我能想象我应该对图片进行类似的漕做:保持缓存的图片数量在最近的10到20张。

如果你有好的代码解决这些问题,请告诉我。

学到的课程

一路走来还是有几个坑的。我已经提到过caches.match,他无论如何都会返回所以你不能使用catch语句去处理在缓存中找不到文件的情况。

还有一些值得一提的:

fetch(request);

在功能上和下面是一样的。

fetch(request)
  .then(function (response) {
    return response;
  });

这可能是很显然的,但是这还是让我花了一点时间去弄明白。同样的:

caches.match(request);

和下面是一样的

caches.match(request)
  .then(function (response) {
    return response;
  });

这是另外一个电子,你可能会注意到有的时候我会这么写

fetch(request);

但是有的时候我会这么写

fetch(request, { credentials: 'include' } );

这是因为,fetch请求默认情况下不包括cookies。如果请求的是静态文件,这不会产生什么问题。但是如果是一个潜在的动态HTML页面,你可能会希望确保Service Worker的请求和正常的浏览器请求是一样的。你可以通过传入第二个(可选)参数来达成这一目的。

但可能最棘手的是让你按照Promises的方法进行思考。编写JavaScript通常是一个十分程序性的事情,但是一旦你需要处理then语句,你就必须要与那种异步返回的情况进行教授。所以,语句在then语句之后可能会比then语句之内的语句先执行。这还是挺难解释的,但是如果你发现你的Service Worker代码出了点问题,你可以检查一下是不是这种原因。

最后记住,分享你的代码和你遇到的坑,现在还是Service Workers的开发早期,所以每一个改进都可能。

更新

在我发出这篇文章后,我得到了Jake非常有用的回应。

Expires headers(过期头)

默认情况下,JavaScript文件在我的服务器上会被缓存一个月。但是一个Service Worker脚本根本不应该被缓存(或者缓存很短的一段时间)。我相应地更新了我的.haccess规则

<FilesMatch "serviceworker.js">
  ExpiresDefault "now"
</FilesMatch>
Credentials(证书)

如果这是一个浏览器生成的请求,我不用这么声明

fetch(request, { credentials: 'include' } );

我只需要这样子

fetch(request);
Scope(范围)

我把我的Service Worker范围参数设置为"/"……,但是因为Service Worker文件被放置在根目录上,我其实并不需要这么做。我可一个这样注册他。

if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/serviceworker.js');
}

反之,如果Service Worker放置在其他路径上,我希望他对整个网站都起作用,那么我需要确定一下范围变量。

if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/path/to/serviceworker.js', {
    scope: '/'
  });
}

另外,我还需要发送一个特别的头部。这样他可以最简单的把Service Worker脚本放在根目录下。

w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复