使用 ServiceWorker 缓存来优化体验

5,511 阅读7分钟

作者介绍

缘一,专有钉钉前端团队成员,负责专有钉钉 PC 客户端、端上应用、端上模块插件化的开发。

前言

本文不会花大篇幅讲解 ServiceWorker 的基础内容以及 ServiceWorker Api。本文主要描述 ServiceWorker 在缓存上的使用场景。

联系我们

嗨,专有钉钉前端团队目前组内正需要优秀的前端开发工程师,如果你对我们的职位感兴趣,则可以联系我们内推简历。或者加我:a564575471

基本概念

提到 Service Worker ,不得不先介绍一下 Web Worker,众所周知 javaScript 是执行在单线程的,如果执行大量计算任务就会堵塞了前端的渲染。而通过独立的线程的能力,Web Worker 可以分解耗时的任务。但是它的功能不应只局限于此,Service Worker 便是在 Web Worker 的基础上增加了离线缓存的能力。

Service Worker 可以拦截处理页面的所有网络请求,可以使用 cache 和 indexDB 的 api,可以让开发者自己主动管理缓存的内容以及版本,为离线弱网环境下的 web 的运行提供了可能,让 web 在体验上更加贴近 native。

更多关于 Service Worker 的基本 Api 和概念可以参考文档: developer.mozilla.org/zh-CN/docs/…

使用场景

预加载 && 离线化

这里以 www.yuque.com/ 语雀文档 举例:

image.png

打开开发者工具:

image.png

第二次进入后就会发现文件都已经从 ServiceWorker 的缓存中读取:

image.png

访问开发者后台 的 service-worker.js 查看源代码

