Skip to content

离线 PWA 的隐形陷阱:当应用从不请求网络 ​

译注:本文翻译自 dev.to 作者 ohugonnot 的技术随笔,原文链接见文末。作者以自己为无口语能力儿童开发的图卡沟通应用 Mes mots 为例,讲述了一个"永不请求网络"的 PWA 在缓存、部署路径与静默更新上遇到的真实问题。原文发布于 2026 年 9 月 17 日。

在上一篇文章中,作者讲述了为什么要开发 Mes mots——一款面向尚不能开口说话的孩子的图卡沟通应用。这个项目的一条硬性约束是:它必须在飞行模式下可用,而承载它的平板可能连续数天都连不上 Wi-Fi。本文只讨论这一点:如何构建一个真正离线的应用,尤其是它如何在长期断网的情况下依然完成更新。

"离线优先"的常见误解 ​

大多数打着"离线优先"标签的应用,背后其实都有服务器。Trello、Google Docs、邮件客户端都是如此:它们做缓存,允许离线写入,等网络恢复后再同步并解决冲突。这是一个真实的工程难题——冲突解决、向量时钟、三方合并,相关文献大多围绕这些展开。

Mes mots 没有这个问题,因为它根本没有服务器。没有需要同步的东西,因为另一端没有人。偶尔从网络"到达"的唯一东西,是应用自身代码的新版本,而不是数据。这属于一个更罕见的类别:不是带网络兜底的离线优先,而是彻底的离线,外加偶发的更新。

这消除了一整类问题(没有冲突、没有需要刷新的鉴权令牌、没有需要展示的部分同步状态),却引入了另一个更狭窄也更奇怪的问题:如何让代码更新抵达,同时完全不打扰用户当前正在做的事,而且除了用户正在看的这块屏幕之外,没有任何其他方式与他们沟通。

需要缓存什么,以及那个总被遗忘的文件 ​

项目使用 vite-plugin-pwa 插件,它通过一份简单的声明式配置生成由 Workbox 驱动的 service worker。起点是预缓存文件列表:

typescript
workbox: {
  // phrase MP3s must be cached: without them the app is mute offline
  globPatterns: ['**/*.{js,css,html,png,svg,woff2,mp3}'],
  // a previous version's cache has already served a bundle gone from disk, which
  // silently hid a shipped feature: the family would stay on an old version
  // without knowing it, with no way to notice
  cleanupOutdatedCaches: true,
},

陷阱藏在一个被遗忘的扩展名里。{js,css,html,png,svg,woff2} 这个模式能覆盖典型前端构建产出的所有东西,但这个应用是会"说话"的:每一张图卡都通过播放一个 .mp3 文件来念出它的词。如果列表里没有 mp3,应用安装完全正常,离线打开毫无报错,图卡正常渲染,一切看起来都没问题——但扬声器里什么都不会出来。控制台没有异常,没有错误页面,只有一片沉默,而家长会在飞行模式下、在最糟糕的时刻发现它。

这就是 PWA 缓存 bug 的本质:它不破坏任何东西,只是悄悄移除了一项功能。唯一的安全网,是显式列出应用在无网络下运转所需的一切,而不只是代码。

根域名、子目录,以及没有报错的白屏 ​

应用运行在两个地方:家庭平板上,服务于某个域名的根路径(anime-sanctuary.net);以及 GitHub Pages 上的公开演示,位于 /mes-mots/ 之下。service worker 及其预缓存资源的作用域绑定在特定的 base URL 上,而为根路径构建的 PWA 在子目录下服务时,会去高一层的位置寻找自己的文件。

typescript
/**
 * The tablet serves the app at its domain root. The public demo lives under
 * `/mes-mots/` on GitHub Pages, and without this prefix it would look for its files
 * one level too high: blank page, no readable error. `BASE_PUBLIQUE` exists only for it.
 */
const BASE = process.env.BASE_PUBLIQUE ?? '/'

一个环境变量,在构建时读取一次,决定这两种情况。这里的教训不是变量本身,而是失败模式:错误的 base 路径不会抛出可以在 devtools 里读到的清晰异常,它只会产生一个白屏。这是那种无法远程调试的 bug——尤其当它出现在一个没有终端的用户的平板上。

