Service Worker 缓存静态资源
1.7k7WEBCache2022-07-09

最近有用到 Service WorkerCacheStorage 做离线缓存, 一方面是因为项目资源包着实大, 另一方面也是因为之前没有用缓存, 这也是需要的优化。
相比起 Http 缓存或者 manifest 文件管理缓存(如果还有其他缓存方法, 欢迎提出来讨论!), Service Worker 完全由前端维护, 让前端对资源拥有了更大的控制权, 能自由的对资源进行增删改, 另一方面也是因为自己没用过。也碰到了一些问题, 查阅了一些资料, 希望对你有帮助。

Service Worker

附上 MDN 文档 ,还有一个 Demo 除了 MDN, Google 的文档也是很有帮助的

Service Worker 的特点

  • 是一个浏览器与网络之间的拦截器, 通过 Service Worker 你可以拦截任何网络请求
  • 必须在 Https 下运行, 但 localhost 作为开发环境也可以
  • 拥有自己的 Worker 上下文 ServiceWorkerGlobalScope (继承于 WorkerGlobalScope,而 WorkerGlobalScope 继承于 EventTarget), 与主线程 (原有的浏览器上下文) 互不干扰,ServiceWorkerGlobalScope 包含的一些属性
    • Caches,这个后面会提到
    • Clients,
  • 在主线程中需要使用 navigator.serviceWorker, 该对象的原型是 ServiceWorkerContainer, 包含注册,删除,更新 Service Worker 以及与 Service Worker 通信的方法。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
       if ("serviceWorker" in navigator) {
    try {
    const registration = await navigator.serviceWorker.register("/worker.js?v=" + version)
    if (registration.installing) {
    console.log("Service worker installing")
    } else if (registration.waiting) {
    console.log("Service worker installed")
    } else if (registration.active) {
    console.log("Service worker active")
    }
    } catch (error) {
    console.error(`Registration failed with ${error}`)
    }
    }
  • 如同页面有 load unload 等生命周期事件一样, Service Worker 也拥有自己的生命周期, 下图来自 MDN, 描绘了 Service Worker 的生命周期
    生命周期
    安装中 => 安装后 => 激活中 => 激活后 => 结束
    用户第一次进入网页,只是安装 woker,那么第二次进入网站才会激活 worker,激活后才会开始响应各类事件
  • 相关事件,可以使用 addEventListener 进行监听
    • install, 生命周期事件
    • activate,生命周期事件
    • fetch,当浏览器发起请求获取资源时,可在此监听和拦截
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      self.addEventListener("fetch", function (event) {
      const { request, currentTarget } = event
      event.respondWith(
      caches.match(_name).then((cachedResponse) => {
      if (cachedResponse) return cachedResponse
      return fetch(request).then((response) => {
      return caches.open(cacheName).then(function (cache) {
      cache.put(_name, response.clone())
      return response
      })
      })
      })
      )
      })
    • push,
    • sync

caches && cache

上面提到了 Service Worker 可以拦截请求 => 拦截到静态资源的请求 => 获得 Response => 存储 Response, caches 登场。

caches 被定义为 ServiceWorkerGlobalScope 的一部分, 但也被暴露在主线程的作用域下, ServiceWorkerGlobalScope.cachesWindow.caches 都可以调用, 所以 caches 并不一定只能和 Worker 搭配使用。

注意:这里有两个重要的 api, cachescahce, 字面意思理解, 一个单数一个复数。

  • cache, 一个存储区域, 以 Request / Response 作为 key / value 的模式来进行存储, 包含 add addAll keys delete match matchAll put 等一些 对数据进行增删查改 的 api
  • caches, 接口模型是 CacheStorage, 是一个 cache 实例的集合, 包含 keys open delete has match 等一些 cache 增删查改 的 api

以上所有方法都是以 promise 的形式返回结果

如果我们需要操作数据, 那么需要先使用 open 方法获取 cache 实例, 如果该 cache 不存在就会新建一个

1
2
3
4
caches.open(cacheName).then(function (cache) {
// todo...
// 使用 Cache 实例上的方法操作数据
})

存储限制

