Vue 基础体系 · 第 57/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue PWA:Service Worker、缓存更新、离线、安装和回退

PWA(Progressive Web App)不是 Vue 的一种组件类型,而是一组浏览器能力和工程约定:网页可以在安全上下文中注册 Service Worker,使用 Cache API 保存资源,通过 Web App Manifest 描述应用身份,并在满足浏览器条件时被安装到用户设备上。

Vue 只负责应用界面和运行时逻辑;Vite 负责构建、资源指纹和静态文件输出;Service Worker 负责拦截请求、读取缓存和处理部分离线场景。三者的职责不同,混在一起时最容易出现“开发环境正常、生产环境更新失败”或“页面能打开但数据永远是旧的”等问题。

本文示例基于 Vue 3、Composition API、TypeScript 和现代 Vite 工具链。示例中的手写 Service Worker 适合理解机制和实现简单回退;生产项目如果需要完整预缓存、依赖追踪和复杂缓存策略,可以使用 Workbox 或基于 Workbox 的 Vite PWA 插件,但其具体配置应以所使用版本的文档为准。


一、先区分 PWA 的几个组成部分

一个 Vue PWA 通常包含以下部分:

浏览器
├── Vue 应用:渲染界面、发起 API 请求、显示更新提示
├── Web App Manifest:应用名称、图标、启动方式、主题色
├── Service Worker:拦截请求、缓存资源、处理离线与更新
├── Cache Storage:Service Worker 可访问的缓存存储
└── HTTP 缓存:浏览器普通缓存,与 Cache Storage 不是同一个 API

其中最重要的边界是:

  • Service Worker 不是 Vue 组件,不能直接访问 DOM、window 或 Vue 响应式状态。
  • Cache Storage 不是 HTTP 缓存,它由 Service Worker 通过 caches.open()cache.match()cache.put() 显式操作。
  • Manifest 不会自动让网页离线,它只描述“如何安装和启动这个网页应用”。
  • 安装成功不等于离线能力完整,应用仍然需要 Service Worker 缓存必要资源。
  • SPA 的服务器回退不等于离线回退。服务器回退解决的是在线时刷新 /settings 找不到文件的问题;Service Worker 回退解决的是网络不可用时如何返回内容。

二、Service Worker 的运行模型

2.1 Service Worker 是什么

Service Worker 是运行在浏览器独立线程中的事件驱动脚本。它可以在页面之外处理若干事件,最常见的是:

  • install:新版本 Worker 被安装时触发;
  • activate:新版本 Worker 接管前触发;
  • fetch:受控制页面发起网络请求时触发;
  • message:页面向 Worker 发送消息时触发;
  • pushsync 等:需要额外浏览器支持,且不属于本文核心。

Service Worker 具有独立生命周期:

stateDiagram-v2
    [*] --> Parsed: 浏览器发现 sw.js
    Parsed --> Installing: 下载并执行新脚本
    Installing --> InstalledWaiting: install 成功
    Installing --> Redundant: install 失败
    InstalledWaiting --> Activating: 无旧 Worker 控制页面\n或收到 skipWaiting
    Activating --> Activated: activate 成功
    Activating --> Redundant: activate 失败
    Activated --> Running: 控制页面请求
    Running --> Updating: 浏览器再次检查脚本
    Updating --> InstalledWaiting: 新版本安装成功

这里有三个容易混淆的状态:

  1. 安装完成:新 Worker 已经执行完 install,但可能还在等待。
  2. 激活完成:新 Worker 已经执行完 activate
  3. 控制页面:页面的请求实际由这个 Worker 处理。

新 Worker 安装完成后,默认不会立即抢占仍被旧 Worker 控制的页面。这样可以避免同一个页面的 HTML、JavaScript 和缓存策略来自不同版本。

2.2 作用域决定哪些请求可以被拦截

如果注册代码是:

navigator.serviceWorker.register('/sw.js')

默认作用域通常是 /,因为脚本位于站点根路径。

如果注册:

navigator.serviceWorker.register('/pwa/sw.js')

默认作用域通常是 /pwa/,那么 /dashboard 不会被它控制。scope 只能扩大到脚本路径允许的范围;浏览器还会检查 Service-Worker-Allowed 响应头。

因此,部署在根路径的 Vue 应用通常把 Worker 放到:

public/sw.js

Vite 构建时会把 public 目录中的文件原样复制到构建输出目录,于是生成:

dist/sw.js

sw.js 必须最终位于它想控制的 URL 路径范围内。

2.3 安全上下文是前置条件

Service Worker 只能在安全上下文中使用:

  • 生产环境应使用 HTTPS;
  • http://localhost 和通常的本机开发地址被浏览器视为可信开发环境;
  • 普通 HTTP 远程站点不能注册 Service Worker。

