PWA 初探

322 阅读3分钟

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第 1 篇文章,点击查看活动详情”

本文只涉及 ServiceWorker 和 Manifest 的讲解,其他部分请参考官方文档。

应用场景

首先我们需要明白 PWA 主要作为离线应用来缓存资源,所以对于数据更新不频繁的场景是非常适用的,可以加速用户访问,提高用户体验,比如文档网站:

  • 个人博客网站
  • 官网

其他网站也可以用作缓存一些静态资源,加速网站访问等等。

ServiceWorker

ServiceWorker 概述

ServiceWorker 是 PWA 的基石,它本身相当于一个浏览器和服务器之间的代理服务器,但是本质上它也是一个Worker,所以它不能访问 DOM 等等。

不同的状态的 ServiceWorker

  • waiting
    等待被安装
  • installing
    安装中
  • active
    激活状态

ServiceWorker 的状态

  • installing
    安装中
  • installed
    已安装
  • activating
    激活中
  • activated
    已激活
  • redundant
    已销毁

ServiceWorker 的事件

  • install
    安装回调
  • activate
    激活回调
  • fetch
    获取回调
  • sync
    后台同步回调
  • push
    服务器推送回调

ServiceWorker 使用

在外部,我们进行ServiceWorker 的注册,卸载以及版本更新逻辑。__SW_ENABLE__ 是通过 DefinePlugin 插件定义的一个变量,控制ServiceWorker 的开启和关闭

// registerSW.js
const __sw_enable__ = window.__SW_ENABLE__
function emitUpdate() {
  const event = document.createEvent('Event')
  event.initEvent('sw:update', true, true)
  document.dispatchEvent(event)
}
window.addEventListener('load', () => {
  if ('serviceWorker' in navigator) {
    if (!__sw_enable__) {
      navigator.serviceWorker.register(`/sw.js`, {
        scope: '/'
      }).then((res) => {
        if (res.waiting) {
          emitUpdate()
          return
        }
        res.onupdatefound = function() {
          const installingWorker = res.installing
          installingWorker.onstatechange = function() {
            switch (installingWorker.state) {
              case 'installed':
                if (navigator.serviceWorker.controller) {
                  emitUpdate()
                }
                break
            }
          }
        }
      }).catch((error) => {
        console.error(`sw register error, it caused by error: ${error}`)
      })
    } else {
      navigator.serviceWorker.getRegistrations().then(function(regs) {
        for (const reg of regs) {
          reg.unregister()
        }
      })
    }
    let reload = true
    navigator.serviceWorker.addEventListener('controllerchange', () => {
      if (navigator.serviceWorker.controller) {
        if (reload) {
          window.location.reload()
          reload = false
        }
      }
    })
  }
})

// main.js
document.addEventListener('sw:update', () => {
  if (window.confirm(`是否更新 ${window.__VERSION__}?`)) {
    try {
      navigator.serviceWorker.getRegistration().then(reg => {
        reg?.waiting?.postMessage?.({ type: 'SKIP_WAITING' })
      })
    } catch (e) {
      window.location.reload()
    }
  }
})

编写 ServiceWorker 内部脚本我们有两种方式:

  • 原生
  • Workbox

如果原生自己写的话,我们需要写很多代码来控制不同资源的更新策略,相较比较麻烦;所以一般推荐使用 Workbox 来控制。

原生使用

const version = 'v1'
const cacheTargetList = []
// install
this.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(version).then((cache) => {
      return cache.addAll(
        cacheTargetList
      )
    })
  )
})
// updated
// 缓存策略
// 不在同一个域的任何资源一定不能使用 CacheFirst | CacheOnly
// 1、StaleWhileRevalidate:如果缓存存在响应,那么取缓存,同时发起请求更新缓存,后续每次请求相当于最近一次更新数据
// /\.(?:js|css)/ CDN
// 2、CacheFirst:如果缓存存在响应,那么取缓存;如果缓存没有响应,则发起网络请求,完成响应,并将结果缓存
// /\.(?:js|css)/ hash 同域名
// /\.(?:png|jpg|jpeg|webp)/ 设置一定失效时间
// 3、NetworkFirst:对于频繁更新的请求,网络优先策略是理想的解决方案,默认情况下,它尝试从网络请求响应,成功后将结果缓存,失败则取缓存
// /\.html$/
// 4、NetworkOnly:仅使用网络来响应
// 5、CacheOnly:仅使用缓存来响应,如果你有自己的预先缓存的步骤,可能很有用
this.addEventListener('fetch', (event) => {
  if (event.request.url.startsWith('http')) {
    event.respondWith(
      caches.match(event.request).then((resp) => {
        return resp || fetch(event.request).then((response) => {
          return caches.open(version).then((cache) => {
            // 由于 Cache API 不支持 POST 方法,没有不能缓存 POST 请求
            if (event.request.method === 'GET') {
              cache.put(event.request, response.clone())
            }
            return response
          })
        })
      }).catch(() => {
        return caches.match('error')
      })
    )
  }
})

// remove old cache
this.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keyList) => {
      return Promise.all(keyList.map(key => {
        if (key !== version) {
          return caches.delete(key)
        }
      }))
    })
  )
  self.clients.claim()
})
// receive message
self.addEventListener('message', (event) => {
  const port = event.ports[0]
  if (port) {
    port.postMessage(event.data)
  }
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting()
  }
})
// post message
// self.clients.matchAll().then((clients) => {
//   clients.forEach((client) => {
//     client.postMessage({
//       action: 'post message'
//     })
//   })
// })