self.assets = ["https://gw.alipayobjects.com/os/chair-script/skylark/common.b4c03be5.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/common.e2d71ce8.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__components__Explore__Recommend~p__explore__routers__Docs~p__explore__routers__Repos~p_~d6257766.aa1bcc43.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__components__Explore__Recommend~p__explore__routers__Docs~p__explore__routers__Repos~p_~d6257766.fac19d5b.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/c__Lakex~p__editor__routers__TextEditor.9defba11.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/c__Lakex~p__editor__routers__TextEditor.3a98afb8.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__bookRepo__routers.244d87f7.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__bookRepo__routers.ef3c862f.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/c__Lakex.acd5cec4.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/c__Lakex.653d1e93.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/layout__MainLayout.ae548301.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/layout__MainLayout.c0075e36.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__bookRepo__model.511a24e3.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__bookRepo__routers__EditCustomIndex.d4fbfe9e.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__bookRepo__routers__EditCustomIndex.28048163.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__bookRepo__routers__ShareExpired.8113c1a2.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__bookRepo__routers__ShareExpired.b6dff962.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__bookRepo__routers__SharePassword.1a6ae926.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__bookRepo__routers__SharePassword.f76c7685.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__components__Explore__Events.6d43e196.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__components__Explore__Events.979d04c6.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__components__Explore__Recommend.ab8c57cb.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__components__Explore__Recommend.ac025d9d.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__model.2d27d4bc.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__App.4d4a0a8c.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__App.08fcac15.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__CollabBooks.40627926.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__CollabBooks.91d8d56d.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__Collects.0a516ca7.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__Collects.b5f172fe.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__Dashboard.5f89b7f3.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__Dashboard.be7c1714.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__Explore.b51bb073.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__Groups.198f522b.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__Groups.ad67b3b7.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__History.086ddd9c.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__History.5387e7a8.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__MyBooks.40627926.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__MyBooks.61608f6e.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__Notes.a878e2d7.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__Notes.ffe2cc7a.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__Recycles.ab448ca1.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__dashboard__routers__Recycles.3434b09c.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__doc__model__page.424fcfd2.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__doc__routers.66f72a35.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__doc__routers.39267068.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__doc__routers__version.e7b71a05.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__doc__routers__version.186ff53b.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__model.7ef254a2.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__Asl.60282b53.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__Asl.fa585dad.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__TextEditor.f413dbfc.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__TextEditor.81c5d11d.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__board.591d841b.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__board.832f1003.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__doc.a1ccd84d.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__doc.e652cf65.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__embed.500645af.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__embed.743631c5.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__embed_extreme.5563bfd4.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__embed_extreme.88434cbe.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__sheet.8a86af45.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__sheet.2daf2fb0.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__show.75463f8e.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__show.14157f9c.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__table.60aad9c2.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__editor__routers__table.29a799ed.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__model.263db0b2.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Books.cfc93cd2.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Books.8ffd07d8.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Custom.710dc957.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Custom.604bf4aa.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Embed.daf129f3.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Embed.1a8cd333.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Error403.8113c1a2.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Error403.e426da8e.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Group.a1fbd1b1.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Group.aca6ba40.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Members.c73713ca.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Members.fc9d4e92.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Migrate.e821f2d6.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Migrate.c5718315.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Recycles.724821a4.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Recycles.9b99a94d.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Statistics.e849f2e3.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Statistics.e2b4dc68.async.js", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Upgrade.a42075c1.chunk.css", "https://gw.alipayobjects.com/os/chair-script/skylark/p__group__routers__Upgrade.d80c9df1.async.js", "https://gw.alipayobjects.com/os/lib/??react/16.13.1/umd/react.production.min.js,react-dom/16.13.1/umd/react-dom.production.min.js,react-dom/16.13.1/umd/react-dom-server.browser.production.min.js,moment/2.24.0/min/moment.min.js", "https://gw.alipayobjects.com/as/g/larkgroup/lake-codemirror/6.0.2/CodeMirror.js", "https://gw.alipayobjects.com/as/g/larkgroup/lark-sheet/11.0.20/lark-sheet.css", "https://gw.alipayobjects.com/a/g/lark/??immutable/3.8.2/immutable.min.js", "https://gw.alipayobjects.com/as/g/larkgroup/lark-sheet/11.0.20/lark-sheet.js"];
self.resourceBase = "https://gw.alipayobjects.com/os/chair-script/skylark/";

self.addEventListener("install", (e => {
  	//预加载常用资源
    Array.isArray(self.assets) && e.waitUntil(caches.open("v1").then((e => {
      e.addAll(self.assets)
    })))
})), self.addEventListener("activate", (e => {
  Array.isArray(self.assets) && caches.open("v1").then((e => {
    e.keys().then((t => {
      t.forEach((t => {
        //过期资源释放
        self.assets.includes(t.url) || e.delete(t)
      }))
    }))
  }))
}));
const r = [self.resourceBase, "https://at.alicdn.com/t/", "https://gw.alipayobjects.com/os/"];
self.addEventListener("fetch", (e => {
  //拦截资源,满足上述域名,优先使用缓存,否则使用网络下载资源并更新资源。
  r.some((t => e.request.url.startsWith(t))) && e.respondWith(caches.match(e.request).then((t => t && 200 === t.status ? t : fetch(e.request).then((t => {
    if (200 !== t.status) return t;
    const r = t.clone();
    return caches.open("v1").then((t => {
      t.put(e.request, r)
    })), t
  })).catch((() => {})))))
}))

此处只是访问首页,Cache 就已经加载了大量的 js 资源和 css 资源,总缓存占据了 64 MB,作为一个偏展示型官网,加载 64MB 显示是不科学的,更合理的解释是,在不堵塞用户使用的情况,优先缓存了后续页面所需要使用的 js 文件来加速后续页面的打开速度,比如其 TextEditor 组件就是为了后续的编辑页提前缓存的:

image.png

简而言之,A 页面开启 ServiceWorker 线程独立去下载未来页面可能会用到的资源,放在缓存中。可以认为未来的页面为高频应用场景,提前缓存。

image.png

其他预加载使用场景

那么现在换为钉钉的场景,以这样一个工作台为例,如果你的大 Boss 今天给你的任务是秒开工作台应用下的一个子应用,包括第一次打开,如何利用 ServiceWorker 来做到?

image.png

这里我们把一个新页面的时间简化为资源加载时间 + 渲染时间,假如在工作台缓存了子应用的 html,js,css,并且假如子应用使用 SSR,某种意义上我们就能达到相当程度上的 “秒开”。

image.png

关键代码和上面语雀的很相似,如下所示:

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
        .then(res => {
          //1. 如果请求的资源已被缓存,则直接返回
          if (res) return res;
          //2. 没有,则发起请求并缓存结果
          let requestClone = event.request.clone();
          return fetch(requestClone).then(netRes => {
              if(!netRes || netRes.status !== 200) return netRes;
              let responseClone = netRes.clone();
              caches.open(cacheName).then(cache => cache.put(requestClone, responseClone));
              return netRes;
          });
     })
   );
});

错误页

这里分享一个降级策略,当你的某个重要资源无法从网络下载时,可以返回到一个 Error 页面,这个 Error 页面可以是你事先缓存的一个资源。

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

以这样的工作台场景为例,之前有一个团队小故障是子应用白屏,子应用白屏时会如下面的 gif 图所示,产生出完全白色的界面,原因是下一个页面的某个 html 或者 js 资源获取异常,导致了页面白屏。

1629084731569-4e9b1e66-d05d-47cf-9384-b09c780b3681.gif

如果使用 ServiceWorker 拦截了请求,并判断出了某个关键资源的网络异常则可以实现下面的效果:

