Service Worker简易教程

6,559 阅读11分钟

面试中经常问service worker的内容,但是网上没有一篇文章完整的

简介

W3C组织早在20145月就提出过Service Worker这样的一个HTML5 API ,主要用来做持久的离线缓存。
浏览器中js运行中单一主线程中,同一时间只能做一件事情。 如果一段代码运算太过耗时,就会一直占用浏览器主线程,造成性能下降。基于这个问题,W3C提出了web Worker,将耗时太长的任务交给web worker,然后通过post Message告诉主线程,主线程通过onMessage得到结果。
但是web Worker是临时的,每次运行的结果不能持久的保持下来,下次有复杂的运算,还需要重新计算一次。为了解决这个问题,推出了Service Worker,相对于web worker增加了离线缓存能力。
Service Worker主要有以下特点和功能:

  1. 离线缓存
  2. 消息推送
  3. 请求拦截

如何使用Service Worker

注册

创建一个html,底部加入注册service Worker

 if ('serviceWorker' in navigator) {
      window.addEventListener('load', () => {
        navigator.serviceWorker.register('/sw.js', {scope: '/'})
        .then(() => {
          console.log('Service Worker registration successful')
        })
        .catch((err) => {
          console.log('Service worker registration failed')
        })
      })
    }

这段代码首先是判断service Worker是否支持,然后支持就调用他的register方法。
这里的scope官方文旦上写的是想让Service Worker 控制的内容的子目录,感觉挺迷惑的,例如我的目录是这样的

然后scope写成./config,那么service-worker只会拦截config目录下的fetch事件,但是在下面提到的cache.addAll仍然可以缓存/下面的index.html的内容。 也可以看到service worker不是服务单一页面的,所以需要注意在service worker中定义的全局变量。
service worker基本都是基于promise操作,当注册完成以后产生成功和失败回调,最后可以看到结果

安装

注册完service Worker以后,service worker就会进行安装,触发install事件,在install事件里边可以缓存一些资源,如下:

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

首先是监听install事件, 调用了waitUntil,这个方法主要是用于延长事件的寿命,内部需要传入一个Promise,需要等到内部传入的Promise变为resolve。这里主要是为了延长service workerinstalling周期,等资源缓存完成以后达到installed生命周期。
具体的缓存内容可以通过ApplicationCache Storage进行查看

上述代码已经能达到一个资源缓存的效果了,但是没有对缓存资源进行使用,下面编写Service Worker使用缓存的代码

请求拦截

Service Worker具有请求拦截的功能,在页面发送HTTP请求时,service worker可以通过fetch事件进行请求拦截,并且给出自己的响应,所以为了安全,需要使用https,下面来编写具体的内容:

this.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request)
        .then((response) => {
        // 如果 service worker有自己的放回,就直接返回,减少一次http请求
            if (response) {
                return response;
            }
            // 如果service worker没有返回,那就直接请求真实远程服务
            var request = event.request.clone();
            return fetch(request)
                .then((res) => {
                    // 请求失败,直接返回失败的结果
                    if (!res || res.status !== 200) {
                        return res;
                    }
                    
                    // 请求成功的话,将请求缓存起来
                    var responseClone = res.clone;
                    caches
                        .open('my-test-cache-v1')
                        .then((cache) => {
                            cache.put(event.request, respondClone)
                        })
                    return res;
                })
        })
    )
})

首先监听fetch事件,然后调用event.respondWith,这个函数的使用和waitUntil类似,当传入的Promise resolved之后,才会把对应的response返回给浏览器。和cache中的数据对比,看是否有缓存内容,如果有就使用缓存内容,没有则请求远程服务。请求缓存这个部分需要注意因为Service Worker会拦截所有的请求,所以需要注意判断哪些内容需要缓存,哪些不需要缓存,例如ajax就没有必要进行缓存。当我们重新访问index.html的时候,可以看到index.html直接从service worker中获取

service worker更新