注册前应检查:

if ('serviceWorker' in navigator) {
  // 可以尝试注册
}

这只是能力检测,不代表注册一定成功。HTTPS、响应 MIME 类型、作用域、脚本语法和服务器响应都可能导致注册失败。


三、Vite 构建结果与为什么资源要使用哈希文件名

Vite 在生产构建中通常会把模块输出为带内容哈希的文件,例如:

dist/
├── index.html
├── assets/
│   ├── index-Bx8dK2mA.js
│   └── index-C4mL91pQ.css
├── offline.html
├── manifest.webmanifest
└── sw.js

假设入口 JavaScript 的内容发生变化:

旧版本:assets/index-Bx8dK2mA.js
新版本:assets/index-N7p3Yx4R.js

哈希文件名使“新旧资源共存”成为可能:

  • 旧页面还在使用旧文件时,旧文件不会立刻被新文件覆盖;
  • 新的 index.html 指向新文件;
  • Service Worker 可以逐步清理不再使用的旧缓存。

index.html 通常没有哈希名,因此它特别适合使用 network-first 或短缓存策略。否则 HTML 被长期缓存后,即使 JavaScript 已经重新构建,用户也可能继续拿到旧的入口文件。


四、一个可运行的最小实现

下面实现三个能力:

  1. 缓存一个离线回退页面;
  2. 静态资源优先使用缓存;
  3. 页面导航优先访问网络,失败时返回缓存的离线页面。

它不是完整的“所有页面都能离线运行”的实现,但足以展示 Service Worker 的生命周期和回退机制。

4.1 创建离线页面

在项目中创建 public/offline.html

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>当前处于离线状态</title>
    <style>
      body {
        margin: 0;
        min-height: 100vh;
        display: grid;
        place-items: center;
        font-family: system-ui, sans-serif;
        color: #1f2937;
      }

      main {
        max-width: 32rem;
        padding: 2rem;
        text-align: center;
      }
    </style>
  </head>
  <body>
    <main>
      <h1>当前处于离线状态</h1>
      <p>网络恢复后,请重新加载页面。</p>
    </main>
  </body>
</html>

Vite 构建后,这个文件会出现在 /offline.html

4.2 编写 Service Worker

创建 public/sw.js

const CACHE_VERSION = 'v1';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const RUNTIME_CACHE = `runtime-${CACHE_VERSION}`;

const OFFLINE_URL = '/offline.html';

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE).then((cache) => {
      return cache.addAll([OFFLINE_URL]);
    })
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys
          .filter((key) => {
            return (
              key.startsWith('static-') &&
              key !== STATIC_CACHE
            ) || (
              key.startsWith('runtime-') &&
              key !== RUNTIME_CACHE
            );
          })
          .map((key) => caches.delete(key))
      );
    })
  );
});

self.addEventListener('fetch', (event) => {
  const request = event.request;

  // 只处理 GET 请求。POST、PUT、DELETE 等请求不能按同样方式缓存。
  if (request.method !== 'GET') {
    return;
  }

  // 页面导航:先访问网络,失败后返回离线页面。
  if (request.mode === 'navigate') {
    event.respondWith(networkFirstNavigation(request));
    return;
  }

  // 同源静态资源:缓存优先,缓存没有时访问网络并写入运行时缓存。
  const url = new URL(request.url);

  if (url.origin === self.location.origin) {
    event.respondWith(cacheFirstStatic(request));
  }
});

async function networkFirstNavigation(request) {
  try {
    const response = await fetch(request);

    // 只有成功响应才写入缓存。
    if (response.ok) {
      const cache = await caches.open(RUNTIME_CACHE);
      await cache.put(request, response.clone());
    }

    return response;
  } catch {
    const offlineResponse = await caches.match(OFFLINE_URL);

    return (
      offlineResponse ||
      new Response(
        '<!doctype html><meta charset="utf-8"><title>离线</title><h1>网络不可用</h1>',
        {
          status: 503,
          headers: {
            'Content-Type': 'text/html; charset=utf-8',
          },
        }
      )
    );
  }
}

async function cacheFirstStatic(request) {
  const cachedResponse = await caches.match(request);

  if (cachedResponse) {
    return cachedResponse;
  }

  try {
    const response = await fetch(request);

    if (response.ok) {
      const cache = await caches.open(RUNTIME_CACHE);
      await cache.put(request, response.clone());
    }

    return response;
  } catch {
    return new Response('', {
      status: 504,
      statusText: 'Gateway Timeout',
    });
  }
}

这个实现中有几个关键事实。

event.waitUntil() 的作用

installactivate 事件中的异步任务必须交给 event.waitUntil()

event.waitUntil(asyncOperation());

