Service Worker初探

时间:2022-07-25
本文章向大家介绍Service Worker初探,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

编者按:本文作者王若铮,奇舞团前端开发工程师。

本文是奇舞团泛前端分享会Service Worker初探的一次记录,是对360扫地机器人App内嵌web页面使用Service Worker优化的一次总结。

本文所有代码示例均已提交到github,地址1

Service Worker是什么

Service Worker是渐进式web应用(pwa)的核心技术。

通过注册之后,可以独立于浏览器在后台运行,控制我们的一个或者多个页面。如果我们的页面在多个窗口中打开,Service Worker不会重复创建。

就算浏览器关闭之后,Service worker也同样运行。但是浏览器是不会允许Service Worker一直处于工作状态。因为随着用户打开越来越多的注册了Service Worker的页面,性能肯定会收到影响。在后面的生命周期中,我们会一起探讨Service Worker的运行原理。

Service Worker是客户端和服务端的代理层,客户端向服务器发送的请求,都可以被Service Worker拦截,并且可以修改请求,返回响应。

同时也会在用户离线的时候正常工作,当浏览器发送请求,Service Worker检测到离线状态的时候,可以直接返回缓存数据和提前准备好的离线页面。

进一步来讲,用户关闭了所有的页面,Service Worker同样可以和服务器通信。完成尚未完成的数据请求,可以确保用户的任何操作都可以发送到服务器。

Service Worker的优势

1. 支持离线访问

传统的web页面,在每次访问的时候,都会去请求服务器的资源。在使用Service Worker之后,第一次访问的时候,可以将我们的静态资源缓存下来,下次访问的时候可以通过Service Worker返回缓存,就可以支持离线访问了。

2. 加载速度快

页面资源缓存之后,不需要依赖网络加载服务器资源。无论用户是否具有良好的的网络状态,甚至在离线的情况下,都可以瞬间加载我们的web页面。

3. 离线状态下的可用性

在不追求返回结果的数据请求中,可以使用Service Worker进行代理。当客户端从离线转为在线的时候,就算已经关闭了页面。Service Worker也能够帮助我们继续发送代理的请求。无论,用户是在线、离线还是网络不稳定的时候,借助Service Worker都能够提供一个相对完整的用户体验。

安全策略

由于serviceworker功能强大,可以修改任何通过它的请求,因此需要对其进行一定的安全限制。

1. 使用https或者localhost本地域名的页面才可以使用Service Worker

正常情况下,只有使用https的页面才能够注册Service Worker。为了方便我们的开发和调试,在开发的过程中,可以使用localhost来使用Service Worker。一旦把应用部署到服务器之后,必须使用https保证Service Worker的正常工作。

2. Service Worker的作用域

每个Service Worker都有一个有限的控制范围。这个范围就是通过放置Service Worker的js文件的目录决定的,也就是Service Worker所在目录以及所有的子目录。

也可以通过注册Service Worker的时候传入一个scope选项,用来覆盖默认的作用域。但是,只能将作用域的范围缩小,不能将它扩大。换句话来说,scope的值,必须是Service Worker所在目录或者是子目录。

navigator.serviceWorker.register('serviceworker.js', { scope: '/' })

如何使用

下面我们根据一个简单的示例,看一下Service Worker是如何运行的。

在浏览器环境下,我们可以通过navigator.serviceWorker.register注册一个Service Worker。register方法的第一个参数是Service Worker的js文件的地址,第二个参数是规定了Service Worker的作用域。

window.onload = function() {    if ('serviceWorker' in navigator) {      navigator.serviceWorker.register('./serviceworker.js', { scope: '/' })    }  }

注册之后,Service Worker可以独立于浏览器在后台运行,来控制我们的页面。如果我们的页面在多个窗口中打开,Service Worker不会重复创建,在不同窗口中的页面,均由一个Service Worker统一管理。

下面我们创建一下serviceworker.js文件。

在这里,监听了两个事件。在install事件中,我们将一个离线页面缓存进来。在fetch事件中,如果资源请求失败的话,使用刚才缓存的离线页面。这样,我们的web应用就会在离线状态下,加载这个离线页面了。

self.addEventListener('install', function(event) {  event.waitUntil(    caches.open('cache').then((cache) => {      return cache.add('./offline.html')    })  )})
self.addEventListener('fetch', function(event) {  event.respondWith(    fetch(event.request).catch(() => {      return caches.match('./offline.html')    })  )})

