编者按:本文作者王若铮,奇舞团前端开发工程师。
本文是奇舞团泛前端分享会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所在目录或者是子目录。
代码语言:javascript复制
navigator.serviceWorker.register('serviceworker.js', { scope: '/' })
如何使用
下面我们根据一个简单的示例,看一下Service Worker是如何运行的。
在浏览器环境下,我们可以通过navigator.serviceWorker.register注册一个Service Worker。register方法的第一个参数是Service Worker的js文件的地址,第二个参数是规定了Service Worker的作用域。
代码语言:javascript复制
window.onload = function() { if ('serviceWorker' in navigator) { navigator.serviceWorker.register('./serviceworker.js', { scope: '/' }) } }
注册之后,Service Worker可以独立于浏览器在后台运行,来控制我们的页面。如果我们的页面在多个窗口中打开,Service Worker不会重复创建,在不同窗口中的页面,均由一个Service Worker统一管理。
下面我们创建一下serviceworker.js文件。
在这里,监听了两个事件。在install事件中,我们将一个离线页面缓存进来。在fetch事件中,如果资源请求失败的话,使用刚才缓存的离线页面。这样,我们的web应用就会在离线状态下,加载这个离线页面了。
代码语言:javascript复制
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和在其他域下的接口数据。
缓存模式
缓存模式主要探讨了一个关于缓存利用率和更新的权衡问题。如果缓存利用率高了的话,代码更新速度必然受到影响。
我们先来看一下第一种,缓存优先,在没有缓存的情况下请求网络资源。这是一种高效、省流量的方法。但是资源的更新可能会收到影响。这种模式通常适用于不会更新的静态资源,比如图片和代码库。
代码语言:javascript复制
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 }) }) }) )})
第二种模式是,缓存优先,频繁更换资源。这是一种高效的方案。并且在第二次加载的时候显示可用的最新版本。带宽消耗和使用缓存一样。
代码语言:javascript复制
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请求失败的情况下,我们可以返回一个断网页面。在图片请求失败的情况下,我们可以提供一个默认图片
代码语言:javascript复制
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发生变化的时候,就重载页面,完成页面的及时更新。
代码语言:javascript复制
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的插件帮助我们完成替换。
代码语言:javascript复制
// serviceworker.jsconst VERSION = self.__VERSION__const cacheKey = 'cache-' VERSIONconst CACHE_LIST = self.__WEBPACK_INJECT_CACHE_LIST__
下面我们再来看一下webpack插件的配置,ServiceWorkerPlugin是我们的自定义插件。
代码语言:javascript复制
// 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跳过等待。
代码语言:javascript复制
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立刻掌控页面,实现页面的及时更新。
代码语言:javascript复制
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. 使用未过期的缓存
代码语言:javascript复制
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。
代码语言:javascript复制
// 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分钟。
代码语言:javascript复制
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() }) ) }})
下面,我们可以写一个简单服务器,用来尝试这个例子。
代码语言:javascript复制
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的同步事件采取什么样的代码逻辑,第二个部分就是参数。这两个部分使用"_"进行分割。
代码语言:javascript复制
// 浏览器环境navigator.serviceWorker.ready.then((registration) => { document.getElementById('submit').addEventListener('click', () => { const content = document.getElementById('content').value registration.sync.register(`send-messages_${content}`) })})
代码语言:javascript复制
// 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个步骤。
- 打开数据库
- 启动事务
- 打开对象存储
- 在对象存储中完成操作
通过代码的形式来展示一下如何操作indexedDB。
代码语言:javascript复制
// 定义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方法完成对数据参数的存储
代码语言:javascript复制
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全部发送。成功之后清除数据。
代码语言:javascript复制
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的优化能力不仅仅是这些,相信它还有更加强大的作用等着我们一起来挖掘!
文内链接
- https://github.com/wrz199306/ServiceWorkerDemo