如果不这样做,浏览器可能在异步缓存操作完成前结束 Worker 事件。install 阶段的缓存失败会使安装失败;activate 阶段的清理失败也可能使激活失败。

为什么 response.clone()

Response body 通常只能消费一次。下面的代码先把响应返回给浏览器,再尝试写入缓存:

const response = await fetch(request);
await cache.put(request, response.clone());
return response;

clone() 创建了可分别消费的副本。如果直接 cache.put(request, response),之后再返回同一个已消费的响应,可能导致失败。

为什么只缓存 response.ok

网络请求可能返回 404500 或其他错误响应。把错误页面写进资源缓存,会导致后续请求一直读到错误结果。因此简单实现一般只缓存状态码为 2xx 的响应。

这不是绝对规则:某些应用确实需要缓存特定的 404 或业务错误响应,但必须显式设计缓存语义。

4.3 注册 Service Worker

src/registerServiceWorker.ts 中:

export async function registerServiceWorker(): Promise<
  ServiceWorkerRegistration | undefined
> {
  if (!('serviceWorker' in navigator)) {
    return undefined;
  }

  // 开发环境通常不注册,避免开发时被旧缓存干扰。
  if (!import.meta.env.PROD) {
    return undefined;
  }

  try {
    const registration = await navigator.serviceWorker.register('/sw.js', {
      scope: '/',
    });

    console.info('Service Worker registered:', registration.scope);

    registration.addEventListener('updatefound', () => {
      const worker = registration.installing;

      if (!worker) {
        return;
      }

      worker.addEventListener('statechange', () => {
        console.info('Service Worker state:', worker.state);

        if (worker.state === 'installed') {
          if (navigator.serviceWorker.controller) {
            console.info('发现新的 Service Worker 版本');
          } else {
            console.info('Service Worker 已完成首次安装');
          }
        }
      });
    });

    return registration;
  } catch (error) {
    console.error('Service Worker registration failed:', error);
    return undefined;
  }
}

src/main.ts 中调用:

import { createApp } from 'vue';
import App from './App.vue';
import { registerServiceWorker } from './registerServiceWorker';

createApp(App).mount('#app');

void registerServiceWorker();

构建并预览:

npm run build
npm run preview

预期结果通常是:

dist/sw.js 存在
dist/offline.html 存在
preview 服务器启动
浏览器访问预览地址后,Application/应用面板中可以看到 Service Worker

不能只执行 npm run dev 就断言生产 PWA 正常。开发服务器的资源路径、热更新脚本和缓存行为与生产构建不同,Service Worker 还可能保留旧版本。


五、缓存策略:请求类型决定数据流

缓存策略不是“缓存越多越好”,而是要先回答两个问题:

  1. 请求的数据是否允许短时间过期?
  2. 网络失败时是否有可以接受的替代结果?

常见策略如下。

5.1 Cache First:缓存优先

形式化地说:

结果 =
  cache(request),如果命中
  fetch(request) 并写入 cache,否则

适合内容地址稳定或文件名带内容哈希的静态资源:

index-Ab12.js
logo-Cd34.svg

优点是离线可用且速度快;缺点是缓存一旦命中,网络上的新版本不会立刻被发现。因此不能对无哈希的 index.html 盲目使用长期 cache-first。

5.2 Network First:网络优先

形式化地说:

结果 =
  fetch(request),如果成功
  cache(request),如果网络失败且缓存命中

适合 HTML 导航或用户希望优先看到最新数据的内容。

本文的导航回退使用此策略:

if (request.mode === 'navigate') {
  event.respondWith(networkFirstNavigation(request));
}

其故障路径是:

flowchart TD
    A[导航请求] --> B{网络请求成功?}
    B -->|是| C[返回网络响应]
    C --> D[可选: 写入缓存]
    B -->|否| E{离线页面是否在缓存?}
    E -->|是| F[返回 offline.html]
    E -->|否| G[返回 503 简易响应]

5.3 Stale While Revalidate:缓存先返回,后台更新

形式化地说:

若缓存命中:
  立即返回缓存
  后台 fetch 新响应并更新缓存
若缓存未命中:
  等待网络并缓存

这种策略能降低延迟,但本次请求看到的通常是旧数据。它适合头像、文章列表等允许短暂陈旧的资源,不适合余额、权限、库存等强一致性数据。

5.4 API 不应因为“离线”而随意缓存

Service Worker 可以拦截 API,但缓存 API 响应前必须明确:

  • 用户数据是否与账号绑定;
  • 是否存在隐私风险;
  • 响应是否包含过期时间;
  • 多标签页之间如何处理不同版本;
  • 用户登出后是否清理缓存;
  • 网络恢复后如何同步离线写操作。

尤其要区分两类离线功能:

  • 离线读:读取之前缓存的文章或应用资源;
  • 离线写:断网时提交表单,联网后再发送。

