Service Worker

314 阅读10分钟

在现代前端开发中,性能优化与用户体验的提升已经成为核心目标之一。为了实现更快的加载速度、更流畅的交互体验,甚至支持离线访问,浏览器提供了一系列缓存机制。其中,Service WorkerHTTP 缓存(即浏览器默认缓存) 是两个关键的技术手段。它们都涉及“缓存”,但工作原理、控制方式和适用场景却截然不同。

理解这两者的关系与差异,不仅有助于我们合理设计资源加载策略,更能为构建 PWA(渐进式 Web 应用)打下坚实基础。


一、从生活场景说起:缓存的本质是“提前准备”

想象你每天早上都要去面包店买一个面包。如果每次都要现做,效率很低。于是你开始想办法优化这个过程:

  • 方案一:看保质期
    面包店告诉你:“这个面包保质期1小时,1小时内再来,我就直接给你。”
    这就像 HTTP 缓存——服务器通过响应头告诉浏览器:“这个资源有效期内可以直接用。”

  • 方案二:自己存面包
    你雇了一个管家,他提前把面包买回来放在家里。你一说要吃,他就立刻拿出来,根本不用去店里。
    这就是 Service Worker——它作为一个后台代理,主动拦截请求,优先返回本地缓存。

这两个方案都能减少跑腿次数,但背后的逻辑完全不同:一个是被动遵循规则,另一个是主动掌控流程。


二、HTTP 缓存:浏览器的自动行为

HTTP 缓存,也常被称为“浏览器默认缓存”,是一种由 HTTP 协议定义的、浏览器自动执行的资源复用机制。它的核心在于服务器通过响应头来指导浏览器如何缓存资源。

最常见的控制方式是 Cache-Control 头:

Cache-Control: max-age=3600

这表示该资源在 3600 秒内无需再次请求服务器,浏览器可以直接使用本地缓存。这种机制被称为强缓存,在此期间,浏览器甚至不会向服务器发送任何请求,开发者也不需要写任何 JavaScript 代码,整个过程完全自动化。

当强缓存过期后,浏览器会进入协商缓存阶段。它会携带 ETagLast-Modified 等标识向服务器发起验证请求。如果资源未更新,服务器返回 304 Not Modified,浏览器继续使用缓存;如果已更新,则返回新的资源。

这一整套机制被称为“浏览器默认缓存”,因为它不需要开发者干预,只要服务器正确设置响应头,浏览器就会自动处理缓存逻辑。它是静态资源加速的基础,广泛应用于 CDN、图片、JS、CSS 文件的分发。


三、Service Worker:可编程的网络代理

Service Worker 的最大特点是完全可编程。你可以用 JavaScript 写出复杂的缓存策略,比如:

  • 安装时预缓存关键资源;
  • 对特定请求返回伪造的响应;
  • 实现“缓存优先”或“网络优先”的策略;
  • 支持完全离线访问。

例如,在一个新闻类 PWA 应用中,你可以让 Service Worker 在用户首次访问时就缓存首页 HTML、核心 CSS 和 JS。当下次用户打开应用时,即使没有网络连接,Service Worker 也能立即返回缓存内容,实现秒开体验。

此外,Service Worker 还能处理推送通知、后台同步等高级功能,是构建现代 Web 应用的核心技术之一。

需要注意的是,Service Worker 不直接操作 DOM,也无法访问 localStorage,它专注于网络请求的拦截与响应。出于安全考虑,Service Worker 只能在 HTTPS 环境下运行(开发时 localhost 除外),并且需要通过 navigator.serviceWorker.register() 显式注册才能生效。


四、当请求一张图片时,缓存是如何工作的?

假设页面中有一个 <img src="/images/photo.jpg"> 标签,浏览器会按以下流程处理请求:

  1. 前端发起请求
    浏览器解析 HTML,发现图片标签,准备发起网络请求。

  2. Service Worker 拦截请求
    如果 Service Worker 已注册并激活,它会首先接收到 fetch 事件。在这个阶段,你可以决定是否直接返回缓存:

    self.addEventListener('fetch', event => {
      event.respondWith(
        caches.match(event.request)
          .then(cached => cached || fetch(event.request))
      );
    });
    

    如果缓存命中,浏览器将直接返回结果,跳过后续所有网络行为

  3. 浏览器检查 HTTP 缓存
    如果 Service Worker 选择放行请求(即调用 fetch(event.request)),浏览器会接着检查自身的 HTTP 缓存。如果资源仍在 max-age 有效期内,浏览器会直接使用磁盘或内存中的副本。

  4. 发送网络请求
    只有当 Service Worker 未命中缓存,且浏览器的 HTTP 缓存也失效时,真正的网络请求才会被发送到服务器。

  5. 服务器返回资源
    服务器响应图片数据,并附带缓存控制头,如 Cache-Control: max-age=3600

  6. 浏览器缓存并渲染
    浏览器将图片存入本地缓存,供后续 HTTP 缓存使用,同时将其交给页面进行渲染。

