PWA 开发指南(三):vite-plugin-pwa 部署实践

734 阅读13分钟

大家好我是 HOHO。

如果你没有看过上一篇的话,推荐先读一下 service worker 相关生态介绍。这对理解本文内容会有帮助。

本篇文章咱们就来正式了解下怎么使用 vite-plugin-pwa 来给前端项目添加 PWA 能力,同时本文也会一并介绍 PWA 相关的细节、原理、怎么调试、以及需要提前做好的规划。

启动项目

首先我们建个新的项目来试一下:

pnpm create vite

我这边创建的时候选了原始 js 和 typeScript,之后跑一下看看效果:

image.png

没问题,然后安装 pwa 依赖:

pnpm install -D vite-plugin-pwa

最后在 vite.config.ts 里注册一下插件:

import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";

export default defineConfig({
  plugins: [
    VitePWA({}),
  ],
});

很简单对吧,这样项目里就已经获取了 pwa 的能力了,我们打包一下然后预览看看效果:

pnpm build
pnpm preview

如果你在打开网站前就 F12 的话,就可以看到这么几个前面带小齿轮的特殊请求:

image.png

这些就是 pwa 插件自动添加的 service worker 在后台静默对资源进行预加载缓存。

这时候你再刷新下页面的话,就能看到这样的请求:

image.png

这就代表着你的 service worker 已经生效啦,这些请求目前都没有发送到服务器,而是在浏览器就被 service worker 拦截下来并使用缓存响应了。

我们可以在 Application 面板里点击 service workers,就可以看到目前正在运行的 service worker:

image.png

每个网站同时只能启用一个 service worker。这个 worker 会全权代理当前作用域的所有接口请求。

事实上我们可以给一个网站注册多个 sw,但是大多数场景都用不到,所以我们就按上面这个说法理解好了,如果你真的想了解的话,看 这里

在下面还有个 Cache storage,这个是给 service worker 特别准备的超级缓存。你在 Network 里看到的来自 service worker 的数据实际上就是保存在这里的。这个缓存的优先级很高,并且强刷浏览器是不会清除掉这里的数据的。所以没处理好的话就很容易会导致出现用户浏览器里一直用的老资源。

接下来,我们来看一下这个插件实际都做了哪些事。

pwa 插件做的工作

我们打开 dist 目录,在这里可以找到这几项变更:

1、index.html 注入 pwa 资源:

pwa 插件会在 index.html 里注入对应的资源引用,第一个用于告诉浏览器这是个 pwa 应用,第二个用于注册 service worker。

<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Vite + TS</title>
  <script type="module" crossorigin src="/assets/index-CXCn1R-M.js"></script>
  <link rel="stylesheet" crossorigin href="/assets/index-BaiKPFAq.css">
+ <link rel="manifest" href="/manifest.webmanifest">
+ <script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>
</head>

2、生成 manifest.webmanifest

pwa 插件会根据 package.json、index.html 等相关信息自动组合并生成一个 manifest.webmanifest 文件。

3、生成 registerSW.js

这个文件作用很简单,做一下兜底,如果浏览器支持 service worker 的话,就进行注册。

这个行为可以通过 这个属性 进行配置。比如你可以选择不生成这个文件,直接把 sw 注册代码内联在 index.html 里。

4、生成 sw.js

这个就是 pwa 插件生成的核心文件了,这里就是 service worker 实际执行的代码。也就是我们上一篇文章里提到的使用 workbox build 生成出的 sw 脚本文件。我们下面会详细介绍这个文件的内容。

5、生成 workbox-4723e66c.js

这个就是上一篇文章里提到的 workbox sdk,标题里的 hash 跟版本和你 sw.js 使用的 api 有关系,一般情况下只有改 pwa 配置了这里才会变。

这个文件会再 sw.js 里引用,并提供 sw.js 里需要用到的 api。

总结

从上面几个文件我们可以发现,pwa 插件基本做的事情就这三件:

  • 根据配置生成 service worker 脚本(sw.js)
  • 根据配置生成 manifest.webmanifest 文件
  • 把上面这俩东西注入到 index.html 里

OK,看起来很简单对吧,接下来我们来看下第一个问题

为什么我地址栏里没有 pwa 安装按钮?

刚才有眼尖的同学可能就发现了,耶我安装按钮呢?

image.png

如果你不知道安装按钮是啥的话可以回去读下 第一篇文章

要解决这个问题,我们可以在 F12 的 Application 面板里寻找一些线索:

image.png

浏览器会在 Manifest 选项卡的 Installability 里详细介绍当前 PWA 应用的“可安装性”。

如果你这里显示的是 No manifest detected 的话,说明你的 manifest.webmanifest 没有正确引入,你需要检查下 index.html 里有没有正确的注入这个文件。

从图里我们就能看出来,哦~原来是没有配置 icon,这简单,在 vite.cinfig.ts 里配置一下就行了:

import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";

export default defineConfig({
  plugins: [
    VitePWA({
+     includeAssets: ["vite.svg"],
+     manifest: {
+       icons: [
+         {
+           src: "vite.svg",
+           sizes: "any",
+         },
+       ],
+     },
    }),
  ],
});

