Day18 X Service Workers Cache

如果你听过 PWA,那麽对今天的主题ㄧ定不陌生,因为今天要讲的 Service Worker 就是 PWA 的一个重要元件。不过 PWA 这个主题本身就已经足够写好几本书了,所以今天并不会聚焦在 PWA 上,而是会专注於 Service Worker 的快取功能上,完全没听过 Service Workers 的读者也不要担心,在进入正题前会先对 Service Workers 做个简单的介绍,那我们就直接进入快取之旅的第二站吧!

(当然如果懂 PWA 的基本概念的话会更好,如果完全不知道 PWA 的读者建议可以先看看这篇文章。)

什麽是 Service Workers ?

Service Workers 严格来说属於 Web Workers(系列文第 22 天时会介绍) 的一种,我们都知道开发时所撰写的 JavaScript 是运行在 Main Thread(或称 UI Thread)上的,Service Workers 则是运行在不同於 main thread 的 thread 上,因此执行可以不被画面的渲染或运算 block 住,并且具有可以在浏览器关闭时继续在背景执行的能力。

而 Service Workers 又跟一般的 Web Workers 不太ㄧ样,它是一层在浏览器与 network 层级之间的 proxy,拥有拦截使用者发出请求的能力(透过监听 fetch 事件),另外 Service Workers 也提供了快取的功能,可以在拦截使用者发出的请求後决定要不要回传快取的内容。

在昨天有提到各种快取的优先级别,Service Workers 的 Cache 的优先级别是高於 HTTP Caching 的,如果排除掉由各家浏览器自己实作且没有明确规范的 memory cache 的话,Service Workers Cache 就是前端开发者可控范围内快取的第一道防线。

有了 Service Workers 的帮助,网页应用可以做到许多以往做不到或是不容易实现的功能,例如:

  • 透过 Service Workers 的 Cache 实现离线浏览功能
  • 像 Native App 一样的推播功能
  • Background Sync

虽然看似强大,前几年也一直有 PWA 将大为流行甚至可以取代 Native App 的舆论与势头,不过碍於一些作业系统的限制,例如 Web App manifest、Launching screen、Installation prompt API、Push notification、Background Sync、In-App Browser 等 PWA 重要的功能在我写这篇文章的当下 ios 都是没有支援的,因此 PWA 也许没有我们想的那麽顺利美好。

不过这并不代表跟 PWA 相关的 features 就没有学习与使用的价值了,今天的主角 Service Workers Cache 就是一个仍然值得我们投入的技术,学会善用它不仅有机会增加使用者体验,同样也有机会优化网页的效能。

Service Workers 的生命周期

Service Workers(SW)拥有一个完全独立於 Web App 的生命周期,Service Workers 有一个特性是开发者对它有很高的控制权,可以细粒度的去决定它的行为,今天主要要介绍的 Service Workers Cache 就是在 Service Worker 的 Lifecycle 中透过撰写 JavaScript 的方式来达成,这稍後会再说明,这边需要注意一点,虽然说 Service Worker 也是靠 JavaScript 来操作其行为,但它不同於一般我们写的 code,在 SW 中没有办法操作 DOM,并且需要依赖 postMessage 来与页面沟通。

在进入 SW 的生命周期前,需要先检查当前浏览器有没有支援 SW,如果有支援的话,还得先注册写好的 SW 档案,例如以下的 JavaScript 判断式:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker
    .register('./some path to sw/sw.js',  { scope: '/ironman' }) // 注册 Service Worker
    .then(function(reg) {
      console.log('Registration succeeded. SW working scope is ' + reg.scope); 
    })
    .catch(function(error) {
      console.log('Registration failed with ' + error); // 注册失败
    });

要使用 SW 有一个限制是网站必须支援 HTTPS 或是 localhost,再来注意到 register 中有传入第二个 scope 参数,这个 scope 代表 SW 可以作用的范围,没有传入的话预设会是根目录,以上图来说的话就是 (my-domain | localhost)/ironman/ 之下都属於 SW 作用的范围。

至於要如何操控 SW 的生命周期呢?答案是靠事件监听机制。

Install

刚刚有提到浏览器会先注册我们写好的 SW 档案,注册成功後浏览器会在 background 启动一个 Service Worker 并开始安装,这边对应到的就是 install 这个事件,通常在安装阶段会快取一些静态资源,以供离线浏览时使用,如果指定的档案都快取成功,就会进入到下一个 Activate 阶段。

如果有资源快取失败,则整个安装过程都会失败,Service Worker 会进入 Error 状态,并等待下次重新 install。

