一个需求引发的 Service Worker 实战

1,237 阅读6分钟

背景

做了一个基于 vue3 的web项目,该项目有离线缓存需求

  1. 运行在 electron 提供的 Chrome 浏览器中
  2. 离线时首页不能白屏,有部分基础功能
  3. 该网页可能一直不关闭,所以需要定时刷新更新版本

理解 Service Worker

请看概念

理解 Workbox

上面我们已经确定了,需要使用 SW 缓存开发。那 Workbox 又是什么呢?

是 Google Chrome 团队推出的一套 PWA 的解决方案,这套解决方案当中包含了核心库和构建工具,因此我们可以利用 Workbox 实现 Service Worker 的快速开发。

workbox-webpack-plugin 是什么

我们项目是基于 webpack 构建的,而谷歌又提供了 Workbox 的 webpack 插件 -> workbox-webpack-plugin

workbox-webpack-plugin 使用

  1. 安装 npm install workbox-webpack-plugin

  2. 引入插件并配置 这里有两种配置方式,我们可以一起看看:
    第一种:GenerateSW
    通过配置在项目中引入 service.worker.js 适用于:

  • 预缓存与构建过程相关的文件,包括哈希值 URL 文件
  • 有简单的运行时的缓存需求
const WorkboxPlugin = require('workbox-webpack-plugin');

module.exports = {  
    plugins: [    
        new WorkboxPlugin.GenerateSW({ 
            // Do not precache images
            exclude: [/\.(?:png|jpg|jpeg|svg)$/],
            // Define runtime caching rules.
            runtimeCaching: [{        
                // Match any request that ends with .png, .jpg, .jpeg or .svg.       
                 urlPattern: /\.(?:png|jpg|jpeg|svg)$/,        
                // Apply a cache-first strategy.        
                handler: 'CacheFirst',        
                options: {         
                     // Use a custom cache name.          
                    cacheName: 'images',          
                    // Only cache 10 images.          
                    expiration: {            
                        maxEntries: 10,          
                    },       
                },      
            }],    
        })  
    ]
};

第二种:InjectManifest
通过已有的 service-worker.js 文件生成新的 service-worker.js 适用于:

  • 更多的定制化缓存需求、路由需求等
  • 需要在service worker中导入其他脚本或添加其他逻辑
new workboxPlugin.InjectManifest({  
    // 目前的service worker 文件
    swSrc: './src/sw.js',  
    // 打包后生成的service worker文件,一般存到disk目录 
    swDest: 'sw.js'
})

思路

  1. service-worker.js 编写 第一步,通常需要编写 service-worker.js 脚本。这里我们使用 workbox-webpack-plugin 插件,通过各种配置(如缓存策略配置)来自动生成相应的 service-worker.js

  2. 注册 Service Worker 一般来说,我们可以使用原生代码注册 SW 即可,如

// Check that service workers are supported
if ('serviceWorker' in navigator) { 
    // Use the window load event to keep the page load performant  
    window.addEventListener('load', () => {    
        navigator.serviceWorker.register('/sw.js');  
    });
}

但由 vue 脚手架生成的项目,默认带有register-service-worker 。这个三方库能代替原生代码进行SW注册。在这个库里,抛出了自定义 Service Worker 实例的状态,对麻烦的 Service Worker 更新做了处理,抛出了 updated 自定义事件,供用户自行处理。所以本项目使用默认的 register-service-worker

基础功能实现

service-worker.js 脚本生成

思路

打包配置 vue.config.js 里,增加 SW 相应的更新机制和缓存策略。

  1. 通过需分析求,我们设置了三种缓存模式
  • 首页不能白屏,所以首页所涉及的api请求需要缓存,在离线时可以取到上一次的数据呈现
  • 图片类需要缓存,因为首页有大量图片展示
  • 特殊接口 u.js 在首页中也调用了,也缓存
  1. 将生成的文件命名为 sw.js 代码