W3C 描绘了两种存储类型 TemporaryPersistent参考 W3C 文档 Temporary vs Persistent

CacheStorage 属于 Temporary, 是作为类似 temp/ 中的临时数据, 当浏览器存储到达了极限,那么会根据 LRU(least recently used) policy 来删除 Temporary 类型的数据,使存储再次回到限制内。(参考 MDN 文档 Browser storage limits and eviction criteria

参考代码

  • 我们可以在前端资源包中添加一个 Worker.json 的文件

    1
    2
    3
    4
    5
    {
    useWorker: true, // 是否启用 `Worker`,根据该属性来注销和注册 `Worker`
    version: 1.0 // 项目版本号
    swVersion: 2.0 // worker 的版本号
    }
  • 在应用初始化时以接口的方式请求该 Worker.json 文件, 对比返回的版本和当前的版本来决定是否对资源进行增删改

  • 通过修改 swVersion 版本号,也可以动态更新 Worker.js 文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
     const sw = {
    registration: async (version) => {
    if ("serviceWorker" in navigator) {
    try {
    const registration = await navigator.serviceWorker.register("/worker.js?v=" + version)
    if (registration.installing) {
    console.log("Service worker installing")
    } else if (registration.waiting) {
    console.log("Service worker installed")
    } else if (registration.active) {
    console.log("Service worker active")
    }
    } catch (error) {
    console.error(`Registration failed with ${error}`)
    }
    }
    },
    unregistration: () => {
    navigator.serviceWorker.getRegistrations().then((registrations) => {
    for (let registration of registrations) {
    registration.unregister()
    sw.cleanRes()
    }
    })
    },
    cleanRes: () => {
    const cacheName = "myResource"
    caches.delete(cacheName).then((res) => {
    res && window.location.reload()
    })
    },
    }
    api.system.getFrontendVersion().then((res) => {
    if (!res.useWorker) {
    Sw.unregistration()
    return
    }
    Sw.registration(res.swVersion)
    const _new = `${res.version}`,
    _old = `${Settings.version}`
    if (_new !== _old) {
    Sw.cleanRes()
    }
    })
  • Worker.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    const resPath = "static",
    cacheName = "myResource",
    exclude = ["version.json"]
    self.addEventListener("fetch", function (event) {
    const { request, currentTarget } = event
    const _name = isCacheable(request.url, currentTarget.registration.scope)
    if (_name)
    event.respondWith(
    caches.match(_name).then((cachedResponse) => {
    if (cachedResponse) return cachedResponse
    return fetch(request).then((response) => {
    return caches.open(cacheName).then(function (cache) {
    cache.put(_name, response.clone())
    return response
    })
    })
    })
    )
    })
    const isCacheable = (url, scope) => {
    const _i = url.match(new RegExp(`${scope}${resPath}\/(?<name>.*)`))
    if (!_i) return false
    const name = _i.groups.name
    if (exclude.some((el) => name.indexOf(el) > -1)) return false
    return name
    }

问题集锦

fetch event 不生效

Q: Worker 注册后, 调用了 install event, 调用了 activate event, 但却一个 fetch event 都没有调用, 为什么? fetch event 不生效?

A: 在实际场景里, 服务器路径由后端配置, 而静态资源通常是单独配置了路径。那如果我们需要监听所有的静态资源请求, 是否把 work 文件放在静态资源的目录下就可以了?

scope 这个参数在 MDN 中描绘得并不详细, Google 文档中的 scope 描述会更生动些。

A Service Worker‘s scope is determined by its location on a web server. If a Service Worker runs on a page located at /subdir/index.html, and is located at /subdir/sw.js, the Service Worker‘s scope is /subdir/.

所以我理解 Worker 文件需要和 Html 文件在同一级下, Html 产生的请求才会被捕获。所以不妨再请求下后端同学支持, 再为 Worker 配置一个路径, 与 html 同级。

Cannot construct a Request with a Request object that has already been used

request has already consumed by the fetch(), and request can’t use twice. so clone request before using it

an opaque request

it‘s not allowed to access response body from an opaque request. cors-origin request with mode: no-cors and their response cannot be intercept by service worker, but change CORS-safelisted request headers.