Skip to content

使用 Workbox 快速让你的网站变身 PWA

Published: at 13:11

PWA 在过去的一年可以说十分火爆,各种前端技术会议都有相关课题,我最早知道 PWA 也是在技术会议上。目前 Google 系的浏览器基本已经支持,Apple 系的浏览器也已经在开发者版本中支持 (浏览器支持情况[^1])。作为一个前端开发者,是时候掌握这一门技能了。

PWA 主要有 Service Worker、Web App Manifest、Web Push 等特性。这次我们先实现 Service Worker 和 Web App Manifest,GoogleChrome 团队提供了一个 Workbox,对缓存控制进行封装,是十分有用的几个工具。

Web App Manifest

Web App Manifest 的实现十分简单,我们可以借助 App Manifest Generator[^2] 快速生成,放置对应资源到合适的目录,然后在页面 head 中增加 <link rel="manifest" href="/manifest.json"> 即可。

Service Worker

Service Worker 可以帮你预加载资源,拦截或修改网络请求。我们这次主要实现静态资源预加载,网络请求缓存,图片自动支持 webp。前端代码构建完成以后,我们可以自定义一些规则,整理出需要预加载的资源,进行缓存。我们使用 Workbox 命令行工具,扫描出需要预加载的目录,生成 Service Worker 文件。

Workbox 配置文件

主要定义静态文件存放的目录,处理 CDN 地址规则。

// 需要先安装orkbox 命令行工具 npm i workbox-cli -g
// workbox-cli-config.js
const fs = require('fs')

// 业务相关,每个项目需要自己修改
const buildStats = require('./build/build-stats.json')
const buildID = fs.readFileSync('./build/BUILD_ID').toString()
const cdnServer = 'https://o88aw8tfp.qnssl.com'

module.exports = {
  globDirectory: './build/', // 静态资源存放目录
  globPatterns: [
    'app.js', // 自定义扫描规则,与业务相关
    'bundles/pages/*.js',
    'chunks/*.js'
  ],
  swSrc: 'static/workbox.js', // Service Worker 模板文件,相关业务文件写在这里
  swDest: 'static/sw.js', // 最终生成的 Service Worker 文件,不要手动修改
  modifyUrlPrefix: { // 静态资源 CDN 路径处理,与业务相关
    'app.js': `${cdnServer}/_next/${buildStats['app.js'].hash}/app.js`,
    'bundles/pages/': `${cdnServer}/_next/${buildID}/page/`,
    'chunks/': `${cdnServer}/_next/${buildID}/webpack/chunks/`
  }
}

Workbox 模板文件

实现图片自动转换为 webp 格式,缓存网络请求,以备离线时使用。

// static/workbox.js
/* eslint-env worker */
/* global FetchEvent WorkboxSW */

var workboxCDN = 'https://cdn.jsdelivr.net/npm/workbox-sw@2.1.2/build/importScripts/workbox-sw.prod.v2.1.2.min.js'
importScripts(workboxCDN) // 加载 Workbox 库