请注意,我们刚刚提到过Service Worker的安全策略只允许我们在Https或者localhost下注册它,所以我们一定要开启一个本地服务器来运行我们的代码示例。

下面,我们对于刚才的例子做一个小小的改动。我们新建一个new_offline.html文件,将serviceworker.js中的offline.html替换为new_offline.html。如果你刚才已经运行过上一版的代码,你就会发现,页面并没有发生改变,在离线状态下,页面依然是旧版的offline.html。

当我们关闭所有运行代码的标签页之后再次打开,我们就会惊奇的发现,页面更新了。想要搞明白这些问题,我们必须要了解Service Worker的生命周期。

生命周期

在注册Service Worker之后,Service Worker会马上进去installing的生命周期进行安装,同时会进入Service Worker的install事件中。如果在installing中,有任何资源加载失败,都会导致安装失败,Service Worker会直接进入废弃状态。

在安装成功之后,在正常情况下,会进入Activated状态,同时会进入Service Worker的activate事件中。当activate中的代码执行完成后,Service Worker会进入Idle的状态。

只有在这个状态下,fetch、sync、message的一系列事件事件才能够正常监听。所以,有的时候我们发现,在页面第一次加载,fetch中的逻辑并没有生效,那是因为Service Worker在注册完成之前,我们的数据请求早已经加载完成了。

同时,在这个状态下。Service Worker是否工作也和这些事件绑定在一起。当某个Service Worker中的这些事件被触发,Service Worker将被唤醒,处理事件,然后终止。这样,就会防止当浏览器加载越来越多的Service Worker的页面导致浏览器卡顿的问题。

回到安装的时候,如果当前的页面已经存在了一个激活的Service Worker的时候,在新的Service Worker安装完成,会进入Waiting状态。如果页面所有的标签页全部关闭之后,或者导航到一个不在控制范围内的页面。再次打开新的Service Worker才会生效。

CacheStorage API

在Service Worker中,我们通常使用CacheStorage来管理缓存。

CacheStorage是一种全新的缓存层,让我们对缓存具有完全的控制权。和Cookie一样,都是具有同源策略的。

CacheStorage为我们提供了一系列的api来操作缓存。这些api都是基于Promise的,所有方法的返回值都是一个Promise。

caches.open(cacheName) => Primose<cache>

CacheStorage是可以分组的,可以通过这个方法传入cacheName来打开一个分组。如果没有这个分组,那就会创建。最终返回当前的cache,一般情况下,基于这个cache来操作缓存。

caches.keys() => Primose<cacheName[]>

这个方法可以获取所有的缓存名称的列表。

cache.addAll(url[])

通过open方法拿到目标cache,之后可以调用addAll,传入一个url列表之后,会将这些url全部缓存下来。

cache.put(url)

如果我们要添加单个缓存可以使用cache.put方法

cache.add(key, value)

在缓存一个请求数据的时候,我们希望将缓存和当前的请求想匹配的话。不单单是匹配url,还要匹配请求参数以及是POST还是GET甚至是匹配请求头的时候,可以使用cache.put方法,第一个参数是key,这里的key可以是一个Request对象,当我们去查询缓存的时候,只有当key完全相等的时候才能够匹配。第二个参数value,必须是一个Response的结构。

cache.delete(key)

已经不需要的缓存可以通过cache.delete方法进行删除。

cache.match(url | Requst) | caches.match(url | Requst)

在查询相关的缓存的时候,通过match方法,传入url或者Request。究竟传入什么参数,取决于如何添加的缓存。如果在具体的cache上调用这个方法,就是在当前缓存下去查找,如果在window.caches下调用,就是在全局缓存中匹配。

CacheStorage和http缓存的关系

在发送http请求的时候,请求会先到达Service Worker。在Service Worker中,使用CacheStorage来查询是否具有可用的缓存。

如果没有,浏览器先会检测Cache-Control是否使用当前的浏览器缓存,这就是我们常说的强缓存。

如果浏览器缓存已过期,请求正式到达服务器。再去判断资源的ETag和Last-Modified有没有发生变化,决定是否使用服务器缓存。

CacheStorage不能取代过去的HTTP缓存。CacheStorage因为Service Worker的作用域问题,只能控制范围内的缓存,无法控制cdn和在其他域下的接口数据。

缓存模式

缓存模式主要探讨了一个关于缓存利用率和更新的权衡问题。如果缓存利用率高了的话,代码更新速度必然受到影响。