this.addEventListener('install', function(event) {
 // waitUntil 确保 SW 在安装完成後才会去快取这些资源
  event.waitUntil(
    // 指定快取的版本号
    caches.open('v1').then(function(cache) {
      // 指定要快取的资源
      return cache.addAll(['/ironman.js', '/ironman.css']);
    }),
  );

Service Worker 处理 cache 的写法是使用 Cache Web API,想了解更多的读者可以再自行去深入了解一下。

Activate

完成安装後 SW 就会接着启动进入 Activate 的状态并接管在自己 scope 下的页面,在这个阶段开发者可以指定 SW 做一些事,例如清除旧的快取...等等。

this.addEventListener('activate', function(event) {
  // do some work
});

SW 进入 activate 并接管页面後,如果页面没有被使用时,Service Worker 会进入停止(Terminated)的状态,以节省记忆体的耗费。

Message

使用 postMessage 跟页面做讯息的传递,因为不是本篇重点,这边就不再赘述了。

this.addEventListener('message', function(e) {
  e.source.postMessage('Message: ' + e.data);
});

function sendingMsg(msg) {
  return new Promise(function(resolve, reject) {
    const messageChannel = new MessageChannel();
    
    messageChannel.port1.onmessage = function(e) {
      if (e.data.error) {
        reject(e.data.error);
      } else {
        resolve(e.data);
      }
    };
    navigator.serviceWorker.controller.postMessage(msg, [messageChannel.port2]);
  });
}

sendingMsg('message testing');

Fetch

Fetch 是今天的重头戏,在本篇的开头有提到,SW 是在浏览器与服务器之间的一个 proxy,拥有拦截使用者发出的请求的能力,而这个拦截的能力就来自於 fetch 事件。

在 fetch 事件中,最基本的应用是可以去快取看看有没有使用者想要的资源,如果有的话就从快取回传,如果没有的话就再发出网路请求

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      // Cache 中有找到就直接回传,不然就发出网路请求去要资料
      return response || fetch(event.request);
    })
  );
});

Why Service Worker Cache ?

等等...昨天不是才介绍完 HTTP Caching 吗?看起来 HTTP Caching 已经十分强大,那为什麽我们还需要 Service Worker 的 Caching 机制呢?

  • 对快取有更细粒度的控制权

    从刚刚 fetch 生命周期的介绍读者应该就可以感受到这一点了,我们可以透过程序码来决定拦截到网路请求後要做什麽处理,基本上只要遵守 SW 的限制,大部分想得到的需求都能够做到。

    如果对快取稍微了解的读者,应该知道快取有许多不同的 strategies,例如 Cache First 与 Network First...等等,透过 SW 我们也可以实作出不同的 strategies 来对应自己的需求,这部分等等再介绍。

    相较於变化性没那麽大的 HTTP Caching,SW Caching 可以对快取做到更细粒度的控制,可谓非常的强大。

  • Offline 离线浏览功能

    Offline 是 SW 提供的一个重要的功能,而得以实现离线浏览的关键就在於 Service Worker 的 Cache。透过把一些重要的资源放到 SW 的快取里,使用者在失去网路连线时还能够看到快取的资源,而不是显示网路连线错误的画面,大大提升了使用者体验。

    你可能会有这样的疑问:「为什麽 HTTP Caching 没办法做到离线浏览呢?」原因如下:

    • Cache-Control 本身的设计就不是针对离线浏览
    • HTTP Caching 需要经过服务器与浏览器的共同协商,如果在离线前没有造访过一些页面,就不会有那些页面的快取,也就没办法实现离线浏览。而 Service Worker 则是可以透过程序决定要快取哪些资源,不一定要造访过页面才能将资源存到快取。
    • 每种浏览器的行为不一样,就算浏览器已经有该资源的快取,也没办法保证它ㄧ定会从快取拿而不是发出网路请求,例如在某些条件下,浏览器就不会从快取载入资源。

不同的 Cache Strategies

关於实作离线浏览的 cache 机制,其实还有分成很多不同的 strategies,今天会介绍较常见的 5 种 strategies:

  • cache only
  • network only
  • cache falling back to network
  • network falling back to cache
  • cache then network

cache only

无论如何只回传快取版本。一般来说不会直接采用这个方式,在快取没有资源时回到 network 是比较常见也比较好的方式。如果真的要使用,比较适合用在一些与 codebase 结合的静态资源。

self.addEventListener('fetch', function(event) {
  event.respondWith(caches.match(event.request));
});

network only

无论如何都会直接发出网路请求。同样一般来说不会直接使用这个方式,真的要使用的话比较适合一些不适合在 offline 使用的资源,例如一些 non-GET 的 request。

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

cache falling back to network

这应该是蛮常见的一种模式,先去快取找看看有没有,有就回传,没有就发出 network request,如果是 non-GET 的 requests 因为不能被快取,所以会直接发出请求。

如果希望网页可以达到 offline first,这就会是一个蛮适合的 strategy。

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

network falling back to cache

