PWA 开发指南(四):避坑指南

392 阅读10分钟

大家好啊我是 HOHO。

上一篇文章 vite-plugin-pwa 部署实践 中我们讲了下如何用 vite-plugin-pwa 给简单的前端项目提供 PWA 支持和如何处理一些基础问题。

本篇讲一下在遇到比较高级或者复杂的需求时如何进行 PWA 适配。

问题1:发版后还是加载了旧资源

这个问题其实我们上一篇文章已经详细讨论过了。这里就简单总结一下。

  • 如果你正在使用 vite-plugin-pwa 且配置了 registerType: prompt(或者没配 registerType)且没有在代码里使用 virtual:pwa-register 添加安装按钮。
  • 如果你自己开发了一个 service worker 但是又没有用 workbox.clientsClaim() 来自动升级 sw 脚本。

这两种情况都会导致老的 sw 将永远生效,而携带新版本静态资源的新 sw 将永远等待安装。从而导致无论怎么发版,用户访问的都是老资源的问题。

怎么解决?

使用 vite-plugin-pwa 的 selfDestroying 生成一个新的 sw.js,再部署上线后,这个脚本将会直接清除老 sw 并自毁。

如果你的系统已经有很多人在用的话,推荐把这个自毁 sw.js 放在 public 里并在 index.html 里手动注册一下。然后使用 filename 指定一个新的 sw 名字,例如这样:

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

export default defineConfig({
  plugins: [
    VitePWA({
+     filename: "sw-v2.js",
      // ...
    }),
  ],
});

这样配置之后,系统里就会有两个 sw,一个(sw.js)用来销毁老的故障 sw,一个(sw-v2.js)用来实现最新的 sw 功能。

问题2:错误的导航重定向回 index.html

在默认情况下,生成的 sw.js 里有这么一行。

e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")));

如果地址栏里访问的资源不存在的话,就会重定向到 index.html。这样不需要后端配合就能实现单页应用的 history 路由模式。

但是这会导致一些问题:如果你静态资源里有其他需要访问的页面或者资源,都会被 sw 重定向回来。

比如我们往 public 目录下塞一个 test.pdf。正常情况下,我们打完包之后是能直接通过 localhost/test.pdf 来访问到这个文件的对吧。

image.png

但是当你启用了 vite-plugin-pwa 后,访问网站等待 sw 加载完成后,再访问一下 localhost/test.pdf,你就会发现页面被重定向回首页了:

image.png

试想一下,你项目里有一些使用手册和用户文档,会随着前端的静态资源一起提供出去。而在你上线了 PWA 之后,这些文档就看不到了。

而且还有个令人匪夷所思的细节,你可以在 main.ts 里用 fetch 下载这个文件:

fetch("/test.pdf");

build 之后再预览一下,你会发现这个文件居然是能访问到的:

image.png

通过代码访问文件是可以的,而通过浏览器地址栏访问就是不行的。

事实上确实如此,默认生成的 sw.js 只会拦截地址里的请求(我们称之为导航路由),而不会拦截通过 ajax 或者 fetch 发送的请求。只要是地址栏里输入的地址不在 sw.js 的预载列表里,它就会直接重定向回首页。

这种情况在大型的产品或者微前端方案里更严重,因为你的前端服务可能只是个入口,不同的客户会有一些交付定开的页面或者微前端子应用通过你的前端服务暴露出去。如果不对这些进行额外配置的话,上线之后你就会惊恐的发现所有的子应用都进不去了,每一个访问子应用的人都会被一脸问号的重定向回首页。

现在问题明了了,那么怎么解决?

怎么解决?

这个问题有两个解决方案,如果你的系统里没有额外的子应用或者入口页的话,只是有一些静态的资源想通过地址栏访问,那可以直接使用 includeAssets 来包含这个文件:

export default defineConfig({
  plugins: [
    VitePWA({
-     includeAssets: ["vite.svg"],
+     includeAssets: ["vite.svg", "test.pdf"],
      registerType: "autoUpdate",
      manifest: { /** ... */  },
    }),
  ],
});

重新 build 之后就可以发现 test.pdf 已经加入到预载列表里了。

image.png

现在再去地址栏里访问这个 pdf 就发现它可以正常访问了。


如果说你的项目确实比较大,有一些子应用的入口也在同一个网站下面,甚至这些入口是其他团队维护的,你都不知道哪些能通过地址栏访问。那我建议你直接关掉这个功能:

export default defineConfig({
  plugins: [
    VitePWA({
      includeAssets: ["vite.svg"],
      registerType: "autoUpdate",
      manifest: { /** ... */  },
+     workbox: {
+       navigateFallbackDenylist: [/./],
+     }
    }),
  ],
});

navigateFallbackDenylist 参数可以配置哪些路由不启用重定向,配置 /./ 就能让所有的路由都不再重定向。

要注意的是,把这个功能关掉需要有其他的后台服务来帮助前端实现 SPA 的 history 路由重定向。不过如果你的服务真的庞大到这个地步,那么应该早就已经有一个 docker nginx 或者 BFF 来做这些工作了。

不要尝试用 navigateFallbackDenylist 和 navigateFallbackAllowlist 一点点把你的可访问路由配置出来。因为你永远不知道下游团队能给你暴露出什么奇葩的玩意。

问题3:又能访问到新资源又能访问到老资源

这个问题相当抽象,排查起来异常困难,客户可能根本没法把问题清晰的描述出来。但是问题原因其实比较简单:

使用 navigateFallbackDenylist 禁用了 history 路由重定向,并且还缓存了 index.html

