PWA 之 Service Worker 深入详解

前端工程师有很多性能优化的手段,其实这绝大部分情况就是在干一件事情,那就是尽量降低一个页面的网络请求成本从而缩短页面加载资源的时间并降低用户可感知的延时。

那么到了今天,如果有人告诉你:“我们的站点可以秒开,离线的情况下也能浏览,不是file://协议的哦!”,你是不是要送他一个大大的问号脸

利用 Service Worker,就可以完全实现上面所说的功能。在本小节中,沃师傅我就和大家一起来看一下什么是 Service Worker,以及如何使用它

什么是 Service Worker


W3C 组织早在 2014 年 5 月就提出过 Service Worker 这样的一个 HTML5 API ,主要用来做持久的离线缓存。

当然这个 API 不是凭空而来,至于其中的由来我们可以简单的捋一捋:

浏览器中的 JavaScript 都是运行在一个单一主线程上的,在同一时间内只能做一件事情。随着 Web 业务不断复杂,我们逐渐在 JavaScript 中加了很多耗资源、耗时间的复杂运算过程,这些过程导致的性能问题在 WebApp 的复杂化过程中更加凸显出来。

W3C 组织早早的洞察到了这些问题可能会造成的影响,此时有个叫 Web Worker 的 API 被造出来了,这个 API 的唯一目的就是解放主线程,Web Worker 是脱离在主线程之外的,将一些复杂的耗时的活交给它干,完成后通过 postMessage 方法告诉主线程,而主线程通过 onMessage 方法得到 Web Worker 的结果反馈。

一切问题好像是解决了,但 Web Worker 是临时的,每次做的事情的结果还不能被持久存下来,如果下次有同样的复杂操作,还得费时间的重新来一遍。

那我们能不能有一个 Worker 是一直持久存在的,并且随时准备接受主线程的命令呢?基于这样的需求推出了最初版本的 Service Worker ,Service Worker 在 Web Worker 的基础上加上了持久离线缓存能力。

那么 Service Worker 到底用来干啥的呢?Service Worker 有以下功能和特性:

  • 一个独立的 worker 线程,独立于当前网页进程,有自己独立的 worker context。

  • 一旦被 install,就永远存在,除非被手动 unregister

  • 用到的时候可以直接唤醒,不用的时候自动睡眠

  • 可编程拦截代理请求和返回,缓存文件,缓存的文件可以被网页进程取到(包括网络离线状态)

  • 离线内容开发者可控

  • 能向客户端推送消息

  • 不能直接操作 DOM

  • 必须在 HTTPS 环境下才能工作

  • 异步实现,内部大都是通过 Promise 实现

所以我们基本上知道了 Service Worker 的伟大使命,就是让缓存做到优雅和极致,让 Web App 相对于 Native App 的缺点更加弱化,也为开发者提供了对性能和体验的无限遐想。

下图展示了普通 Web App 与添加了 Service Worker 的 Web App 在网络请求上的差异:

Service Worker 生命周期

Service Worker 的使用过程很简单,所处理的事情也相对单一,从 Service Worker 的注册到消失,经历了生命周期中不同的状态。

通常我们如果要使用 Service Worker 基本就是以下几个步骤:

  1. 首先我们需要在页面的 JavaScript 主线程中使用serviceWorkerContainer.register()来注册 Service Worker ,在注册的过程中,浏览器会在后台启动尝试 Service Worker 的安装步骤。

  2. 如果注册成功,Service Worker 在 ServiceWorkerGlobalScope 环境中运行;这是一个特殊的 worker context,与主脚本的运行线程相独立,同时也没有访问 DOM 的能力。

  3. 后台开始安装步骤,通常在安装的过程中需要缓存一些静态资源。如果所有的资源成功缓存则安装成功,如果有任何静态资源缓存失败则安装失败,在这里失败的不要紧,会自动继续安装直到安装成功,如果安装不成功无法进行下一步:激活 Service Worker。

  4. 开始激活 Service Worker,必须要在 Service Worker 安装成功之后,才能开始激活步骤,当 Service Worker 安装完成后,会接收到一个激活事件(activate event)。激活事件的处理函数中,主要操作是清理旧版本的 Service Worker 脚本中使用资源。

  5. 激活成功后 Service Worker 可以控制页面了,但是只针对在成功注册了 Service Worker 后打开的页面。也就是说,页面打开时有没有 Service Worker,决定了接下来页面的生命周期内受不受 Service Worker 控制。所以,只有当页面刷新后,之前不受 Service Worker 控制的页面才有可能被控制起来。

