8.2 离线支持:Service Worker实现基本离线功能
在弱网或断网场景下,用户对应用的“可用性”需求远高于“实时性”。想象一下:用户在地铁上打开你的Vue3应用,却因网络中断看到空白页面——这种体验会直接导致用户流失。Service Worker(以下简称SW)作为浏览器在后台运行的脚本,能拦截网络请求、管理缓存资源,是实现“离线可用”的核心技术。本节将结合TypeScript与Vue3生态,从原理到实战,完整落地离线功能,同时避开开发中的高频“坑点”。
8.2.1 核心原理:Service Worker是什么?
SW是浏览器提供的独立于网页主线程的后台线程,本质是一段运行在浏览器后台的JavaScript脚本,具备“拦截请求、操作缓存、后台同步”三大核心能力,其工作流程与核心特性可概括为以下三点:
1. 生命周期:从注册到激活的完整链路
SW的生命周期完全独立于网页,分为“注册→安装→激活→运行”四个阶段,每个阶段都有明确的触发条件和核心任务:
- 注册(Register):由网页主线程(如Vue组件)发起,告知浏览器SW脚本的路径(如
/service-worker.js)。仅在HTTPS环境(或localhost调试环境)下生效,这是浏览器的安全限制。 - 安装(Install):浏览器下载并解析SW脚本后触发,核心任务是缓存“核心静态资源”(如首页HTML、Vue运行时JS),通过
caches.open()创建缓存空间,cache.addAll()批量缓存资源。若此阶段报错,SW会被废弃。 - 激活(Activate):安装完成后触发,此时SW尚未拦截请求。核心任务是“清理旧缓存”(如删除上一版本的缓存资源),通过
self.clients.claim()获取当前页面控制权,确保新SW立即生效。 - 运行(Fetch/Message):激活后SW进入运行状态,可监听
fetch事件拦截所有网络请求,根据缓存策略返回资源;通过message事件与网页主线程通信(如接收“清理缓存”指令)。
2. 核心能力:离线可用的技术支撑
SW通过“缓存API + 事件监听”实现离线功能,核心依赖以下浏览器API:
- Cache API:用于创建缓存空间、存储/读取/删除缓存资源,支持按“缓存名称”分类管理(如区分“静态资源缓存”和“API数据缓存”)。
- Fetch API:拦截网页发起的所有
fetch请求,结合Cache API实现“缓存优先”“网络优先”等策略。 - Client API:实现SW与网页主线程的通信,如通知页面“缓存已更新”“网络已恢复”。
3. 与Vue3的协同:主线程与SW的通信模型
Vue3应用的主线程(组件、工具类)与SW是“分离但协同”的关系:
- 主线程负责“触发操作”:如注册SW、发送“清理缓存”指令、展示离线状态。
- SW负责“后台执行”:如拦截请求、管理缓存、响应主线程指令。
- 通信桥梁:通过
postMessageAPI实现双向通信,结合TypeScript类型定义确保通信数据的规范性。
8.2.2 前置认知:离线功能的核心设计原则
在动手编码前,需明确离线功能的设计边界——并非所有资源都需要缓存,盲目缓存会导致“资源冗余”和“数据滞后”。基于Vue3应用的特性,需遵循以下三大原则:
-
缓存分层策略:按资源类型差异化处理
- 核心静态资源(首页HTML、Vue运行时JS、全局CSS):强制缓存,优先从缓存返回,确保离线时能打开应用。
- 非核心静态资源(商品图片、非首页组件JS):按需缓存,网络请求失败时再返回缓存。
- API数据(如商品列表、用户信息):网络优先,离线时返回缓存兜底,避免展示过期数据。
- 敏感请求(支付接口、验证码接口):禁止缓存,仅通过网络请求,避免安全风险。
-
版本化缓存管理:解决“新功能不生效”问题
为每个版本的缓存添加唯一标识(如static-cache-v20250510),在SW激活阶段删除非当前版本的缓存,避免“旧缓存残留导致新功能无法加载”的问题。 -
状态可视化:管理用户预期
Vue组件需实时感知“网络状态”(在线/离线)和“SW状态”(激活/未注册),通过视觉提示(如离线banner)告知用户当前状态,避免用户因“操作无反馈”产生困惑。
8.2.3 实战基础:Vue3+TS环境的SW配置
在Vue3+TypeScript+Vite项目中,需先完成依赖配置与环境适配,为SW开发铺路。
1. 环境依赖与Vite配置
SW脚本需单独编译(浏览器无法直接识别TypeScript),且Vite需支持SW脚本的打包。通过以下步骤完成配置:
步骤1:安装依赖
# 用于SW的TypeScript类型支持
npm install -D @types/service-worker
# Vite插件:自动生成静态资源清单,避免手动维护缓存列表
npm install -D vite-plugin-pwa
步骤2:Vite配置调整(vite.config.ts)
开启HTTPS(SW安全要求),配置PWA插件自动生成资源清单:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
vue(),
// PWA插件配置:生成SW资源清单
VitePWA({
// 关闭自动生成SW,我们手动编写以控制缓存策略
injectRegister: null,
// 生成静态资源清单(dist/asset-manifest.json)
manifest: false,
workbox: {
// 禁用workbox默认逻辑,完全自定义SW
disableDevLogs: true,
injectManifest: {
// 自定义SW脚本路径
swSrc: 'src/service-worker.ts',
// 打包后SW脚本路径(根目录,确保scope覆盖整个应用)
swDest: 'dist/service-worker.js'
}
}
})
],
// 开发环境开启HTTPS,满足SW安全要求
server: {
https: true
},
// 构建配置:确保静态资源路径正确,便于SW缓存
build: {
assetsDir: 'assets',
rollupOptions: {
output: {
// 静态资源按类型分类,便于SW匹配
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
chunkFileNames: 'assets/chunk/[name]-[hash].js',
entryFileNames: 'assets/entry/[name]-[hash].js'
}
}
}
});
2. TypeScript类型扩展
SW脚本运行在独立环境中,TypeScript默认无法识别self.caches、self.clients等全局API,需创建类型声明文件扩展ServiceWorkerGlobalScope接口:
// src/types/service-worker.d.ts
declare global {
interface ServiceWorkerGlobalScope {
// 缓存API
caches: CacheStorage;
// 客户端管理API
clients: Clients;
// 消息事件监听
addEventListener(type: 'message', listener: (event: ExtendableMessageEvent) => void): void;
}
}
// 确保此文件被视为模块
export {};
在SW脚本中导入该类型文件,即可消除类型报错:
// src/service-worker.ts
import '../types/service-worker.d.ts';
8.2.4 核心实现:SW脚本与Vue3协同
离线功能的核心是“SW脚本+Vue3主线程协同”——SW负责缓存管理与请求拦截,Vue3负责状态展示与用户交互。以下分模块实现完整功能。
模块1:Service Worker核心脚本(TypeScript)
SW脚本需实现“缓存初始化、请求拦截、缓存更新、消息通信”四大核心逻辑,采用“版本化缓存+分层策略”设计,避免常见坑点。
// src/service-worker.ts
import '../types/service-worker.d.ts';
// 1. 配置常量:版本化缓存+核心资源
const CACHE_CONFIG = {
// 缓存版本:发布新功能时更新此版本号
VERSION: 'v20250510',
// 缓存名称:按类型分类,便于管理
CACHE_NAMES: {
STATIC: `static-cache-${CACHE_CONFIG.VERSION}`, // 静态资源缓存
API: `api-cache-${CACHE_CONFIG.VERSION}`, // API数据缓存
FALLBACK: `fallback-cache-${CACHE_CONFIG.VERSION}` // 离线兜底资源缓存
},
// 核心静态资源:离线必须加载的资源(安装阶段缓存)
CORE_STATIC_RESOURCES: [
'/', // 首页HTML
'/index.html',
'/assets/entry/main-*.js', // 匹配Vite打包后的入口JS(哈希自动匹配)
'/assets/ext/vue-*.js', // Vue运行时资源
'/assets/ext/style-*.css' // 全局CSS
],
// 离线兜底资源:无网络且无缓存时返回的资源
FALLBACK_RESOURCES: {
'/offline': '/assets/offline.html',
'image': '/assets/offline.png'
},
// 缓存策略配置:按请求类型匹配策略
STRATEGY_CONFIG: {
// 静态资源:缓存优先,同时更新缓存(确保下次是最新资源)
static: (request: Request) => handleStaticRequest(request),
// API数据:网络优先,离线用缓存兜底
api: (request: Request) => handleApiRequest(request),
// 敏感请求:仅网络,不缓存
noCache: (request: Request) => fetch(request),
// 兜底策略:返回离线页面
fallback: (request: Request) => handleFallbackRequest(request)
}
};
// 2. 工具函数:判断请求类型
function isCoreStaticResource(request: Request): boolean {
const url = new URL(request.url);
return CACHE_CONFIG.CORE_STATIC_RESOURCES.some(pattern => {
// 处理带通配符的资源匹配(如/main-*.js)
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
return regex.test(url.pathname);
});
}
function isStaticResource(request: Request): boolean {
const url = new URL(request.url);
const staticExts = ['js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'woff2'];
const ext = url.pathname.split('.').pop() || '';
// 静态资源且非跨域
return staticExts.includes(ext) && url.origin === self.location.origin;
}
function isApiResource(request: Request): boolean {
const url = new URL(request.url);
// 匹配后端API路径(根据实际业务调整)
return url.pathname.startsWith('/api/') && request.method === 'GET';
}
function isNoCacheResource(request: Request): boolean {
const url = new URL(request.url);
// 敏感请求:支付、验证码、登录接口(不缓存)
const noCachePaths = ['/api/pay/', '/api/captcha/', '/api/login/'];
return noCachePaths.some(path => url.pathname.startsWith(path));
}
function isImageResource(request: Request): boolean {
return request.destination === 'image';
}
// 3. 缓存策略实现:静态资源处理
async function handleStaticRequest(request: Request): Promise<Response> {
const cache = await caches.open(CACHE_CONFIG.CACHE_NAMES.STATIC);
// 1. 先从缓存获取资源
const cachedResponse = await cache.match(request);
// 2. 同时发起网络请求更新缓存(后台更新,不阻塞当前请求)
const fetchPromise = fetch(request).then(networkResponse => {
if (networkResponse.ok) {
// 仅缓存200状态的资源
cache.put(request, networkResponse.clone());
}
return networkResponse;
});
// 3. 缓存有资源则返回,否则等待网络请求
return cachedResponse || fetchPromise;
}
// 4. 缓存策略实现:API数据处理
async function handleApiRequest(request: Request): Promise<Response> {
const cache = await caches.open(CACHE_CONFIG.CACHE_NAMES.API);
try {
// 1. 优先发起网络请求
const networkResponse = await fetch(request);
if (networkResponse.ok) {
// 2. 网络请求成功,更新缓存(缓存有效期10分钟,避免数据过期)
const responseToCache = new Response(networkResponse.clone().body, {
headers: {
...networkResponse.headers,
'Cache-Control': 'max-age=600', // 10分钟有效期
'X-Cache-Time': Date.now().toString()
}
});
cache.put(request, responseToCache);
}
return networkResponse;
} catch (error) {
// 3. 网络失败,从缓存获取
const cachedResponse = await cache.match(request);
if (cachedResponse) {
// 检查缓存是否过期(超过10分钟则返回兜底)
const cacheTime = Number(cachedResponse.headers.get('X-Cache-Time') || 0);
if (Date.now() - cacheTime < 600 * 1000) {
return cachedResponse;
}
}
// 无缓存或缓存过期,返回离线提示
return new Response(JSON.stringify({ code: -1, msg: '离线状态,数据无法更新' }), {
headers: { 'Content-Type': 'application/json' }
});
}
}
// 5. 缓存策略实现:离线兜底处理
async function handleFallbackRequest(request: Request): Promise<Response> {
const cache = await caches.open(CACHE_CONFIG.CACHE_NAMES.FALLBACK);
// 图片资源兜底
if (isImageResource(request)) {
return cache.match(CACHE_CONFIG.FALLBACK_RESOURCES.image) || new Response('', { status: 404 });
}
// 其他资源返回离线页面
return cache.match(CACHE_CONFIG.FALLBACK_RESOURCES['/offline']) || new Response('离线中,请检查网络', {
headers: { 'Content-Type': 'text/plain' },
status: 503
});
}
// 6. 消息通信处理:接收主线程指令(如清理缓存、获取缓存状态)
async function handleMessage(event: ExtendableMessageEvent) {
const { type, data } = event.data;
let response = { code: 0, msg: 'success', data: null };
switch (type) {
// 获取缓存状态
case 'GET_CACHE_STATUS':
response.data = await getCacheStatus();
break;
// 清理指定类型缓存
case 'CLEAR_CACHE':
await clearCache(data.cacheType);
break;
// 更新指定资源缓存
case 'UPDATE_CACHE':
await updateResourceCache(data.resources);
break;
default:
response = { code: -1, msg: '未知指令' };
}
// 向主线程发送响应
event.source?.postMessage(response);
}
// 7. 辅助功能:获取缓存状态(供主线程展示)
async function getCacheStatus() {
const status = {
version: CACHE_CONFIG.VERSION,
staticCacheSize: 0,
apiCacheSize: 0,
totalCacheSize: 0
};
// 计算各缓存空间大小
const staticCache = await caches.open(CACHE_CONFIG.CACHE_NAMES.STATIC);
const staticKeys = await staticCache.keys();
status.staticCacheSize = staticKeys.length;
const apiCache = await caches.open(CACHE_CONFIG.CACHE_NAMES.API);
const apiKeys = await apiCache.keys();
status.apiCacheSize = apiKeys.length;
status.totalCacheSize = status.staticCacheSize + status.apiCacheSize;
return status;
}
// 8. 辅助功能:清理缓存
async function clearCache(cacheType: 'static' | 'api' | 'all') {
const cacheNames = Object.values(CACHE_CONFIG.CACHE_NAMES);
const keys = await caches.keys();
await Promise.all(
keys.map(key => {
if (cacheType === 'all' && cacheNames.includes(key)) {
return caches.delete(key);
}
if (cacheType === 'static' && key === CACHE_CONFIG.CACHE_NAMES.STATIC) {
return caches.delete(key);
}
if (cacheType === 'api' && key === CACHE_CONFIG.CACHE_NAMES.API) {
return caches.delete(key);
}
return Promise.resolve(false);
})
);
}
// 9. 辅助功能:更新指定资源缓存
async function updateResourceCache(resources: string[]) {
const staticCache = await caches.open(CACHE_CONFIG.CACHE_NAMES.STATIC);
const apiCache = await caches.open(CACHE_CONFIG.CACHE_NAMES.API);
await Promise.all(
resources.map(async (url) => {
const request = new Request(url, { mode: 'cors' });
const response = await fetch(request);
if (response.ok) {
if (isStaticResource(request)) {
await staticCache.put(request, response.clone());
} else if (isApiResource(request)) {
await apiCache.put(request, response.clone());
}
}
})
);
}
// 10. 生命周期事件:安装(缓存核心资源)
self.addEventListener('install', (event) => {
// 强制等待缓存完成后再进入激活阶段
event.waitUntil(
Promise.all([
// 缓存核心静态资源
caches.open(CACHE_CONFIG.CACHE_NAMES.STATIC)
.then(cache => cache.addAll(CACHE_CONFIG.CORE_STATIC_RESOURCES))
.catch(error => {
console.error('核心资源缓存失败:', error);
// 核心资源缓存失败,主动放弃安装
throw error;
}),
// 缓存离线兜底资源
caches.open(CACHE_CONFIG.CACHE_NAMES.FALLBACK)
.then(cache => {
const fallbackRequests = Object.entries(CACHE_CONFIG.FALLBACK_RESOURCES)
.map(([key, value]) => new Request(value));
return cache.addAll(fallbackRequests);
})
]).then(() => {
// 安装完成后立即激活(跳过等待阶段)
self.skipWaiting();
})
);
});
// 11. 生命周期事件:激活(清理旧缓存+获取控制权)
self.addEventListener('activate', (event) => {
event.waitUntil(
Promise.all([
// 清理所有非当前版本的缓存
caches.keys().then(keys => {
return Promise.all(
keys.filter(key => {
// 保留当前版本的缓存和浏览器默认缓存
return !Object.values(CACHE_CONFIG.CACHE_NAMES).includes(key) && !key.startsWith('workbox-');
}).map(key => caches.delete(key))
);
}),
// 立即获取所有客户端控制权(确保新SW生效)
self.clients.claim()
])
);
});
// 12. 生命周期事件:拦截请求(核心逻辑)
self.addEventListener('fetch', (event) => {
// 忽略跨域请求和非GET请求(仅处理同源GET请求)
if (event.request.method !== 'GET' || new URL(event.request.url).origin !== self.location.origin) {
return;
}
// 根据请求类型匹配缓存策略
let strategy: (request: Request) => Promise<Response> = CACHE_CONFIG.STRATEGY_CONFIG.fallback;
if (isNoCacheResource(event.request)) {
strategy = CACHE_CONFIG.STRATEGY_CONFIG.noCache;
} else if (isApiResource(event.request)) {
strategy = CACHE_CONFIG.STRATEGY_CONFIG.api;
} else if (isStaticResource(event.request)) {
strategy = CACHE_CONFIG.STRATEGY_CONFIG.static;
}
// 执行策略并返回响应
event.respondWith(strategy(event.request).catch(() => {
// 策略执行失败(如网络和缓存都不可用),返回兜底响应
return handleFallbackRequest(event.request);
}));
});
// 13. 生命周期事件:接收主线程消息
self.addEventListener('message', handleMessage);
模块2:Vue3主线程工具类(管理SW与状态)
为避免SW操作与Vue组件耦合,封装ServiceWorkerManager工具类,统一管理SW的注册、通信、状态同步,通过响应式数据让组件实时感知状态变化。
// src/utils/serviceWorkerManager.ts
import { ref, onUnmounted, Ref } from 'vue';
// 1. 类型定义:确保TypeScript类型安全
export type SwMessageType = 'GET_CACHE_STATUS' | 'CLEAR_CACHE' | 'UPDATE_CACHE' | 'CACHE_STATUS';
export interface SwMessage {
type: SwMessageType;
data?: Record<string, any>;
}
export interface SwResponse {
code: number;
msg: string;
data?: Record<string, any>;
}
// SW状态枚举:覆盖全生命周期状态
export type SwStatus = 'unsupported' | 'unregistered' | 'registering' | 'registered' | 'activated' | 'error';
// 缓存状态结构:供组件展示
export interface CacheStatus {
version: string;
staticCacheSize: number;
apiCacheSize: number;
totalCacheSize: number;
}
// 2. SW管理核心类
export class ServiceWorkerManager {
// 响应式状态:供Vue组件直接绑定
public swStatus: Ref<SwStatus> = ref('unregistered');
public cacheStatus: Ref<CacheStatus | null> = ref(null);
public isOnline: Ref<boolean> = ref(navigator.onLine);
// 内部状态:SW实例、消息回调队列(支持多组件监听)
private swInstance: ServiceWorker | null = null;
private messageCallbacks: ((response: SwResponse) => void)[] = [];
// 防重复注册锁
private isRegistering: boolean = false;
constructor() {
// 初始化:检查已有SW、监听网络状态
this.checkExistingSw();
this.listenNetworkStatus();
}
/**
* 检查已注册的SW:页面刷新后同步状态,避免状态丢失
*/
private checkExistingSw() {
// 浏览器不支持SW,直接标记状态
if (!('serviceWorker' in navigator)) {
this.swStatus.value = 'unsupported';
return;
}
// 监听已激活的SW,同步实例与状态
navigator.serviceWorker.ready
.then(registration => {
this.swInstance = registration.active;
this.swStatus.value = 'activated';
// 主动拉取缓存状态
this.getCacheStatus();
})
.catch(error => {
console.error('检查已有SW失败:', error);
this.swStatus.value = 'unregistered';
});
// 监听SW更新事件(新SW激活时触发)
navigator.serviceWorker.addEventListener('controllerchange', () => {
this.swInstance = navigator.serviceWorker.controller;
this.swStatus.value = 'activated';
this.getCacheStatus();
// 通知所有监听者:缓存已更新
this.notifyMessage({
code: 0,
msg: '缓存已更新',
data: { reason: '新SW激活', version: this.cacheStatus.value?.version }
});
});
// 监听SW发送的消息(如缓存状态响应)
navigator.serviceWorker.addEventListener('message', (event) => {
const response = event.data as SwResponse;
this.handleSwResponse(response);
this.notifyMessage(response);
});
}
/**
* 监听网络在线/离线状态:实时同步到响应式变量
*/
private listenNetworkStatus() {
const updateOnlineStatus = () => {
this.isOnline.value = navigator.onLine;
// 网络恢复时,主动更新核心缓存
if (navigator.onLine && this.swStatus.value === 'activated') {
this.updateResourceCache(['/', '/index.html']);
}
};
// 初始状态赋值
updateOnlineStatus();
// 绑定事件监听
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
}
/**
* 注册SW:核心方法,带安全校验与防重复逻辑
* @param swPath SW脚本路径(默认dist下的service-worker.js)
*/
public async register(swPath: string = '/service-worker.js'): Promise<boolean> {
// 前置校验:浏览器不支持、正在注册、已激活则直接返回
if (!('serviceWorker' in navigator)) {
this.swStatus.value = 'unsupported';
return false;
}
if (this.isRegistering || ['registering', 'registered', 'activated'].includes(this.swStatus.value)) {
return true;
}
this.isRegistering = true;
this.swStatus.value = 'registering';
try {
const registration = await navigator.serviceWorker.register(swPath, {
scope: '/' // 控制整个应用,需与SW脚本路径匹配
});
// 监听安装状态变化
if (registration.installing) {
this.swStatus.value = 'registered';
// 安装完成后监听激活状态
registration.installing.addEventListener('statechange', (e) => {
const sw = e.target as ServiceWorker;
if (sw.state === 'activated') {
this.swInstance = sw;
this.swStatus.value = 'activated';
this.getCacheStatus();
}
});
} else if (registration.active) {
// 已激活状态:直接同步实例与状态
this.swInstance = registration.active;
this.swStatus.value = 'activated';
this.getCacheStatus();
}
this.isRegistering = false;
return true;
} catch (error) {
console.error('SW注册失败:', error);
this.swStatus.value = 'error';
this.isRegistering = false;
return false;
}
}
/**
* 注销SW:调试或特殊场景使用(如用户手动关闭离线功能)
*/
public async unregister(): Promise<boolean> {
if (!('serviceWorker' in navigator)) return false;
const registrations = await navigator.serviceWorker.getRegistrations();
// 批量注销所有SW
await Promise.all(registrations.map(reg => reg.unregister()));
this.swInstance = null;
this.swStatus.value = 'unregistered';
return true;
}
/**
* 向SW发送消息:封装通信逻辑,支持请求-响应模式
*/
public async sendMessage(msg: SwMessage): Promise<SwResponse> {
if (!this.swInstance || this.swStatus.value !== 'activated') {
return { code: -1, msg: 'SW未激活,无法执行操作' };
}
return new Promise((resolve) => {
// 监听SW的回复消息
const listener = (event: MessageEvent) => {
const response = event.data as SwResponse;
navigator.serviceWorker.removeEventListener('message', listener);
resolve(response);
};
// 绑定监听并发送消息
navigator.serviceWorker.addEventListener('message', listener);
// 超时处理:3秒未响应则返回错误
setTimeout(() => {
navigator.serviceWorker.removeEventListener('message', listener);
resolve({ code: -1, msg: 'SW消息响应超时' });
}, 3000);
this.swInstance.postMessage(msg);
});
}
// 快捷方法:获取缓存状态
public async getCacheStatus() {
const response = await this.sendMessage({ type: 'GET_CACHE_STATUS' });
if (response.code === 0) {
this.cacheStatus.value = response.data as CacheStatus;
}
return response;
}
// 快捷方法:清理指定类型缓存
public async clearCache(cacheType: 'static' | 'api' | 'all') {
return this.sendMessage({
type: 'CLEAR_CACHE',
data: { cacheType }
});
}
// 快捷方法:更新指定资源缓存(如发布后主动刷新)
public async updateResourceCache(resources: string[]) {
return this.sendMessage({
type: 'UPDATE_CACHE',
data: { resources }
});
}
/**
* 订阅SW消息:支持多组件监听
*/
public onMessage(callback: (response: SwResponse) => void) {
this.messageCallbacks.push(callback);
}
/**
* 取消订阅:避免组件卸载后内存泄漏
*/
public offMessage(callback: (response: SwResponse) => void) {
this.messageCallbacks = this.messageCallbacks.filter(cb => cb !== callback);
}
/**
* 内部方法:处理SW响应,更新响应式状态
*/
private handleSwResponse(response: SwResponse) {
if (response.code === 0 && response.type === 'GET_CACHE_STATUS') {
this.cacheStatus.value = response.data as CacheStatus;
}
}
/**
* 内部方法:通知所有订阅者
*/
private notifyMessage(response: SwResponse) {
this.messageCallbacks.forEach(cb => {
try {
cb(response);
} catch (err) {
console.error('消息回调执行失败:', err);
}
});
}
/**
* 销毁方法:组件卸载时调用,清理资源
*/
public destroy() {
this.messageCallbacks = [];
window.removeEventListener('online', this.listenNetworkStatus);
window.removeEventListener('offline', this.listenNetworkStatus);
}
}
模块3:Vue3组件集成(状态展示与交互)
在Vue3根组件中集成ServiceWorkerManager,实现“生产环境自动注册、开发环境调试可控”的逻辑,同时通过独立组件展示离线状态,提升用户体验。
1. 根组件集成(src/App.vue)
<template>
<div id="app" class="app-container">
<!-- 离线状态提示:仅在离线且SW激活时显示 -->
<OfflineNotice
v-if="swManager.swStatus.value === 'activated' && !swManager.isOnline.value"
/>
<!-- 开发环境调试面板:方便开发与测试验证 -->
<template v-if="import.meta.env.DEV">
<div class="sw-debug-panel">
<h4 class="debug-title">SW离线功能调试面板</h4>
<div class="debug-item">
<label>SW状态:</label>
<span :class="getStatusClass(swManager.swStatus.value)">
{{ formatSwStatus(swManager.swStatus.value) }}
</span>
</div>
<div class="debug-item">
<label>网络状态:</label>
<span :class="swManager.isOnline.value ? 'status-online' : 'status-offline'">
{{ swManager.isOnline.value ? '在线 ✅' : '离线 ❌' }}
</span>
</div>
<div class="debug-item" v-if="swManager.cacheStatus.value">
<label>缓存版本:</label>
<span>{{ swManager.cacheStatus.value.version }}</span>
</div>
<div class="debug-item" v-if="swManager.cacheStatus.value">
<label>缓存统计:</label>
<span>静态{{ swManager.cacheStatus.value.staticCacheSize }}个 | API{{ swManager.cacheStatus.value.apiCacheSize }}个</span>
</div>
<div class="debug-buttons">
<button
@click="handleRegister"
:disabled="['registering', 'registered', 'activated'].includes(swManager.swStatus.value)"
>
注册SW
</button>
<button
@click="handleUnregister"
:disabled="['unregistered', 'unsupported', 'error'].includes(swManager.swStatus.value)"
>
注销SW
</button>
<button
@click="handleClearCache"
:disabled="swManager.swStatus.value !== 'activated'"
>
清空所有缓存
</button>
<button
@click="handleUpdateCache"
:disabled="swManager.swStatus.value !== 'activated' || !swManager.isOnline.value"
>
更新核心缓存
</button>
</div>
</div>
</template>
<router-view />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { ServiceWorkerManager, SwStatus } from '@/utils/serviceWorkerManager';
import OfflineNotice from '@/components/OfflineNotice.vue';
// 初始化SW管理器
const swManager = new ServiceWorkerManager();
// 格式化SW状态文本:更友好的展示
const formatSwStatus = (status: SwStatus) => {
const statusMap = {
unsupported: '浏览器不支持',
unregistered: '未注册',
registering: '注册中...',
registered: '已注册(待激活)',
activated: '已激活',
error: '注册失败'
};
return statusMap[status];
};
// 根据状态获取样式类:直观区分状态
const getStatusClass = (status: SwStatus) => {
const classMap = {
unsupported: 'status-warning',
unregistered: 'status-normal',
registering: 'status-loading',
registered: 'status-loading',
activated: 'status-success',
error: 'status-error'
};
return classMap[status];
};
// 注册SW:开发环境手动触发,生产环境自动注册
const handleRegister = async () => {
const success = await swManager.register('/service-worker.js');
if (success) {
alert('SW注册已触发,可在Chrome Application面板查看状态');
} else {
alert('SW注册失败,请检查HTTPS环境与脚本路径');
}
};
// 注销SW:调试时使用
const handleUnregister = async () => {
if (confirm('确定要注销SW吗?离线功能将失效')) {
const success = await swManager.unregister();
alert(success ? 'SW注销成功' : 'SW注销失败');
}
};
// 清空缓存:带用户确认
const handleClearCache = async () => {
if (confirm('确定要清空所有缓存吗?离线内容将暂时不可用')) {
const response = await swManager.clearCache('all');
alert(response.code === 0 ? '缓存清空成功' : `清空失败:${response.msg}`);
}
};
// 更新核心缓存:主动同步最新资源
const handleUpdateCache = async () => {
const response = await swManager.updateResourceCache(['/', '/index.html', '/assets/entry/main-*.js']);
alert(response.code === 0 ? '核心缓存更新完成' : `更新失败:${response.msg}`);
};
onMounted(() => {
// 关键优化:延迟1秒注册SW,避免拦截Vue初始化请求
setTimeout(async () => {
// 生产环境自动注册,开发环境手动控制
if (import.meta.env.PROD) {
await swManager.register('/service-worker.js');
}
}, 1000);
// 监听SW消息:如缓存更新通知
swManager.onMessage((response) => {
if (response.code === 0 && response.msg === '缓存已更新') {
console.log('缓存更新通知:', response.data);
// 可在这里添加用户提示,如“新内容已更新,刷新生效”
}
});
});
onUnmounted(() => {
// 组件卸载时销毁管理器,清理资源
swManager.destroy();
});
</script>
<style scoped>
.app-container {
min-height: 100vh;
}
/* 调试面板样式 */
.sw-debug-panel {
position: fixed;
bottom: 20px;
right: 20px;
padding: 16px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 9999;
width: 320px;
}
.debug-title {
margin: 0 0 12px 0;
font-size: 16px;
color: #1f2937;
border-bottom: 1px solid #f3f4f6;
padding-bottom: 8px;
}
.debug-item {
margin-bottom: 8px;
font-size: 14px;
color: #4b5563;
}
.debug-item label {
font-weight: 500;
color: #1f2937;
}
.debug-buttons {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.debug-buttons button {
padding: 6px 12px;
border: none;
border-radius: 4px;
background: #3b82f6;
color: #fff;
cursor: pointer;
font-size: 14px;
}
.debug-buttons button:disabled {
background: #9ca3af;
cursor: not-allowed;
}
/* 状态样式 */
.status-success {
color: #10b981;
}
.status-error {
color: #ef4444;
}
.status-warning {
color: #f59e0b;
}
.status-loading {
color: #3b82f6;
}
.status-online {
color: #10b981;
}
.status-offline {
color: #ef4444;
}
</style>
2. 离线提示组件(src/components/OfflineNotice.vue)
<template>
<div class="offline-banner">
<div class="offline-icon">⚠️</div>
<div class="offline-content">
<h3 class="offline-title">当前处于离线状态</h3>
<p class="offline-desc">已为您加载缓存内容,部分实时功能可能受限</p>
<button @click="handleRefresh" class="refresh-btn">
{{ isOnline ? '立即刷新' : '网络恢复后自动刷新' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
const isOnline = ref(navigator.onLine);
// 网络恢复后自动刷新
const handleOnline = () => {
isOnline.value = true;
// 延迟1秒刷新,给用户感知
setTimeout(() => {
window.location.reload();
}, 1000);
};
// 手动触发刷新
const handleRefresh = () => {
if (navigator.onLine) {
window.location.reload();
} else {
alert('网络尚未恢复,请稍候重试');
}
};
onMounted(() => {
window.addEventListener('online', handleOnline);
});
onUnmounted(() => {
window.removeEventListener('online', handleOnline);
});
</script>
<style scoped>
.offline-banner {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
padding: 12px 20px;
background: #fffbeb;
border-left: 4px solid #f59e0b;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 9998;
max-width: 500px;
width: 90%;
}
.offline-icon {
font-size: 24px;
margin-right: 12px;
color: #f59e0b;
}
.offline-content {
flex: 1;
}
.offline-title {
margin: 0 0 4px 0;
font-size: 16px;
color: #92400e;
}
.offline-desc {
margin: 0 0 8px 0;
font-size: 14px;
color: #b45309;
}
.refresh-btn {
padding: 6px 12px;
background: #f59e0b;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.refresh-btn:hover {
background: #d97706;
}
</style>
3. 离线兜底页面(public/assets/offline.html)
当用户访问无缓存的页面且断网时,返回此兜底页面,避免空白屏幕:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网络中断 | 请检查连接</title>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
font-family: "Microsoft YaHei", sans-serif;
background: #f5f5f5;
}
.offline-container {
text-align: center;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
.offline-img {
width: 120px;
height: 120px;
margin-bottom: 20px;
}
h1 {
color: #e65100;
margin-bottom: 16px;
font-size: 24px;
}
p {
color: #666;
margin-bottom: 24px;
font-size: 16px;
}
.refresh-btn {
padding: 10px 24px;
border: none;
border-radius: 8px;
background: #42b983;
color: #fff;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.refresh-btn:hover {
background: #359469;
}
</style>
</head>
<body>
<div class="offline-container">
<img src="/assets/offline.png" alt="离线图标" class="offline-img">
<h1>哎呀,网络断了</h1>
<p>请检查Wi-Fi或数据连接,点击按钮重试</p>
<button class="refresh-btn" onclick="window.location.reload()">重试连接</button>
</div>
<script>
// 网络恢复后自动刷新
window.addEventListener('online', () => window.location.reload());
</script>
</body>
</html>
8.2.5 实践反例与正例对比
离线功能开发中,“路径写死”“策略一刀切”“注册时机错误”是高频坑点。通过以下对比,明确错误根源与优化方向。
反例1:缓存路径写死,打包后缓存失效
// 错误的SW缓存配置
const CORE_RESOURCES = [
'/src/main.ts', // 源码路径,打包后变为/assets/main-abc123.js
'/src/style.css'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('app-cache').then(cache => cache.addAll(CORE_RESOURCES))
);
});
问题分析:Vite打包后,源码路径会转换为带哈希的资源路径(如main.ts→main-abc123.js),写死的源码路径无法匹配,核心资源缓存失败,离线时白屏。
正例1:动态匹配打包后的资源
// 正确的缓存配置(支持哈希匹配)
const CORE_RESOURCES = [
'/assets/entry/main-*.js', // 通配符匹配带哈希的入口JS
'/assets/ext/style-*.css' // 匹配全局CSS
];
// 工具函数:通配符匹配资源
function matchResource(url: string, pattern: string): boolean {
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
return regex.test(url);
}
优化点:使用通配符匹配打包后的资源路径,结合工具函数实现动态匹配,确保缓存生效。
反例2:缓存策略一刀切,API数据滞后
// 错误的请求拦截逻辑
self.addEventListener('fetch', (event) => {
// 所有请求统一使用“缓存优先”
event.respondWith(
caches.match(event.request).then(cachedRes => cachedRes || fetch(event.request))
);
});
问题分析:商品列表、用户余额等API数据会被缓存,即使网络恢复,用户看到的仍是旧数据,可能导致“库存已空却显示有货”等业务问题。
正例2:分层缓存策略
// 正确的请求拦截逻辑
self.addEventListener('fetch', (event) => {
const request = event.request;
let responseStrategy;
// 静态资源:缓存优先
if (isStaticResource(request)) {
responseStrategy = handleStaticRequest(request);
}
// API数据:网络优先
else if (isApiResource(request)) {
responseStrategy = handleApiRequest(request);
}
// 敏感请求:仅网络
else if (isNoCacheResource(request)) {
responseStrategy = fetch(request);
}
// 兜底:离线页面
else {
responseStrategy = handleFallbackRequest(request);
}
event.respondWith(responseStrategy);
});
优化点:按资源类型差异化处理,静态资源保离线可用,API数据保实时性,敏感请求保安全性。
反例3:SW注册时机过早,拦截Vue初始化
// main.ts 错误代码
import { createApp } from 'vue';
import App from './App.vue';
// 初始化Vue前注册SW
navigator.serviceWorker.register('/service-worker.js');
createApp(App).mount('#app');
问题分析:SW注册优先于Vue初始化,会拦截Vue的核心资源请求(如App.vue编译后的JS),若SW尚未完成安装,会返回404,导致页面白屏。
正例3:Vue初始化后延迟注册
// App.vue 正确代码
onMounted(() => {
// 延迟1秒注册,确保Vue初始化完成
setTimeout(async () => {
if (import.meta.env.PROD) {
await swManager.register('/service-worker.js');
}
}, 1000);
});
优化点:在Vue根组件mounted后延迟注册SW,避免拦截初始化请求,同时开发环境手动控制注册,便于调试。
8.2.6 代码评审要点
离线功能的评审需覆盖“兼容性、功能性、安全性、可维护性”,以下10项核心要点可直接作为团队评审Checklist:
1. 环境兼容性检查
- ✅ 是否判断浏览器对SW的支持(
'serviceWorker' in navigator),不支持时有无降级提示? - ✅ SW注册是否仅在HTTPS/localhost环境触发,避免HTTP环境注册失败?
2. 注册与生命周期检查
- ✅ SW注册是否在Vue初始化完成后执行(如根组件
mounted后),无拦截核心请求风险? - ✅ 安装阶段是否缓存核心资源,激活阶段是否清理旧缓存,生命周期无遗漏?
3. 缓存策略合理性检查
- ✅ 静态资源是否使用“缓存优先+后台更新”策略,API是否使用“网络优先+缓存兜底”?
- ✅ 支付、登录等敏感请求是否使用“仅网络”策略,无缓存泄露风险?
4. 缓存管理检查
- ✅ 缓存名称是否带版本号(如
static-cache-v1),支持版本迭代? - ✅ 激活阶段是否清理非当前版本的缓存,避免“缓存污染”?
5. 类型安全检查
- ✅ SW脚本是否导入类型声明文件,
self.caches等API无TypeScript报错? - ✅ SW与主线程的通信数据是否有明确类型定义,无隐式
any?
6. 离线降级检查
- ✅ 断网且无缓存时,是否返回离线兜底页面,而非空白屏幕?
- ✅ 离线提示是否清晰,支持网络恢复后自动刷新?
7. 状态联动检查
- ✅ Vue组件是否能实时感知SW状态(激活/未注册)和网络状态(在线/离线)?
- ✅ SW与主线程的通信是否有超时处理,避免无限等待?
8. 异常处理检查
- ✅ SW注册、缓存操作失败时,是否有错误捕获与用户提示?
- ✅ 消息回调执行失败时,是否有异常处理,不影响整体功能?
9. 开发支持检查
- ✅ 开发环境是否提供SW调试面板,支持手动注册、清理缓存?
- ✅ 生产环境是否禁用SW调试日志,避免控制台冗余输出?
10. 安全性检查
- ✅ 是否过滤跨域请求和非GET请求,仅处理同源GET请求?
- ✅ 缓存的API数据是否包含敏感信息(如token),如有是否有清理机制?
8.2.7 对话小剧场:离线功能的“踩坑与填坑”
【场景】前端团队小美、小迪、小稳,后端大熊,质量工程师小燕在会议室复盘离线功能测试问题,全程充满“网感”吐槽与技术干货。
小燕(质量工程师):家人们,昨天测小美写的离线功能,发现三个大bug,堪称“离线翻车现场”。第一个,我用IE打开咱们项目,直接白屏,控制台报“navigator.serviceWorker未定义”——这是把兼容当空气了?
小美(前端开发):啊这…我光在Chrome上测了,忘了IE这茬!马上补判断,先查'serviceWorker' in navigator,不支持就标状态为“unsupported”,不执行注册逻辑,给个“浏览器版本过低”的提示,总比白屏强。
小迪(前端开发):我上次也踩过这坑,后来加了个兼容层,把SW相关代码全包在判断里,保准IE不报错。对了小燕,还有啥问题?
小燕:第二个更离谱,我断网测试商品详情页,点“加入购物车”没反应,控制台报“无法获取/api/cart”——合着离线就不能加购物车了?
小美:哦!我把购物车API归为“仅网络”策略了,想着要实时同步后端。但离线时确实该降级,比如先存localStorage,网络恢复后自动提交。我改下策略,购物车用“网络优先+localStorage兜底”,离线时先存本地,在线了再同步。
大熊(后端开发):这个思路靠谱,后端这边已经支持“批量提交购物车”接口,你网络恢复后调一次就能同步,不用一条条发,减少请求压力。
小燕:第三个问题,我更新了代码(小美改了缓存版本),刷新页面还是旧功能,清了缓存才出来——用户总不能每次都清缓存吧?这要是上线,运营得追杀咱们。
小稳(前端开发,老大哥):小美你是不是没在激活阶段清旧缓存?我看看你SW代码…果然!const CACHE_NAME = 'app-cache'; 没带版本号,新SW认不出旧缓存,当然不更新。得用版本化缓存,比如static-cache-v20250510,激活时把不带当前版本的缓存全删了。
小美:我错了!当时赶进度,把版本号忘了。现在就改,缓存名称加版本,激活阶段遍历缓存keys,把旧版本全清掉,保证新缓存生效。
小稳:还有个细节,你注册SW的时机是不是太早了?我看你代码里在main.ts里直接注册,这会拦截Vue的初始化请求,容易白屏。得挪到根组件mounted后,再延迟1秒执行,让Vue先加载完。
小美:对啊!我昨天也遇到过一次白屏,以为是偶然。那我把注册逻辑挪到App.vue的mounted里,加个setTimeout延迟,保证Vue先初始化。
小迪:再加个开发环境调试面板呗,像我上次那样,放个“注册SW”“清缓存”的按钮,小燕测试也方便,不用总开Chrome的Application面板,点一下就搞定。
小燕:这个必须安排!还有个小建议,离线提示能不能加个“网络恢复后自动刷新”的按钮?现在只显示“离线中”,用户不知道啥时候能好,容易慌。
小美:安排!我在离线组件里加个online事件监听,网络恢复后延迟1秒刷新,再给个“正在同步数据”的提示,体验拉满。
小稳:总结一下,这次翻车主要是“兼容没做、策略一刀切、更新机制漏了、注册时机早了”——这些坑咱们踩过一次,下次就得记牢。离线功能看着简单,细节全是魔鬼,得把“版本化缓存、分层策略、生命周期协同”刻在脑子里。
众人:收到!下次绝对不犯了!
8.2.8 总结
Service Worker为Vue3+TypeScript应用提供了“无网可用”的核心能力,其价值不仅是弱网/离线时的体验兜底,更是通过缓存策略优化在线时的响应速度。本节实现的方案覆盖“核心资源缓存、API离线复用、缓存更新、状态联动”四大需求,可直接落地生产环境。
核心要点回顾
- 分层缓存策略:静态资源“缓存优先+后台更新”,API数据“网络优先+缓存兜底”,敏感请求“仅网络”,平衡离线可用性与数据实时性。
- 版本化缓存管理:缓存名称带版本号,激活阶段清理旧缓存,避免“缓存污染”导致新功能不生效。
- Vue与SW协同:通过
ServiceWorkerManager工具类封装SW操作,响应式状态联动组件,确保状态可视化与交互可控。

1129