mainfest 属性里的配置会被合并到最终生成的文件里。我们同时使用 includeAssets 来让 sw 预载这个图片。

然后再 pnpm build pnpm preview 看一下效果:

image.png

还是没有,怎么回事?我代码明明已经打包好了啊?

其实你的代码没有问题,有问题的是 service worker 的机制。这就是 service worker 机制导致的一个很容易出现的大问题:更新后页面没有生效。

实际上这时你检查一下 dist 目录里生成的文件,你就会发现 manifest 实际上已经更新了。但是生产环境里还是显示没有配置 icon。

没有相关经验的同学遇到这个问题很容易就傻眼了,很多人因为对 PWA 机制不太理解,排查起这个问题来也相当的艰难,这也是我写这几篇文章的原因。

那么到底是什么情况呢?应该怎么解决呢?

service worker 未更新问题 & 解决方法

首先我们打开 F12 Application 的 Service workers 选项卡,可以看到我们的 sw 状态发生了一些变化:

image.png

这里为什么有两个点点?状态为什么又是 running 又是 waiting 的?什么叫做 skipWaiting?Received 又是什么东西?

这里其实涉及到了一个比较复杂的东西:service worker 的生命周期,如果你感兴趣的话可以去读一下官方的:The service worker lifecycle。我们这里就简单解释一下。

service worker 的生命周期

刚才我们已经知道了,每个网站同时只能存在一个 sw。那么,当 sw 更新的时候浏览器会有什么反应呢?很多同学下意识的就以为,有更新就获取新的 js 文件呗,然后老的自然就没用了啊。

并不是。

浏览器对待 sw 的更新有一套完全不同的机制。首先每次进入网站的时候浏览器都会去请求一遍注册的 sw.js 文件。这里再强调一遍:每次进入网站都会去请求一遍。每次都会,哪怕你 sw.js 文件这个 GET 请求配置了一年的浏览器强缓存,也没用。一旦注册成功,浏览器会越过所有的缓存机制,每次都去请求一遍这个 sw.js 文件。

因为 service worker 会缓存网站的请求,如果 service worker 再被缓存的话,开发者可能就永远丧失了对网站的更新能力了,因此这个文件不会被任何缓存机制控制。

这个概念很重要,大家要记住。

接下来,当浏览器发现 sw.js 这个文件更新之后,会在保持老 sw 运行的同时,启动新的 sw。此时,新的 sw 会处于一个叫做 installing 的阶段,sw 执行完一些代码之后,认为自己安装好了,就可以调用一个 api,然后就会进入 waiting 阶段。

而我们目前在 F12 里看到的,就是老的 sw 还在运行,新的 sw 处于 waiting 阶段的情况

image.png