生命周期

我们已经知道了,Service Worker 的工作原理是基于注册、安装、激活等步骤在浏览器 js 主线程中独立分担缓存任务的,那么我们如何在这些 API 自身一系列的操作中进行一些我们自己想让 worker 干的事情呢?

这里我们需要了解一下 Service Worker 的生命周期的概念,这有利于我们学会在各个生命周期的阶段进行有目的性的回调,让我们自定义的工作在 Service Worker 中正确有效的开展下去。MDN 给出了详细的 Service Worker 生命周期图:

我们可以看到生命周期分为这么几个状态:安装中,安装后,激活中,激活后,废弃。

1. 安装中(installing):这个状态发生在 Service Worker 注册之后,表示开始安装,触发 install 事件回调指定一些静态资源进行离线缓存。install 事件回调中有两个方法:

  • event.waitUntil()方法:传入一个 Promise 为参数,等到该 Promise 为 resolve 状态为止。

  • self.skipWaiting()方法:self 是当前 context 的 global 变量,执行该方法表示强制当前处在 waiting 状态的 Service Worker 进入 activate 状态。

2. 安装后(installed):Service Worker 已经完成了安装,并且等待其他的 Service Worker 线程被关闭。

3. 激活(activating):在这个状态下没有被其他的 Service Worker 控制的客户端,允许当前的 worker 完成安装,并且清除了其他的 worker 以及关联缓存的旧缓存资源,等待新的 Service Worker 线程被激活。activate 回调中有两个方法:

  • event.waitUntil()方法:传入一个 Promise 为参数,等到该 Promise 为 resolve 状态为止。

  • self.clients.claim()方法:在 activate 事件回调中执行该方法表示取得页面的控制权, 这样之后打开页面都会使用版本更新的缓存。旧的 Service Worker 脚本不再控制着页面,之后会被停止。

4. 激活后(activated):在这个状态会处理 activate 事件回调(提供了更新缓存策略的机会)。并可以处理功能性的事件 fetch(请求)、sync(后台同步)、push(推送)。

5. 废弃状态(redundant):这个状态表示一个 Service Worker 的生命周期结束。

注意:进入废弃(redundant)状态的原因可能为这几种:- 安装(install)失败 – 激活(activating)失败 – 新版本的 Service Worker 替换了它并成为激活状态。

持的事件

上面我们介绍了一个 Service Worker 完整的生命周期,不同的生命周期也会触发不同的事件。大致对应的事件如下:

  • install:Service Worker 安装成功后被触发的事件,在事件处理函数中可以添加需要缓存的文件。

  • activate:当 Service Worker 安装完成后并进入激活状态,会触发 activate 事件。通过监听 activate 事件你可以做一些预处理,如对旧版本的更新、对无用缓存的清理等。

  • message:Service Worker 运行于独立 context 中,无法直接访问当前页面主线程的 DOM 等信息,但是通过 postMessage API,可以实现他们之间的消息传递,这样主线程就可以接受 Service Worker 的指令操作 DOM。

除了上面的这 3 个事件以外,PWA 中还有几个重要的功能也依赖于 Service Worker。

  • fetch(请求):当浏览器在当前指定的 scope 下发起请求时,会触发 fetch 事件,并得到传有 response 参数的回调函数,回调中就可以做各种代理缓存的事情了。

  • push(推送):push 事件是为推送准备的。不过首先需要了解一下 Notification API 和 PUSH API。通过 PUSH API,当订阅了推送服务后,可以使用推送方式唤醒 Service Worker 以响应来自系统消息传递服务的消息,即使用户已经关闭了页面。

  • sync(后台同步):sync 事件由 background sync(后台同步)发出。background sync 配合 Service Worker 推出的 API,用于为 Service Worker 提供一个可以实现注册和监听同步处理的方法。

(图为 Service Worker 中所支持的事件)

怎么使用 Service Worker

