参考文档
这是和文章内容基本一致的一个demo,大家可以测试下访问后的service-worker的情况,还有离线的访问能力
demo地址
项目地址
简介
笔者使用service-worker在项目中的实践
解决的问题
- sw文件自身的缓存问题
- sw的更新的交互形式
- sw更新失败的兜底策略
生命周期
一个service worker在启动前经历了三步:
- 注册(Registration)
- 安装(Installation)
- 激活(Activation)
- 更新(updated)
配置
用到的依赖
- vue-cli3
- @vue/cli-plugin-pwa
- workbox
- sw-register-webpack-plugin
service-worker注册
let path = '/sw-test/sw.js'
let scope = '/sw-test/'
navigator.serviceWorker.register(path, { scope }).then(function(reg) {
// registration worked
console.log('Registration succeeded. Scope is ' + reg.scope);
}).catch(function(error) {
// registration failed
console.log('Registration failed with ' + error);
});
- path通常理解为路径,就是service-worker存放的位置,也可以理解为请求service-worker的url地址。可以通过后端请求将service-worker从其他目录展示到你需要的Url地址下。
- scope 理解为作用域,意义是在该作用域以及其下级目录下发起的fetch请求都受当前的service-worker控制,在作用域以外地址下发起的请求,sw是无法进行代理的。
- 在不填写scope的情况下,默认的scope就是path的父级目录,上图的path是/sw-test/sw.js,默认scope就是/sw-test/。
- 配置scope只能在默认作用域,也就是path的范围内再自定义,相当于只能缩小作用域,不能扩大作用域的范围。假如默认scope为/a/b/,可以通过传入{scope: '/a/b/c/'}来指定自己的scope,自定义为/d/e/就不行。
service-worker更新和缓存
- service-worker.js也会受http的缓存策略控制
- 如果新的worker未被成功下载,或者解析错误,或者在运行时出错,或者在安装阶段不成功,新的worker会被丢弃,旧的会被保留
- 一旦新的worker被成功安装,更新的worker会进入等待状态,新的worker会等待旧的worker下线才会激活,新的worker和旧的会并存
self.skipWaiting()
会强制跳过等待状态,直接让新的worker在安装后进入激活状态,这样可能会有缓存问题- 浏览器会 diff 当前打开页面的 service-worker.js,并判断是否更新,如果 diff 结果为更新,则重新安装最新的 service-wroker.js,并且全量更新缓存
- 任何静态资源包括 service-worker.js 都会被 HTTP 缓存
- 服务器对某个资源进行
no-cache
设置可以避免 HTTP 缓存
针对上述的情况,service-worker的更新就是必须解决的问题。
下面分两种方法
- 在服务器端配置service-worker的header,Cache-control: no-cache,使其不被缓存
- 前端进行service-worker的版本控制,每次注册都添加版本号进行改写
- 下面是一种简单粗暴的解决方法,缺点就是每次会重新请求service-worker
// sw-register.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js' + Date.now()).then(function (reg) {
})
}
- 利用
sw-register-webpack-plugin
插件,可以自动生成版本号,github地址
// npm install sw-register-webpack-plugin --save-dev
const SwRegisterWebpackPlugin = require('sw-register-webpack-plugin')
webpack({
// ...
plugins: [
new SwRegisterWebpackPlugin(/* options */);
]
// ...
});
- 折中的方法,用webpack.DefinePlugin插件,将版本号替
// webpack.config.jg
const webpack = require('webpack')
function getVersion () {
var d = new Date()
return '' + d.getFullYear() + d.getMonth() + 1 + d.getDate() + d.getHours() + d.getMinutes() + d.getSeconds()
}
webpack({
// ...
plugins: [
new webpack.DefinePlugin({
__SW_VERSION__: getVersion()
})
]
// ...
});
// sw-register.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js?version=' + __SW_VERSION__)
.then(function (reg) {
})
.catch(function (e) {
})
}
serivice-worker激活
1 skipWaiting跳过等待阶段
2 页面提示
3 添加加载动画,等待sw下载
由于浏览器的内部实现原理,当页面切换或者自身刷新时,浏览器是等到新的页面完成渲染之后再销毁旧的页面。这表示新旧两个页面中间有共同存在的交叉时间,因此简单的切换页面或者刷新是不能使得 service worker 进行更新的。
既然service-worker的激活无法通过刷新解决,那么还有个skipWaiting
可以用。
但是最好不要直接skipWaiting
(跳过等待阶段), 推荐的做法应该是在浏览器发现更新后,给用户弹出提示。然后用户点击重新加载时,一方面刷新页面 (location.reload()
),一方面让新的 SW 接管页面 (skipWaiting
)。
具体的流程:
- 在注册service-worker时就监听sw的更新状况
- 如果有更新,并且安装完成后,就发送自定义事件sw.update
- 自定义事件被触发,显示更新按钮
- 用户点击更新按钮触发更新
function emitUpdate () {
var event = document.createEvent('Event')
event.initEvent('sw.update', true, true)
window.dispatchEvent(event)
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/zhangyi/sw')
.then(function (reg) {
if (reg.waiting) {
emitUpdate()
return
}
reg.onupdatefound = function () {
var installingWorker = reg.installing
installingWorker.onstatechange = function () {
switch (installingWorker.state) {
case 'installed':
if (navigator.serviceWorker.controller) {
// 自定义的更新事件
emitUpdate()
}
break
}
}
}
})
.catch(function (e) {
console.error('Error during service worker registration:', e)
})
}
let refreshing = false
export default {
name: 'SWUpdatePopup',
data () {
return {
showSwUpdate: false
}
},
mounted () {
this.addListener()
},
methods: {
addListener () {
window.addEventListener('sw.update', this.handleUpdate)
this.$once('hook:beforeDestroy', function () {
window.removeEventListener('sw.update', this.handleUpdate)
})
},
handleUpdate () {
this.showSwUpdate = true
},
handleSkipWaiting () {
navigator.serviceWorker.getRegistration()
.then(reg => this.skipWaiting(reg))
.then(() => {
window.location.reload(true)
})
},
handleSWChange () {
if (refreshing) {
return
}
refreshing = true
window.location.reload()
},
skipWaiting (registration) {
const worker = registration.waiting
if (!worker) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data.error) {
reject(event.data.error)
} else {
resolve(event.data)
}
}
worker.postMessage({ type: 'skip-waiting' }, [channel.port2])
})
},
handleRefresh () {
window.location.reload(true)
}
}
}
配置主要基于 vue-cli 的 pwa 插件和 workbox-webpack-plugin
workbox-webpack-plugin主要提供两种模式:
**GenerateSW **模式根据配置生成sw文件,适用场景:
- 简单的运行时配置需求
- 不涉及Web Push
**InjectManifest **模式通过既有sw文件再加工,适用场景;
- 涉及Web Push
- 更复杂的自定义配置
这里使用的GenerateSW模式
// vue.config.js
const { InjectManifest } = require('workbox-webpack-plugin')
module.exports = {
configureWebpack: config => {
config.plugins.push(
new InjectManifest({
swSrc: './src/service-worker.js',
importsDirectory: 'js',
importWorkboxFrom: 'disabled', // 不使用谷歌workerbox的cdn
exclude: [/\.map$/, /^manifest.*\.js$/, /\.html$/]
})
)
}
}
serivice-worker卸载
当service-worker新版本的更新出现问题,那么就要考虑如何保证用户看到的版本是最新的
我选择的策略是卸载当前的sw,用线上的文件,并且不再安装当前错误版本的。
// sw-register.js
const version = Number(__SW_VERSION__)
const project = __PROJECT_NAME__
function emitUpdate () {
var event = document.createEvent('Event')
event.initEvent('sw.update', true, true)
window.dispatchEvent(event)
}
function emitUnregister () {
var event = document.createEvent('Event')
event.initEvent('sw.unregister', true, true)
window.dispatchEvent(event)
}
function unregister () {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistration()
.then(function (registration) {
if (registration) {
registration.unregister().then(function () {
emitUnregister()
})
}
})
}
}
const failSwName = 'fail:' + project + '-sw-version'
function getFailVersion () {
const version = window.localStorage.getItem(failSwName)
if (version) {
return Number(version)
}
return ''
}
function setFailVersion () {
window.localStorage.setItem(failSwName, version)
}
if (getFailVersion() !== version && 'serviceWorker' in navigator) {
// 如果是新的版本,那就尝试注册安装
navigator.serviceWorker.register(`/${project}/service-worker.js?version=${version}`) // eslint-disable-line
.then(function (reg) {
if (reg.waiting) {
emitUpdate()
return
}
reg.onupdatefound = function () {
var installingWorker = reg.installing
installingWorker.onstatechange = function () {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
emitUpdate()
}
}
}
}
})
.catch(function (e) {
console.error('Error during service worker registration:', e)
// 注册失败后,在session中写入失败的版本,并直接卸载
setFailVersion()
unregister()
})
} else {
// 直接卸载
unregister()
}
// service-worker.js
//...
self.addEventListener('message', event => {
const replyPort = event.ports[0]
const message = event.data
if (replyPort && message && message.type === 'skip-waiting') {
event.waitUntil(
self.skipWaiting()
.then(() => replyPort.postMessage({ error: null }))
.catch(error => replyPort.postMessage({ error }))
)
}
})
//...
更新的弹窗
<template>
<div>
<div
class="sw-update-dialog"
v-if="showSwUpdate"
>
<button @click="handleSkipWaiting">
更新
</button>
</div>
<div
class="sw-update-dialog"
v-if="showSwUnregister"
>
<button @click="handleRefresh">
更新
</button>
</div>
</div>
</template>
<script>
let refreshing = false
export default {
name: 'SWUpdatePopup',
data () {
return {
showSwUpdate: false,
showSwUnregister: false
}
},
mounted () {
this.addListener()
},
methods: {
addListener () {
window.addEventListener('sw.update', this.handleUpdate)
window.addEventListener('sw.unregister', this.handleUnregister)
this.$once('hook:beforeDestroy', function () {
window.removeEventListener('sw.update', this.handleUpdate)
window.removeEventListener('sw.unregister', this.handleUnregister)
})
},
handleUpdate () {
this.showSwUpdate = true
},
handleSkipWaiting () {
navigator.serviceWorker.getRegistration()
.then(reg => this.skipWaiting(reg))
.then(() => {
window.location.reload(true)
})
},
handleSWChange () {
if (refreshing) {
return
}
refreshing = true
window.location.reload()
},
skipWaiting (registration) {
const worker = registration.waiting
if (!worker) {
return Promise.resolve()
}
// 这里是参考vue-press的写法
// 利用MessageChannel返回一个promise
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data.error) {
reject(event.data.error)
} else {
resolve(event.data)
}
}
worker.postMessage({ type: 'skip-waiting' }, [channel.port2])
})
},
handleUnregister () {
this.showSwUnregister = true
},
handleRefresh () {
window.location.reload(true)
}
}
}
</script>
项目示例
这是和文章内容基本一致的一个demo,大家可以测试下访问后的service-worker的情况,还有离线的访问能力。
demo地址
项目地址
由于笔者水平有限,文中难免有所错误,希望读者朋友不吝赐教,欢迎斧正。 有更好的解决方案可在评论中说明或直接在项目issue中沟通。