离线写不是简单的 cache.put()。它需要队列、幂等请求、重试策略和冲突处理。把 POST 请求直接缓存,不能自动实现可靠的离线提交。


六、预缓存、运行时缓存与真正的离线应用

上面的示例只在安装时预缓存了 offline.html,因此它保证的是:

离线时可以显示“当前处于离线状态”

它没有保证:

离线时可以完整启动 Vue 应用

要让 Vue SPA 在首次访问后断网仍能打开,至少需要缓存:

/index.html
入口 JavaScript
入口 CSS
代码分割后的必要 chunk
字体、图标和其他启动资源

6.1 为什么不能手写一个固定的 JS 文件名

Vite 会生成哈希文件名。下面这种写法很脆弱:

cache.addAll([
  '/',
  '/assets/index.js',
  '/assets/index.css',
]);

因为构建结果可能实际是:

/assets/index-Bx8dK2mA.js
/assets/index-C4mL91pQ.css

正确方案有两类。

方案一:构建时生成预缓存清单

构建插件读取 Vite 的产物清单,把实际生成的文件写进 Service Worker。Workbox 及其生态通常用于完成这件事。

其核心思想是:

构建输入
  → Vite 生成带哈希的产物
  → 生成 precache manifest
  → Service Worker 安装时缓存这些精确文件

这比手写文件名可靠,因为构建产物变化时,预缓存清单也随之变化。

方案二:入口导航动态缓存

可以在第一次在线访问时缓存导航返回的 HTML,并通过资源请求的 cache-first 逐步缓存脚本和样式。这种方式实现简单,但首次访问不一定把所有懒加载 chunk 都缓存下来;用户首次离线进入未访问过的路由时仍可能失败。

因此:

  • “离线显示一个回退页”可以手写 Worker;
  • “离线完整运行 SPA”通常需要构建期预缓存和明确的资源清单。

七、缓存更新:新 Worker 为什么不会立即生效

7.1 浏览器如何发现新版本

当页面注册:

navigator.serviceWorker.register('/sw.js');

浏览器会定期检查脚本是否变化。若 sw.js 的内容发生变化,浏览器下载并安装一个新的 Worker。

重要的是,更新比较的是 Worker 脚本本身,而不是缓存名称:

const CACHE_VERSION = 'v1';

修改 CACHE_VERSION 有助于清理旧缓存,但如果 sw.js 的脚本内容没有实际变化,仅期待通过改变缓存名触发 Worker 更新是不可靠的。生产构建通常会让 Worker 内容随预缓存清单变化,从而产生脚本差异。

7.2 默认更新路径

假设当前页面由 sw-v1 控制,服务器发布了 sw-v2

1. 页面注册 /sw.js
2. 浏览器发现脚本内容变了
3. 下载并安装 v2
4. v2 执行 install,缓存 v2 资源
5. v2 进入 waiting
6. 现有页面仍由 v1 控制
7. 旧页面关闭或不再受 v1 控制后,v2 才 activate
8. 之后新页面由 v2 控制

这种延迟接管保护了当前页面的一致性。否则旧页面可能加载旧 HTML,却被新 Worker 按新版本规则处理资源。

7.3 skipWaiting()clientsClaim() 的风险

可以在 Worker 中调用:

self.skipWaiting();

并在 activate 中调用:

self.clients.claim();

它们的含义大致是:

  • skipWaiting():让等待中的新 Worker 尽快跳过 waiting;
  • clients.claim():激活后尽快接管当前页面。

这会让更新更快,但不能自动解决资源混用问题。例如:

旧 index.html
→ 请求旧入口 JS
→ 新 Worker 拦截
→ 新缓存策略或新资源版本

如果新旧资源协议不兼容,页面可能出现运行时错误。因此更稳妥的更新流程通常是:

  1. 安装新 Worker;
  2. 页面提示“发现新版本”;
  3. 用户点击“立即更新”;
  4. 页面向 waiting Worker 发送消息;
  5. Worker 执行 skipWaiting()
  6. 页面监听 controllerchange 并重新加载。

7.4 一个可控的更新实现

public/sw.js 中加入:

self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    Promise.all([
      cleanOldCaches(),
      self.clients.claim(),
    ])
  );
});

async function cleanOldCaches() {
  const keys = await caches.keys();

  await Promise.all(
    keys
      .filter((key) => {
        return (
          key.startsWith('static-') &&
          key !== STATIC_CACHE
        ) || (
          key.startsWith('runtime-') &&
          key !== RUNTIME_CACHE
        );
      })
      .map((key) => caches.delete(key))
  );
}

在 Vue 侧可以封装一个组合式函数:

import { onMounted, onUnmounted, ref } from 'vue';