1. 前提条件

Service Worker 出于安全性和其实现原理,在使用的时候有一定的前提条件。大致条件如下:

  • 由于 Service Worker 要求 HTTPS 的环境,我们通常可以借助于 GitHub Pages 进行学习调试。当然一般浏览器允许调试 Service Worker 的时候 host 为 localhost 或者 127.0.0.1 也是 OK 的。

  • Service Worker 的缓存机制是依赖 Cache API 实现的

  • 依赖 HTML5 fetch API

  • 依赖 Promise 实现

2. 注册

要安装 Service Worker, 我们需要通过在 JavaScript 主线程注册 Service Worker 来启动安装,这个过程将会通知浏览器我们的 Service Worker 线程的 JavaScript 文件在什么地方呆着。

注册 Service Worker 的代码如下:

js // 判断是否支持 serviceWorker if ('serviceWorker' in navigator) { // 如果支持 则在页面加载好的时候开始注册 Service Worker window.addEventListener('load', function () { // 注册时候接收 2 个参数:Service Worker 所在目录、以及子目录 navigator.serviceWorker.register('/sw.js', {scope: '/'}) .then(function (registration) { // 注册成功 console.log('ServiceWorker registration successful with scope: ', registration.scope); }) .catch(function (err) { // 注册失败 console.log('ServiceWorker registration failed: ', err); }); }); }

我们来看一下这段代码具体做了些什么。简单来讲,做了下面这几件事:

  • 这段代码首先是要判断 Service Worker API 的可用情况,支持的话咱们才继续谈实现,否则免谈了。

  • 如果支持的话,在页面 onload 的时候注册位于/sw.js的 Service Worker。

  • 每次页面加载成功后,就会调用register()方法,浏览器将会判断 Service Worker 线程是否已注册并做出相应的处理。

  • register 方法的 scope 参数是可选的,用于指定你想让 Service Worker 控制的内容的子目录。本示例中服务工作线程文件位于根网域,这意味着服务工作线程的作用域将是整个来源。

关于 register 方法的 scope 参数,需要说明一下:Service Worker 线程将接收 scope 指定网域目录上所有事项的 fetch 事件,如果我们的 Service Worker 的 javaScript 文件在 /a/b/sw.js, 不传 scope 值的情况下,scope 的值就是 /a/b。

scope 的值的意义在于,如果 scope 的值为 /a/b, 那么 Service Worker 线程只能捕获到 path 为 /a/b 开头的( /a/b/page1, /a/b/page2,…)页面的 fetch 事件。通过 scope 的意义我们也能看出 Service Worker 不是服务单个页面的,所以在 Service Worker 的 js 逻辑中全局变量需要慎用。

  • then()函数链式调用我们的 promise,当 promise resolve 的时候,里面的代码就会执行。

  • 最后面我们链了一个catch()函数,当 promise rejected 才会执行。

代码执行完成之后,我们这就注册了一个 Service Worker,它工作在 worker context,所以没有访问 DOM 的权限。在正常的页面之外运行 Service Worker 的代码来控制它们的加载。

注册了 Service Worker 之后,可以使用如下命令进行查看:

js chrome://inspect/#service-workers

示例如下:

3. 安装

Service Worker 注册成功之后,我们的浏览器中就已经有了一个属于我们自己 Web App 的 worker context。此时,浏览器就会尝试为站点里面的页面安装并激活它,并且在这里对静态资源进行缓存。

install 事件我们会绑定在 Service Worker 文件中,在 Service Worker 安装成功后,install 事件被触发。

install 事件一般是被用来做浏览器的离线缓存的。为了达成这个目的,我们使用 Service Worker 新的标志性的存储 cache API。这是一个 Service Worker 上的全局对象,它使我们可以存储网络响应发来的资源,并且根据它们的请求来生成 key。这个 API 和浏览器的标准的缓存工作原理很相似,但是只对应我们的站点的域。它会一直持久存在,直到我们告诉它不再存储,在此期间我们拥有全部的控制权。

注:localStorage 的用法和 Service Worker cache 的用法很相似,但是由于 localStorage 是同步的用法,所以不允许在 Service Worker 中使用。IndexedDB 也可以在 Service Worker 内做数据存储。