如果资源的变化频率很高,例如文章列表、分数排行榜..等等,就适合以 network request 为优先以确保拿到最新的资料,如果使用者失去网路连线,可以先回传快取的旧资料,使用者体验会比显示 network error 好很多。

不过这种策略的缺点是当使用者没有完全 offline 但网路连线非常微弱缓慢时,需要等待网路请求完成,这个等待会很漫长并严重影响使用者体验。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    fetch(event.request).catch(function() {
      return caches.match(event.request);
    })
  );
});

cache then network

又称作 stale while revalidate,同样也十分适合变化频率高的内容,当使用者对资源发出请求时,先直接回传快取的版本,同时去网路抓取最新的内容,当有新的资料回传时再更新快取,在这个策略下使用者可以迅速的拿到资源,提升使用者的体验,并且後续的请求都可以拿到更新後的资料。

self.addEventListener('fetch', event => {
  const cached = caches.match(event.request);
  const fetched = fetch(event.request);
  const fetchedCopy = fetched.then(resp => resp.clone());

  // 用 Promise.race 看 cache 跟网路请求谁先回传(通常是 cache),如果 cache 没资料就等 network
  event.respondWith(
    Promise.race([fetched.catch(_ => cached), cached])
      .then(resp => resp || fetched)
      .catch(_ => new Response(null, {status: 404}))
  );

  // 用 fetch 回来的资料更新快取
  event.waitUntil(
    Promise.all([fetchedCopy, caches.open('cache-v1')])
      .then(([response, cache]) => cache.put(event.request, response))
      .catch(_ => {/* eat any errors */})
  );
});

Service Workers Cache 搭配 HTTP Cache

虽然 Service Workers Cache 与 HTTP Cache 有些相同的特性与功能,不过严格上来说两者的目标并不一样,所以如何将两者搭配使用就很重要了。如果配合得好的话网站就像有双层快取防护一样,第一层 cache miss 了不要怕,也许第二层会帮你守住防线。

至於要如何搭配?就是得靠设置快取的 Expire Time。如果 SW Caching 与 HTTP Caching 的过期时限都设置成一样的话,基本上 HTTP Caching 会等於没什麽用处,因为如果 SW Cache 失守了,来到 HTTP Cache 这层同样快取的资料也已经过期了,所以ㄧ定会回到网路请求中。

因此比较建议的方式是两者的过期时间设定成不一样的时间,搭配不同的快取策略,可以达到不同的效果。

因为这个主题比较深入了一点,就不在这边说明。关於这点 Google 有发表了一篇很棒的文章,有兴趣的读者可以看看。

Workbox

自己写 Service Workers 是一件很累也很复杂的事,有可能需要写大量的 boilerplate code,所幸 Google 推出了 workbox 这个服务,可以让开发者不用亲自写 Service Workers 的程序就能轻松实作出大部分的功能。除非需要很客制化的功能,不然非常建议要做 PWA 或是一些 Service Worker 可以实作的功能时优先考虑 workbox 这个解决方案。

本日小结

今天进入 Cache 章节的第二篇,介绍了 Service Workers 的基本概念与快取,有了 Service Workers 的帮助,我们可以更细粒度的去操作快取,并实作适合的 Cache Strategies,甚至还可以配合 HTTP Caching,建立双层的快取保护网,充份利用快取功能来提昇网页效能。

今天的内容一直不断提到 Service Workers 让离线浏览成为可能,我们也知道要达到离线浏览得依赖 Service Workers 的快取机制,不过为了实现离线浏览并提供最佳的使用者体验,到底要快取哪些资源才好呢?我们明天揭晓!

Reference & 图片来源

https://bitsofco.de/web-workers-vs-service-workers-vs-worklets/

https://blog.sessionstack.com/how-javascript-works-service-workers-their-life-cycle-and-use-cases-52b19ad98b58

https://developers.google.com/web/ilt/pwa/caching-files-with-service-worker

https://gist.github.com/surma/eb441223daaedf880801ad80006389f1


<<:  ASP.NET MVC 从入门到放弃(Day28)- MVC web api 加入swagger介绍

>>:  CSS微动画 - Animation会影响网页效能!

中央状态指挥中心- Vuex [序]

什麽是 Vuex ? 为 Vue.js 开发的状态管理模式,集中管理元件的状态。 像是电商网站中的购...

铁人赛 Day2 -- SQL到底是什麽东西?讲中文好不好

SQL到底是什麽东西? 全名叫做"结构化查询语言(Structured Query Lan...

[Day25] Query Builder查询生产器

介绍 Laravel 的数据库查询构建器提供了一个方便、流畅的界面来创建和运行数据库查询。它可用於在...

前言

作为一个ARM的初学者,想藉由这30天来分享我对於ARM一步一步的技术认识。 因为是正在学习中,技术...

JS 原始型别的包裹物件与原型的关联 DAY67

var b = new String('abcdef'); // 这里 String 为建构函式 c...