;(function () {
  var workbox = new WorkboxSW({
    clientsClaim: true,
    skipWaiting: true
  }) // 生成 Workbox 实例
  var networkFirstStrategy = workbox.strategies.networkFirst({
    cacheExpiration: {
      maxEntries: 3000,
      maxAgeSeconds: 3600 * 24 * 7
    }
  }) // 创建一个网络请求优先的缓存仓库
  var cacheFirstStrategy = workbox.strategies.cacheFirst({
    cacheExpiration: {
      maxEntries: 3000,
      maxAgeSeconds: 3600 * 24 * 7
    }
  }) // 创建一个缓存优先的缓存仓库

  /**
   * webp 处理
   * 匹配非 webp 的 cdn 图片资源,缓存其 webp 格式
   */
  workbox.router.registerRoute(function (args) {
    var useWebp = false
    var supportWebp = /image\/webp/i.test(args.event.request.headers.get('Accept')) // 通过 HTTP Accept 头信息判断浏览器是否支持 webp
    var url = args.url.href.split('?')[0]

    if (!supportWebp) {
      return useWebp
    }

    // 判断哪些图片需要转为 webp 格式,业务相关
    if (/qnssl\.com/i.test(url) && /(jpg|png)$/i.test(url)) {
      useWebp = true
    }

    return useWebp // 觉得是否需要拦截路由,true 拦截,false 不拦截
  }, function (args) {
    // 拦截到的请求处理规则
    var url = args.url.href.split('?')[0]

    // 重新构造 fetch 请求
    args.event = new FetchEvent(args.event.type, {
      request: new Request(url + '?imageView2/2/q/85/format/webp'), // CDN 转换图片格式参数,各 CDN 不同
      clientId: args.event.clientId,
      isReload: args.event.isReload
    })

    return cacheFirstStrategy.handle(args) // 将请求交给缓存仓库处理,如无缓存,会从网络加载
  })

  workbox.router.registerRoute(/\/.*/i, networkFirstStrategy) // 所有同域下的请求进行缓存,以备离线使用,此处实现比较粗糙,具体项目需要细致化到各类路由

  workbox.precache([workboxCDN]) // 缓存 Workbox 库文件
  workbox.precache([]) // 占位符,Workbox 命令行工具会转换为具体的静态资源
}())

生成 Service Worker 文件

在你的项目构建流程中增加 workbox inject:manifest 代码,或者命令行手动执行 workbox inject:manifest。构建后就可以查看生成的文件。

// 生成的文件存放在 static/sw.js
/* eslint-env worker */
/* global FetchEvent WorkboxSW */

var workboxCDN = 'https://cdn.jsdelivr.net/npm/workbox-sw@2.1.2/build/importScripts/workbox-sw.prod.v2.1.2.min.js'
importScripts(workboxCDN)

