service-worker的踩坑实践

6,310 阅读6分钟

参考文档

这是和文章内容基本一致的一个demo,大家可以测试下访问后的service-worker的情况,还有离线的访问能力
demo地址
项目地址

简介

笔者使用service-worker在项目中的实践

解决的问题

  • sw文件自身的缓存问题
  • sw的更新的交互形式
  • sw更新失败的兜底策略

生命周期

一个service worker在启动前经历了三步:

  • 注册(Registration)
  • 安装(Installation)
  • 激活(Activation)
  • 更新(updated)

配置

用到的依赖

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);
  });
  1. path通常理解为路径,就是service-worker存放的位置,也可以理解为请求service-worker的url地址。可以通过后端请求将service-worker从其他目录展示到你需要的Url地址下。
  2. scope 理解为作用域,意义是在该作用域以及其下级目录下发起的fetch请求都受当前的service-worker控制,在作用域以外地址下发起的请求,sw是无法进行代理的。
  3. 在不填写scope的情况下,默认的scope就是path的父级目录,上图的path是/sw-test/sw.js,默认scope就是/sw-test/。
  4. 配置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的更新就是必须解决的问题。
下面分两种方法

  1. 在服务器端配置service-worker的header,Cache-control: no-cache,使其不被缓存
  2. 前端进行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中沟通。