这篇文章记录下对博客进行的 PWA(Progressive-Web-App)改造!因为博客是基于 Vite 的,所以理论上来说安装配置一下 VitePWA 就 ok 了。

PWA

PWA 这个词听上去可能很让人陌生,但在国内我们早就熟悉一个很类似的概念了——小程序。

PWA 是 Progressive Web App 的缩写,是一种 Web App 的新模式,可以让网站具备类似原生 App 的体验。

好吧,还是很抽象,那么来看看 PWA 好处都有啥?最重要的一点,PWA 可以让网页自行管理某个作用域(例如 /)的所有请求,例如第一次请求后在本地储存一份缓存下来的版本,之后每次访问就不需要再联网了:

其中预取资源对我们的博客网站来说是非常有用的,可以大大加载访问速度。当然,PWA 还有很多其他特性,比如推送通知等,但这些对于一个静态博客网站来说就不是那么重要了。

那么 PWA 有没有什么坏处呢?其实也有,相信大家都遇到过微信小程序提示更新后才能打开的情况,这是因为小程序的缓存机制导致的。PWA 也有类似的问题,如果我们的博客(程序)更新了,用户需要手动/自动刷新才能看到最新内容。本篇文章会具体解释这一更新机制。

VitePWA

pnpm add -D vite-plugin-pwa

配置过程参考了 VitePWA 的 PWA Minimal RequirementsAutomatic reloadStatic assets handling。此外,Periodic Service Worker Updates 解释了如何以指定间隔检查更新,Unregister Service Worker 解释了如何在启用后禁用 PWA 功能,也值得看看。

index.html 中需要补充一些元信息:

<head>
    <title>llyのblog</title>
    <meta name="description" content="我的个人博客,写点想写的">
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="icon" href="/favicon.ico">
    <link rel="icon" href="/favicon-32x32.png" type="image/png" sizes="32x32">
    <link rel="icon" href="/favicon-16x16.png" type="image/png" sizes="16x16">
    <link rel="apple-touch-icon" href="/apple-touch-icon.png">
    <meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
    <meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
</head>

vite.config.ts 中配置:

import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [VitePWA({
    // 配置自动更新
    registerType: 'autoUpdate',
    workbox: {
      // 配置缓存列表
      globPatterns: ['**/*.{js,css,ico,svg}', 'index.html'],
      // https://github.com/vite-pwa/vite-plugin-pwa/issues/120
      navigateFallback: null,
    },
    includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
    manifest: {
      name: 'llyのblog',
      short_name: 'llyのblog',
      description: '我的个人博客,写点想写的',
      lang: 'zh-CN',
      theme_color: '#ffffff',
      icons: [
        {
          src: 'pwa-192x192.png',
          sizes: '192x192',
          type: 'image/png',
        },
        {
          src: 'pwa-512x512.png',
          sizes: '512x512',
          type: 'image/png',
        },
      ],
    },
  })],
})

图标可以在 Favicon InBrowser.App 生成。

main.ts 里需要引入 registerSW 以实现自动更新:

import { registerSW } from 'virtual:pwa-register'

registerSW({ immediate: true })

如果不引入这个虚拟模块,每次打开页面后也会检查 sw.js 的更新,但是后台静默安装完更新(包括下载新资源并删除旧资源)后并不会与页面产生交互(这里就是刷新页面)。旧 JavaScript 资源的删除会导致我们无法在 SPA 应用中导航去新的页面(因为新的页面的动态路由区块的 JavaScript 资源被删除了),所以一般情况需要引入它来实现更新后刷新页面(最后会介绍一种不需要自动刷新页面的更新策略)。

此时可以编译出来测测了:

pnpm build
pnpm preview

可以使用 Lighthouse 的 PWA 测试,也可以关闭 pnpm 的 preview 后测试离线访问。DevTools 的 Network 和 Application 面板对调试也会很有帮助。

PWA 的更新

上文提到:

如果我们的博客(程序)更新了,用户需要手动/自动刷新才能看到最新内容。

现在可以整理一下更新逻辑了。首先,博客(程序)是指 sw.js 和在 sw.js 缓存列表(就是 vite.config.ts 中配置的 workbox.globPatterns)中的文件(而不是所有的静态文件!注意未被缓存的静态文件的更新不必引起 PWA 的更新)。缓存列表中的文件的更新会引起 sw.js 的更新,因为 sw.js 中保存了所有缓存列表的文件的 hash 值。

随后,随着 index.html 的打开和 JavaScript 的加载,客户端会去请求 sw.js,当发现有更新后会后台静默安装好更新并刷新页面(这里是指上面提到的 registerSW({ immediate: true }) 配置)。

但是,对于我们的博客网页来说,这样的更新存在下列问题:

一个更新策略是应用框架与数据分离。可以把博客想象成一个论坛 app,UI 和动态的帖子数据(这里就是我们的每一篇博客内容)是分离的。只有当 UI 更新时才需要强制刷新,而帖子数据可以做成动态的数据请求,比如请求一个 JSON 文件。帖子的更新通过 JSON 文件的更新来实现,只有 UI 的更新会更改 sw.js

为此,我们需要指定这个 JSON 文件不被缓存。也就是不要把它包含在 workbox.globPatterns 中。这可以通过检查最后生成的 sw.js 文件来确认。

上面的策略可以确保看到的是最新博客内容,并且一定程度上缓解了强制刷新的问题。那么,有没有更到位的解决方案呢?确实是有的,但需要牺牲 PWA 的离线能力。

道理很简单,我们所加载的所有 JavaScript 文件都是入口的 index.html 指定的,所以只要不把 index.html 缓存起来,就可以保证每次打开页面都是最新的。分类讨论:

同理,对于一些 SSR/SSG 场景,也不需要 HTML 文件被缓存,可以参考这个 issue 的讨论。

需要注意 workbox 的配置:

workbox: {
  // 资源文件或不需要保证实时性的文件可以放在缓存列表中
  // 不需要包含 HTML 文件
  globPatterns: ['**/*.{js,css,ico,svg}', 'friends.json'],
  // https://github.com/vite-pwa/vite-plugin-pwa/issues/120
  navigateFallback: null,
},

再移除 registerSW({ immediate: true }) 就愉快的收工了。