1628993481588-b52a8d87-6f0e-4c29-9cb0-f38b3fc04d28.gif

当前如果把 error 链接设置成一个动态地址,我们就可以实现在后台做到各种策略的优雅降级了。

个性化预加载资源

上面的预加载其实隐藏了一个隐患,特别是工作台可能打开多个子应用场景,当你的工作台子应用越来越多,达到上百个及以上时,且大部分打开率都很高,那么你的工作台会缓存一个巨大的离线包,达到上百 MB以上,大大增加整个应用的体积。

image.png

这个 Service 可以是基于推荐系统产生的预测用户 Top10 未来路径的服务,也可以是基于统计归纳出一个规则。使用这个动态预测的服务,我们就可以大幅减小工作台场景离线的资源数量,且保持一个高命中率。

其他常见策略

仅使用Cache(Cache only)

几乎没用

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

仅使用网络(Network only)

需要强制更新的资源,时效性要求很高。如不需要离线访问的 HTML 资源。

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

先使用 SW 缓存,没有则使用网络资源

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

缓存资源与网络资源,谁快用谁

function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    promises = promises.map(p => Promise.resolve(p));
    promises.forEach(p => p.then(resolve));
    promises.reduce((a, b) => a.catch(() => b))
      .catch(() => reject(Error("All failed")));
  });
};
 
self.addEventListener('fetch', function(event) {
  event.respondWith(
    promiseAny([
      caches.match(event.request),
      fetch(event.request)
    ])
  );
});

优先使用网络,失败则使用缓存(Network )

对于时效性要求比较高的资源,或者关键性需要降级显示的资源。

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

先使用 SW 缓存,再访问网络更新缓存 (Fastest)

可以缓存但可以二次请求后生效的资源。保持相对最高的缓存速度和高失效性。


self.addEventListener('fetch', function(event) {
  fetch(event.request).then(res => caches.update(res));
  event.respondWith(
    caches.match(event.request)
  );
});

检查缓存离线时间

不同缓存的时效时间可以由服务获取,然后开启定期检查,消灭失效资源。

setInterval(async () => {
  const res = await fetch('/pageA/sw-cache-config');
  const data = await res.json();
  caches.checkCacheLifeTime(data);
}, SW_CACHE_INTERVAL);

HTML: 可以离线访问使用 NetworkFirst,不需要离线访问,使用 NetworkOnly JS & CSS: 情况比较复杂,因为一般站点的 CSS,JS 都在 CDN 上,SW 并没有办法判断从 CDN 上请求下来的资源是否正确(HTTP 200),如果缓存了失败的结果,问题就大了。这种我建议使用 Fastest 策略,既保证了页面速度,即便失败,用户刷新一下就更新了。 图片:可以使用 CacheFirst,并设置缓存失效时间。

开源 SW 框架推荐

Workbox

developers.google.com/web/tools/w…

简而言之就是大幅简化写法,封装常见的策略,具体策略参考文档。

import { registerRoute } from 'workbox-routing';
import { precacheAndRoute } from 'workbox-precaching';
import {
  NetworkFirst,
  StaleWhileRevalidate,
  CacheFirst,
} from 'workbox-strategies';

import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// 所有页面跳转(html)使用网络优先原则
registerRoute(
  // 检查请求是否是跳转到一个新页面
  ({ request }) => request.mode === 'navigate',
  // 使用网络有限远侧
  new NetworkFirst({
    // 缓存的名字
    cacheName: 'pages',
    plugins: [
      // 只有返回 200 状态码的时候才会被缓存
      new CacheableResponsePlugin({
        statuses: [200],
      }),
    ],
  }),
);

// 使用预缓存
precacheAndRoute(self.__WB_MANIFEST);

sw-toolbox

github.com/GoogleChrom…

其中 sw-toolbox 的 handler 封装了上述的几种场景策略

  1. cacheFirst:先使用 SW 的缓存资源,如果没有命中,使用网络下载,如果网络下载成功,更新 SW 缓存。
  2. cacheOnly:仅使用 SW 缓存资源。
  3. networkOnly:禁止使用缓存资源。
  4. networkFirst:优先使用网络资源,如果网络资源超时或者失败,可以降级使用 SW 资源。
  5. fastest:优先使用缓存,同时网络下载资源更新资源,下次生效。

预缓存写法

toolbox.precache(['/index.html', '/site.css', '/images/logo.png']);

自定义路由匹配写法:

toolbox.router.get(':foo/index.html', self.toolbox.networkOnly);

urlPattern 匹配路由类似express,并遵循 路由优先匹配 原则。

参考文章:

Workbox 3:Service Worker 可以如此简单:fed.taobao.org/blog/taofed… The offline cookbook:jakearchibald.com/2014/offlin…