export function useServiceWorkerUpdate() {
  const updateReady = ref(false);

  let registration: ServiceWorkerRegistration | undefined;
  let onControllerChange: (() => void) | undefined;

  onMounted(async () => {
    if (!('serviceWorker' in navigator)) {
      return;
    }

    registration = await navigator.serviceWorker.getRegistration('/');

    if (!registration) {
      return;
    }

    const checkForUpdate = () => {
      if (registration?.waiting) {
        updateReady.value = true;
      }
    };

    registration.addEventListener('updatefound', () => {
      const worker = registration?.installing;

      worker?.addEventListener('statechange', () => {
        if (
          worker.state === 'installed' &&
          navigator.serviceWorker.controller
        ) {
          updateReady.value = true;
        }
      });
    });

    await registration.update();
    checkForUpdate();

    onControllerChange = () => {
      // 防止重复刷新逻辑由多个组件同时执行。
      window.location.reload();
    };

    navigator.serviceWorker.addEventListener(
      'controllerchange',
      onControllerChange
    );
  });

  onUnmounted(() => {
    if (onControllerChange) {
      navigator.serviceWorker.removeEventListener(
        'controllerchange',
        onControllerChange
      );
    }
  });

  function applyUpdate() {
    const waiting = registration?.waiting;

    if (!waiting) {
      return;
    }

    waiting.postMessage({ type: 'SKIP_WAITING' });
  }

  return {
    updateReady,
    applyUpdate,
  };
}

组件中使用:

<script setup lang="ts">
import { useServiceWorkerUpdate } from '@/useServiceWorkerUpdate';

const { updateReady, applyUpdate } = useServiceWorkerUpdate();
</script>

<template>
  <aside v-if="updateReady">
    <span>发现新版本。</span>
    <button type="button" @click="applyUpdate">
      立即更新
    </button>
  </aside>
</template>

这个示例需要注意两点:

  • registration.update() 会主动检查 Worker,但不是所有浏览器场景都应在每次渲染时调用;
  • controllerchange 可能触发多次,生产实现应确保只刷新一次,否则会出现刷新循环或多个组件重复刷新。

八、缓存清理与版本设计

缓存名称应包含可变版本:

const STATIC_CACHE = 'static-v2';
const RUNTIME_CACHE = 'runtime-v2';

当版本从 v1 改为 v2 时,activate 阶段删除旧缓存:

const keys = await caches.keys();

await Promise.all(
  keys
    .filter((key) => key !== STATIC_CACHE && key !== RUNTIME_CACHE)
    .map((key) => caches.delete(key))
);

但“按版本全部删除”并不总是安全:

  • 旧页面可能仍然由旧 Worker 控制;
  • 旧页面仍可能需要旧版本资源;
  • 多标签页可能同时运行不同版本;
  • 大缓存删除可能影响激活时间。

如果采用等待接管,新 Worker 通常应避免在旧页面仍使用时删除旧缓存,或者确保资源以内容哈希共存。缓存生命周期设计必须与 Worker 接管策略一起考虑。

另外,Cache Storage 没有无限容量保证。浏览器可能因存储压力清理缓存,隐私模式和移动设备上的行为也可能不同。Service Worker 缓存不能作为唯一数据持久化方案;需要可靠持久化时应考虑 IndexedDB,并仍然准备网络失败路径。


九、离线页面、SPA 回退和 HTTP 回退不是一回事

9.1 服务器回退

Vue Router 使用 HTML5 History 模式时,用户访问:

https://example.com/settings

服务器必须把这个 URL 返回到 Vue 应用的入口 HTML,否则直接刷新可能得到 404。

服务器回退处理的是:

在线 + 请求了一个前端路由
→ 服务器返回 index.html
→ Vue Router 再决定渲染哪个组件

9.2 Service Worker 的导航回退

Service Worker 处理的是:

离线 + 浏览器发起导航请求
→ fetch 失败
→ 返回缓存的 offline.html 或缓存的应用壳

它不等价于把所有未知 URL 都返回 index.html。如果离线时把任意请求都返回 Vue 入口,可能出现以下问题:

  • 用户访问不存在的路径,却看到应用界面而不是 404;
  • API 请求被错误地返回 HTML;
  • 图片、脚本请求得到 HTML,产生 MIME 或解析错误;
  • 浏览器历史和路由状态变得难以诊断。

因此应优先只对:

request.mode === 'navigate'

的请求进行导航回退,并明确区分应用路由、API 和静态资源。

9.3 应用壳回退

如果要让已访问过的 SPA 在离线时继续启动,可以缓存入口 HTML 和必要的静态资源:

const APP_SHELL = '/index.html';

async function appShellNavigation(request) {
  try {
    return await fetch(request);
  } catch {
    const shell = await caches.match(APP_SHELL);

    return shell || caches.match(OFFLINE_URL);
  }
}

