cisen / blog

Time waits for no one.
134 stars 20 forks source link

workbox-webpack-plugin 插件相关 #655

Open cisen opened 5 years ago

cisen commented 5 years ago

总结

为什么需要这个webpack插件?

cisen commented 5 years ago

总结

目前流行的 Service Worker 开发工具大多以命令行或者构建工具(Gulp、Webpack)插件形式提供,因此可以很方便地集成到我们已有的开发流程中。从工具的使用方式上又可以分成向已有 Service Worker 注入代码,和生成全新 Service Worker 两种。

Service Worker 需要处理两类缓存:预缓存和动态缓存。 下面我们先介绍下两者的概念。

预缓存

首先我们需要了解预缓存的概念。在 Service Worker 安装阶段,我们可以请求并缓存一些需要被长时间缓存的静态资源,这种在 Service Worker 正式工作之前就进行的缓存就叫做“预缓存”。

从内容上看,App Shell 所需的静态资源就是十分适合被预缓存的,后续这些资源可以使用 CacheFirst 策略进行响应。

// service-worker.js

function precache() {
  return caches.open(CACHE).then(function(cache) {
    // 预缓存列表
    return cache.addAll([
      './index.html',
      './index.js',
      './index.css'
    ]);
  });
}
self.addEventListener('install', function(evt) {
  evt.waitUntil(precache());
});

在实际的项目开发中,每次代码发生变更,为了保证客户端能同步到最新版本的代码,这个预缓存列表也是需要更新的。这就要求在每次项目的构建阶段,我们都需要生成最新的预缓存列表。

另外,在 Service Worker 中,如果按照上述代码编写,显然会带来一个问题。那就是当列表中的资源已经存在于缓存中且没有发生改变,依然会重新发送请求,造成带宽的浪费。理想状态下,Service Worker 应该只请求发生了变化的静态资源。

最后,为了对比最新预缓存列表和老版本中的资源情况,我们需要为列表中每一个资源生成对应的版本号。这个版本号可以体现在文件名中,例如 index.[hash:8].js,也可以单独存在于构建时注入 Service Worker 的资源列表中。在后续具体工具的介绍中,我们会看到这两种方式更加详细的使用情况。

动态缓存

通过对预缓存的介绍,我们很容易看出某些资源并不适合放在预缓存列表中。例如列表中的图片,用户头像,API 请求的 JSON 响应数据等等。通常我们希望实际请求这部分资源时才放入缓存,这就是动态缓存。

不同于预缓存使用 CacheFirst 响应策略,对于不同的资源请求类型,应该采取不同的动态缓存策略。 关于这些缓存策略的介绍以及应用场景,可以参考 offline-cookbook

下面我们将正式介绍 sw-precache sw-toolboxworkbox 这三款工具的使用方法。

sw-precache

首先需要声明,这两款工具推出的时间较早,因此不感兴趣的可以直接跳到下一小节对于 Workbox 的介绍。另外,我们的介绍也仅包含集成到 Webpack 构建流程的情况。

首先是 sw-precache,顾名思义这是负责在构建生成预缓存列表的插件,使用方式十分简单。值得一提的是 Vue 官方的 PWA 模板也使用了这款插件。

示例项目中我们使用了最基本的配置,即缓存名称和输出的 Service Worker 文件名:

// webpack.config.js

new SWPrecacheWebpackPlugin({
  cacheId: 'sw-tools',
  filename: 'service-worker.js'
})

Service Worker 内容

插件会自动生成 Service Worker,其中包含了预缓存列表,列表中每一项包含了当前资源文件名和根据内容生成的版本号。

// service-worker.js

var precacheConfig = [
  [".../dist/index.js","636d...fdb4"],
  [".../dist/index.css","ddc3...bf73"],
  [".../dist/index.html","4d5b...9086"]
];

运行效果 打开 Chrome 开发者工具我们可以发现在 Service Worker 安装完毕之后,预缓存列表中的资源就被放入了我们指定的缓存中了: image

sw-toolbox

接下来我们来看看动态缓存的使用方式,sw-toolbox 已经集成到了 sw-precache 中,因此可以在上述 sw-precache 的基础上增加配置项 runtimeCaching,其中缓存规则列表中每一项都包含了匹配规则和对应的缓存策略。示例项目

// webpack.config.js