所以说你会看到两个点点,绿色的就是老的 sw(编号#409),正在运行,而黄色的就是新的 sw(编号 #410),正在等待。

那他在等什么呢?等用户的确认。

这时我们应该在页面上显示一些弹窗,让用户选择是不是要更新,用户选择更新后,我们再调用 api 来让新的 sw 正式启用。这时老的 sw 就彻底废弃掉了,而新的 sw 会进入 active 阶段并一直保持在这个阶段,直到下个 sw 来替换它。

怎么通知用户确认更新?

vite-plugin-pwa 提供了一个包来处理这个更新,在 main.ts 里添加下面的内容:

import { registerSW } from "virtual:pwa-register";

const updateSW = registerSW({
  onNeedRefresh() {
    const userConfirmed = confirm("你确定要更新本应用么?");
    if (userConfirmed) {
      updateSW();
    }
  },
  onOfflineReady() {
    alert("太棒了,你的应用现在已经支持离线访问了!");
  },
});

这里简单解释一下:registerSW 就是用来注册 sw 相关内容的,里边接受几个回调,这里我们用到的有:

  • onNeedRefresh:当有新的 sw 正在等待更新时触发,开发者需要在这个回调里弹窗提示用户安装应用。如果用户确定了,可以调用 updateSW 来启用新 sw。
  • onOfflineReady:没啥用,当第一个 sw 就绪时触发。

为什么引入的包带一个 virtual 前缀?

这是一个 vite 的特殊功能叫做“虚拟包”。这个 import 进来的包实际上是动态生成出来的。因为要实现和 sw 脚本的交互,所以这里引入的代码会根据你 vite.config 里的 pwa 配置动态变化的。

好了,现在我们重新 build 再预览看一下。

然后你就会发现,无论怎么刷新,页面都没有变化。怎么回事?

这时候你研究一下就会发现:坏事了,死循环了

image.png

相当于你想展示更新弹窗的话,就要启用新 sw;而想启用新 sw 的话,你得先展示更新弹窗。

如果你的代码这时已经发到生产环境并被很多人使用了,那恭喜你,完犊子了。

完蛋了,怎么补救

开个玩笑,实际上设计 sw 的人已经考虑到了这一点,所以说虽然麻烦一点,但是还是有方法去补救的。

首先在 vite.config.ts 里添加这么一个参数:

import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";

export default defineConfig({
  plugins: [
    VitePWA({
+     selfDestroying: true,
      includeAssets: ["vite.svg"],
      manifest: {
        icons: [
          {
            src: "vite.svg",
            sizes: "any",
          },
        ],
      },
    }),
  ],
});

他并不是简单的删除已经存在的 sw.js,而是生成一个新的 sw.js,里边会清空缓存并立刻替代老的 sw。

再次打包并预览,就可以看到:

image.png

此时所有的 service worker 功能就都失效了,地址栏里也会立刻显示出 PWA 安装按钮。

所以说,如果你真的发现生产环境里已经部署了一个功能缺失或者有问题的 sw 脚本,那么只能使用这种方案去替换掉用户已经安装的 service worker。这是一个比较漫长且痛苦的过程,如果你是 toC 的话,那就只能永远保留这个 selfDestroying 的 sw.js,然后换个名字再重新实现 service worker 功能了。

不过幸好我们目前还在本地开发。所以只需要把 selfDestroying 注释掉,再打包一下并预览就可以了,然后你就可以看到我们写的代码生效了:

image.png

这时候我们再随便改点什么,比如 index.html 里的 title:

<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>Vite + TS</title>
+ <title>hello PWA</title>
</head>

然后再重新 build,刷新预览后就可以看到我们等待已久的更新按钮:

image.png

点击确定后就可以看到刚才还在等待的 #425 已经代替旧 sw 开始运行了。而且也可以看到左上角的标题也同步更新了。

image.png

小结

这一部分的内容基本可以算是 PWA 里最复杂的流程了。如果经验不足的话很容易在这里栽跟头。这里简单总结一下,整个流程是这样的:

image.png

有同学可能会有点望而却步的感觉,这个有点复杂啊,我只是应付一下客户的需求,有没有简单一点的方案?

有的,有的兄弟。

简单一点的方案

如果你的应用并没有类似桌面端应用“版本更新要让用户确认升级”到需求,而是想像正常的网页应用那样,刷新就更新了。并且又想保留 sw 的缓存能力带来的流畅度提升。那么下面这个方案就是为你准备的。

很简单,只需要在 vite.config.ts 里添加这么一行配置:

import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";

export default defineConfig({
  plugins: [
    VitePWA({
      // selfDestroying: true,
      includeAssets: ["vite.svg"],
+     registerType: "autoUpdate",
      manifest: {
        icons: [
          {
            src: "vite.svg",
            sizes: "any",
          },
        ],
      },
    }),
  ],
});

然后把 registerSW 和弹窗确认更新的那一段代码删掉就可以了。

为了方便判断效果,我把 index.html 的 title 改成了 hello PWA with autoUpdate,现在我们 build 再看一下:

image.png

如果你开着 F12 的话就可以看到这里一闪而过的新 sw,他在短暂的准备后会立刻代替老的 sw 脚本并触发一次浏览器刷新。刷新之后就已经是新的内容了:

image.png

怎么做到的?

这里我贴一下使用默认方案 registerType: "prompt" 和使用新方案 registerType: "autoUpdate" 生成的两个 sw.js,我们来看一下它是怎么实现的。

首先是 prompt 模式生成的 sw.js:

image.png

我们在核心代码里可以看到:

  • 在引入 workbox 的 sdk 之后,首先使用 addEventListener("message", ...) 来监听主进程发来的消息。这里在对面的主进程里通信的实际就是我们之前提到的 registerSW,用户确认更新弹窗后调用 updateSW 方法,这个方法就会向 sw 发送 SKIP_WAITING 的 message。

  • 然后是一段 precacheAndRoute,这个方法会去预加载列表里的静态资源,这些静态资源都是缓存优先的。也是我们老是遇到旧缓存的“罪魁祸首”。

  • cleanupOutdatedCaches,这个 api 很简单,就是去对比一下当前的缓存库和预加载列表里的缓存,如果有过期的就清理掉。

  • e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html"))),这段代码是用来实现 SPA 单页应用的 history 模式导航,如果你地址栏里访问了一个预载列表里不存在的资源,这行代码就会给你导航回 index.html。这个功能之前一般都是由后端或者 nginx 负责。但是注意,这里也是有一些坑的。

那使用 autoUpdate 模式生成的 sw.js 呢?

image.png

可以看到,这里不再等待 message,而是直接调用 skipWaiting 和 clientsClaim,clientsClaim 这个 api 会让新的 sw 立刻接管浏览器请求代理。

总结

可以看到实际上要做的配置确实是很少的,但是我们花了很大的篇幅去讲 Service Worker 背后都做了哪些工作,由于这部分工作的特殊点,在经验不足的情况下很容易出现缓存问题的出现。

讲到这里,我们就已经基本实现了给简单的前端项目引入 PWA 能力了。结合上一篇文章的内容,我们应该已经可以对项目的 PWA 做一些符合自己需求的自定义了。那么如果我的项目复杂一点,会不会出现其他问题呢?

我们下一篇文章就来举几个例子来介绍一下。