但这仍有一个前提:入口 HTML 引用的 JS、CSS 和代码分割 chunk 也必须在缓存中。只缓存 index.html 而不缓存其依赖,离线启动仍会失败。


十、安装:Manifest、浏览器条件和 Vue UI

10.1 Web App Manifest 的作用

创建 public/manifest.webmanifest

{
  "name": "WR Blog",
  "short_name": "WR Blog",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2563eb",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

index.html<head> 中引用:

<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#2563eb" />

Manifest 描述的是安装后的应用元数据:

  • name:完整名称;
  • short_name:空间不足时显示的短名称;
  • start_url:从图标启动时打开的 URL;
  • display:例如 standalone
  • icons:安装图标;
  • theme_colorbackground_color:启动与系统 UI 的颜色提示。

Manifest 本身不是安装命令。浏览器还会综合考虑安全上下文、Manifest 内容、Service Worker、用户行为和平台策略。不同浏览器的安装条件和界面可能变化,不能把某一浏览器的条件当成跨浏览器规范保证。

10.2 beforeinstallprompt 的边界

Chromium 系浏览器可能触发 beforeinstallprompt,应用可以保存事件并在用户点击按钮时调用 prompt()。该事件不是所有浏览器都提供,TypeScript 的标准 DOM 类型也不一定包含完整定义,因此可自行声明一个最小类型:

// src/types/pwa.d.ts
interface BeforeInstallPromptEvent extends Event {
  readonly platforms: string[];
  prompt(): Promise<void>;
  userChoice: Promise<{
    outcome: 'accepted' | 'dismissed';
    platform: string;
  }>;
}

组合式函数:

import { onMounted, onUnmounted, ref } from 'vue';

export function useInstallPrompt() {
  const canInstall = ref(false);
  const installed = ref(false);

  let deferredPrompt: BeforeInstallPromptEvent | null = null;

  function onBeforeInstallPrompt(event: Event) {
    event.preventDefault();

    deferredPrompt = event as BeforeInstallPromptEvent;
    canInstall.value = true;
  }

  function onAppInstalled() {
    installed.value = true;
    canInstall.value = false;
    deferredPrompt = null;
  }

  onMounted(() => {
    window.addEventListener(
      'beforeinstallprompt',
      onBeforeInstallPrompt
    );

    window.addEventListener('appinstalled', onAppInstalled);
  });

  onUnmounted(() => {
    window.removeEventListener(
      'beforeinstallprompt',
      onBeforeInstallPrompt
    );

    window.removeEventListener('appinstalled', onAppInstalled);
  });

  async function install() {
    if (!deferredPrompt) {
      return false;
    }

    await deferredPrompt.prompt();
    const choice = await deferredPrompt.userChoice;

    deferredPrompt = null;
    canInstall.value = false;

    return choice.outcome === 'accepted';
  }

  return {
    canInstall,
    installed,
    install,
  };
}

组件:

<script setup lang="ts">
import { useInstallPrompt } from '@/useInstallPrompt';

const { canInstall, install } = useInstallPrompt();
</script>

<template>
  <button v-if="canInstall" type="button" @click="install">
    安装应用
  </button>
</template>

这里的流程是:

浏览器决定当前可安装
→ 触发 beforeinstallprompt
→ 应用保存事件
→ 用户主动点击“安装”
→ 调用 prompt()
→ 用户接受或拒绝
→ 读取 userChoice

不能在页面加载时无条件调用 prompt(),也不能假设事件一定触发。浏览器可能因为已经安装、用户拒绝过、Manifest 不完整、当前平台不支持或其他启发式条件而不提供该事件。

10.3 iOS 等平台的差异

部分平台不支持同样的 beforeinstallprompt 流程,用户需要通过浏览器菜单手动“添加到主屏幕”。因此安装按钮应当是条件显示的:

  • 支持事件并且事件已触发:显示安装按钮;
  • 不支持事件:可以显示平台相关的手动说明,但不能伪造自动安装;
  • 已安装:隐藏安装按钮或显示“已安装”状态。

“安装”是浏览器和操作系统参与的流程,Vue 只能提供提示和调用入口,不能保证安装结果。


十一、Service Worker 与 Vue 应用之间如何通信

Service Worker 不能直接修改 Vue 的 ref

// Service Worker 中不能这样做
someVueRef.value = true;

页面和 Worker 之间应通过消息通信:

Vue 页面
  ── postMessage ──> Service Worker
  <─ message ─────── Service Worker

页面向 Worker 发送消息:

const registration = await navigator.serviceWorker.getRegistration();

registration?.waiting?.postMessage({
  type: 'SKIP_WAITING',
});

Worker 接收消息:

self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

Worker 向页面发送消息:

self.clients.matchAll({ type: 'window' }).then((clients) => {
  for (const client of clients) {
    client.postMessage({
      type: 'CACHE_UPDATED',
      url: '/articles/1',
    });
  }
});

页面接收:

navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data?.type === 'CACHE_UPDATED') {
    console.info('缓存已更新:', event.data.url);
  }
});