下面是安装部分的代码示例:

js // 监听 service worker 的 install 事件 this.addEventListener('install', function (event) { // 如果监听到了 service worker 已经安装成功的话,就会调用 event.waitUntil 回调函数 event.waitUntil( // 安装成功后操作 CacheStorage 缓存,使用之前需要先通过 caches.open() 打开对应缓存空间。 caches.open('my-test-cache-v1').then(function (cache) { // 通过 cache 缓存对象的 addAll 方法添加 precache 缓存 return cache.addAll([ '/', '/index.html', '/main.css', '/main.js', '/image.jpg' ]); }) ); });

上面的代码,主要做了如下几件事:

  • 这里我们新增了一个 install 事件监听器,接着在事件上接了一个ExtendableEvent.waitUntil()方法,这会确保 Service Worker 不会在waitUntil()里面的代码执行完毕之前安装完成。

  • waitUntil()内,我们使用了caches.open()方法来创建了一个叫做 v1 的新的缓存,将会是我们的站点资源缓存的第一个版本。它返回了一个创建缓存的 promise,当它 resolved 的时候,我们接着会调用在创建的缓存实例(Cache API)上的一个方法addAll(),这个方法的参数是一个由一组相对于 origin 的 URL 组成的数组,这些 URL 就是你想缓存的资源的列表。

  • 如果 promise 被 rejected,安装就会失败,这个 worker 不会做任何事情。这也是可以的,因为你可以修复你的代码,在下次注册发生的时候,又可以进行尝试。

  • 当安装成功完成之后,Service Worker 就会激活。在第一次你的 Service Worker 注册/激活时,这并不会有什么不同。但是当 Service Worker 更新的时候 ,就不太一样了。

4. 自定义请求响应

经历过上一步,我们已经成功的对站点资源进行了缓存。之后如果客户端再次发送请求,我们就可以将缓存返回给客户端。通过 fetch 事件,这是很容易做到的。

每次任何被 Service Worker 控制的资源被请求到时,都会触发 fetch 事件,这些资源包括了指定的 scope 内的 html 文档,和这些 html 文档内引用的其他任何资源(比如 index.html 发起了一个跨域的请求来嵌入一个图片,这个也会通过 Service Worker),这下 Service Worker 代理服务器的形象开始慢慢露出来了,而这个代理服务器的钩子就是凭借 scope 和 fetch 事件两大利器就能把站点的请求管理的井井有条。

如下图所示:

    • 浏览器发起请求,请求各类静态资源(html/js/css/img)。

    • Service Worker 拦截浏览器请求,并查询当前 cache。

    • 若存在 cache 则直接返回,结束。

    • 若不存在 cache,则通过 fetch 方法向服务端发起请求,并返回请求结果给浏览器。

具体的代码如下所示:

js this.addEventListener('fetch', function (event) { event.respondWith( caches.match(event.request).then(function (response) { // 如果 Service Worker 有自己的返回,就直接返回,减少一次 http 请求 if (response) { return response; } // 如果 service worker 没有返回,那就得直接请求真实远程服务 var request = event.request.clone(); // 把原始请求拷过来 return fetch(request).then(function (httpRes) { // http请求的返回已被抓到,可以处置了。 // 请求失败了,直接返回失败的结果就好了。。if (!httpRes || httpRes.status !== 200) { return httpRes; } // 请求成功的话,将请求缓存起来。var responseClone = httpRes.clone(); caches.open('my-test-cache-v1').then(function (cache) { cache.put(event.request, responseClone); }); return httpRes; }); }) ); });

5. Service Worker 版本更新

然而,如果你细心的话,会发现一个小问题:当我们将资源缓存后,除非注销 sw.js、手动清除缓存,否则新的静态资源将无法缓存。

解决这个问题的一个简单方法就是修改 cacheName。由于浏览器判断 sw.js 是否更新是通过字节方式,因此修改 cacheName 会重新触发 install 并缓存资源。此外,在 activate 事件中,我们需要检查 cacheName 是否变化,如果变化则表示有了新的缓存资源,原有缓存需要删除。

js self.addEventListener('activate', function (e) { console.log('Service Worker 状态: activate'); // 清理旧版本 var cachePromise = caches.keys().then(function (keys) { return Promise.all(keys.map(function (key) { if (key !== 'my-test-cache-v1') { return caches.delete(key); } })); }) e.waitUntil(cachePromise); return self.clients.claim(); // 更新客户端 });

6. 完整代码示例

最后,我们来看一下完整的代码。项目目录如下:

js 根路径 / | |----manifest.json // 清单文件 | |----index.html | |----app.png // 图标文件 | |----fate.jpg // 图片文件 | |----sw.js // Service Worker | |----main.css

index.html

“`js

Learning PWA


this service is running on Web Server for Chrome.

 “`

main.css

js p{ color: red; }

manifest.json

js { "short_name": "短名称", "name": "这是一个完整名称", "icons": [{ "src": "app.png", "type": "image/png", "sizes": "144x144" }], "display": "standalone", "start_url": "index.html", "theme_color": "red", "background_color": "#333", "orientation": "portrait-primary" }

sw.js

“`js // 监听 service worker 的 install 事件 this.addEventListener(‘install’, function (event) { // 如果监听到了 service worker 已经安装成功的话,就会调用 event.waitUntil 回调函数 event.waitUntil( // 安装成功后操作 CacheStorage 缓存,使用之前需要先通过 caches.open() 打开对应缓存空间。caches.open(‘my-test-cache-v1’).then(function (cache) { // 通过 cache 缓存对象的 addAll 方法添加 precache 缓存 return cache.addAll([ ‘/’, ‘/index.html’, ‘/main.css’, ‘/fate.jpg’ ]); }) ); });

this.addEventListener(‘fetch’, function (event) { event.respondWith( caches.match(event.request).then(function (response) { // 如果 Service Worker 有自己的返回,就直接返回,减少一次 http 请求 if (response) { return response; } // 如果 service worker 没有返回,那就得直接请求真实远程服务 var request = event.request.clone(); // 把原始请求拷过来 return fetch(request).then(function (httpRes) { // http请求的返回已被抓到,可以处置了。// 请求失败了,直接返回失败的结果就好了。。if (!httpRes || httpRes.status !== 200) { return httpRes; } // 请求成功的话,将请求缓存起来。var responseClone = httpRes.clone(); caches.open(‘my-test-cache-v1’).then(function (cache) { cache.put(event.request, responseClone); }); return httpRes; }); }) ); });

self.addEventListener(‘activate’, function (e) { console.log(‘Service Worker 状态:activate’); // 清理旧版本 var cachePromise = caches.keys().then(function (keys) { return Promise.all(keys.map(function (key) { if (key !== ‘my-test-cache-v1’) { return caches.delete(key); } })); }) e.waitUntil(cachePromise); return self.clients.claim(); // 更新客户端 }); “`

效果:设置为 Offline 模式后仍然可以访问到页面内容

查看 Cache 下面的 Cache Storage,可以看到静态资源内容已经成功被缓存。

修改静态资源以后,在有网络的情况下会重新进行缓存,之后断开网络拿到的就是新缓存的内容。

如何进行 Service Worker 调

在本小节的最后,我们一起来看一下如何进行 Service Worker 的调试。大致存在以下几种方式:

  • 借助 Chrome 浏览器 debug

  • 查看 Service Worker 缓存内容

  • 网络跟踪

1. 借助 Chrome 浏览器 debug

使用 Chrome 浏览器,可以通过进入控制台 Application 下面的 Service Workers 面板进行查看和调试。如下图所示:

2. 查看 Service Worker 缓存内容

我们已经了解过,Service Worker 使用 Cache API 缓存只读资源,我们同样可以在 Chrome DevTools 上查看缓存的资源列表。

Cache Storage 选项卡提供了一个已使用(Service Worker 线程)Cache API 缓存的只读资源列表。

3. 网络跟踪

此外经过 Service Worker 的 fetch 请求 Chrome 都会在 Network 标签页里标注出来,其中:

  • 来自 Service Worker 的内容会在 Size 字段中标注为 from ServiceWorker

  • Service Worker 发出的请求会在 Name 字段中添加 ⚙ 图标。

未经允许不得转载:大自然的搬运工 » PWA 之 Service Worker 深入详解

赞 (0)

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址