Workbox 使用

由于项目采用 Webpack 打包,所以需要安装 workbox-webpack-plugin 依赖。 如果你不采用 Webpack 打包,可以使用 workbox-build 自定义构建。Workbox 有两种方式配置 swGenerateSWInjectManifestGenerateSW 属于配置型,不需要编写 sw 脚本,相较比较便捷,InjectManifest 需要自行编写 sw 脚本,相较比较灵活。

GenerateSW
const { GenerateSW } = require('workbox-webpack-plugin')
plugins:[
     new GenerateSW({
        cacheId: `sw-${version}`,
        swDest: 'sw.js',
        inlineWorkboxRuntime: false,
        skipWaiting: false,
        clientsClaim: false,
        navigateFallback: '/index.html',
        cleanupOutdatedCaches: true,
        // 'CacheFirst' | 'CacheOnly' | 'NetworkFirst' | 'NetworkOnly' | 'StaleWhileRevalidate'
        runtimeCaching: [
          {
            urlPattern: /.*\.html/,
            handler: 'NetworkFirst',
            options: {
              cacheName: `html-cache`,
              cacheableResponse: {
                statuses: [0, 200]
              }
            }
          },
          {
            urlPattern: /\.js[\?]?/,
            handler: 'CacheFirst',
            options: {
              cacheName: `js-cache`,
              cacheableResponse: {
                statuses: [0, 200]
              }
            }
          },
          {
            urlPattern: /\.css[\?]?/,
            handler: 'CacheFirst',
            options: {
              cacheName: `css-cache`,
              cacheableResponse: {
                statuses: [0, 200]
              }
            }
          },
          {
            urlPattern: new RegExp(`^${process.env.VUE_APP_BASE_API}`),
            handler: 'StaleWhileRevalidate',
            options: {
              cacheName: `api-get-cache`,
              cacheableResponse: {
                statuses: [0, 200]
              },
              fetchOptions: {
                mode: 'cors',
                method: 'GET',
                credentials: 'omit'
              }
            }
          },
          {
            urlPattern: new RegExp(`^${process.env.VUE_APP_BASE_API}`),
            handler: 'NetworkOnly',
            method: 'POST',
            options: {
              cacheName: `api-post-cache`,
              cacheableResponse: {
                statuses: [0, 200]
              }
            }
          },
          {
            urlPattern: /\.(?:png|gif|jpg|jpeg|svg)[\?]?/,
            handler: 'CacheFirst',
            options: {
              cacheName: `image-cache`,
              cacheableResponse: {
                statuses: [0, 200]
              },
              expiration: {
                maxEntries: 60, // 最大的缓存数,超过之后则走 LRU 策略清除最老最少使用缓存
                maxAgeSeconds: 30 * 24 * 60 * 60 // 最长缓存时间为 30 天
              }
            }
          }
        ]
      }),
]
InjectManifest
const { InjectManifest } = require('workbox-webpack-plugin')
plugins:[
    new InjectManifest({
        swSrc: './src/sw.js',
        swDest: './dist/sw.js',
        compileSrc: true
    })
]

Mainfest 配置

配置 PWA 的 图标,这样可以被安装成一个应用。

{
  "name": "XX项目",
  "short_name": "XX",
  "icons": [
    {
      "src": "/static/img/android-chrome-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/static/img/android-chrome-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": "/index.html",
  "display": "standalone",
  "background_color": "#000000",
  "theme_color": "#4DBA87"
}
<!--manifest配置-->
<link rel="manifest" href="/static/manifest.json" />
<!--主题颜色-->
<meta name="theme-color" content="#4DBA87" />
<!--是否启用 WebApp 全屏模式,删除苹果默认的工具栏和菜单栏-->
<meta name="apple-mobile-web-app-capable" content="yes" />
<!--设置苹果工具栏颜色-->
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<!--添加到主屏后的标题(iOS 6 新增)-->
<meta
  name="apple-mobile-web-app-title"
  content="<%= webpackConfig.name %>"
/>
<!--在iPhone,iPad,iTouch的safari浏览器上可以使用添加到主屏按钮将网站添加到主屏幕上,方便用户以后访问-->
<link
  rel="apple-touch-icon"
  href="/static/img/icons/apple-touch-icon-152x152.png"
/>
<!--Safari 10开始支持固定书签页的SVG favicons-->
<link
  rel="mask-icon"
  href="/static/img/icons/safari-pinned-tab.svg"
  color="#4DBA87"
/>
<!--Windows 8 磁贴图标-->
<meta
  name="msapplication-TileImage"
  content="/static/img/icons/msapplication-icon-144x144.png"
/>
<!--Windows 8 磁贴颜色-->
<meta name="msapplication-TileColor" content="#000000" />

总结

本文主要探讨了 PWA 的两个核心部分:ServiceWorker 和 Mainfest。讲解了ServiceWorker 两种实现方式,并介绍通过 Workbox 打造一个简略可靠的 ServiceWorker 应用,最后介绍了 Mainfest 配置部分,实现应用的注册。