从0开始使用Service Worker实现离线缓存 含Demo

本文出现的所有代码都可以从我的git地址中找到,如果对你有帮助,还请点点star,欢迎提出您宝贵的意见。 你也可以在此狠狠地尝试Demo(记得打开谷歌开发者工具查看NetWork)

背景

先前已经在我的另一篇文章:浏览器缓存策略详解中提到了Service Worker,其实它是一个特别大的概念,我们今天就来稍微深入地学习一下他的cacheStorage缓存功能。相信大家都听说过PWA(Progressive Web APP)------渐进式网页;致力于实现与原生 APP 相似的交互体验。PWA总体具有以下特点:(以下特点来自知乎

  • 渐进式:适用于选用任何浏览器的所有用户,因为它是以渐进式增强作为核心宗旨来开发的。
  • 自适应 :适合任何机型:桌面设备、移动设备、平板电脑或任何未来设备。
  • 连接无关性 :能够借助于服务工作线程在离线或低质量网络状况下工作。
  • 离线推送 :使用推送消息通知,能够让我们的应用像Native App一样,提升用户体验。
  • 及时更新:在服务工作线程更新进程的作用下时刻保持最新状态。
  • 安全性:通过 HTTPS 提供,以防止窥探和确保内容不被篡改。

总结下来,PWA的实现其实主要依赖于以下三点:

  1. manifest 实现手机主界面的web app图标、添加进桌面、标题、icon等;
  2. Service Worker实现离线缓存请求、更新缓存、删除缓存;(用插件实现文件更新即版本号更新从而缓存更新)
  3. 前端registration实现用户订阅,后端web-push实现消息推送,前端Service Woker监听push实现消息通知,但是Chrome需要能够连接到外网,因为push用的是谷歌的云服务。

这里就主要谈谈如何使用Service Worker实现离线缓存:

什么是Service Worker?

众所周知, js 是被设计为单线程语言,因为主要用途是与用户互动和操作DOM,单线程设计可以简化并发问题,避免多线程并发时的竞态条件、死锁和其他问题。但单线程存在的问题是,GUI线程js线程 需要抢占资源,在 js 执行比较耗时的逻辑时,容易造成页面假死,用户体验较差。后来html5开放了Web Worker可以在浏览器后台挂载新线程。它无法直接操作DOM,无法访问windowdocument等对象。而Service Worker可以说是Web Worker进一步发展后的产物。Service Worker也是运行在浏览器背后的独立线程 ,主要用于代理网页请求,可缓存请求结果;可实现离线缓存功能;可跨页面通信。也拥有单独的作用域范围和运行环境

Service Worker的特点

Service Worker诞生之前,Web Worker就已经"服役"很久了,他们都是独立于js线程外的线程,但是Web Worker有个特点就是:当网页关闭时,Web Worker就失效了,而Service Worker的诞生就是为了解决这个问题的,它具有以下特点:

  1. 一旦被install,就永远存在,除非被手动unregister
  2. 拥有自己独立的worker线程 ,独立于当前网页进程,有自己独立的worker上下文(context)。
  3. 用到的时候就可以直接唤醒,不用的时候自动睡眠。
  4. 可拦截代理fetch请求和响应,不支持xmlHttpRequest请求。
  5. 可操作缓存文件,且缓存文件可以被网页进程取到(包括网路离线状态)。
  6. 能向客户推送消息。
  7. 不能直接操作DOMwindowparent等 。(但是它有自己的**self**对象来代替window
  8. 必须在HTTPS环境下才能工作。(本地调试可以用localhost)
  9. 异步实现,内部大都是通过Promise实现,以防止浏览器卡顿。所以Service Worker的各类操作都被设计为异步 ,我们在调用的时候要使用Promise语法

Service Worker的生命周期

当我们注册了Service Worker后,它会经历生命周期的各个阶段,同时会触发相应的事件。整个生命周期包括了:installing --> installed --> activating --> activated --> redundant。当Service Workerinstalled 完毕后,会触发**install事件;而 activated完毕后,则会触发 activate**事件。

Service Worker同时提供了事件监听函数对这些状态进行捕获,例如:

javascript 复制代码
self.addEventListener('install', function(event) { /* 安装后... */ });
self.addEventListener('activate', function(event) { /* 激活后... */ });

self.addEventListener('fetch', function(event) { /* 请求后... */ }); //用来响应和拦截各种请求。

基本上,Service Worker的所有应用都是基于上面3个事件的,例如,我们接下来的实战内容。install用来缓存文件,activate用来缓存更新,fetch用来拦截请求直接返回缓存数据。三者齐心,构成了完成的离线缓存控制结构。

实战1. 创建项目,

项目目录结构如下:

css 复制代码
├── README.md
├── app.html
├── sw.js
├── src
│   └── index.js
├── assets
│   └── css
│       └── style.css
│   └── images
│       └── background1.jpg
│       └── background2.jpg

先在app.html中引入图片资源,index.jsstyle.css并在html中随便写点内容:

xml 复制代码
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>HTTPS - Learn SW OffLine</title>
    <link rel="stylesheet" href="./assets/css/style.css" />
  </head>

  <body>
    <p>
      图片1:<img
        src="./assets/images/background1.jpg"
        alt="image1"
        width="512"
        height="256"
      />
    </p>
    <p>
      图片2:<img
        src="./assets/images/background2.jpg"
        alt="image2"
        width="512"
        height="256"
      />
    </p>
    <p>Service Worker注册:<span class="text" id="register"></span></p>


    <script src="./src/index.js" type="text/javascript"></script>
  </body>
</html>

实战2. 注册Service Worker

如果注册成功,Service Worker就会被下载到客户端并尝试安装或激活,这将作用于整个域内用户可访问的URL,或者其特定子集。这里我们将sw.js文件注册为一个Service Worker,注意文件的路径不要写错了。

ruby 复制代码
navigator.serviceWorker.register(url, options);
    //url:service worker文件的路径,路径是相对于 Origin ,而不是当前文件的目录的
    //options: scope:表示定义service worker注册范围的URL;
    //默认值是基于当前的location(./),并以此来解析传入的路径。
    //假设你的sw文件放在根目录下位于/src/sw.js路径的话,那么你的sw就只能监听/src/*下面的请求。
    //如果想要监听所有请求有两个办法,一个是将sw.js放在根目录下,或者是在注册是时候设置scope。
javascript 复制代码
// index.js
window.addEventListener("load", () => {
  if (navigator.serviceWorker) {
    navigator.serviceWorker
      // scope是自定义sw的作用域范围为根目录,默认作用域为当前sw.js所在目录的页面
      .register("./sw.js", { scope: "./" })
      .then(function (registration) {
        // 注册成功后会返回registration对象,指代当前服务线程实例
        document.getElementById("register").innerHTML = "成功!";
      })
      .catch(function (err) {
        console.error(e);
        document.getElementById("register").innerHTML = "失败!";
      });
  } else {
    console.log("当前浏览器不支持service worker");
  }
});

实战3. 缓存静态资源

注册完Service Worker后,下一步就是把我们需要缓存的文件缓存下来。我们需要添加事件监听 ,来在合适的时机触发Service Worker的相应操作。现在,要使我们的Web离线可用,就需要将所需资源缓存下来。我们需要一个资源列表,当Service Worker被激活时,会将该列表内的资源缓存进cache

javascript 复制代码
//sw.js
// 定义缓存空间名称
const CACHE_NAME = "tuland-1"; //修改此值可以强制更新缓存
// 定义需要缓存的文件目录
const FILE_TO_CACHE= [
  "./app.html",
  "./src/index.js",
  "./assets/css/style.css",
  "./assets/images/background1.jpg",
  "./assets/images/background2.jpg",
];

// 监听install事件,回调中缓存所需文件
self.addEventListener("install", (e) => {
  console.log("Service Worker 状态: instal");
  e.waitUntil(
    // cacheStorage API 可直接用caches来替代
    // open方法创建/打开缓存空间,并会返回promise实例
    // then来接收返回的cache对象索引
    caches.open(CACHE_NAME).then(function (cache) {
      // cache对象addAll方法解析(同fetch)并缓存所有的文件
      return cache.addAll(FILE_TO_CACHE);
    })
  );
});

可以看到,首先在FILE_TO_CACHE中我们列出了所有的静态资源依赖。当Service Worker install时,我们就会通过caches.open()cache.addAll()方法将资源缓存起来。open(CACHE_NAME)这里CACHE_NAME会成为这些缓存的key值。 上面这段代码中,caches是一个全局变量,通过它我们操作的其实是CacheStorage相关接口。CacheStorage MDN文档

实战4:使用缓存的静态资源

到目前为止,我们仅仅是注册了一个Service Worker,并在其install时缓存了一些静态资源,但我们还没有使用这些缓存下来的资源。那么要如何才能使用呢?答案是拦截fetch

  1. 浏览器发起请求,请求各类静态资源(html/js/css/img)。
  2. Service Worker拦截浏览器请求,并查询当前cache。
  3. 若存在cache则直接返回,结束。
  4. 若不存在cache,则通过fetch方法向服务端发起请求,并返回请求结果给浏览器。
javascript 复制代码
// 拦截所有请求事件
// 如果缓存中已经有数据就直接用缓存,否则去请求数据
self.addEventListener("fetch", (e) => {
  console.log("处理fetch事件:", e.request.url);
  e.respondWith(
    caches
      .match(e.request)
      .then(function (response) {
        if (response) {
          console.log("缓存匹配到res:", response.url);
          return response;
        }
        console.log("缓存未匹配对应request,准备从network获取", caches);
        return fetch(e.request);
      })
      .catch((err) => {
        console.error(err);
        return fetch(e.request);
      })
  );
});

fetch事件会监听所有浏览器的请求。e.respondWith()方法接受Promise作为参数,通过它让Service Worker向浏览器返回数据。caches.match(e.request)则可以查看当前的请求是否有一份本地缓存:如果有缓存,则直接向浏览器返回cache ;否则Service Worker会向后端服务发起一个fetch(e.request)的请求,并将请求结果返回给浏览器。

到目前为止,运行我们的demo

  1. 当一次打开网页时,所依赖的静态资源就会被缓存在本地;
  2. 刷新浏览器,在network选项中可以看到缓存内容的请求已经被拦截了,从sw缓存中获取了。
  3. chrome控制台中,把network状态改成offline,再次刷新浏览器,虽然没网,但是还是可以从本地缓存读取内容。

第一次进入页面和第二次进入页面,如图:

普通情况离线加载和使用`Service Worker`后离线加载,如图:

实战5:更新静态缓存资源

更新sw.js

然而,一旦我们将资源缓存后,除非注销(unregisterService Worker或者手动清除缓存,否则新的静态资源将无法缓存。在仅有上述代码的情况下,我们修改sw.js,我们会发现,在上个Service Worker的有效时长内:浏览器用的永远是上一次缓存下来的sw.js 。 解决这个问题的一个简单方法就是修改CACHE_NAME。由于浏览器判断sw.js是否更新是通过字节方式,因此修改CACHE_NAME会重新触发install并缓存资源。此外,在activate事件中,我们需要检查CACHE_NAME是否变化,如果变化则表示有了新的缓存资源,原有缓存需要删除。

javascript 复制代码
//sw.js
const CACHE_NAME = "tuland-2"; //修改此值可以强制更新缓存
...
this.addEventListener("install", (event) => { 
  this.skipWaiting();// 强制更新sw.js
  ...
})

// 监听active事件
self.addEventListener("activate", (event) => {
  // 获取所有的缓存key值,将需要被缓存的路径加载放到缓存空间下
  const cacheDeletePromise = caches.keys().then((keyList) => {
    console.log("keyList:", keyList);
    Promise.all(
      keyList.map((key) => {
        if (key !== CACHE_NAME) {
          const deletePromise = caches.delete(key);
          return deletePromise;
        } else {
          Promise.resolve();
        }
      })
    );
  });
  // 等待所有的缓存都被清除后,直接启动新的缓存机制
  event.waitUntil(
    Promise.all([cacheDeletePromise]).then((res) => {
      this.clients.claim();
    })
  );
});

我们添加skipWaiting(),同时把缓存CACHE_NAME改为tuland-2,再次刷新浏览器。查看控制台log,新的Service Worker安装完成之后立即被激活了,我们也可以看到activate事件在更新我们的缓存文件。

更新app.html

在上面的流程中,我们使用skipWaiting完成了sw.js的更新,当下一次 用户访问Web时候,则直接获取并使用新的缓存。但这时还存在着一个很重要的问题:用户这一次访问的Web网页,还是上一次缓存中的Web网页!

我们此时修改app.html

less 复制代码
//app.html添加
<p>我更新啦!</p>

在更改sw.js中的CACHE_NAMEtuland-3

objectivec 复制代码
//sw.js
const CACHE_NAME = "tuland-3"; //修改此值可以强制更新缓存 可以用版本控制工具自动更新

刷新浏览器,新的Service Worker已经激活了,可是页面上还是之前的内容,再次刷新才能出现"我更新啦!"

解决方案:

这边会有很多解决方案,我在demo里使用的是让主线程Service Worker互相通信,实现弹窗来通知用户刷新页面:

javascript 复制代码
//app.html 添加对话框

...
    <dialog class="dialog" id="myDialog">
      <div class="dialogContent">
        <p>检查到网页存在更新,请立即刷新</p>
        <button type="submit" onclick="location.reload()">确定</button>
      </div>
    </dialog>
ini 复制代码
//index.js 主线程监听onMessage
    navigator.serviceWorker.onmessage = function (event) {
      var data = event.data;
      if (data.command == "reload") {
        console.log(data);
        const myDialog = document.querySelector("#myDialog");
        myDialog.showModal();
      }
    };
javascript 复制代码
//sw.js  sw对所有客户发送消息
...
self.addEventListener("activate", (event) => {
  // 获取所有的缓存key值,将需要被缓存的路径加载放到缓存空间下
  const cacheDeletePromise = caches.keys().then((keyList) => {
    console.log("keyList:", keyList);
    Promise.all(
      keyList.map((key) => {
        if (key !== CACHE_NAME) {
          const deletePromise = caches.delete(key);
          //告诉用户需要重新刷新
          console.log("need reload");
          self.clients.matchAll().then(function (clients) {
            clients.forEach(function (client) {
              client.postMessage({
                command: "reload",
                message: "blablablablabla",
              });
            });
          });
          return deletePromise;
        } else {
          Promise.resolve();
        }
      })
    );
  });
  // 等待所有的缓存都被清除后,直接启动新的缓存机制
  event.waitUntil(
    Promise.all([cacheDeletePromise]).then((res) => {
      this.clients.claim();
    })
  );
});

这种方法虽然可行,但是通知用户刷新浏览器结果并不可控。而如果直接刷新页面,又显得太暴力,从而让用户体验非常差。知乎的这位老哥也遇到了诸如此类的问题,它最终是选择了拦截fetch并比较url标识的方法

但我们在经过搜查资料和组内讨论后,最终得出了一个方案:Service Worker控制的页面中,优先使用在线资源,Service Worker充当面向客户端的代理服务器角色;当在线资源获取出错(服务器宕机,网络不可用等情况),则使用Service Worker本地缓存。 私以为这是比较可靠的。

总结:

本文出现的所有代码都可以从我的git地址中找到,如果对你有帮助,还请点点star,欢迎提出您宝贵的意见。

截止到目前,就算浅浅完成了一个可以实现离线缓存的demo了,但要当成PWA上线,还需要非常多的工作,比如每次都修改CACHE_NAME是不现实的,我们要结合webpack实现自动打包生成资源号;比如我们还有CDN,还有推送消息功能,甚至包括Service Worker本身,我们可以挖掘的东西也还有很多,这些都留着下次再细细讲吧。

相关推荐
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang2 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、5 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤6 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui