Service Worker离线缓存


一. service worker 介绍

service worker 的由来

service worker 是浏览器的一个高级特性,本质是一个 web worker,是独立于网页运行的脚本。 web worker 这个 api 被造出来时,就是为了解放主线程。因为,浏览器中的 JavaScript 都是运行在单一个线程上,随着 web 业务变得越来越复杂,js 中耗时间、耗资源的运算过程则会导致各种程度的性能问题。 而 web worker 由于独立于主线程,则可以将一些复杂的逻辑交由它来去做,完成后再通过 postMessage 的方法告诉主线程。 service worker 则是 web worker 的升级版本,相较于后者,前者拥有了持久离线缓存的能力。

service worker 的特点

sw 有以下几个特点:

  • 独立于主线程、在后台运行的脚本
  • 被 install 后就永远存在,除非被手动卸载
  • 可编程拦截请求和返回,缓存文件。sw 可以通过 fetch 这个 api,来拦截网络和处理网络请求,再配合 cacheStorage 来实现 web 页面的缓存管理以及与前端 postMessage 通信。
  • 不能直接操纵 dom:因为 sw 是个独立于网页运行的脚本,所以在它的运行环境里,不能访问窗口的 window 以及 dom。
  • 必须是 https 的协议才能使用。不过在本地调试时,在 http://localhosthttp://127.0.0.1 下也是可以跑起来的。
  • 异步实现,sw 大量使用 promise。

service worker 的生命周期

service worker 从代码的编写,到在浏览器中的运行,主要经过下面几个阶段 installing -> installed -> activating -> activated -> redundant;

installing:这个状态发生在 service worker 注册之后,表示开始安装。在这个过程会触发 install 事件回调指定一些静态资源进行离线缓存。

installed:sw 已经完成了安装,进入了 waiting 状态,等待其他的 Service worker 被关闭(在 install 的事件回调中,可以调用 skipWaiting 方法来跳过 waiting 这个阶段)

activating: 在这个状态下没有被其他的 Service Worker 控制的客户端,允许当前的 worker 完成安装,并且清除了其他的 worker 以及关联缓存的旧缓存资源,等待新的 Service Worker 线程被激活。

activated: 在这个状态会处理 activate 事件回调,并提供处理功能性事件:fetch、sync、push。(在 acitive 的事件回调中,可以调用 self.clients.claim())

redundant:废弃状态,这个状态表示一个 sw 的使命周期结束

service worker 代码实现

//在页面代码里面监听 onload 事件,使用 sw 的配置文件注册一个 service worker
if ("serviceWorker" in navigator) {
  window.addEventListener("load", function () {
    navigator.serviceWorker
      .register("serviceWorker.js")
      .then(function (registration) {
        // 注册成功
        console.log(
          "ServiceWorker registration successful with scope: ",
          registration.scope
        );
      })
      .catch(function (err) {
        // 注册失败
        console.log("ServiceWorker registration failed: ", err);
      });
  });
}
//serviceWorker.js
var CACHE_NAME = "my-first-sw";
var urlsToCache = ["/", "/styles/main.css", "/script/main.js"];

self.addEventListener("install", function (event) {
  // 在 install 阶段里可以预缓存一些资源
  event.waitUntil(
    caches.open(CACHE_NAME).then(function (cache) {
      console.log("Opened cache");
      return cache.addAll(urlsToCache);
    })
  );
});

//在 fetch 事件里能拦截网络请求,进行一些处理
self.addEventListener("fetch", function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      // 如果匹配到缓存里的资源,则直接返回
      if (response) {
        return response;
      }

      // 匹配失败则继续请求
      var request = event.request.clone(); // 把原始请求拷过来

      //默认情况下,从不支持 CORS 的第三方网址中获取资源将会失败。
      // 您可以向请求中添加 no-CORS 选项来克服此问题,不过这可能会导致“不透明”的响应,这意味着您无法辨别响应是否成功。
      if (
        request.mode !== "navigate" &&
        request.url.indexOf(request.referrer) === -1
      ) {
        request = new Request(request, { mode: "no-cors" });
      }

      return fetch(request).then(function (httpRes) {
        //拿到了http请求返回的数据,进行一些操作

        //请求失败了则直接返回、对于post请求也直接返回,sw不能缓存post请求
        if (
          !httpRes ||
          (httpRes.status !== 200 &&
            httpRes.status !== 304 &&
            httpRes.type !== "opaque") ||
          request.method === "POST"
        ) {
          return httpRes;
        }

        // 请求成功的话,将请求缓存起来。
        var responseClone = httpRes.clone();
        caches.open("my-first-sw").then(function (cache) {
          cache.put(event.request, responseClone);
        });

        return httpRes;
      });
    })
  );
});

二. service worker 在 seed 中的引入

上面展示了在半年前研究 pwa 离线缓存时写的代码,而这次,真正要在正式环境上使用时,我决定使用 webpack 一个插件:workbox-webpack-plugin。workbox 是 google 官方的 pwa 框架,workbox-webpack-plugin 是由其产生的其中一个工具,内置了两个插件:GenerateSW 、InjectManifest

  • GenerateSW:这个插件会帮你生成一个 service worker 配置文件,不过这个插件的能力较弱,主要是处理文件缓存和 install、activate
  • InjectManifest:这个插件可以自定义更多的配置,比如 fecth、push、sync 事件