当我们更改缓存策略的时候,service worker是如何进行更新的呢,主要有下面几种策略

  1. service worker文件URL的更新
  2. service worker文件内容发生更改
  3. 用户在无操作24小时可以自动更新

更改service workerURL

先看一下这种方式是否是可行的,假如第一次访问的时候,在sw1.jsindex.html进行缓存,这时候我们更改index.html的内容,发现展现的页面内容并没有发生更改,需要这个时候就需要更改service worker,我们可以选择把sw1.js改为sw2.js,意思是说重新注册一个service worker,来进行新文件的缓存如下:

我们更改了service worker文件的url
但是在浏览器中发现,index.html并没有发生改变,是因为用户访问站点的时候由于sw1.js的作用,从缓存中取出的index.html引用的仍然是v1,并不是我们升级后引用的v2,那有人说直接不缓存html的内容不就行了吗,那这个应用就失去了离线使用的功能了

更改service worker文件内容

如果sw.js内容有更新,当访问网站页面时浏览器获取了新的文件,逐字节比对/sw.js文件不同时他会认为有更新,于是会安装新的文件并触发install文件,但是此时已经处于激活状态的旧的service worker还在运行,新的service worker完成安装后会进入waiting状态。直到所有已打开的页面都关闭,旧的service worker自动更新,新的service worker才会在接下来重新打开的页面里生效。
例如在sw.js中加入一个版本号,用于service worker的更新

var version = '0.0.1';
// 跳过等待,直接进入active
this.addEventListener('install', funciton (event) {
    event.waitUntil(self.skipWaiting())
})
this.addEventListener('activate', function (event) {
    event.waitUntil(
        Promise.all([
            // 更新客户端
            self.clients.claim(),
            // 清理旧版本
            caches.keys().then((cacheList) => {
                return Promise.all(
                    cacheList.map((cacheName) => {
                        if (cacheName !== 'my-test-cache-v1') {
                            return caches.delete(cacheName)
                        }
                    })
                )
            })
        ])
    )
})

首先我们调用self.skipWaiting直接跳过installing阶段,接管老的service worker,如果不执行这一步你会发现

页面原来有一个service worker,需要把当前页面关闭以后新的service worker才能生效,所以这里调用skipWaiting跳过installing,直接接管老的service worker,监听activate,处理老的缓存。
但是这样做存在一个问题,假如有如下场景:

  1. 一个页面index.html已经安装了old_sw
  2. 用户打开这个页面,所有网络请求都通过了old_sw进行处理,页面加载完成
  3. 因为service worker具有异步安装的特性,一般在浏览器空闲时,他会去执行那句navigator.serviceWorker.register。这时候浏览器发现了有个new_sw,于是安装让他等待
  4. 但是由于new_swinstall阶段有self.skipWaiting(),所以浏览器强制退出了old_sw,让new_sw马上激活并控制页面
  5. 用户如果在index.html后续操作有网络请求,就由new_sw处理 很明显,同一个页面,前半部分是由old_sw控制,而后半部分由new_sw控制。就可能导致两者行为不一致从而出现未知错误

手动更新service worker

和上面同理,都使用一个版本号,进行更新

var version = '1.0.1'
navigator.serviceWorker.register('/sw.js')
    .then((reg) => {
        if(localStorage.getItem('sw_version') !== version) {
            reg.update()
                .then(() => {
                    localStorage.setItem('sw_version', version)
                })
        }
    })

自动更新

Service Worker 的特殊之处除了由浏览器触发更新之外,还应用了特殊的缓存策略: 如果该文件已 24 小时没有更新,当Update 触发时会强制更新。这意味着最坏情况下Service Worker会每天更新一次。

如何对html进行缓存

在上述例子中提到对service worker的更新,资源可以缓存到缓存数据库中。但是在上面的例子中存在一个问题就是我们虽然说对资源进行了缓存,但是需要第二次访问的时候页面展示才是更改后的内容。因为html不像其他静态资源有一个文件摘要,所以需要对html文件进行特殊处理。
因为第一次从缓存中中取到的仍然是老的资源,针对这个问题我提了一些想法: 就是对html格式文件进行特殊处理,如果是有网的情况就从服务端获取新的资源,html文件的缓存策略一般使用协商缓存,r如果没网就使用缓存的html内容,这样就能达到每次每次访问到的页面都是最新的,然后也达到了离线使用的效果。具体思路,就是在fetch中判断是不是有网并且是不是html格式的:

