JavaScript性能优化系列(八)弱网环境体验优化 - 8.2 离线支持:Service Worker实现基本离线功能

JavaScript性能优化实战 10w+人浏览 423人参与

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负责“后台执行”:如拦截请求、管理缓存、响应主线程指令。
  • 通信桥梁:通过postMessage API实现双向通信,结合TypeScript类型定义确保通信数据的规范性。

8.2.2 前置认知:离线功能的核心设计原则

在动手编码前,需明确离线功能的设计边界——并非所有资源都需要缓存,盲目缓存会导致“资源冗余”和“数据滞后”。基于Vue3应用的特性,需遵循以下三大原则:

  1. 缓存分层策略:按资源类型差异化处理

    • 核心静态资源(首页HTML、Vue运行时JS、全局CSS):强制缓存,优先从缓存返回,确保离线时能打开应用。
    • 非核心静态资源(商品图片、非首页组件JS):按需缓存,网络请求失败时再返回缓存。
    • API数据(如商品列表、用户信息):网络优先,离线时返回缓存兜底,避免展示过期数据。
    • 敏感请求(支付接口、验证码接口):禁止缓存,仅通过网络请求,避免安全风险。
  2. 版本化缓存管理:解决“新功能不生效”问题
    为每个版本的缓存添加唯一标识(如static-cache-v20250510),在SW激活阶段删除非当前版本的缓存,避免“旧缓存残留导致新功能无法加载”的问题。

  3. 状态可视化:管理用户预期
    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.cachesself.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.tsmain-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离线复用、缓存更新、状态联动”四大需求,可直接落地生产环境。

核心要点回顾

  1. 分层缓存策略:静态资源“缓存优先+后台更新”,API数据“网络优先+缓存兜底”,敏感请求“仅网络”,平衡离线可用性与数据实时性。
  2. 版本化缓存管理:缓存名称带版本号,激活阶段清理旧缓存,避免“缓存污染”导致新功能不生效。
  3. Vue与SW协同:通过ServiceWorkerManager工具类封装SW操作,响应式状态联动组件,确保状态可视化与交互可控。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值