可以看到,Service Worker 是整个请求流程的第一道关卡,它有能力在最前端就终止网络请求,而 HTTP 缓存则是在其后的后备机制。


如何使用 Service Worker 实现缓存与离线访问

1. 创建 Service Worker 文件

文件路径: public/sw.js

❓ 为什么要放在 public 目录?

  • src 目录中的文件会被构建工具(如 Vite、Webpack)打包,文件名可能被哈希化(如 main.js → main.abc123.js)。
  • Service Worker 必须通过固定路径注册(如 /sw.js),不能使用动态路径。
  • public 目录中的文件会被原样复制,不参与打包,确保 /sw.js 路径不变。

// 缓存版本号,用于更新机制
// 当你修改了缓存内容,必须更新版本号,否则浏览器不会安装新 SW
const CACHE_VERSION = 'v1.0.0';
const CACHE_NAME = `huanc-cache-${CACHE_VERSION}`;

// API 缓存单独管理,避免静态资源更新时清掉 API 数据
const API_CACHE_NAME = 'api-cache-v1';

// 要在安装阶段预缓存的资源
// 包括首页、CSS、JS、图片、离线页
// 这些资源在首次加载时就会被存到本地
const PRECACHE_URLS = [
  '/',
  '/index.html',
  '/style.css',
  '/main.js',
  '/logo.png',
  '/offline.html' // 必须提前缓存,否则离线时无法显示
];

✅ 安装阶段:预缓存静态资源

self.addEventListener('install', (event) => {
  // event.waitUntil 确保安装过程不会提前结束
  // 直到缓存操作完成,SW 才会进入“等待”状态
  event.waitUntil(
    caches.open(CACHE_NAME) // 打开指定名称的缓存
      .then(cache => cache.addAll(PRECACHE_URLS)) // 添加所有资源到缓存
      .then(() => {
        // 跳过等待阶段,立即激活新 SW
        // 否则需要手动刷新或关闭所有页面才能激活
        self.skipWaiting();
      })
  );
});

为什么需要 skipWaiting()
默认情况下,旧的 Service Worker 仍控制页面,新的 SW 处于“waiting”状态。
调用 skipWaiting() 让新 SW 立即激活,避免用户看不到更新。


✅ 激活阶段:清理旧缓存

self.addEventListener('activate', (event) => {
  // 清理不再使用的缓存
  // 只保留当前版本的缓存,删除旧版本
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(name => {
          // 如果缓存名不是当前版本,也不是 API 缓存,就删除
          if (name !== CACHE_NAME && name !== API_CACHE_NAME) {
            return caches.delete(name);
          }
        })
      );
    })
  );

  // 让当前 SW 立即控制所有客户端(页面)
  // 否则只有新打开的页面才会被控制
  self.clients.claim();
});

为什么需要 clients.claim()
默认情况下,Service Worker 只控制它安装后打开的页面。
调用 clients.claim() 后,它能立即控制所有已打开的页面。


✅ 拦截网络请求

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

  // 如果是页面跳转请求(如刷新、点击链接)
  // 需要特殊处理,因为页面可能已离线
  if (request.mode === 'navigate') {
    event.respondWith(handleHtmlRequest(request));
    return;
  }

  // 如果是 API 请求(如 /api/users)
  // 使用“网络优先”策略
  if (request.url.includes('/api/')) {
    event.respondWith(handleApiRequest(request));
    return;
  }

  // 其他静态资源(CSS、JS、图片等)
  // 使用“缓存优先”策略
  event.respondWith(handleStaticRequest(request));
});

为什么要区分请求类型?

  • 页面和静态资源变化少,适合缓存优先,提升加载速度。
  • API 数据变化频繁,适合网络优先,保证数据最新。

处理 HTML 请求(优先缓存,失败用离线页)

async function handleHtmlRequest(request) {
  // 先查缓存
  const cached = await caches.match(request);
  if (cached) return cached;

  // 缓存没有,尝试网络请求
  try {
    const response = await fetch(request);
    return response;
  } catch (error) {
    // 网络失败,返回离线页面
    const offline = await caches.match('/offline.html');
    return offline;
  }
}