new SWPrecacheWebpackPlugin({
  cacheId: 'sw-tools',
  filename: 'service-worker.js',
  runtimeCaching: [{ //  增加的配置项
    urlPattern: '/.*\.png$', // 匹配规则
    handler: 'networkFirst' // 缓存策略
  }]
})

Service Worker 内容

上述配置生成的 Service Worker 包含如下内容,在之前 sw-precache 生成的内容基础之上,包含了对 sw-toolbox 的引用以及根据匹配规则生成的调用相应 API 代码:

// service-worker.js

// 之前的 precache 代码...
// 引入 sw-toolbox 代码...

toolbox.router.get("/.*.png$", toolbox.networkFirst, {});

运行效果 当页面请求一张图片(fog.png)时,我们可以看到命中了匹配规则,被放入了动态缓存中。 image

Workbox

相比 sw-precache 和 sw-toolbox,Workbox 作为 Google 力推的 Service Worker 开发工具,拥有更加灵活友好的 API 设计和开发 debug 信息。如果之前是 sw-precache 和 sw-toolbox 的使用者,也可以遵循官方的迁移教程方便地完成迁移工作。

除了使用对应的 Webpack 插件,Workbox 还提供了 CLI 和 Node 模块的使用方式。另外,除了生成全新 Service Worker,也支持注入已有 Service Worker。

在下面的介绍中,我们选取了如下使用方式:

Webpack 插件配置

首先 Workbox Webpack 插件提供了两种使用方式,即完全依赖 Workbox 生成(generateSW) Service Worker 和注入(injectManifest)已有Service Worker。两者各有优劣,对于简单场景前者其实完全够用,而在需要对 Service Worker 进行更细粒度控制的复杂场景(复杂动态路由规则,结合 Web Push 等)下,后者显然更加合适。

其次在两种模式下,Workbox Webpack 插件都会根据配置生成预缓存列表,这一点和 sw-precache 其实是一样的。只不过是以单独文件形式存在。

下面我们将选取注入模式进行介绍,其中几个重要的配置项如下:

const {InjectManifest} = require('workbox-webpack-plugin'); // 注入模式 new InjectManifest({ // 已有 SW 路径 swSrc: path.resolve(__dirname, 'src/service-worker.js'), // 目标文件名 swDest: 'service-worker.js', // 过滤掉图片 exclude: [/.png$/], // 使用本地 Workbox 文件 importWorkboxFrom: 'local' })