由于禁用了 sw 的 history 重定向,那么访问页面路由(例如 /detail/123)会让 sw 请求服务器,而访问根路由(/)则会让 sw 直接返回 index.html。

如果这时候服务器恰好更新了。那就会出现访问页面路由时服务器直接返回了新的 index.html,而直接访问根路由则会返回 sw 缓存的老 index.html。

sw 不也会更新缓存么?为什么还会返回老的?

浏览器发现 sw 更新之后,会先让新的 sw 预载缓存,等所有缓存预载完之后才会实际更新。

也就是说,根据缓存的大小和客户的网速,在发版之后客户第一次打开网址,到客户实际访问到新的网址。中间可能会有几十秒到几分钟的时间段仍会访问到老页面,问题就是在这一段时间内出现的。

所以说你可能会收到各种各样的投诉:

  • 你这接口怎么老是报错啊?(后端更新了前端 sw 还在慢慢刷缓存)
  • 你这是不是有缓存的?没事了,刷新下就好了,诶,不对没好。(用户在页面路由和根路由之间反复横跳)
  • 是不是前端没发版啊?我刷新了好多次了都不行,清空浏览器缓存也不行(用户在根路由一直刷新)

可以看到,这些甚至是比较高级的客户,他们甚至都知道怎么清缓存。但是依旧会遇到很多问题,而且你也很难解释。这种情况下其实只需要等几分钟,让 sw 的缓存刷新完毕就好了。但是你这投诉算是吃定了,而且每次发版都要吃几个。

怎么解决?

解决方案很简单,把 index.html 从缓存中排除就行了:

VitePWA({
  registerType: 'autoUpdate',
  manifest: false,
  workbox: {
    navigateFallbackDenylist: [/./],
    globIgnores: [
      'version.json',
      '404.html',
+      'index.html'
    ],
    runtimeCaching: [
      {
        urlPattern: /favicon.svg/,
        handler: 'NetworkFirst',
      },
    ],
  },
}),

这样相当于 sw 的功能退化成了一个纯粹的缓存提速工具。以后每次访问 index.html 都会去请求后端。

问题4:可变的静态资源也被强缓存了

有这么一个场景:打包出的静态资源只是个占位用的,实际环境上会有其他资源代替它存在。

比如你正在做一个标准产品,这个产品里有一些 logo 和 slogen 图片。在上线时如果客户要求的话,这些图标就会替换成客户的 logo。否则才会用自带的图片。

再比如首页里有几张宣传用的图片,这些图片平时不会变,但是客户会周期性的替换这些图片。

再再比如为了配合某些活动,你需要把系统自带的背景图换成新的,过段时间再换回来。

现在如果这些占位的资源是直接放在你的前端项目里的,而你的 pwa 配置又恰好把这些图片、字体、css 加入到了预载列表里。那你就会发现上线之后无论再怎么配置这些图片,客户的浏览器都不会去加载新的内容了。

原因其实很简单,现在大家的经验应该瞬间就能反应过来:sw 把默认占位的资源预载进缓存了,以后所有的请求实际上都是用的缓存,完全不会走网络请求了

无论你在前端给这些自定义配置做了什么机制,nginx 配置,304 协商缓存,hash 确认,都不会有效果了。所有请求都会无脑从缓存里读取。

怎么解决?

这个问题可以通过两方面来处理:

export default defineConfig({
  plugins: [
    VitePWA({
      includeAssets: ["vite.svg"],
      registerType: "autoUpdate",
      manifest: { /** ... */  },
      workbox: {
        navigateFallbackDenylist: [/./],
+       globIgnores: ['**\/node_modules\/**\/*', 'favicon.svg'],
+       runtimeCaching: [
+         {
+           urlPattern: /favicon.svg/,
+           handler: 'StaleWhileRevalidate',
+         },
+       ],
      }
    }),
  ],
});

首先是 globIgnores,这个是必配的,把你要排除的资源放在这里,sw.js 在生成时就不会把资源加入到预载缓存里。

然后是 runtimeCaching,这个用来指导 sw.js 在遇到不同资源时应该怎么处理。这里的 handler 是一个字符串,是 workbox 预设好的几个场景。我这里配置的就是当浏览器请求 favicon.svg 时,使用 SWR 模式请求。你可以在 这里 找到所有的配置。

SWR(Stale-while-revalidate),先用缓存响应,再静默更新缓存。现在的前端请求库基本都用到了这个模式。例如 use-request、use-swr、tanstack-query。

你也可以不配置 runtimeCaching。这样这个资源就会每次请求都走网络,其他的缓存机制就可以正常生效(浏览器缓存,协商缓存)。

但是我还是比较推荐配一个的,可以配一个 NetworkFirst。如果你前端服务更新的时候会出现短暂几秒加载不到资源的情况,那么这个配置就可以很好的把这个小毛病掩盖过去。

另外就是注意配置 globIgnores,这个的优先级很高,一旦文件进入预载列表了,那无论你 runtimeCaching 怎么配置都没用的,它会直接返回缓存里的资源。

总结

其实 PWA 的问题几乎都集中在预缓存的逻辑上,如果你的系统非常复杂,包含了数个其他团队维护的子系统,还有用微前端引入的其他应用,那就得留点心,最好对这些涉及到前端静态资源加载的地方多进行一些测试。还是那句话,如果你只是应付一下需求的话,不妨回到我们第一篇文章里讲的那样去做吧。

至此,我们的 PWA 开发指南就差不多结束了,希望看完之后能对你开发 PWA 功能有一些帮助。

如果还有其他问题或者疑问的话,欢迎评论区沟通交流哦。