为什么 HTML 要先查缓存?
即使用户在线,从缓存加载页面也比网络快,提升体验。


处理静态资源(缓存优先)

async function handleStaticRequest(request) {
  // 先查缓存
  const cached = await caches.match(request);
  if (cached) return cached;

  // 缓存没有,请求网络
  const response = await fetch(request);

  // 将网络响应存入缓存,供下次使用
  // clone() 是因为 Response 只能读一次
  const cache = await caches.open(CACHE_NAME);
  cache.put(request, response.clone());

  return response;
}

为什么要 clone()
Response 对象是流,只能被读取一次。
一份用于返回给页面,一份用于存入缓存,所以需要克隆。


处理 API 请求(网络优先)

async function handleApiRequest(request) {
  try {
    // 先尝试网络请求
    const response = await fetch(request);

    // 成功后存入缓存
    const cache = await caches.open(API_CACHE_NAME);
    cache.put(request, response.clone());

    return response;
  } catch (error) {
    // 网络失败,返回缓存数据
    const cached = await caches.match(request);
    return cached;
  }
}

为什么 API 要网络优先?
用户期望看到最新数据,比如订单状态、消息列表。
只有在网络失败时才使用缓存,作为降级方案。


2. 注册 Service Worker

文件路径: src/main.jsx

function registerServiceWorker() {
  // 检查浏览器是否支持 Service Worker
  if ('serviceWorker' in navigator) {
    // 等待页面完全加载后再注册
    // 避免影响首屏性能
    window.addEventListener('load', () => {
      navigator.serviceWorker.register('/sw.js')
        .then(registration => {
          console.log('SW 注册成功', registration.scope);
        })
        .catch(error => {
          console.error('SW 注册失败', error);
        });
    });
  }
}

registerServiceWorker();

为什么要 window.load 后注册?
避免注册过程阻塞页面加载,影响用户体验。


3. 离线页面

文件路径: public/offline.html

<!DOCTYPE html>
<html>
<head>
  <title>离线</title>
</head>
<body>
  <h1>当前离线</h1>
  <p>请检查网络连接</p>
  <button onclick="location.reload()">重试</button>
  <script>
    // 当网络恢复时自动刷新页面
    window.addEventListener('online', () => {
      location.reload();
    });
  </script>
</body>
</html>

当用户断网且缓存中没有请求的页面时,提供友好提示,而不是白屏或报错。


4. 监听网络状态

文件路径: src/App.jsx

import { useState, useEffect } from 'react';

function App() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const onOnline = () => setIsOnline(true);
    const onOffline = () => setIsOnline(false);

    // 监听浏览器的 online/offline 事件
    window.addEventListener('online', onOnline);
    window.addEventListener('offline', onOffline);

    // 组件卸载时移除监听
    return () => {
      window.removeEventListener('online', onOnline);
      window.removeEventListener('offline', onOffline);
    };
  }, []);

  return (
    <div>
      网络状态:{isOnline ? '在线' : '离线'}
    </div>
  );
}

让用户知道当前是否在线,可以在 UI 上做相应提示,比如显示“已恢复连接”。


5. 主线程与 Service Worker 通信

主线程发送消息

const getCacheInfo = () => {
  // 创建一个消息通道,用于双向通信
  const channel = new MessageChannel();
  
  // 接收来自 SW 的回复
  channel.port1.onmessage = (event) => {
    if (event.data.type === 'CACHE_INFO') {
      console.log('缓存信息:', event.data);
    }
  };

  // 发送消息给 SW,携带 port2
  navigator.serviceWorker.controller.postMessage(
    { type: 'GET_CACHE_INFO' },
    [channel.port2]
  );
};

为什么要用 MessageChannel
postMessage 默认是单向通信。
使用 MessageChannel 可以让 SW 通过 port 主动回复,实现双向通信。


Service Worker 接收消息

self.addEventListener('message', (event) => {
  if (event.data.type === 'GET_CACHE_INFO') {
    // 获取所有缓存名称
    caches.keys().then(cacheNames => {
      // 通过 port 回复
      event.ports[0].postMessage({
        type: 'CACHE_INFO',
        caches: cacheNames,
        version: CACHE_VERSION
      });
    });
  }
});

6. 如何更新缓存?

  1. 修改 CACHE_VERSION = 'v1.0.1'
  2. 构建并部署新版本
  3. 用户访问时,浏览器发现新版本 SW
  4. 新 SW 安装 → 激活时删除旧缓存 → 控制页面

为什么必须改版本号?
浏览器通过 SW 文件内容判断是否更新。
如果不改版本号,即使内容变了,浏览器也可能认为没有更新。