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 发送消息时触发;push、sync等:需要额外浏览器支持,且不属于本文核心。
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: 新版本安装成功
这里有三个容易混淆的状态:
- 安装完成:新 Worker 已经执行完
install,但可能还在等待。 - 激活完成:新 Worker 已经执行完
activate。 - 控制页面:页面的请求实际由这个 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 已经重新构建,用户也可能继续拿到旧的入口文件。
四、一个可运行的最小实现
下面实现三个能力:
- 缓存一个离线回退页面;
- 静态资源优先使用缓存;
- 页面导航优先访问网络,失败时返回缓存的离线页面。
它不是完整的“所有页面都能离线运行”的实现,但足以展示 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() 的作用
install 和 activate 事件中的异步任务必须交给 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
网络请求可能返回 404、500 或其他错误响应。把错误页面写进资源缓存,会导致后续请求一直读到错误结果。因此简单实现一般只缓存状态码为 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 还可能保留旧版本。
五、缓存策略:请求类型决定数据流
缓存策略不是“缓存越多越好”,而是要先回答两个问题:
- 请求的数据是否允许短时间过期?
- 网络失败时是否有可以接受的替代结果?
常见策略如下。
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 拦截
→ 新缓存策略或新资源版本
如果新旧资源协议不兼容,页面可能出现运行时错误。因此更稳妥的更新流程通常是:
- 安装新 Worker;
- 页面提示“发现新版本”;
- 用户点击“立即更新”;
- 页面向 waiting Worker 发送消息;
- Worker 执行
skipWaiting(); - 页面监听
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_color和background_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,而是以下任一环节:
- 新的
sw.js没有真正部署; - 新 Worker 处于
waiting,没有接管页面; index.html或资源仍被 HTTP 缓存;- 旧 Service Worker 的 Cache Storage 仍命中;
- 注册作用域与实际页面路径不匹配;
- 多个 Service Worker 或多个缓存版本同时存在。
诊断步骤:
- 打开浏览器开发者工具的 Application/应用面板;
- 查看当前注册的 Service Worker URL 和 Scope;
- 查看状态是 installing、waiting 还是 activated;
- 查看当前页面是否有 controller;
- 查看 Cache Storage 中的缓存名和资源;
- 在 Network 面板确认响应来自网络、Memory Cache 还是 Service Worker;
- 临时执行 unregister 和清理站点数据,确认是否只是旧缓存问题;
- 修复部署流程后重新验证,而不是把“清缓存”当成正式解决方案。
页面中可以输出:
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: private、no-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 和浏览器平台决定是否允许安装
其中最重要的工程判断有三条:
- 静态哈希资源、入口 HTML、API 和导航请求不能使用同一种缓存策略;
- Service Worker 的安装、激活和接管是不同状态,更新提示与页面刷新必须显式设计;
- 离线回退、SPA 服务器回退和安装能力分别解决不同问题,不能相互替代。
只要把这些边界分别建模,Vue 负责的界面状态、Vite 负责的构建产物、Service Worker 负责的缓存生命周期,以及浏览器负责的安装流程就能保持清晰,故障也更容易定位和恢复。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue Source Map 与发布诊断:生成、上传、隐私和版本映射
- 下一篇:Vue Monorepo:Workspace、共享包、构建图、版本和边界
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论