module.exports = {
  ...
  pwa: {
    workboxPluginMode: "GenerateSW",
    workboxOptions: {
      importWorkboxFrom: "local",
      swDest: "sw.js", // 固定的脚本名
      cacheId: "pro-v1",  // cache key
      skipWaiting: true,
      clientsClaim: true,
      cleanupOutdatedCaches: true,
      navigationPreload: true,
      runtimeCaching: [
        // 缓存配置
        // 静态资源默认使用缓存
        {
          //(策略NetworkFirst  优先用网络,网络失败用缓存)仅缓存首页用到的接口
          urlPattern: /^https:\/\/main.domain.com\/xxx/,
          handler: "NetworkFirst",
          options: {
            cacheName: "api",
            networkTimeoutSeconds: 10000,
            expiration: {
              maxAgeSeconds: 60 * 24 * 60 * 60, // 这只最长缓存时间为2个月
            },
            cacheableResponse: {
              statuses: [0, 200],
            },
          },
        },
        {
          // (策略StaleWhileRevalidate  优先用缓存,发请求提供下一次更新使用)
          urlPattern: /^https:\/\/main.u.com.*/,
          handler: "StaleWhileRevalidate",
          options: {
            cacheName: "ujs",
            cacheableResponse: {
              statuses: [0, 200],
            },
          },
        },
        {
          //下载图片(策略 Cache First,只缓存成功的)
          urlPattern: /^https:\/\/main.pic.com.*.(?:png|jpg|jpeg|svg|gif|PNG|JPG|JPEG|SVG|GIF)/,
          handler: "CacheFirst",
          options: {
            cacheName: "images",
            expiration: {
              //maxEntries: 30, 最大的缓存数,超过之后则走 LRU 策略清除最老最少使用缓存
              maxAgeSeconds: 60 * 24 * 60 * 60,
            },
            cacheableResponse: {
              statuses: [0, 200],
            },
          },
        },
      ]
  }
}

结果

上述配置后,我们在编译后的 dist 文件夹,就会变成这样

- dist
  - css
  - js
  ... // 原有的
  - workbox-v4.3.3
  - sw.js

我们会有 workbox 文件夹,它是个解决方案集合包,当中包含了核心库和构建工具
我们还有一个 sw.js 文件,这就是通过配置生成的 Service Worker 脚本

注册 Service Worker

我们现在已经有了 Service Worker 脚本,现在需要注册它。

配置

// package.json

"dependencies": {
  "register-service-worker": "^1.7.2",
}

注册文件

新建一个 registerServiceWorker.js 文件,在该文件里引入 register-service-worker 插件,并注册 Service Worker 脚本
参考git仓做了如下配置,由于我们不想激活 SW 后刷新才能接管,所以执行了刷新操作(一般情况,激活后,要刷新后 SW 才会接管资源)

import { register } from "register-service-worker";

if (process.env.NODE_ENV === "production") {
  // 我们知道 sw.js 位于 dist 里,也就是 BASE_URL 里
  register(`${process.env.BASE_URL}sw.js`, {
    ready() {
      console.log("Service worker is active.");
    },
    registered() {
      console.log("Service worker has been registered.");
    },
    cached() {
      console.log("Content has been cached for offline use.");
    },
    updatefound() {
      console.log("New content is downloading.");
    },
    updated() {
      console.log("New content is available; please refresh.");
      window.location.reload(); // 强制刷新以接管请求
    },
    offline() {
      console.log("No internet connection found.");
    },
    error(error) {
      console.error("Error during service worker registration:", error);
    },
  });
}

注册

在入口文件里引入 registerServiceWorker.js

// index.js
import "./registerServiceWorker";

Bug 分析

缓存多余文件

项目中,原本对 u.js 进行了缓存。但在测试环境中,无法正常访问 u.js,针对 u.js 的缓存任务无法停止 (生产环境是可以的)。原因是在Chrome浏览器中,当新的 SW 执行替换操作时,检测到旧SW存在未完成的任务,会取消替换,导致无法安装新SW。
解决方案:不缓存 u.js

// vue.config.js
pwa: {
  ...
        // 移除此配置
        // {
        //   urlPattern:  /^https:\/\/main.u.com.*/,
        //   handler: "StaleWhileRevalidate",
        //   options: {
        //     cacheName: "ujs",
        //     cacheableResponse: {
        //       statuses: [0, 200],
        //     },
        //   },
        // },
}

静态资源缓存失败

在站点部署过程中,访问站点,由于项目有灰度功能,就会出现新老 SW 实例并存的情况。这时候一部分资源被 旧SW 接管,当 新SW 安装成功时,执行了强刷动作,此时又有一部分资源被 新SW 接管。这样一来,资源就会出现混乱,会返回错误的缓存。
这时候,要做的就是清除缓存,从头再来

解决方案:在index.html中捕获全局错误,当资源加载失败时,清除 SW 缓存内容,并重新加载页面

// index.html
<script type="text/javascript">
  (function(){
    window.addEventListener(
       "error",
       function(e) {
            var url = e.target.href || e.target.src;
            if (/\/js\/.*\.js/.test(url) || /\/css\/.*\.css/.test(url)) {
              setTimeout(function() {
                if (
                  Object.prototype.toString.call(caches) ===
                  "[object CacheStorage]"
                ) {
                  caches
                    .keys()
                    .then(function(keyList) {
                      return Promise.all(
                        keyList
                          .filter(function(key) {
                            return key !== "images";
                          })
                          .map(function(key) {
                            return caches.delete(key);
                          })
                      );
                    })
                    .then(tryReload);
                }
              }, 5000);
            }
      },
      true
    );
    
    //刷新策略
    function tryReload() {
       window.location.reload();
    }
  })()
</script>

TODO 其实这里,在 updated 里清除缓存效果应该一样。

进阶需求

由于场景特殊,该网页可能长达一个月不关闭不刷新,那这时候,如果更新版本了,那又没有重新刷新界面,可能就无法及时更新。这时候就有了定时刷新需求:于1:30~2:30时,检查是否有更新,若存在,则主动更新并刷新

// registerServiceWorker.js

import { register } from "register-service-worker";

if (process.env.NODE_ENV === "production") {
  ...
}

/**
 * control {promise} control状态完成后再执行更新sw操作
 * 基于updated中的reload方法进行重载页面,主动更新service-worker
 */

export function updateServiceWorker(control) {
  if ("serviceWorker" in navigator) {
    navigator.serviceWorker.ready
      .then((registration) => {
        control.then(() => registration.update());
      })
      .catch((error) => console.log(error));
  }
}
// index.js
import { updateServiceWorker } from "./registerServiceWorker";

function timeCheck(){
  //自动修正定时器时间,每隔一小时检查一次,
  const now = dayjs();
  const timeEnd = now.minute(59).second(59);
  const interval = timeEnd.diff(now);
  setTimeout(function() {
    const now = dayjs();
    const start = now
      .hour(1)
      .minute(30)
      .second(0)
      .millisecond(0);
    const end = start.add(1, "hour");
    if (now.isBetween(start, end, "millisecond")) {
      updateServiceWorker(Promise.resolve());
    }
    midnightCheck();
  }, interval);
}

timeCheck();

参考

谷歌文档
workbox-webpack-plugin
register-service-worker