基本概念

Service Worker 是一个被注册在指定源和路径下的事件驱动 Worker,它通过一个 JavaScript 文件控制 Web 页面及网站,拦截并修正地址导航及资源请求,同时缓存资源,使开发者可以完全控制应用程序在特殊情况下(通常是在网络不可连接时)的行为。

一个 Service Worker 被运行在一个 Worker 上下文中,因此不允许在其中访问 DOM。

Service Worker 是运行在独立于当前 JavaScript 主线程的线程中,所以不会造成阻塞。因为它是完全异步的,所以 JavaScript 中任何同步 API (同步XHR以及localstorage)都不能运行在 Service Worker 中。

出于安全性考虑,Service Worker 只能被运行在 HTTPS 协议中。出于学习目的,开发者可以在本地网站(以“http://localhost”域名开头的本地计算机中的网站)中将 Service Worker 运行在HTTP协议中。

在 Service Worker API 中,通常会等待服务器端的响应,然后根据请求成功与否实行后续处理,Promise 正是强于实现这种异步处理的,所以 Service Worker API 中大量使用了 Promise。

注册安装

注册

开发者可以在每次页面加载时注册 Service Worker,浏览器将自动检测 Service Worker 是否被注册,如果没有则注册该 Service Worker。

在 register 中指定 Service Worker 脚本文件的指定位置。注意,这个脚本文件的位置是相对于源(网站顶级域名+端口),而不是当前页面。例如:Service Worker 脚本的完整 URL 是 http://www.test.com/serviceWorker/test/sw.js,页面的 URL 是 http://www.test.com/serviceWorker/html/index.html,那么指定的脚本文件应该写成 /serviceWorker/test/sw.js,而不是 ../test/sw.js。

下面是一个例子。

  1. <!-- /serviceWorker/index.html -->
  2. <!DOCTYPE html>
  3. <html lang="en">
  4. <head>
  5. <meta charset="UTF-8">
  6. <meta http-equiv="X-UA-Compatible" content="IE=edge">
  7. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  8. <title>Document</title>
  9. </head>
  10. <body>
  11. </body>
  12. </html>
  13. <script>
  14. if("serviceWorker" in navigator) {
  15. navigator.serviceWorker.register('/serviceWorker/sw.js').then(function(registration) {
  16. // 注册成功
  17. console.log("service worker注册成功", registration.scope);
  18. }).catch(function(error) {
  19. console.log("注册失败", error);
  20. });
  21. } else {
  22. console.log('浏览器不支持serviceWorker');
  23. }
  24. </script>
// /serviceWorker/sw.js

// 暂时不用编写内容,只要有这个文件存在即可

在上述代码中 Service Worker 文件位于网站的 /serviceWorker 目录下,这表示 Service Worker 的作用范围为 /serviceWorker 这个 URL 开头的所有页面(如:/serviceWorker/page1.html,/serviceWorker/page/index.html)的 fetch 事件。
image.png

单个 Service Worker 可以控制很多页面。可以通过使用 scope 属性指定作用范围。当该范围内的页面加载完时,安装在页面的 Service Worker 就可以控制它,每个页面不会有自己独有的 Service Worker。

navigator.serviceWorker.register('/serviceWorker/sw.js', { scope: '/serviceWorker/test/' })
  .then(function(registration) {
      // 注册成功
      console.log("service worker注册成功", registration.scope);
    })
  .catch(function(error) {
      console.log("注册失败", error);
    });

image.png

如果 Service Worker 注册失败,可能由以下原因造成:

  • 页面被运行在互联网环境中,但没有使用 HTTPS 协议;
  • Service Worker文件的地址没有写对,在指定脚本文件位置时使用的 URL 是相对于源(即网站顶级域名+端口),而不是相对于当前网页文件所处位置;
  • Service Worker文件位于不属于你的页面所处的其他源中。

安装

用户首次访问 Service Worker 脚本文件时,该脚本文件将被下载到客户端。之后至少在24小时内被自动下载一次。
当首次下载 Service Worker 脚本文件或下载脚本文件是一个新文件时,浏览器将尝试对它进行安装。

在 Chrome 浏览器中,当 Serive Worker 被安装成功后,可以从 chrome://serviceworker-internals/ 页面中查看该Service Worker。
image.png

在安装过程中,将触发 Service Worker 的安装事件,可以对这个事件进行监听并指定事件处理函数,在这个事件中,可以缓存站点资源以便让所有资源可离线访问。(当所有文件都被成功缓存,service Worker 将被成功安装,否则 service Worker 将安装失败,不会启动)。

为了缓存服务器端资源,需要使用 Service Worker API 中的子 API——Cache API。Cache 对象为一个 Service Worker 中的全局对象,它使我们可以存储网络响应发来的资源,并且根据它们的请求来生成一个 key。

示例如下。示例中首先创建一个 /serviceWorker/styles/main.css 文件,然后在 /serviceWorker/sw.js 中编写以下内容。

// /serviceWorker/sw.js

// 为安装设置回调函数
self.addEventListener("install", function (event) {
    // 执行安装过程
    event.waitUntil(
        caches
            .open("v1")
            .then(function (cache) {
                // 缓存想要缓存的文件
                return cache.addAll(["/serviceWorker", "/serviceWorker/styles/main.css"]);
            })
            .then(function () {
                console.log("所有资源被成功缓存");
            })
            .catch(function (error) {
                console.log("预抓取失败:", error);
            })
    );
});

image.png

在上面的示例中,首先监听 Service Worker 的 install 事件,它会在 Service Worker 被安装时触发。
在事件处理处理函数中,使用事件对象的 waitUntil 方法,它使用 Promise 对象作为参数。该方法用于扩展事件的生命周期,确保浏览器在 Promise 对象参数中指定的异步操作完成之前不会终止事件所处工作线程。该方法不返回任何值。
在 Promise 参数中,使用 CacheStorage 对象的 open 方法创建并打开一个缓存(Cache)对象。open 方法使用一个参数,用于指定缓存名称,返回创建缓存的 Promise 对象。成功创建的缓存 cache 被自动设置为 Promise 对象的 resolve 回调函数的参数值。
接着,用 Cache 对象的 addAll 方法在缓存中存入所有资源文件,它接收一个字符串数组作为参数,该方法也返回一个 Promise 对象,当存入所有文件成功时返回肯定结果,否则返回否定结果,Service Worker 安装失败,Service Worker 不会再做任何事情。

自定义请求的响应

现在你已经将你的站点资源缓存了,接下来你需要告诉 Service Worker 让它用这些缓存内容来做点什么。

每当被 Service Worker 控制的资源被请求时,都会触发 Service Worker 的 fetch 事件,通过调用事件对象的 respondWith 方法来劫持我们的 HTTP 响应。respondWith 方法接收一个 Response 对象,代表一个自定义响应。这个自定义响应将代替原有发出请求资源后返回的响应。

// 首先监听Service Worker的install事件,当Service Worker被安装时触发
self.addEventListener("install", function (event) {
    // 使用事件对象的waitUntil方法,接收一个Promise对象
    // 该方法用于扩展事件的生命周期,确保浏览器在Promise对象参数中指定的异步操作
    // 完成之前不会终止事件所处工作线程。该方法不返回任何值
    event.waitUntil(
        // 使用CacheStorage对象的open方法创建并打开一个缓存(Cache)对象
        caches
            .open("v1")
            .then(function (cache) {
                // 缓存想要缓存的文件
                return cache.addAll(["/serviceWorker", "/serviceWorker/styles/main.css"]);
            })
            .then(function () {
                console.log("所有资源被成功缓存");
            })
            .catch(function (error) {
                console.log("预抓取失败:", error);
            })
    );
});
self.addEventListener("fetch", function (event) {
  // 将所有请求的结果修改为 “测试响应”
    event.respondWith(new Response("测试响应"));
});

image.png

可以定义为更为复杂的响应。

self.addEventListener("fetch", function (event) {
    event.respondWith(
        new Response("测试响应", {
            headers: { "Content-Type": "text/html" },
        })
    );
});

cacheStorage缓存

当客户端请求的资源处于缓存清单中时,浏览器直接以缓存清单中的资源进行响应,否则使用 fetch 方法从服务器端抓取资源。

Cache Storage 对象具有一个 match 方法,用于查询客户端请求资源是否存在于 Service Worker 缓存中,该方法使用一个 Request 对象,返回一个 Promise 对象,当查询的资源存在则在回调函数中响应该资源,否则响应为 undefined。

在HTML 5中,新增 fetch 方法,用于发起获取资源的请求。它返回一个 Promise 对象,在请求响应后返回肯定结果,并传回响应(Response)对象。

self.addEventListener("install", function (event) {
    event.waitUntil(
        // 使用CacheStorage对象的open方法创建并打开一个缓存(Cache)对象
        caches
            .open("v1")
            .then(function (cache) {
                // 缓存想要缓存的文件只有main.css
                return cache.addAll(
          [
              "/serviceWorker/styles/main.css",                    // main.css进行缓存
            // "/serviceWorker/serviceWorker.html"        // serviceWorker.html不作缓存
          ]
        );
            })
            .then(function () {
                console.log("所有资源被成功缓存");
            })
            .catch(function (error) {
                console.log("预抓取失败:", error);
            })
    );
});

self.addEventListener("fetch", function (event) {
    event.respondWith(
    // match查询Service Worker是否有缓存请求的资源,有直接获取缓存,否则通过fetch请求
        caches.match(event.request).then(function (response) {
            if (response) return response;
            return fetch(event.request);
        })
    );
});
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  示例文字
</body>
</html>
<script>

  if("serviceWorker" in navigator) {
    navigator.serviceWorker.register('/serviceWorker/sw.js').then(function(registration) {
      // 注册成功
      console.log("service worker注册成功", registration.scope);
    }).catch(function(error) {
      console.log("注册失败", error);
    });
  } else {
    console.log('浏览器不支持serviceWorker');
  }
</script>
body {
    background-color: pink;
}

在这个示例中,我们指定 Service Worker 缓存清单中只有“/styles/main.css”一个文件,接着重新注册 service Worker。注册完成后,查看 cache Storage 中只缓存了“main.css”文件。
image.png

接着修改“main.css”中颜色为 “blue”。可以看到页面并没有跟着变化。
image.png

接着将 serviceWorker.html 的文字修改为 “修改后的示例文字”,然后访问 serviceWorker.html 页面,页面文字随之发生变化。
image.png

由此可以证明,当访问缓存清单中的资源时返回缓存资源,当访问不存在于缓存清单中的资源时,浏览器返回服务器端的该资源文件。

put方法将请求资源保存在缓存中

我们还可以使用 put 来将请求到的资源保存到缓存中,以便将来离线时所用。Cache 对象的 put 方法用于同时抓取一个请求及其响应,并将响应所指向的资源添加到 ServiceWorker 缓存中。它接收两个参数,分别是客户端请求 request,服务端响应 response。

<!-- /serviceWorker/serviceWorker.html -->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  示例文字
</body>
</html>
<script>
  if("serviceWorker" in navigator) {
    navigator.serviceWorker.register('/serviceWorker/sw.js').then(function(registration) {
      // 注册成功
      console.log("service worker注册成功", registration.scope);
    }).catch(function(error) {
      console.log("注册失败", error);
    });
  } else {
    console.log('浏览器不支持serviceWorker');
  }
</script>
// /serviceWorker/sw.js

self.addEventListener("install", function (event) {
    event.waitUntil(
        caches
            .open("v1")
            .then(function (cache) {
                return cache.addAll(
            [
              // "/serviceWorker/styles/main.css",                    // main.css不作缓存
            "/serviceWorker/serviceWorker.html"                        // serviceWorker.html进行缓存
          ]
        );
            })
            .then(function () {
                console.log("所有资源被成功缓存");
            })
            .catch(function (error) {
                console.log("预抓取失败:", error);
            })
    );
});

self.addEventListener("fetch", function (event) {
    event.respondWith(
        caches.match(event.request).then(function (response) {
            return (
                response ||
                fetch(event.request).then(function (response) {
                    // v1缓存应该已存在
                    return caches.open("v1").then(function (cache) {
                        cache.put(event.request, response.clone());    // 指定的是响应源的副本
                        return response;
                    });
                });
            );
        })
    );
});
body {
    background-color: pink;
}

同样地,先重新注册 service Worker,这里不同的是我们首先缓存“serviceWorker.html”,对“main.css”不作缓存。打开页面如下图所示。
image.png

接着访问“serviceWorker/styles/main.css”文件,可以看到“main.css”也加入到缓存中。
image.png

再次修改“main.css”文件的 color 为“blue”,浏览器并没有发生变化。证明 “main.css”是通过访问到资源后,通过 put 加入到缓存中的,并不是一开始就缓存的。
image.png

:::info 疑问:为什么不直接将参数值指定为原响应 response?
这是因为响应的本质是一个流,只能被消耗一次,如果想在缓存消耗响应的同时让浏览器消耗缓存,我们需要对其进行复制从而得到两个流,以便将一个流送往浏览器,将另一个流送往缓存。 :::

提供默认反馈页面

现在唯一的问题是当请求没有匹配到缓存中的任何资源,且网络不可用的时候,我们的请求依然会失败。这时我们可以提供一个默认的反馈页面以便不管发生了什么,用户至少能看到一个反馈页面(需要预先被缓存在 Service Worker 缓存中)。

在这个示例中,我们在请求没有匹配到缓存中的任何资源,且网络不可用时为用户显示一个 fallback.html。首先创建一个 fallback.html。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  您请求的资源当前不可用
</body>
</html>

修改 sw.js 文件的代码,添加预先缓存反馈页面 fallback.html,加上请求失败时的反馈代码,如下所示。

// /serviceWorker/styles/main.css

self.addEventListener("install", function (event) {
    event.waitUntil(
        caches
            .open("v1")
            .then(function (cache) {
                // 缓存想要缓存的文件
                return cache.addAll([
                    "/serviceWorker/serviceWorker.html",
+                    "/serviceWorker/fallback.html",                    // 必须预先缓存该页面
                ]);
            })
            .then(function () {
                console.log("所有资源被成功缓存");
            })
            .catch(function (error) {
                console.log("预抓取失败:", error);
            })
    );
});

self.addEventListener("fetch", function (event) {
    event.respondWith(
        caches
            .match(event.request)
            .then(function (response) {
                return (
                    response ||
                    fetch(event.request).then(function (response) {
                        // v1缓存应该已存在
                        return caches.open("v1").then(function (cache) {
                            cache.put(event.request, response.clone());
                            return response;
                        });
                    })
                );
            })
+            .catch(function () {
+                return caches.match("/fallback.html");
+            })
    );
});

接着将网络勾选成“offline”,离线状态下请求一个不存在的页面 test.html。效果如下图所示,返回反馈页面 fallback.html 内容。
image.png
image.png

定制针对某个请求的响应

fetch 事件的事件对象的 reques t属性值代表客户端请求,拥有以下四个属性:

  • event.request.url:客户端请求的目标URL地址;
  • event.request.method:客户端请求的方法;
  • event.request.headers:客户端请求的请求头;
  • event.request.body:客户端请求的请求体。

利用这四个属性,我们可以更精确地定制针对某个请求所返回的响应。如下示例,我们可以定制,当客户端请求 test2.html 网页时,返回 fallback.html 页面。

// /serviceWorker/styles/main.css

self.addEventListener("install", function (event) {
    event.waitUntil(
        caches
            .open("v1")
            .then(function (cache) {
                // 缓存想要缓存的文件
                return cache.addAll([
                    "/serviceWorker/serviceWorker.html",
                    "/serviceWorker/fallback.html",                    // 必须预先缓存该页面
                ]);
            })
            .then(function () {
                console.log("所有资源被成功缓存");
            })
            .catch(function (error) {
                console.log("预抓取失败:", error);
            })
    );
});

self.addEventListener("fetch", function (event) {
+ if(event.request.url === "http://localhost:5500/serviceWorker/test2.html") {
+      event.respondWith(caches.match("/serviceWorker/fallback.html"));
+ } else {
        event.respondWith(
      caches
        .match(event.request)
        .then(function (response) {
          return (
            response ||
            fetch(event.request).then(function (response) {
              // v1缓存应该已存在
              return caches.open("v1").then(function (cache) {
                cache.put(event.request, response.clone());
                return response;
              });
            })
          );
        })
        .catch(function () {
          return caches.match("/fallback.html");
        })
    );
  }
});

再次重新注册 service Worker,访问 test2.html 和 test.html。效果如下。test2.html 返回的是 fallback.html 内容,而 test.html 因为不存在,所以访问不到,同时加入到缓存中。
image.png
image.png

激活

当 Service Worker 脚本文件被首次安装成功后,它将被立即激活。而有时我们需要对 Service Worker 脚本文件进行更新。这时,浏览器与 Service Worker 的交互过程如下所示:

  1. 更新 Service Worker 脚本文件。当用户访问网站时,浏览器尝试从后台下载 Service Worker 脚本文件。如果已下载的文件与浏览器中的文件存在哪怕有一个字节的差异,这个 Service Worke r就会被视为一个新的Service Worker。
  2. 新的 Service Worker 将被立即重新安装。
  3. 这时旧的 Service Worker 仍然在控制页面,所以新的 Service Worker 将处于等待状态而不会被立即激活,此时它被称为一个“等待中的 worker”。
  4. 当浏览器不再加载 Service Worker 作用范围中的任何页面时,旧的 Service Worker 将会作废,新的 Service Worker 将被激活,成为一个“被激活的 worker”,它的 active(激活)事件也同时被触发。

可以对激活事件进行监听,并在激活事件中对缓存进行管理

如下示例,假如我们现在想要将前面创建的 v1 缓存分离为 v2 缓存及 v3 缓存,为一部分资源使用 v2 缓存,为另一部分资源使用 v3 缓存,这意味着在安装过程中我们想要创建 v2 缓存及 v3 缓存,同时在激活事件中我们想要删除原来的 v1 缓存。

self.addEventListener("install", function (event) {
    event.waitUntil(
        caches
            .open("v2")
            .then(function (cache) {
                // 缓存想要缓存的文件
                return cache.addAll(["/serviceWork/serviceWork.html"]);
            })
            .then(function () {
                caches.open("v3").then(function (cache) {
                    return cache.addAll(["/serviceWorker/styles/main.css"]);
                });
            })
            .then(function () {
                console.log("所有资源被成功缓存");
            })
            .catch(function (error) {
                console.log("预抓取失败:", error);
            })
    );
});

self.addEventListener("activate", function (event) {
    let cacheWhiteList = ["v2", "v3"];
    event.waitUntil(
        caches.keys().then(function (cacheNames) {
            return Promise.all(
                cacheNames.map(function (cacheName) {
                    if (cacheWhiteList.indexOf(cacheName) === -1) {
                        console.log(cacheName + "缓存被删除");
                        return caches.delete(cacheName);
                    }
                })
            );
        })
    );
});

在上面的例子中,首先创建的两个缓存名称 v2 及 v3 到 cacheWhiteList 数组中。
CacheStorage 对象具有一个 keys 方法用于查询 Service Worker 缓存中当前保存的所有缓存名称,该方法返回一个 Promise 对象,在肯定的回调函数中传入一个参数 cacheName,它是当前所有缓存名称组成的数组。
遍历这个数组,查询不存在缓存白名单 cacheWhiteList 中的缓存名称(例子中 v1 不存在),通过 caches.delete 删除掉。

再次访问页面,可以看到 v2 缓存了 serviceWorker.html,v3 缓存了 main.css,而 v1 被删除掉了。
image.png
image.png

其他用例

Service Worker API 也可以用于以下场景:

  • 后台数据同步。
  • 响应来自其他源中的资源请求。
  • 集中进行计算成本比较高的数据更新,比如地理位置和陀螺仪信息,这样多页面中可以利用同一组数据。
  • 客户端编译及例如 CoffeeScript、less、JavaScript 模块等出于开发目的的依赖管理。
  • 提供一个后台服务的钩子。
  • 性能增强,例如预先抓取用户有可能会利用到的资源(如网络相册中当前尚未展示的照片)。

to be continue…