消息数据应保持可结构化克隆的简单对象,不要尝试传递 Vue 响应式代理或不可序列化实例。


十二、故障路径与诊断方法

12.1 页面一直拿到旧版本

常见原因不是 Vue,而是以下任一环节:

  1. 新的 sw.js 没有真正部署;
  2. 新 Worker 处于 waiting,没有接管页面;
  3. index.html 或资源仍被 HTTP 缓存;
  4. 旧 Service Worker 的 Cache Storage 仍命中;
  5. 注册作用域与实际页面路径不匹配;
  6. 多个 Service Worker 或多个缓存版本同时存在。

诊断步骤:

  1. 打开浏览器开发者工具的 Application/应用面板;
  2. 查看当前注册的 Service Worker URL 和 Scope;
  3. 查看状态是 installing、waiting 还是 activated;
  4. 查看当前页面是否有 controller;
  5. 查看 Cache Storage 中的缓存名和资源;
  6. 在 Network 面板确认响应来自网络、Memory Cache 还是 Service Worker;
  7. 临时执行 unregister 和清理站点数据,确认是否只是旧缓存问题;
  8. 修复部署流程后重新验证,而不是把“清缓存”当成正式解决方案。

页面中可以输出:

console.log({
  controller: navigator.serviceWorker.controller?.scriptURL,
  ready: await navigator.serviceWorker.ready,
});

12.2 sw.js 注册失败

检查:

/sw.js 是否返回 200
Content-Type 是否适合 JavaScript
是否通过 HTTPS 或 localhost 访问
sw.js 是否包含语法错误
scope 是否覆盖当前页面
服务器是否错误地返回 index.html

特别要注意服务器配置。如果访问 /sw.js 得到的是 Vue 的 index.html,浏览器不会把它当作正确的 Worker 脚本。

12.3 离线时页面空白

“离线回退页面能显示”与“Vue 应用能离线运行”是不同目标。页面空白通常表示:

  • index.html 命中了缓存,但入口 JS 不在缓存;
  • 代码分割 chunk 未缓存;
  • Service Worker 没有控制当前页面;
  • API 请求在离线时失败,而应用没有错误状态;
  • 缓存中的资源版本互相不匹配。

应在离线前先确认:

首次在线加载是否完成
入口 HTML 是否已缓存
入口 JS/CSS 是否已缓存
当前路由所需 chunk 是否已缓存
API 是否有明确的离线状态

12.4 开发环境被旧 Worker 污染

Service Worker 一旦注册,会继续影响其作用域内的请求,即使当前正在运行 Vite 开发服务器。表现可能包括:

  • 修改代码后浏览器仍显示旧页面;
  • HMR 连接异常;
  • 开发服务器请求被生产缓存拦截;
  • 切换分支后出现与当前代码不一致的资源。

开发环境建议不注册 Worker;如果已经注册过,应在开发者工具中注销并清理站点数据。生产验证使用:

npm run build
npm run preview

并在单独的测试域名或端口验证,避免与开发站点共用注册范围。


十三、缓存 API 的安全和一致性边界

13.1 缓存键不仅是 URL

cache.match(request) 的匹配可能受请求方法、URL、请求头等因素影响。不能简单认为“同一个 URL 永远是同一份内容”。

例如内容依赖以下条件:

Authorization
Accept-Language
Cookie
查询参数
用户角色

如果把用户 A 的响应写入一个没有隔离策略的运行时缓存,用户 B 可能读到错误内容。对带身份信息的 API,至少应设计:

  • 是否禁止缓存;
  • 是否按用户或租户隔离;
  • 登出时是否清理;
  • 响应是否允许被缓存;
  • 缓存过期和失效条件。

13.2 GET 不是天然安全可缓存

GET 通常表示读取,但响应内容可能仍然是私有的。Cache-Control: privateno-store 等 HTTP 语义需要纳入设计。Service Worker 可以绕过普通 HTTP 缓存自己写 Cache Storage,但这不意味着可以忽略服务端的安全和隐私约束。

13.3 失败响应不能被静默吞掉

下面这种代码会把所有异常都变成空响应:

catch {
  return new Response('');
}

它虽然避免了未处理异常,却会掩盖真正的故障。更合理的做法是:

  • 对导航返回有明确提示的 HTML;
  • 对资源返回合适的失败状态;
  • 对 API 让页面显示“离线”或“稍后重试”;
  • 记录必要的诊断信息,但避免把敏感数据写入日志或缓存。