我们先来看一下第一种,缓存优先,在没有缓存的情况下请求网络资源。这是一种高效、省流量的方法。但是资源的更新可能会收到影响。这种模式通常适用于不会更新的静态资源,比如图片和代码库。

self.addEventListener('fetch', (event) => {  event.respondWith(    caches.open('cache-name').then((cache) => {      return cache.match(event.request).then((cacheResponse) => {        return cacheResponse || fetch(event.request).then((networkResponse) => {          cache.put(event.request, networkResponse.clone())          return networkResponse        })      })    })  )})

第二种模式是,缓存优先,频繁更换资源。这是一种高效的方案。并且在第二次加载的时候显示可用的最新版本。带宽消耗和使用缓存一样。

self.addEventListener('fetch', (event) => {  event.respondWith(    caches.open('cache-name').then((cache) => {      return caches.match(event.request).then((cacheResponse) => {        const fetchPromise = fetch(event.request).then((networkResponnse) => {          cache.put(event.request, networkResponnse)          return networkResponnse        })        return cacheResponse || fetchPromise      })    })  )})

第三种模式是,网络优先,失败的时候使用缓存。加载时间较慢,总是展示最新的文件。在请求失败的情况下,使用的缓存也不一定是正在请求资源的缓存,同样也可以是其他的缺省资源。就像第一个代码示例一样,在html请求失败的情况下,我们可以返回一个断网页面。在图片请求失败的情况下,我们可以提供一个默认图片

self.addEventListener('fetch', (event) => {  event.respondWith(    caches.open('cache-name').then((cache) => {      return fetch(event.request).then((networkResponse) => {        cache.put(event.request, networkResponse.clone())        return networkResponse      }).cache(() => {        return cache.match(event.request)      })    })  )})

基于版本控制的缓存模式。

在版本控制的缓存模式下,可以既提高缓存效率,又能解决版本更新不及时的问题。我们通过一个示例来阐述这种模式。

首先,还是要在浏览器环境下注册Service Worker。和以往有所不同的是我们监听了controllerchange事件,当Service Worker发生变化的时候,就重载页面,完成页面的及时更新。

if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {  window.addEventListener('load', function () {    if (!navigator.serviceWorker.controller) {      try {        navigator.serviceWorker.register('serviceworker.js')      } catch (err) {        throw Error(err)      }    }
    navigator.serviceWorker.addEventListener('controllerchange', () => {      window.location.reload()    })  })}

对于Service Worker,我们将对没有过期的资源永远使用缓存,对于过期的资源,加载网络资源并更新缓存。缓存是否过期的判断依据使用,那就是版本号。下面,我们通过四个步骤借助webpack来完成这件事情。借助webpack的目的是,更加方便的获取静态资源列表,已经通过package.json的version字段来设置我们的版本号。

1. 定义资源版本号

首先我们要在serviceworker.js中定义一些变量。cacheKey就是一个特定字符串和VERSION拼接的字符串,作为缓存名称来使用。VERSION、CACHE_LIST就需要借助webpack的插件帮助我们完成替换。

// serviceworker.jsconst VERSION = self.__VERSION__const cacheKey = 'cache-' + VERSIONconst CACHE_LIST = self.__WEBPACK_INJECT_CACHE_LIST__

下面我们再来看一下webpack插件的配置,ServiceWorkerPlugin是我们的自定义插件。

// webpack.config.jsconst fs = require('fs')const path = require('path')
class ServiceWorkerPlugin {  apply (compiler) {    compiler.hooks.emit.tap('ServiceWorkerPlugin', async (compilation) => {      const packageJson = fs.readFileSync(path.resolve(__dirname, './package.json'))      const version = JSON.parse(packageJson).version      const assetKeys = Object.keys(compilation.assets)
      let source = compilation.assets['serviceworker.js'].source().toString()      source = source.replace('self.__WEBPACK_INJECT_CACHE_LIST__', JSON.stringify(assetKeys))      source = source.replace('self.__VERSION__', JSON.stringify(version))
      compilation.assets['serviceworker.js'] = {        source: () => source,        size: () => source.length      }    })  }}

module.exports = {  ...  plugins: [      new ServiceWorkerPlugin()  ]}

在ServiceWorkerPlugin插件中,我们通过webpack的compilation.assets拿到所有的静态资源,通过package.json获取版本号,替换到我们的serviceworker.js文件中。

2. 根据版本号缓存所有静态资源

