Service Worker

Service Worker🐳

Web Worker大家已经很熟悉了吧,那我今天来讲讲它的好兄弟 Service Worker

哥们也是最近两天才知道这个玩意,这几天玩下来感觉这玩意要比 Web Worker 实用一万倍

1. 啥是Service Worker?👹

以下内容来自MDN:

  • Service worker 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。这个 API 旨在创建有效的离线体验,它会 **拦截网络请求 **并根据网络是否可用来采取适当的动作、更新来自服务器的的资源。它还提供入口以推送通知和访问后台同步 API。

说了这么一大堆,个人觉得最核心的还是: 拦截网络请求😶‍🌫️。

如果大家用过axiosinterceptor,那会感觉这俩玩意是出奇的相似🙊,(而且好像真能用ServiceWorker模拟interceptor?虽然很邪门就是了)

2.会拦截网络请求,有什么用?🦄

  • 请求封装:首先,正如上面所说的,它可以像interceptor一样工作,那就可以给咱们每个请求都自动的加上token之类的,虽然其他方法也能做,但这样真的很酷😎,当然,这也不是人家的主要业务
  • 拦截网络请求:当没有网或者网络很差的时候,可以选择不发送网路请求,直接采用缓存内容,保证用户看到的不是一片惨白或者是只小恐龙🦖
  • 导航预加载:当用户选择跳转页面的时候,也可以利用它率先preLoad下一个页面,或者直接采用预设好的页面,这有个响亮的名字: 导航预加载
  • 推送通知Service Worker 能够接收来自服务器的推送通知,并在应用不在前台运行时向用户显示这些通知。这对于保持用户参与度和及时传递信息非常有用。(有api
  • 网络请求代理Service Worker 可以代理网络请求,实现如数据压缩、图片优化、安全策略加强等功能。

3. Service Worker工作流程👻

  1. 获取 service worker 代码,然后使用 serviceWorkerContainer.register() 来注册。如果成功,service worker 将在 ServiceWorkerGlobalScope 中执行;这本质上是一种特殊的上下文,在主脚本执行线程之外运行,没有访问 DOM 的权限。Service Worker 现在已为处理事件做好准备。
  2. 安装完成。install 事件始终是发送给 service worker 的第一个事件(这可用于启动填充 IndexedDB 和缓存站点资源的过程)。在此步骤期间,应用程序正在为离线可用做准备。
  3. install 程序处理完成时,service worker 被视为已安装。此时,service worker 的先前版本可能处于激活的状态并控制着打开的页面。由于我们不希望同一 service worker 的两个不同版本同时运行,因此新版本尚未激活。
  4. 一旦 service worker 的旧版本控制的页面都已关闭,就可以安全地停用旧版本,并且新安装的 service worker 将收到 activate 事件。activate 的主要用途是去清理 service worker 之前版本使用的资源。新的 service worker 可以调用 skipWaiting() 要求立即激活,而无需要求打开的页面关闭。然后,新的 service worker 将立即收到 activate 事件,并将接管任何打开的页面。
  5. 激活后,service worker 将立即控制页面,但是只会控制那些在 register() 成功后打开的页面。换句话说,文档必须重新加载才能真正的受到控制,因为文档在有或者没有 service worker 的情况下开始存在,并在其生命周期内维护它。为了覆盖次默认行为并在页面打开的情况下,service worker 可以调用 clients.claim() 方法。
  6. 每当获取新版本的 service worker 时,都会再次发生此循环,并在新版本的激活期间清理上一个版本的残留。

同一个worker的不同版本:

这不能说是Service Worker的特性,反而是Cache的特性。 下面会讲到, CacheService Worker的好哥们,Cache可以用Open(version)来选择使用哪个版本的缓存,而Service Worker也会根据这个衍生出不同版本,而我们不想要多个版本的 worker 同时存在,因此 worker 的生命周期也比较特别 🤯

4. 来看看如何用它提升用户体验吧🤣

其实主要也就一点:

  • 当缓存里有数据时,不应该再次请求,而是直接使用缓存内容,加快页面响应速度

而提到缓存,大家首先想到的是什么?可能是indexDB,也可能是localStorage,但是Service Worker用的是另一种叫做cache的缓存

Cache:

cacheweb api的一部分,虽然也可以在Service Worker外使用,但是它的主要优势还是缓存重要资源,比如图片、文件,因此经常和Service Worker成对出现,或者说,就是为Service Worker量身打造的api。

关于cache,这里不再过多介绍,可以去:cache详解 看看

好,话不多说,咱们开干!⛽


哥们的文件目录(忽略webworker):

文件目录

  1. Web Worker一样,都得在主js中注册worker,同样的,它也有messageChannel,用于和主线程通信

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    const registerServiceWorker = async () => {
    if("serviceWorker" in navigator) {
    try {
    const serviceWorkerRegistration = await navigator.serviceWorker.register("../serviceWorker.js", {
    scope: "/webworker/public/"
    });
    const statusGroup = ["installing", "waiting", "active"];
    console.group("status")
    statusGroup.forEach((item) => {
    serviceWorkerRegistration[item] && console.log(item);
    })
    console.groupEnd()
    } catch (err) {
    console.error(`registration failed: ${err}`);
    }
    } else {
    console.error("service worker is forbidden")
    }
    }
    registerServiceWorker();

    哦对了,它和Web Worker很大的不同就是它没得自己的构造函数,它只是一个挂载在navigation下面的小特性🤐而且只有https或者localhost才能使用它,因为它实在强的有点离谱.因此,可以看到我们用try catch包裹注册事件,保证即使不是https协议也可以正常访问网站

    之所以不用http是因为,万一哪天这玩意被劫持了,后果不堪设想🙈

  2. 根据你上面填的路径,搞个serviceWorker.js出来😇😇

    我喜欢把这个serviceWorker.js当成纯纯的入口文件,当然,你们要全写在这一个文件里也不是不行,但是那样我觉得不清晰。以下是我的serviceWorker.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
    importScripts("./workers/installServiceWorker.js")
    importScripts("./workers/cacheServiceWorker.js")

    self.addEventListener("install", (event) => {
    event.waitUntil(
    Promise.all([
    addResourcesToCache([
    "/webworker/public/html/index.html",
    "/webworker/public/css/index.css",
    "/webworker/public/js/serviceWorker.js",
    "/webworker/public/workers/installServiceWorker.js",
    "/webworker/public/workers/cacheServiceWorker.js",
    "/webworker/public/assets/3.jpg",
    ]),
    self.registration.navigationPreload.enable()
    ])
    );
    });
    self.addEventListener("fetch", (event) => {
    event.respondWith(
    cacheFirstWithFallback({
    request: event.request,
    preloadResponsePromise: event.preloadResponse,
    fallbackUrl: "/webworker/public/assets/3.jpg",
    }),
    );
    });

    大家有空也可以看看 Martin Fowler重构:改善既有代码的设计(超级好看🤪),哥们的这种方式也是他的设计理念

    Service Worker也是有管理范围的!

    可以通过scope指定范围,但是它的范围只能是跟自己同级或者下级的文件,自己的上级文件就搞不到了,因此我们把serviceWorker.js放在根目录里,也就是为什么第一点的路径是”../serviceWorker.js”

  3. 然后创建一个installServiceWorker.js,用于存放跟install相关的函数和配置,大致如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    const addResourcesToCache = async (resources) => {
    const cache = await caches.open("v1");
    await cache.addAll(resources)
    }

    self.addEventListener("install", (event) => {
    event.waitUntil(
    addResourcesToCache([
    "/webworker/public/html/index.html",
    "/webworker/public/css/index.css",
    "/webworker/public/js/serviceWorker.js",
    "/webworker/public/workers/installServiceWorker.js",
    "/webworker/public/workers/cacheServiceWorker.js",
    "/webworker/public/workers/3.jpg",
    ]),
    );
    });

    其中cache.addAllCache的一个方法,接受一个数组参数,通常是url数组,并把它们全部存储到cachev1

    waitUntil的作用是Service Worker必须在该函数的Promise执行完之后才能继续,接下来会详细介绍,霸道!!

    路径一定要写全!🙃

    到现在,咱们已经完成了Service Worker的挂载和缓存的下载了,咱们可以点开控制台,看看application里的缓存空间或者存储,有没有缓存🤩

    如果顺利的话,应该可以看到如下条目:

    存储

  4. 存储搞定了,那接下来就是如何运用存储了,在这里,我在workers中新建了cacheServiceWorker.js来处理利用缓存相关的逻辑:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const cacheFirst = async (request) => {
    const cachedResponse = await caches.match(request);
    if(cachedResponse) {
    return cachedResponse
    }
    return fetch(request);
    }

    self.addEventListener("fetch", (event) => {
    console.log(event)
    event.respondWith(
    cacheFirst(event.request),
    );
    });

    逻辑很简单对吧,就是首先在caches中寻找有无缓存过的条目,如果有,就直接返回缓存,如果没有,就返回fetch(request)的结果

    顺便提一嘴,cacheFirstrequestRequest类的实例,也就是咱们平时发请求带的对象。fetch会自动把我们传的参数转换成这个对象,也可以直接传一个requestfetch,因此这里可以直接fetch(request)👽

    而下面eventListener中的event属于FetchEvent对象,Service Worker对象专属的api,继承于Event

    (如果对Event不是很了解可以去看我的上一篇:https://konodioda727.github.io/2023/11/26/addEventListener1/)

    它只在监听fetch事件时会产生,它有以下三大特性(包括我们上面讲的waitUntil):

    • event.request:这是一个 Request 对象,包含了被拦截的网络请求的详细信息,如请求的 URL、请求头、请求方法等。
    • **event.respondWith()**:这个方法允许你提供一个用于生成响应的 Promise。这是控制网络响应行为的关键方法。通过这个方法,你可以返回缓存中的响应、生成一个新的响应或者直接调用 fetch 来获取网络上的资源。
    • **event.waitUntil()**:这个方法用于延长事件的生命周期。它对于执行一些额外的异步处理(如缓存更新)很有用。

    那来试试看是什么效果嘞,刷新页面,打开控制台->网络,看到如下红框中的(Service Worker)就算成功啦!

    网络

  5. 这样已经初具人形啦🤫,但是实际中还有许多异步加载的场景,我们要异步加载也能缓存下来,那就得使用更复杂的策略,我们不仅可以从网络中请求资源,还可以将其保存到缓存中,以便稍后对该资源的请求也可以离线检索🙀,做法其实也很简单,就在咱们刚才的基础上让经过请求得到的资源也存入Cache中即可,为此,我新加了putInCache函数,并对cacheFirst函数稍加修改:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const putInCache = async (request, response) => {
    const cache = await caches.open("v1");
    await cache.put(request, response);
    };
    const cacheFirst = async (request) => {
    const cachedResponse = await caches.match(request);
    if(cachedResponse) {
    return cachedResponse
    }
    const NetworkResponse = await fetch(request);
    await putInCache(request, NetworkResponse.clone());
    return NetworkResponse;
    }
  6. 现在已经非常不错啦👾,但还需要思考一个问题: 没网怎么办?,初听可能会觉得很恼火😡,浏览器没网怎么玩?但是这也是Service Worker存在的原因之一,为了能让用户在没网的时候也有点东西看,我们就只能再次打磨cacheFirst函数,让它在请求失败的时候,也能有一个fallback可以使用:

    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
    const cacheFirst = async ({ request, fallbackUrl }) => {
    // 首先,尝试从缓存中获取资源
    const responseFromCache = await caches.match(request);
    if (responseFromCache) {
    return responseFromCache;
    }

    // 然后尝试从网络中获取资源
    try {
    const responseFromNetwork = await fetch(request);
    // 响应可能会被使用
    // 我们需要将它的拷贝放入缓存
    // 然后再返回该响应
    putInCache(request, responseFromNetwork.clone());
    return responseFromNetwork;
    } catch (error) {
    const fallbackResponse = await caches.match(fallbackUrl);
    if (fallbackResponse) {
    return fallbackResponse;
    }
    // 当回落的响应也不可用时,
    // 我们便无能为力了,但我们始终需要
    // 返回 Response 对象
    return new Response("Network error happened", {
    status: 408,
    headers: { "Content-Type": "text/plain" },
    });
    }
    };

    self.addEventListener("fetch", (event) => {
    event.respondWith(
    cacheFirst({
    request: event.request,
    fallbackUrl: "/webworker/public/assets/3.jpg",
    }),
    );
    });

    好了,这下彻底圆满了🤣,当我们打开控制台,关闭网络时,现在也不会有太多的变化,我们的目的也就达到了,当然,实际过程中肯定还要为不同情况搞不同的fallback,但这里意思意思就差不多了💩

5. 导航预加载🥸

如果启用了导航预加载功能,其将在发出 fetch 请求后,立即开始下载资源,并同时激活 service worker。这确保了在导航到一个页面时,立即开始下载,而不是等到 service worker 被激活。

当用户导航到一个新页面时,浏览器可以立即开始加载页面资源,而不需要等待 Service Worker 启动和激活。正常情况下,如果一个 Service Worker 控制着网站,当用户点击链接或进行页面跳转时,浏览器首先需要启动并激活 Service Worker,然后 Service Worker 才会处理页面的网络请求。这可能会引入一些延迟

这种延迟发生的次数相对较少,但是一旦发生就不可避免,而且可能很重要。

首先,必须在 service worker 激活期间使用 registration.navigationPreload.enable() 来启用该功能:

1
2
3
self.addEventListener("activate", (event) => {
event.waitUntil(self.registration?.navigationPreload.enable());
});

MDN上说必须在activate里激活,但是哥们实测install也可以,毕竟install是第一个事件,理论上activate中干的在它里边一样可以.

因此,我在installServiceWorker.js中写了如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
self.addEventListener("install", (event) => {
event.waitUntil(
Promise.all([
addResourcesToCache([
"/webworker/public/html/index.html",
"/webworker/public/css/index.css",
"/webworker/public/js/serviceWorker.js",
"/webworker/public/workers/installServiceWorker.js",
"/webworker/public/workers/cacheServiceWorker.js",
"/webworker/public/assets/3.jpg",
]),
self.registration.navigationPreload.enable()
])
);
});

只是增加了Promise.all(),跟原来的变化不大

然后使用 event.preloadResponse 等待预加载的资源在 fetch 事件处理程序中完成下载。

继续前几节的示例,我们插入代码,以便在缓存检查后等待预加载的资源,如果失败,则再从网络中获取。

新流程是:

  1. 检查缓存
  2. 等待 event.preloadResponse,它作为 preloadResponsePromise 传递给 cacheFirst() 函数。如果返回结果,则缓存它。
  3. 如果两者均没有结果,那么我们就通过Service Worker从网络中获取。

preload主要处理的是导航请求,其过程完全由浏览器操纵,不必担心,原理就是当遇到导航请求时,会不等待Service Worker,绕过Service Worker发送请求,接收到请求就返回到preloadResponse

再对着cacheFirstWithFallback缝缝补补,加上preload的逻辑

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
const cacheFirstWithFallback = async ({request, fallbackUrl, preloadResponsePromise}) => {
const cachedResponse = await caches.match(request);
if(cachedResponse) {
return cachedResponse
}

const preloadResponse = await preloadResponsePromise;
if (preloadResponse) {
console.info("using preload response", preloadResponse);
putInCache(request, preloadResponse.clone());
return preloadResponse;
}

try {
const NetworkResponse = await fetch(request);
await putInCache(request, NetworkResponse.clone());
return NetworkResponse;
} catch (err) {
const fallbackResponse = await caches.match(fallbackUrl);
if (fallbackResponse) {
return fallbackResponse;
}
return new Response("Network error happened", {
status: 408,
headers: { "Content-Type": "text/plain" },
});
}
}

6. 更新Service Worker🥳

之前提到过,浏览器不允许**不同版本的相同Service Worker**同时运行,为此,Service Worker的生命周期就显得很奇怪:

  • 如果你的 service worker 已经被安装,但是刷新页面时有一个新版本的可用,新版的 service worker会在后台安装,但是仍然不会被激活。当不再有任何已加载的页面在使用旧版的 service worker 的时候,新版本才会激活。一旦再也没有这样的已加载的页面,新的 service worker就会被激活。

如果我把cache的版本改成v2,那么这个service worker跟之前的就不一样了,这时,在install中后台会默默下载这个v2的worker,当没有页面在使用之前的版本的时候,这个新的 service worker 就会激活并开始响应请求。🫨


当你更新 service worker 到一个新的版本,你将在它的 install 事件处理程序中创建一个新的缓存。在仍有由上一个 worker 的版本控制的打开的页面,你就需要同时保留这两个版本的缓存,因为之前的版本需要它缓存的版本。你可以使用 activate 事件从之前的缓存中移除数据。

传给 waitUntil() 的 promise 会阻塞其他的事件,直到它完成,因此你可以放心,当你在新的 service worker 中得到你的第一个 fetch 事件时,你的清理操作已经完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const deleteCache = async (key) => {
await caches.delete(key);
};

const deleteOldCaches = async () => {
const cacheKeepList = ["v2"];
const keyList = await caches.keys();
const cachesToDelete = keyList.filter((key) => !cacheKeepList.includes(key));
await Promise.all(cachesToDelete.map(deleteCache));
};

self.addEventListener("activate", (event) => {
event.waitUntil(deleteOldCaches());
});

7.如何处理过期资源?😓

遗憾的是,Service Worker并没有检测过期资源的api,那就只能靠咱们自己手搓了,我的想法就是给cache加一条timeStamp,或者maxAge,

如果缓存时间大于maxAge就清除资源并重新请求缓存(这不就http吗?)

还是老规矩,再对cacheServiceWorker缝缝补补,这次修改的日志:

  • 新增了一个用于删除过期资源的deleteCache函数
  • 新增了一个用于判断是否过期的isResourceExpired函数
  • putInCache以及cacheServiceWorker添加了删除过期缓存的逻辑

修改完毕后大概长这样🤔:

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
45
46
47
48
49
50
51
const cacheName = "v1";
const putInCache = async (request, response) => {
const cache = await caches.open(cacheName);
await cache.put(request, response);
await cache.put(request.url + '-timestamp', new Response(Date.now().toString()));
};
const deleteCache = async (request) => {
const cache = await caches.open(cacheName);
cache.delete(request)
cache.delete(request.url + '-timestamp')
}
async function isResourceExpired(request, maxAge) {
const cache = await caches.open(cacheName);
const timestampResponse = await cache.match(request.url + '-timestamp');
if (timestampResponse) {
const timestamp = await timestampResponse.text();
const age = Date.now() - parseInt(timestamp, 10);
return age > maxAge;
}
return true;
}
const cacheFirstWithFallback = async ({request, fallbackUrl, preloadResponsePromise, maxAge=60}) => {
const cachedResponse = await caches.match(request);
if(cachedResponse && !(await isResourceExpired(request, maxAge))) {
return cachedResponse
}
if(await isResourceExpired(request, maxAge)) {
deleteCache(request)
}
const preloadResponse = await preloadResponsePromise;
if (preloadResponse) {
console.info("using preload response", preloadResponse);
putInCache(request, preloadResponse.clone());
return preloadResponse;
}

try {
const NetworkResponse = await fetch(request);
await putInCache(request, NetworkResponse.clone());
return NetworkResponse;
} catch (err) {
const fallbackResponse = await caches.match(fallbackUrl);
if (fallbackResponse) {
return fallbackResponse;
}
return new Response("Network error happened", {
status: 408,
headers: { "Content-Type": "text/plain" },
});
}
}

8.终于差不多了🥳

到这里,Service Worker的基本内容已经结束了,但还有些worker共有的属性或者方法,比如: messageChannel(超级好用,还可以用来组件间通信)因为太懒就没有讲,之后可能还会有Web Worker的文章?可能到那时候再讲讲好了🦖。

能写这么多哥们也要累死了😓


Service Worker
http://baidu.com/2023/12/01/serviceWorker/
作者
KB
发布于
2023年12月1日
许可协议