this.addEventListener('fetch', function (event) {
  event.respondWith(
      caches.match(event.request).then(function (response) {
            var isHtml = /\.html/.test(reponse ? response.url : '');
            var onLine = navigator.onLine;
            
              // 如果没网,就全部使用缓存内容
            if (!onLine) {
                return response;
            }
            
             // 如果有网并且不是html,而且response存在,就返回response
            if (!isHtml && response) {
                return response;
            }
            // ...
      })

这就能保证在有网的时候每次的html都是最新的。

消息推送

消息推送有着十分广阔的应用场景:

  • 新品上架,推送消息给用户,点击即进入商品详情页面
  • 用户很久没有进入站点了,推送消息告知这段时间站点的更新 使用推送消息通知,能够让我们的应用像Native App一样,提升用户体验

获取授权

在订阅消息前,需要获取用户的授权才能使用消息推送,具体代码如下:

navigator.serviceWorker.register('./sw.js')
    .then((reg) => {
        res.pushManager.getSubscription().then((subscription) => {
            // 如果用户没有订阅
            if (!subscription) {
                subscribeUser(reg)
            } else {
                console.log('you have subscribed our notification')
            }
        })
    })

如果已经订阅了,就不会再次弹出下面的弹窗,如果没有订阅就会调用subscribeUser

订阅推送服务

服务端作为消息来源,委托推送服务发送消息给订阅消息的浏览器,所以需要服务器需要保存浏览器的唯一标识。
这里使用web-push生成一个公钥和私钥,公钥给浏览器通过service worker生成唯一标识,交给服务端。服务端通过这个唯一标识,对浏览器进行推送消息。

function subscribeUser(registration) {
  const applicationServerPublicKey = 'BKzIIoV8RgBqSlOZ5GMle3OY6rZoB-aaoRxldWN8jn5MZOXbtH5tFTchxDRW1jTSLTCOdNPfyk4Yszx0Lk1Clts';
  const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey);
  // 订阅
  registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey,
    })
    .then((subscription) => {
      $.post({
        type: 'post',
        url: 'http://localhost:3000/add',
        data: {
          subscription: JSON.stringify(subscription)
        },
        success: (res) => {
          console.log(res)
        }
      })
    })
    .catch((err) => {
      console.log('Failed to subscribe the user: ', err)
    })
}

applicationServerPublicKey是刚才用web-push生成的公钥,然后调用pushManager.subscribe生成该浏览器的唯一标识,传给后端。这个subscription的内容长下面这种格式

{"endpoint":"https://fcm.googleapis.com/fcm/send/eAWELgsiTME:APA91bGZ4UwYtr26b0JE8K4sTNNFN8Z8GJ07QgDZHJP9aAqeMsjqiJJaaXd4Ype62vm5v4EjRnD0MuSD5ouBLYy6aT6nU5tWFpp5DjSjPmt_bh-h2Nm5pLo9-xY8H83Q8MHTynY7onKk","expirationTime":null,"keys":{"p256dh":"BLpOkRk1lLRXG8kMP3Yc4D6SUmz3aagln-ysP0lslwJsPA7SQhkmeytSFRCLZKBToBwMe3qRaUAMcJ0R3B1ZND4","auth":"pWaweBbyQqi5lNDR0Rqqew"}}

得到这个信息以后,以后服务器就可以通过这个标识向指定的浏览器进行消息推送

服务端实现推送

这里也需要借助web-push进行消息推送,由于消息推送需要借助谷歌的FCM服务,由于我们自身网络的网络就导致无法使用FCM服务。所以这里没法用谷歌浏览器,查阅了大量的资料后,发现firefox也能达到同样的效果,所以建议使用firefox进行实现,也不会存在墙的问题。