我们需要在Service Worker的安装事件中,缓存所有的静态资源。self.skipWaiting方法让当前新版本的Service Worker跳过等待。

self.addEventListener('install', function (event) {  event.waitUntil(    caches.open(cacheKey)      .then((_cache) => _cache.addAll(CACHE_LIST))      .then(self.skipWaiting())  )})

3. 删除过期资源,self.clients.claim方法可以让当前的Service Worker立刻掌控页面,实现页面的及时更新。

self.addEventListener('activate', function (event) {  event.waitUntil(    caches.keys().then((keys) => (      Promise.all(        keys.filter((key) => key !== cacheKey)          .map((key) => caches.delete(key))      )    )).then(() => {      self.clients.claim()    })  )})

4. 使用未过期的缓存

self.addEventListener('fetch', function (event) {  if (CACHE_LIST.find((cache) => {    return event.request.url.endsWith(cache)  })) {    event.respondWith(      caches.match((event.request)).then((cachedResponse) => (        cachedResponse || fetch(event.request)      ))    )  }})

使用后台同步保证离线功能

客户端和web在用户的角度看来,有一个很大的区别是,在客户端执行了一些操作,比如发布文章。就算在断网状态下,用户也不会担心自己编辑的内容丢失。如果在一般的web页面,所有的数据只会跟随浏览器的关闭而消失。

在Service Worker的支持下,我们可以页面上注册一个同步事件发送到Service Worker。在Service Worker中完胜数据请求。

这样,就不需要担心用户数据丢失的问题了。即使用户在断网的状态下发送的数据请求,当设备重新联网的时候,Service Worker会自动帮助我们完成发送。

下面我们就来看一下,如何使用具体代码来实现这个功能。

需要注意的是,我们需要在Service Worker的ready事件中去绑定按钮的点击事件,来确保用户点击的时候,Service Worker已经准备好了。

然后我们通过registration.sync.register('send-messages')来发送给同步事件。send-messages只是当前事件的一个标识。在Service Worker中可以使用它来判断应该处理什么样的逻辑。

事件标识是唯一的,如果Service Worker正在处理或者还没有处理完成一个标识的时候,使用这个已有的标识再次注册sync事件,那么这个事件将会被忽略。如果我们不想让新的操作被忽略,可以在事件后边加上递增ID,例如send-messages1。

// html<button id="submit">发送请求</button>
// jswindow.onload = function() {  navigator.serviceWorker.register('./serviceworker.js')
  navigator.serviceWorker.ready.then((registration) => {    document.getElementById('submit').addEventListener('click', () => {      registration.sync.register('send-messages')    })  })}

在Service Worker中,注册了一个同步事件,通过event.tag拿到我们刚才发送的标识。来处理发送信息的操作。

如果发送信息失败,这个同步事件过一段时间将会再次尝试发送。当event.lastChance属性为true时,将会放弃尝试。在chrome浏览器中测试,一共会发送三次,第一次到第二次的间隔为5分钟,第二次到第三次的间隔为10分钟。

function sendMessages() {  return fetch('http://localhost:3000/').then((response) => {    return response.json()  }).then((data) => {    console.log(data.errCode === 0)    return data.errCode === 0 ? Promise.resolve() : Promise.reject()  })}
self.addEventListener('sync', (event) => {  if (event.tag === 'send-messages') {    event.waitUntil(      sendMessages().catch(() => {        if (event.lastChance) {          console.log('不会再次尝试请求了')        }        return Promise.reject()      })    )  }})

下面,我们可以写一个简单服务器,用来尝试这个例子。

const express = require('express')const app = express()const port = 3000
app.use((req, res, next) => {  res.header('Access-Control-Allow-Origin', '*');  res.header('Access-Control-Allow-Methods', '*');  next();});
app.get('/', (req, res) => {  const response = { errCode: 0 };  const date = new Date();  const hour = date.getHours();  const minutes = date.getMinutes();  const second = date.getSeconds();  const time = `${hour}:${minutes}:${second}`;  console.log('请求成功!参数:', req.query, '返回值:', response, '时间:', time)  res.send(response)})
app.listen(port, () => console.log(`Example app listening on port ${port}!`))

在断网的时候,点击按钮,服务器不会收到请求。当设备恢复网络的时候,服务器会马上收到请求。我们可以将返回值的errCode修改为1,尝试下Service Worker是否会发送多次请求。

sync事件的数据传递

上面的例子中,展示了如何使用Service Worker来代理数据请求。但是大部分的数据请求都是需要参数的,那么如何将参数传递到Service Worker呢。