其他配置项可以参考 [workbox-webpack-plugin#configuration](https://developers.google.com/web/tools/workbox/modules/workbox-webpack-plugin#configuration)。

构建结束后可以发现生成了一个单独的 precache-manifest 文件,其中包含了完整资源名称和版本号:
```js
// precache-manifest.7cf672a.js

self.__precacheManifest = [
  {
    "revision": "4bc1274aea045523f107d03725a9ed41",
    "url": "/sw-tools/examples/workbox/dist/index.html"
  },
  {
    "revision": "6637fa32535dcb55b8e6",
    "url": "/sw-tools/examples/workbox/dist/index.css"
  },
  {
    "revision": "6637fa32535dcb55b8e6",
    "url": "/sw-tools/examples/workbox/dist/index.04ef0e20.js"
  }
];

那么这个单独的文件是如何被 Service Worker 使用的呢?

使用 Workbox API

打开注入后的 Service Worker 文件,可以发现文件顶部多出了如下两行语句:

// dist/service-worker.js

importScripts("/sw-tools/examples/workbox/dist/precache-manifest.7cf614407318b61f9842d1dbb811672a.js",
  "/sw-tools/examples/workbox/dist/workbox-v3.3.1/workbox-sw.js");
workbox.setConfig({modulePathPrefix: "/sw-tools/examples/workbox/dist/workbox-v3.3.1"});

其中第一行通过 importScripts 引用了之前生成的 precache-manifest 文件以及 workbox-sw 代码,其中 workbox-sw 会在运行时根据运行环境(是否在 localhost 下)自动决定引用开发 dev 版本还是 prod 版本。而第二行则是为了帮助 workbox-sw 找到其余 Workbox 类库代码(例如都在 dist/workbox-v3.3.1下)。

下面我们将介绍常用的 Workbox API,这也是 Service Worker 开发者最关心的部分。值得一提的是 Workbox 将这些功能划分成了独立的模块,确保在运行时只引用所需模块。

预缓存

由于 Workbox 已经帮助我们自动生成了预缓存列表文件,而且也已经向 Service Worker 注入了引用代码, 我们开发者要做的只剩下调用一行 API。如果想了解 Workbox 在 Service Worker 安装阶段如何高效地进行预缓存工作,可以阅读 how workbox-precaching works

// src/service-worker.js

workbox.precaching.precacheAndRoute(self.__precacheManifest);

你可能会问为什么 Workbox 不帮我们连这句都自动注入呢?答案是对于这份预缓存列表,开发者还有更多可配置的选项,最终会影响到 URL 的匹配行为。

例如默认情况下 URL 中 utm_ 参数会被忽略,因此 /?utm_campaign=123&utm_source=zhihu 会匹配缓存中的 / 路径,如果想忽略全部参数,配置如下:

// src/service-worker.js

workbox.precaching.precacheAndRoute(
  self.__precacheManifest,
  {
    ignoreUrlParametersMatching: [/.*/]
  }
);

更多 workbox.precaching 模块的使用方式可以参考 incoming requests to precached files

动态缓存

动态缓存由 workbox.routing 模块负责。开发者可以定义一系列路由规则及其处理逻辑,每个被拦截的 fetch 请求都会进行 URL 规则匹配,命中则进入定义的处理逻辑。如果想了解更多的处理细节,可以阅读 how routing is performed

注册路由方法签名如下,其中 pattern 的类型可以是字符串,正则表达式或者函数,而 handler 可以是 workbox.strategies 模块定义的缓存策略或者是自定义处理函数:

workbox.routing.registerRoute(
  pattern,
  handler
);

以最常用的正则表达式为例,我们想针对所有图片类型资源采用 CacheFirst 策略:

workbox.routing.registerRoute(
  /.*\.(?:png|jpg|jpeg|svg|gif)/g,
  workbox.strategies.cacheFirst({
    cacheName: 'my-image-cache',
  })
);

更多针对不同类型资源以及对应缓存策略的场景可以参考 common-recipes

设置缓存名称

设置缓存名称由 workbox.core 模块负责:

workbox.core.setCacheNameDetails({
  prefix: 'sw-tools',
  suffix: 'v1',
  precache: 'precache',
  runtime: 'runtime-cache'
});

另外在这个模块中还可以使用 setLogLevel 设置开发模式下的输出日志级别

Skip Waiting & Clients Claim

了解 Service Worker 生命周期的开发者应该知道,为了让已安装的 Service Worker 立即进入 activate 状态,我们会在 install 事件处理函数中调用 self.skipWaiting()。在 workbox 模块中,我们只需要这样:

workbox.skipWaiting();

另外,为了尽快控制还未受控制的客户端,我们会在 activate 事件处理函数中调用 clients.claim() 。在 workbox 模块中,我们需要这样:

workbox.clientsClaim();

你可能会问为什么 Workbox 不默认开启这两者呢? 关于这个问题可以参考 Workbox 的核心开发人员的回答: what-are-the-downsides-to-using-skipwaiting-and-clientsclaim-with-workbox。我们已经知道在 install 事件处理函数中会进行预缓存列表的请求,Workbox 的做法是将这些资源放入一个临时的缓存中(带有 -temp 后缀),然后在 activate 事件处理函数中将临时缓存的内容移入正式缓存,同时删除已经失效的条目。

熟悉 PRPL 模式的开发者应该知道我们会进行路由级别的 Code Splitting (代码分割),同时预缓存剩余路由,这样在实际路由跳转时才会请求对应文件(Lazy-load)。假设首次 Service Worker 安装完毕,路由 /user 对应的文件 user.123.js 已经存在于缓存之中,此时 Service Worker 发生了更新(服务器上的 user.123.js 已经变成了 user.456.js),经历了 install 阶段 user.456.js 已经处于临时缓存中,由于开启了 skipWaiting(),立刻进入 activate 阶段进行缓存清理,此时正式缓存中也只剩下了 user.456.js。假如此时用户进行路由跳转,前端运行时代码依旧会请求 user.123.js,发现缓存和服务端都已经不存在,则出现错误。而如果不开启 skipWaiting(),至少能命中缓存中的旧版本,应用依然可用。

细心的读者可能已经发现,问题的根源在于部分资源更新时用户已经打开的页面却能及时刷新。换言之,如果每次 Service Worker 更新时能通过页面 UI 引导用户刷新页面,则完全可以放心开启。这里仅给出监听 Service Worker 更新的参考实现:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('path-to-sw').then(function(reg) {
    reg.onupdatefound = function() {
      var installingWorker = reg.installing;
      installingWorker.onstatechange = function() {
        switch (installingWorker.state) {
          case 'installed':
            if (navigator.serviceWorker.controller) {
              // 触发 CustomEvent
              var event = document.createEvent('Event');
              event.initEvent('sw.update', true, true);
              window.dispatchEvent(event);
            }
            break;
        }
      };
    };
  }).catch(function(e) {
      console.error('Error during service worker registration:', e);
  });
}

完整示例

最后附上完整的 Service Worker 代码:

workbox.core.setCacheNameDetails({
  prefix: 'sw-tools',
  suffix: 'v1',
  precache: 'precache',
  runtime: 'runtime-cache'
});

workbox.skipWaiting();
workbox.clientsClaim();

workbox.precaching.precacheAndRoute(self.__precacheManifest);

workbox.routing.registerRoute(
  /.*\.(?:png|jpg|jpeg|svg|gif)/g,
  workbox.strategies.cacheFirst({
    cacheName: 'my-image-cache',
  })
);

其他注意事项

除了以上常用的 API,还有一些注意事项需要开发者留意。

超出存储大小

很多开发者都会关心缓存的使用上限问题。根据 what-is-the-storage-limit-for-a-service-worker 的问答:

In Chrome and Opera: Your storage is per origin (rather than per API). Both storage mechanisms will store data until the browser quota is reached. Apps can check how much quota they’re using with the Quota Management API (as described above). Firefox no limits, but will prompt after 50MB data stored Mobile Safari 50MB max Desktop Safari unlimited (prompts after 5MB) IE10+ maxes at 250MB and prompts at 10MB

在运行时,可以通过 StorageManager 接口获取当前缓存的使用估计值,具体使用方法可以参考 estimating-available-storage-space。不过目前支持度并不高。

为了避免超出缓存大小的情况频繁出现,我们可以为指定的缓存设置存储上限,例如存储数目和过期时间。 下面的例子来自官方文档

workbox.routing.registerRoute(
  new RegExp('\.(?:png|gif|jpg|svg)$'),
  workbox.strategies.cacheFirst({
    // You need to provide a cache name when using expiration.
    cacheName: 'images',
    plugins: [
      new workbox.expiration.Plugin({
        // Keep at most 50 entries.
        maxEntries: 50,
        // Don't keep any entries for more than 30 days.
        maxAgeSeconds: 30 * 24 * 60 * 60,
        // Automatically cleanup if quota is exceeded.
        purgeOnQuotaError: true,
      }),
    ],
  }),
);

这里有两点需要注意:

  1. 要使用 workbox.expiration 模块必须指定缓存名称,也就是这里的 cacheName
  2. purgeOnQuotaError 表示当超出缓存上限抛出 QuotaExceededError 异常时,该缓存是可以被清理的。开发者最了解各个动态缓存的使用场景,因此手动标记某些缓存为“清理安全”是十分有必要的

    Opaque Response

    在请求跨域资源时,非 CORS 模式下会得到 Opaque Response。这样的响应无法读取其内容,也无法获取状态码,因此 Workbox 面对这样的资源在某些缓存策略下就会出问题。

例如我们的请求得到了一个 Opaque Response,假如此时请求失败,由于无法获取状态码,Service Worker 并不知情依旧将错误的结果放入缓存,由于选择了 CacheFirst,将再也没有机会更新。另外,由于无法获取内容,浏览器在估计缓存大小时也只能采取十分保守的策略,有可能出现实际只有几 K 的响应被浏览器认为有几 M,造成缓存空间的浪费。

workbox.routing.registerRoute(
  'https://cdn.xxx.com/lib.min.js',
  workbox.strategies.cacheFirst(),
);

因此 Workbox 在默认情况下仅在 NetworkFirst 和 StaleWhileRevalidate 策略下才会缓存 Opaque Response,除此之外都会报错。

当然开发者也可以强制开启缓存,不过并不推荐这种做法。

cache polyfill

由于 Cache API 中 addAll 方法在某些低版本的安卓机型上不一定支持,所以可以引用 cache-polyfill

// src/service-worker.js

importScripts('serviceworker-cache-polyfill.js');

GA 离线统计

离线状态下的数据统计是一个很大的问题。常用的方法是离线状态下将统计数据持久化到 localStorage 中,上线时再同步。使用 workbox.googleAnalytics 模块让这一切变得十分简单:

// src/service-worker.js

workbox.googleAnalytics.initialize();