;(function () {
  var workbox = new WorkboxSW({
    clientsClaim: true,
    skipWaiting: true
  })
  var networkFirstStrategy = workbox.strategies.networkFirst({
    cacheExpiration: {
      maxEntries: 3000,
      maxAgeSeconds: 3600 * 24 * 7
    }
  })
  var cacheFirstStrategy = workbox.strategies.cacheFirst({
    cacheExpiration: {
      maxEntries: 3000,
      maxAgeSeconds: 3600 * 24 * 7
    }
  })

  /**
   * webp 处理
   * 匹配非 webp 的 cdn 图片资源,缓存其 webp 格式
   */
  workbox.router.registerRoute(function (args) {
    var useWebp = false
    var supportWebp = /image\/webp/i.test(args.event.request.headers.get('Accept'))
    var url = args.url.href.split('?')[0]

    if (!supportWebp) {
      return useWebp
    }

    if (/qnssl\.com/i.test(url) && /(jpg|png)$/i.test(url)) {
      useWebp = true
    }

    return useWebp
  }, function (args) {
    var url = args.url.href.split('?')[0]

    // 重新构造 fetch 请求
    args.event = new FetchEvent(args.event.type, {
      request: new Request(url + '?imageView2/2/q/85/format/webp'),
      clientId: args.event.clientId,
      isReload: args.event.isReload
    })

    return cacheFirstStrategy.handle(args)
  })

  workbox.router.registerRoute(/\/.*/i, networkFirstStrategy)

  workbox.precache([workboxCDN])
  workbox.precache([
  {
    "url": "https://o88aw8tfp.qnssl.com/_next/fc0a873a0fb8865c733eafd4361ee446/app.js",
    "revision": "fc0a873a0fb8865c733eafd4361ee446"
  },
  {
    "url": "https://o88aw8tfp.qnssl.com/_next/3b1a9146-b72d-4133-80dc-bff13eff5969/page/_document.js",
    "revision": "35b6335d59f61b737f1b156c134cf2c2"
  },
  {
    "url": "https://o88aw8tfp.qnssl.com/_next/3b1a9146-b72d-4133-80dc-bff13eff5969/page/_error.js",
    "revision": "ebb56565bb86beef4465afad58568033"
  },
  {
    "url": "https://o88aw8tfp.qnssl.com/_next/3b1a9146-b72d-4133-80dc-bff13eff5969/page/list.js",
    "revision": "b48b7fbc258e95243ecacd6ba28c5415"
  },
  {
    "url": "https://o88aw8tfp.qnssl.com/_next/3b1a9146-b72d-4133-80dc-bff13eff5969/page/post.js",
    "revision": "2319d8d3db08749e2b2aa42cdad347e7"
  },
  {
    "url": "https://o88aw8tfp.qnssl.com/_next/3b1a9146-b72d-4133-80dc-bff13eff5969/page/tag.js",
    "revision": "58e377967c2f6212faffe819b2d642be"
  },
  {
    "url": "https://o88aw8tfp.qnssl.com/_next/3b1a9146-b72d-4133-80dc-bff13eff5969/webpack/chunks/baguettebox_7753f32ed8b1b354d9d9a9f46540e36c.js",
    "revision": "25b303719d46f152f12649f043dda480"
  },
  {
    "url": "https://o88aw8tfp.qnssl.com/_next/3b1a9146-b72d-4133-80dc-bff13eff5969/webpack/chunks/components_Disqus_330b0a098ae588ad4dde7ddac1342b19.js",
    "revision": "a23c97a37071b2128700eb97b569ff70"
  },
  {
    "url": "https://o88aw8tfp.qnssl.com/_next/3b1a9146-b72d-4133-80dc-bff13eff5969/webpack/chunks/nprogress_cc31a9d7a86a6f32932208d2b28f4f1c.js",
    "revision": "bb5f8920b823835f1828dc98a17ffd82"
  },
  {
    "url": "https://o88aw8tfp.qnssl.com/_next/3b1a9146-b72d-4133-80dc-bff13eff5969/webpack/chunks/utils_inject_57190832e9ae540e620d616b9e53c208.js",
    "revision": "f78bb021995310ff2cd09892e6d496aa"
  },
  {
    "url": "https://o88aw8tfp.qnssl.com/_next/3b1a9146-b72d-4133-80dc-bff13eff5969/webpack/chunks/utils_lozad_e11a80f493f09965eee540dea545759d.js",
    "revision": "2cab0d134cb64502de91469894863741"
  }
])
}())

可以看到,生成的 Service Worker 文件已经包含需要预加载的文件。

注册 Service Worker

function register () {
  if (navigator && navigator.serviceWorker && navigator.serviceWorker.register) {
    return navigator.serviceWorker.register(`/sw.js`, { // 注册 Service Worker,与作用域相关,不能乱放
      scope: '/'
    })
  } else {
    return Promise.resolve()
  }
}

function unregister () {
  if (navigator && navigator.serviceWorker && navigator.serviceWorker.getRegistrations) {
    return navigator.serviceWorker.getRegistrations().then(registrations => {
      for (let registration of registrations) {
        registration.unregister()
      }
    })
  } else {
    return Promise.resolve()
  }
}

window.addEventListener('load', function () {
  if ('serviceWorker' in navigator) {
    register().catch(unregister)
  }
})

测试

经过前面的流程,基本开发工作已经完成,现在可以在浏览器进行测试了,上线前一定记得测浏览器兼容。

几张成功的截图

  1. Web App Manifest 注册成功
    Web App Manifest 注册成功
  2. Service Worker 注册成功
    Service Worker 注册成功
  3. LightHouse PWA 测试工具满分 100 分
    LightHouse PWA 测试工具满分 100 分

[^1]:https://ispwaready.toxicjohann.com/ [^2]:https://app-manifest.firebaseapp.com/