1. 使用标识传递参数

对于一些简单参数而言,可以直接使用标示来传递。这样的话,事件标示就有两个组成部分,第一个部分是标识类型,规定了Service Worker的同步事件采取什么样的代码逻辑,第二个部分就是参数。这两个部分使用"_"进行分割。

// 浏览器环境navigator.serviceWorker.ready.then((registration) => {  document.getElementById('submit').addEventListener('click', () => {    const content = document.getElementById('content').value    registration.sync.register(`send-messages_${content}`)  })})
// Service Workerfunction sendMessages(content) {  return fetch(`http://localhost:3000/?content=${content}`).then((response) => {    return response.json()  }).then((data) => {    console.log(data.errCode === 0)    return data.errCode === 0 ? Promise.resolve() : Promise.reject()  })}
self.addEventListener('sync', (event) => {  if (event.tag.startsWith('send-messages')) {    const content = event.tag.split('_')[1]      event.waitUntil(      sendMessages(content).catch(() => {        if (event.lastChance) {          console.log('不会再次尝试请求了')        }        return Promise.reject()      })    )  }})

2. 使用indexedDB传递参数

Service Worker环境中,除了CacheStorage外,也可以使用基于浏览器的本地数据库indexedDB。

indexedDB是一个基于浏览器的本地数据库,操作indexedDB基本可以分为4个步骤。

  1. 打开数据库
  2. 启动事务
  3. 打开对象存储
  4. 在对象存储中完成操作

通过代码的形式来展示一下如何操作indexedDB。

// 定义global对象 因为indexedDB的代码需要在浏览器和Service Worker两个环境下运行const _global = typeof window === 'undefined' ? self : window
// 打开数据库// 如果indexedDB已经存在,window.indexedDB.open方法不会重新创建,只会打开那个已经创建好的数据库。window.indexedDB.open方法的第二个个参数是数据库版本号。// onupgradeneeded只会在数据库版本升级的时候执行,用来创建对象存储。const openDataBase = function () {  return new Promise((resolve, reject) => {    const request = _global.indexedDB.open('conent-db', 1)    request.onupgradeneeded = (event) => {      const db = event.target.result      if (!db.objectStoreNames.contains('list')) {        db.createObjectStore('list', {          keyPath: 'id',          autoIncrement: true        })      }    }    request.onerror = (err) => reject(err)    request.onsuccess = (event) => resolve(event.target.result)  })}
// 启动事务const openObjectStore = async function (storeName, mode) {  const db = await openDataBase()  return db.transaction(storeName, mode).objectStore(storeName)}
_global.db = {  set: async function (content) {    // 打开数据存储    const objectStore = await openObjectStore('list', 'readwrite')    // 新增数据    return objectStore.add({ content })  },
  getAll: async function () {    // 打开数据存储    const objectStore = await openObjectStore('list')    return new Promise((resolve) => {      const data = []      // 根据游标查询数据      // 我们在创建数据库的时候使用autoIncrement设置自增主键,所以需要通过游标查询所有的数据      objectStore.openCursor().onsuccess = function (event) {        const cursor = event.target.result        if (!cursor) {          return resolve(data)        } else {          data.push(cursor.value)          cursor.continue()        }      }    })  },
  clear: async function (ids) {    // 打开数据存储    const objectStore = await openObjectStore('list', 'readwrite')    // 清空对象    return objectStore.clear()  }}

在浏览器环境下,调用刚才封装的indexedDB的set方法完成对数据参数的存储

document.getElementById('submit').addEventListener('click', async () => {    const content = document.getElementById('content').value    await db.set(content)    registration.sync.register(`send-messages`)  })

在Service Worker中,获取到所有的content,通过Promise.all全部发送。成功之后清除数据。

self.addEventListener('sync', (event) => {  if (event.tag === 'send-messages') {    event.waitUntil(      self.db.getAll().then((contents) => {        return Promise.all(          contents.map(({ content }) => {            return sendMessages(content)          })        )      }).then(() => {        return self.db.clear()      })    )  }})

这样我们就完成了使用indexedDB传递参数了。

总结

本文介绍了Service Worker的基本概念和特性,并且从缓存和后台发送请求两个方面阐述了如何优化我们的项目。

其实Service Worker的优化能力不仅仅是这些,相信它还有更加强大的作用等着我们一起来挖掘!

文内链接

  1. https://github.com/wrz199306/ServiceWorkerDemo