由于这次是为了进行资源缓存,所以只使用了 GenerateSW 这部分。

//在webpack配置文件里
var WorkboxPlugin = require("workbox-webpack-plugin");

new WorkboxPlugin.GenerateSW({
  cacheId: "seed-cache",

  importWorkboxFrom: "disabled", // 可填`cdn`,`local`,`disabled`,
  importScripts: "/scripts-build/commseed/workboxswMain.js",

  skipWaiting: true, //跳过waiting状态
  clientsClaim: true, //通知让新的sw立即在页面上取得控制权
  cleanupOutdatedCaches: true, //删除过时、老版本的缓存

  //最终生成的service worker地址,这个地址和webpack的output地址有关
  swDest: "../workboxServiceWorker.js",
  include: [],
  //缓存规则,可用正则匹配请求,进行缓存
  //这里将js、css、还有图片资源分开缓存,可以区分缓存时间(虽然这里没做区分。。)
  //由于种子农场此站点较长时间不更新,所以缓存时间可以稍微长一些
  runtimeCaching: [
    {
      urlPattern: /.*\.js.*/i,
      handler: "CacheFirst",
      options: {
        cacheName: "seed-js",
        expiration: {
          maxEntries: 20, //最多缓存20个,超过的按照LRU原则删除
          maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
        },
      },
    },
    {
      urlPattern: /.*css.*/,
      handler: "CacheFirst",
      options: {
        cacheName: "seed-css",
        expiration: {
          maxEntries: 30, //最多缓存30个,超过的按照LRU原则删除
          maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
        },
      },
    },
    {
      urlPattern: /.*(png|svga).*/,
      handler: "CacheFirst",
      options: {
        cacheName: "seed-image",
        expiration: {
          maxEntries: 30, //最多缓存30个,超过的按照LRU原则删除
          maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
        },
      },
    },
  ],
});
  1. importWorkboxForm 和 importScripts:

importWorkboxFrom:workbox 框架文件的地址,可选 cdn、local、disabled

  • cdn:引入 google 的官方 cdn,当然在国内会被强。。pass
  • Local:workboxPlugin 会在本地生成 workbox 的代码,可以将这些配置文件一起上传部署,这样是每次都要部署一次这个生成的代码。
  • Disabled:上面两种都不选用,将生成出来的 workbox 代码使用 importscript 指定 js 文件从而引入。
    我最终选择的是第三种,因为这样可以由自己指定要从哪里引入,比如以后如果这个站点有了 cdn,可以将这个 workbox.js 放到 cdn 上面。目前是将生成的文件,放到 script 文件夹下。

2.workbox 的策略

  • Stale-While-Revalidate:尽可能快地利用缓存返回响应,缓存无效时则使用网络请求
  • Cache-First:缓存优先
  • Network-First:网络优先
  • Network-Only:只使用网络请求的资源
  • Cache-Only:只使用缓存

一般站点的 CSS,JS 都在 CDN 上,SW 并没有办法判断从 CDN 上请求下来的资源是否正确(HTTP 200),如果缓存了失败的结果,就不好了。这种情况下使用 stale-while-Revalidate 策略,既保证了页面速度,即便失败,用户刷新一下就更新了。

而由于种子项目的 js 和 css 资源都在站点下面,所以这里就直接使用了 cache-first 策略。

在 webpack 中配置好之后,执行 webpack 打包,就能看到在指定目录下由 workbox-webpack-plugin 生成的 service worker 配置文件了。

接入之后,打开网站,在电脑端的 chrome 调试工具上可以看到缓存的资源

接入过程的考虑

  1. 前文也有介绍,service worker 一旦被 install,就永远存在;如果有一天想要去除跑在浏览器背后的这个 service worker 线程,要手动去卸载。所以在接入之前,我得先知道如何卸载 service worker,留好后手:
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.getRegistrations().then(function (registrations) {
    for (let registration of registrations) {
      //安装在网页的 service worker 不止一个,找到我们的那个并删除
      if (registration && registration.scope === "https://seed.futunn.com/") {
        registration.unregister();
      }
    }
  });
}
  1. 使用 service worker 缓存了资源,那下次重新发布了,还会不会拉取新的资源呢?这里也是可以的,只要资源地址不一样、修改了 hash 值,那么资源是会重新去拉取并进行缓存的,如下图,可以看到对同一个 js 的不同版本,都进行了缓存。

  1. 还有个就是对于考虑开发过程的问题,如果以后上线了,sw 这个东西安装下去了,每次打开都直接读取缓存的资源,那以后在本地调试时怎办?试了下,chrome 的“disabled cache”也没有用,总不能在本地开发时也给资源打上 hash 值吧(目前这个项目是在发布到正式环境时才会打上 hash 值)。。然后针对这个问题想了蛮久的,最后发现 chrome 早有这个设置,在 devtool 中可以设置跳过 service worker,bypass for network

  1. 比起浏览器的默认缓存功能,service woker 的缓存功能赋予我们更强大地、更完善地控制缓存的能力。

  2. 这个东西其中一个不足在于,还没有很多浏览器支持 service worker 这个东西,苹果系统是从 11.3 才开始支持,所以直到现在,富途牛牛 ios 版的 webview、微信 ios 版的 webview 都还不支持 service worker 这个特性;在安卓上的支持更为广泛一些,所以这次在种子的优化上,安卓客户可以更好地感受到这个成效。


Author: Eric
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source Eric !
  TOC