一次未经请求就抵达的更新 ​

插件配置中的 registerType: 'autoUpdate' 意味着 service worker 一看到网络就下载新版本,在后台安装,并自行接管,没有"有可用更新"的横幅需要点击。这种模式没有告诉你的是:已经打开的页面会继续运行旧的 JavaScript,直到完整刷新。

浏览器暴露了一个事件来获知这一点:controllerchange,当新的 service worker 刚刚接管页面时触发。

typescript
/**
 * The new service worker takes over on its own, but the displayed page keeps running
 * the old one until a reload: it took two loads to see a fix, and the listener only
 * lived in the parents screen, absent from the child's screen. Here it starts at
 * boot. `controller` doesn't exist on the very first install, which would otherwise
 * look like an update.
 */
const updateReady = ref(false)
if (navigator.serviceWorker?.controller) {
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    updateReady.value = true
  })
}

注释记录了一个真实修复过的回归问题:这个监听器最初只存在于家长界面,从孩子界面永远看不到,而孩子界面才是全天候运行的那个。结果就是,service worker 拿到的更新只有在家长打开自己的空间时才会被检测到,而这可能要等上好几天。对 controller 的判断则反过来避免了把首次安装(此时还没有 controller)误认为是一次更新。

绝不在图卡说话时刷新 ​

检测到更新就绪,并不等于知道何时应用它。立即刷新页面会打断一个正在念出的词,抹掉句子栏上正在拼装的短语,或丢失家长在设置界面填了一半的表单。对于一个不理解屏幕为何突然变化的孩子来说,每一种都是糟糕的意外。

解决办法是定义一个空闲状态,只在此时应用更新:

typescript
/**
 * The reload waits until the tablet isn't serving anyone: otherwise it would cut off
 * a word mid-speech, erase a phrase being composed, or lose a parent's half-filled
 * form. At rest, it goes unnoticed.
 */
const tabletIdle = computed(
  () =>
    mode.value === 'child' &&
    speakingTileId.value === null &&
    !isReadingPhrase.value &&
    phrase.value.length === 0,
)
watch([updateReady, tabletIdle], () => {
  if (updateReady.value && tabletIdle.value) location.reload()
})

四个条件必须同时成立:显示的是孩子界面(而非家长空间)、当前没有图卡在发声、句子栏没有被朗读、已拼装的短语为空。刷新会耐心等待这四项全部变绿,然后才触发。对于一个全天运行、从不关闭的应用来说,这个时刻总会到来,通常就在几分钟之内。

这条规则可以推广到远超本项目的范围:静默的后台更新对于一个可以随手刷新的博客或仪表盘是无害的,但一旦页面状态具有价值——进行中的表单、播放中的媒体、未保存的输入——它就变得危险。此时你需要一个显式的安全点,而不只是一个定时器。

不打开 devtools 也能知道运行的是哪个版本 ​

最后一个细节,代码上很小,实践中却很关键:每次构建都会打印自己的构建日期。

typescript
/** Build date, shown in the parents screen: "which version is running?" should be
 *  answered by looking at the screen, not by digging through a service worker cache. */
const BUILD_VERSION = new Date().toISOString().slice(0, 16).replace('T', ' ')

开发者诊断缓存问题时会打开 devtools 的 Application 面板。家长永远不会打开那个面板,也不应该需要打开。把版本号明明白白地显示在家长界面上,就把一个调试问题("service worker 到底有没有拿到最新版本?")变成了任何人看一眼屏幕就能回答的问题。

结语 ​

在网络开发中,我们已经学会对网络保持警惕:超时、重试、加载状态,一整套词汇都围绕它的缺席而建立。而对于一次悄无声息抵达的更新,这种警惕要少得多。构建一个从不请求网络的应用,并不会消除复杂性,它只是把复杂性挪到了一个你很少思考的地方:在某人正站在地面上时,究竟哪一刻才是抽换他脚下地面的安全时机。

原文链接 ​