离线优先(Offline First)并不是让应用在断网时“假装能用”,而是把网络当作增强手段而非必需条件。在 Vue 3 项目中实现这一目标,Service Worker 是绕不开的核心技术,但真正落地的难点在于工程化:缓存策略怎么选、构建产物怎么预缓存、接口数据断网后从哪读、新版本发布后旧缓存怎么清理。这篇文章就围绕这些问题,给出一套可以直接在 Vite + Vue 3 项目中落地的方案。

一、离线优先的缓存策略选型
Service Worker 的本质是一个可编程的网络代理,所有经过它拦截的请求,都可以由你决定是走缓存、走网络,还是两者结合。不同的资源适合不同的策略,这是离线优先设计的第一步,也是最重要的一步。
对于构建产物的静态资源(JS、CSS、字体、图片),通常采用缓存优先(Cache First)策略。因为这些文件名带有内容哈希,内容变更时文件名必然变化,缓存永远不需要担心过期问题。命中缓存的速度远快于网络请求,首屏加载可以快一个量级。
对于 HTML 入口文件,推荐使用网络优先(Network First)策略:优先拉取最新的 HTML 以保证用户能拿到最新版本,网络失败时回退到缓存,保证离线可用。如果对实时性要求极高,还可以使用仅网络(Network Only)配合超时降级。
对于接口数据,最合适的是过期重新验证策略,也就是著名的 StaleWhileRevalidate:先立刻返回缓存中的旧数据让页面先渲染出来,同时在后台发起网络请求更新缓存,下次访问就是新数据。这种策略在体验上做到了“秒开”与“最终一致”的平衡。
// 原生实现一个简单的 StaleWhileRevalidate 策略
self.addEventListener('fetch', (event) => {
const { request } = event;
// 只处理 GET 请求,其余直接放行
if (request.method !== 'GET') return;
event.respondWith(
caches.open('api-cache-v1').then(async (cache) => {
// 尝试读取缓存
const cached = await cache.match(request);
// 后台更新缓存,不阻塞响应
const fetchPromise = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
});
// 有缓存先返回缓存,否则等待网络
return cached || fetchPromise;
})
);
});上面这段原生代码能帮助理解原理,但真实项目不建议手写,原因在于边界情况太多:跨域请求、Range 请求、opaque 响应的缓存处理都容易出错。工程化实践里更成熟的做法是用 Workbox,它把上述策略封装成了开箱即用的策略类,并处理了大量边界情况。
二、在 Vite + Vue 3 中集成 Workbox
Vite 生态下最常用的插件是 vite-plugin-pwa,它底层依赖 Workbox,能够在构建时自动完成预缓存清单生成、Service Worker 文件产出和注入注册逻辑。先安装依赖:
npm install vite-plugin-pwa -D
然后在 vite.config.js 中配置:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico'],
manifest: {
name: '离线优先的 Vue 应用',
short_name: 'VueApp',
theme_color: '#42b883',
icons: [
{
src: '/pwa-192.png',
sizes: '192x192',
type: 'image/png'
}
]
},
workbox: {
// 预缓存所有构建产物
globPatterns: ['**/*.{js,css,woff2,png,svg}'],
navigateFallback: '/index.html',
runtimeCaching: [
{
// 接口数据使用 StaleWhileRevalidate
urlPattern: /^https:\/\/api\.ipipp\.com\/.*$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 200,
maxAgeSeconds: 60 * 60 * 24 // 一天
},
cacheableResponse: { statuses: [0, 200] }
}
},
{
// 图片资源使用 Cache First
urlPattern: /\.(?:png|jpg|jpeg|webp)$/i,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 300,
maxAgeSeconds: 60 * 60 * 24 * 30
}
}
}
]
}
})
]
});这段配置中有几个关键点值得展开。第一,globPatterns 决定了哪些构建产物会被预缓存,建议只缓存体积可控的文件,大文件应交给运行时缓存。第二,navigateFallback 指定了导航请求的回退页面,当用户离线打开一个未缓存的路径时,会回退到 index.html,再由 Vue Router 接管路由渲染,这样 SPA 在离线状态下才能正常切换页面。第三,runtimeCaching 数组按顺序匹配,匹配到即应用对应策略,规则要从具体到通用排列。
关于注册时机,vite-plugin-pwa 默认注入的注册脚本会在 load 事件后注册,避免和首屏关键资源竞争带宽。如果你选择手动注册,也务必遵守这个原则:
// main.js 中手动注册,注意放在 window load 之后
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((err) => {
console.error('Service Worker 注册失败:', err);
});
});
}还需要提醒一个常见误区:Service Worker 的作用域由其文件位置决定,放在根目录的 /sw.js 可以拦截整站请求;如果放在 /js/sw.js,除非在响应头中设置 Service-Worker-Allowed,否则只能拦截 /js/ 路径下的请求。Vite 插件默认处理好了这个问题,但自己托管 SW 文件时要特别注意。
三、版本更新与缓存清理的工程化处理
离线优先最大的隐患不是断网,而是发版后的旧缓存残留。用户可能停留在旧版本页面上操作,提交的数据结构和接口早已变更,这类问题排查起来非常痛苦,所以版本管理必须从第一天就设计好。
registerType: 'autoUpdate' 模式下,Workbox 每次构建会生成新的预缓存清单,SW 文件内容变化后浏览器会自动触发更新流程:下载新 SW、安装新缓存、激活时删除旧缓存。这个清理逻辑由 cleanupOutdatedCaches 控制,插件默认开启。但对于正在运行的旧页面,默认行为是等用户关闭所有标签页后才切换,这在实际使用中意味着更新可能被无限期拖延。
更好的做法是引入更新提示,让用户主动刷新。在 Vue 3 中可以监听 SW 更新事件,通过一个响应式的状态控制提示条:
// main.js
import { createApp } from 'vue';
import App from './App.vue';
const app = createApp(App);
// vite-plugin-pwa 提供的虚拟模块
import { registerSW } from 'virtual:pwa-register';
const updateSW = registerSW({
onNeedRefresh() {
// 有新版本待激活,通知用户
window.dispatchEvent(new CustomEvent('sw-update-available'));
},
onOfflineReady() {
console.log('应用已具备离线能力');
}
});
// 把更新函数挂到全局,供提示条调用
window.__applySwUpdate = () => updateSW(true);
app.mount('#app');
<!-- App.vue 中的更新提示条 -->
<template>
<transition name="fade">
<div v-if="needRefresh" class="update-bar">
发现新版本,建议刷新页面
<button @click="applyUpdate">立即更新</button>
</div>
</transition>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const needRefresh = ref(false);
const onAvailable = () => (needRefresh.value = true);
onMounted(() => window.addEventListener('sw-update-available', onAvailable));
onUnmounted(() => window.removeEventListener('sw-update-available', onAvailable));
function applyUpdate() {
window.__applySwUpdate?.();
}
</script>对于接口数据的离线写入,单纯的 StaleWhileRevalidate 只能缓存“读”的结果。如果业务需要在断网时也能提交数据,就需要引入本地队列:把离线期间的写请求存入 IndexedDB,监听 navigator.onLine 或 online 事件,恢复联网后按顺序重放。注意重放时要处理冲突,比如服务端数据已变更的情况,最稳妥的方案是给每次提交携带客户端时间戳或版本号,由服务端做幂等判断。
最后是灰度与回滚。建议在构建时给 SW 加上版本号,并在 install 阶段做预缓存完整性校验,任何一个文件下载失败就中止安装,避免出现“半更新”状态导致白屏。生产环境出现问题时,可以通过服务端下发配置直接禁用 SW 注册(返回 404 或特定标记),并在 activate 中清理所有历史缓存,让应用退回纯在线模式,这是离线优先架构必须预留的逃生通道。
四、常见坑点与排查思路
实践中最容易踩的坑有四类。一是开发环境的缓存污染:SW 在 localhost 上同样生效,热更新时代码变了但缓存还是旧的,表现极其诡异,建议开发环境通过 devOptions.enabled 单独控制,排查时记得在 DevTools 的 Application 面板里手动 Unregister。二是跨域请求缓存失败:未配置 CORS 的跨域资源会返回 opaque 响应,状态码为 0,缓存后无法读取内容,还占用缓存空间,需要在 cacheableResponse 中明确允许的状态码,并评估 opaque 响应的体积限制。三是缓存了错误的 HTML:如果对 HTML 也使用 Cache First 且没有版本机制,用户会被锁死在旧版本,这是离线优先方案里最危险的配置。四是注册路径与作用域不匹配,表现为部分页面离线可用、部分不行。
排查这些问题时,Chrome DevTools 的 Application 面板是第一工具:查看 Cache Storage 中实际缓存了哪些资源、SW 的状态是 activating 还是 waiting,再配合 Network 面板中请求的 from ServiceWorker 标记,基本可以定位绝大多数缓存问题。把这套思路和前面的配置结合起来,就能在 Vue 3 项目中稳定地落地一套离线优先的工程化方案。
Vue 3Service Worker离线优先修改时间:2026-09-09 14:40:35