const webpush = require('web-push');

// push的数据
const payload = {
  title: '一篇新的文章',
  body: '点开看看吧',
  icon: 'xx', // 图片链接
  data: {
    url: 'www.baidu.com'
  }
}

const vapidKeys = {
  publicKey: 'BKzIIoV8RgBqSlOZ5GMle3OY6rZoB-aaoRxldWN8jn5MZOXbtH5tFTchxDRW1jTSLTCOdNPfyk4Yszx0Lk1Clts',
  privateKey: 'm5rk4Cann9l5pp7TiLPuNmL2Ho_zmIvgM3wz07EZSSs'
}

const pushSubScription = {
  "endpoint": "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABeaki7zwcdJ8r-2PZhwjyeCkHN3GaFAI4NQP8awz3e5svu0xDP6Peanq7iNTRd6S8weseu8JGpJDmLF1V2CcSZRExeWfLt0p5ksuNvCQmYnC4Bwy6wBzUGt-yQRAQMdq9_RKsEnadYfWAQt6LHENfaUr0gKcJJcj1Jb6vGfel-eqjEmjE",
  "keys": {
    "auth": "QyYLx2m29E-3a5kXzqdIDg",
    "p256dh": "BEX1qgwC7MIRw-Vck7wsQPw5M8CIhkQ6thqs5ZwmPkXYy1zF-7sXvKE9hxeZtlm1rHd5lpvpjJf3q26rJje8zUc"
  }
}
webpush
  .sendNotification(pushSubScription, JSON.stringify(payload), {
    vapidDetails: {
      subject: 'mailto:18223306087@163.com',
      ...vapidKeys,
    },
  })
  .then((res) => {
    console.log(res);
  })
  .catch((err) => {
    console.log(err)
  })

首先先要拿到先前客户端传给我们的subscription还有先前生成的publickKeyprivateKey,然后调用webpush.sendNotification进行主动的消息推送

service Worker监听push

服务端将信息推送到客户端后,我们需要对push事件进行监听,然后展示效果

self.addEventListener('push', function (event) {
    console.log('push');
    // var notificationData = event.data.json();
    // var title = notificationData.title;
    const title = 'push works';
    const options = {
        body: 'push is working',
        icon: 'resource/logo.png',
        badge: 'resource/logo.png'
    }
    event.waitUntil(self.registration.showNotification(title, options));
})
this.addEventListener('notificationclick', function(event) {
    event.notification.close();
    event.waitUntil(
        clients.openWindow('https://baidu.com')
    )
})

这里我们对push进行监听,然后拿到服务端主动推送的消息内容,这里为了简便就随便做一些内容

然后可以监听notificationclick事件,这个主要是点击上面的内容而产生的回调,例如上面例子中写道,如果点击这个通知,就会跳转到百度页面。
这样就看到效果,其实在很多国外的网站都使用了这项技术,但是国内本身因为墙的原因,导致这门技术无法被推广。

页面通信

service worker不能直接操作DOM,但是可以通过postMessage方法和Web页面进行通信,让页面操作DOM

使用postMessage发送请求

service worker发送数据: 在sw.js中向页面发消息,使用client.postMessage,实例代码如下:

this.clients.matchAll()
    .then(function (clients){
        if (clients && clients.length) {
            clients.forEach((client) => {
                // 发送数据
                client.postMessage('sw update')
            })
        }
    })

页面发送数据:在主页面中使用navigator.serviceWorker.controller.postMessage()进行数据发送

if (navigator.serviceWorker.controller) {
    navigator.serviceWorker.controller.postMessage('home update')
}

接收数据

service worker中接受主页面发来的信息,示例如下:

self.addEventListener('message', function (event) {
    console.log(event.data); // home update
});

在主页面中接受service worker发来的信息,示例如下:

navigator.serviceWorker.addEventListener('message', function (event) {
    console.log(event.data)
});

参考资料

PWA文档
understanding-service-worker-scope
谨慎处理 Service Worker 的更新
向网络应用添加推送通知