十四、版本发布与回退策略

Service Worker 更新实际上是一个客户端版本发布系统。发布新版本时至少要验证:

1. 新的 sw.js 是否已部署
2. 新 HTML 是否引用新哈希资源
3. 新 Worker 的 install 是否成功
4. 新 Worker 是否进入 waiting 或 activated
5. 新缓存是否包含所有启动资源
6. 旧缓存是否按预期保留或删除
7. 离线导航是否返回正确页面
8. API 失败时 Vue 是否显示可理解的状态

如果新版本的 install 失败,旧 Worker 通常仍可继续工作,这是 Service Worker 更新模型的保护机制。但如果应用主动调用 skipWaiting(),就必须确保新版本可以处理旧页面和旧缓存,否则会把保护窗口缩短。

回退发布时也不能只回滚服务器文件。客户端可能已经缓存了新版本的 Worker 和资源,恢复步骤应包括:

  • 发布一个能够识别并清理错误缓存的新 Worker;
  • 保证回滚版本的 sw.js 能成功安装;
  • 检查旧版本入口 HTML 和资源仍然可获取;
  • 必要时通过版本化缓存名称建立明确的恢复路径。

不要依赖“让用户手动清空缓存”作为生产回滚方案。


十五、几个典型反例

反例一:把所有请求都缓存

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('all').then(async (cache) => {
      const cached = await cache.match(event.request);

      if (cached) {
        return cached;
      }

      const response = await fetch(event.request);
      await cache.put(event.request, response.clone());
      return response;
    })
  );
});

问题包括:

  • 非 GET 请求可能无法按预期缓存;
  • API 可能包含用户私密数据;
  • 错误响应可能被永久保存;
  • 缓存容量快速增长;
  • 第三方请求、流式响应和不可缓存响应可能失败;
  • 没有版本清理和过期策略。

反例二:只更新缓存版本名,不处理旧 Worker

const CACHE_NAME = 'app-v2';

这只能影响当前脚本执行时使用的缓存名。它不能保证:

  • 浏览器立刻下载新 Worker;
  • 新 Worker 立刻接管页面;
  • 旧 Worker 不再控制其他标签页;
  • 旧缓存被正确删除。

缓存版本、Worker 更新、页面刷新是三个相关但不同的动作。

反例三:离线时所有 URL 都返回入口 HTML

catch {
  return caches.match('/index.html');
}

如果这段逻辑不限制 request.mode === 'navigate',脚本、图片和 API 请求也可能获得 HTML,结果是资源解析失败或业务逻辑收到错误格式的数据。

反例四:把 Manifest 当成离线方案

只添加:

<link rel="manifest" href="/manifest.webmanifest" />

不会自动缓存 Vue 资源,也不会自动处理网络失败。Manifest 解决安装描述,Service Worker 解决缓存和请求拦截,两者需要分别实现。


十六、如何选择实现复杂度

可以按目标选择实现方案:

目标 适合的实现
只希望用户安装应用 Manifest、图标、安装体验
离线时显示提示页 手写 Service Worker + offline.html
已访问页面可离线打开 导航缓存 + 运行时缓存
完整离线启动 Vue SPA 构建期预缓存所有必要产物
离线读取文章 明确的 API 缓存策略和过期策略
离线提交表单 请求队列、重试、幂等和冲突处理
大型项目、多种资源策略 Workbox 或对应的 Vite 集成方案

手写 Worker 的优势是机制透明、依赖少;代价是需要自行处理构建清单、缓存失效、边界请求和升级流程。使用工具链可以减少重复代码,但会引入版本敏感的配置和构建约束,必须验证生成的 Worker 实际行为,而不能只相信插件配置文件。


结语

Vue PWA 的核心不是添加一个 Manifest 或注册一段脚本,而是建立一条完整的数据流:

Vite 构建产物
  → Service Worker 安装并缓存资源
  → 页面被 Worker 控制
  → fetch 事件按请求类型选择策略
  → 网络成功返回并更新缓存
  → 网络失败使用缓存或离线回退
  → 新 Worker 安装、等待、激活并通知页面更新
  → Manifest 和浏览器平台决定是否允许安装

其中最重要的工程判断有三条:

  1. 静态哈希资源、入口 HTML、API 和导航请求不能使用同一种缓存策略;
  2. Service Worker 的安装、激活和接管是不同状态,更新提示与页面刷新必须显式设计;
  3. 离线回退、SPA 服务器回退和安装能力分别解决不同问题,不能相互替代。

只要把这些边界分别建模,Vue 负责的界面状态、Vite 负责的构建产物、Service Worker 负责的缓存生命周期,以及浏览器负责的安装流程就能保持清晰,故障也更容易定位和恢复。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。