用户打开飞行模式都能打开你的网站?Service Worker 做离线缓存,PWA 实战

你坐飞机,关掉网络,旁边小哥还在刷抖音(离线缓存好的视频)。你打开自己的网站,白屏,报错。你默默关上手机,心想:"要是我的网站也能离线看就好了。" 今天我们就来给你的网站装上"离线小精灵"------Service Worker。以后用户没网也能访问,还能把网站装到手机桌面,像原生 App 一样。

前言

PWA(Progressive Web App)这个概念喊了好几年,但真正用上的网站不多。其实它没那么玄乎,核心就是 Service Worker------一个在浏览器后台独立运行的 JS 线程,能拦截网络请求、缓存资源、推送通知。

加了 Service Worker 的网站,就算用户开飞行模式,只要之前访问过,照样能看到页面(至少看到缓存过的内容)。而且速度极快,因为资源从本地取,不用等网络。今天我们就从零给一个静态网站加上离线缓存,顺便让它"可安装"。

一、Service Worker 生命周期:四步走

Service Worker 不是一上来就接管所有请求的,它有严格的生命周期:

  1. 注册:主线程告诉浏览器:"嘿,去下载这个 sw.js 文件。"
  2. 安装 :浏览器下载、解析、执行 sw.js 里的 install 事件。通常在这里缓存核心资源。
  3. 激活 :旧 Service Worker 被替换,新 SW 接管控制权。可以在 activate 事件里清理旧缓存。
  4. 空闲/运行:之后所有 fetch 请求都会被 SW 拦截。

注意:SW 只在 HTTPS(或 localhost)下生效,因为可以拦截网络,不安全。

二、最简单的 Service Worker:离线回退页面

我们先写一个极简版 sw.js,让用户离线时看到一个"你已离线"的页面。

js 复制代码
// sw.js
const CACHE_NAME = 'my-pwa-cache-v1';
const OFFLINE_URL = '/offline.html';

// 安装时缓存离线页面
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.add(OFFLINE_URL))
  );
  // 强制等待中的 SW 立即激活
  self.skipWaiting();
});

// 激活时清理旧缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
      );
    })
  );
  self.clients.claim();
});

// 拦截请求,离线时返回缓存
self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    // 页面导航请求
    event.respondWith(
      fetch(event.request).catch(() => caches.match(OFFLINE_URL))
    );
  } else {
    // 其他资源走缓存优先策略(稍后优化)
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request);
      })
    );
  }
});

然后在 index.html 里注册:

html 复制代码
<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker.register('/sw.js').then(reg => {
        console.log('SW 注册成功', reg);
      }).catch(err => {
        console.log('SW 注册失败', err);
      });
    });
  }
</script>

现在你打开网站,开飞机模式(或 DevTools → Network 离线),刷新页面,应该会显示 offline.html。说明 SW 已经拦下了请求。

三、缓存策略:别把所有鸡蛋放一个篮子

上面的代码对所有资源都用了"缓存优先"------先查 cache,没有才网络。这会导致一个问题:如果某个资源之前缓存过,即使服务器更新了,用户也看不到新版本。所以需要根据资源类型选择策略。

常用策略:

  • Cache First(缓存优先):适合不常变的图片、字体、CSS 库。速度快。
  • Network First(网络优先):适合 API 数据、HTML 页面。先尝试网络,失败再读缓存。
  • Stale-While-Revalidate:先返回缓存(如果有),同时后台更新缓存。兼顾速度和新鲜度。
  • 仅网络:永远不缓存(如支付接口)。
  • 仅缓存:永远从缓存取(如离线页面)。

我们改一下 fetch 事件:

js 复制代码
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  // 如果是 API 请求,走网络优先
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(event.request).catch(() => caches.match(event.request))
    );
    return;
  }
  // 如果是静态资源(js、css、图片),走缓存优先
  if (/\.(js|css|png|jpg|webp)$/.test(url.pathname)) {
    event.respondWith(
      caches.match(event.request).then((cached) => cached || fetch(event.request))
    );
    return;
  }
  // 其他(如 HTML)走 stale-while-revalidate
  event.respondWith(
    caches.open(CACHE_NAME).then(async (cache) => {
      const cached = await cache.match(event.request);
      const fetchPromise = fetch(event.request).then((response) => {
        cache.put(event.request, response.clone());
        return response;
      }).catch(() => cached);
      return cached || fetchPromise;
    })
  );
});

这样,你的网站既能离线访问,又能及时更新动态内容。

四、用 Workbox 简化代码

手写缓存策略很麻烦,尤其还要处理版本、过期、缓存清理。Google 出品了 Workbox,一套工具库,几行配置搞定复杂策略。

安装 Workbox CLI 或直接在 sw.js 里导入 CDN:

js 复制代码
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');

const { registerRoute, strategies, cacheableResponse } = workbox;

// 预缓存静态资源(构建时生成 manifest)
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST || []);

// 图片缓存策略
registerRoute(
  ({ request }) => request.destination === 'image',
  new strategies.CacheFirst({
    cacheName: 'images',
    plugins: [
      new cacheableResponse.CacheableResponsePlugin({ statuses: [0, 200] }),
      new workbox.expiration.ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 })
    ]
  })
);

// API 网络优先
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new strategies.NetworkFirst()
);

配合 webpack/vite 插件,可以自动生成预缓存清单,连 install 里的 cache.add 都不用手动写。

五、让网站可安装(添加到主屏幕)

PWA 另一大特性:用户可以像装 App 一样把网站装到手机桌面。需要满足三个条件:

  1. HTTPS(或 localhost)
  2. 注册了 Service Worker
  3. 有一个 manifest.json 文件,放在根目录

示例 manifest.json:

json 复制代码
{
  "name": "我的离线网站",
  "short_name": "离线站",
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#000000",
  "background_color": "#ffffff",
  "icons": [
    {
      "src": "/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

在 index.html 里引用:

html 复制代码
<link rel="manifest" href="/manifest.json">

之后用户访问网站,浏览器会在地址栏右侧弹出"安装 App"的提示。点一下,桌面就多了一个图标,打开后没有浏览器地址栏,像原生 App。

六、推送通知(可选彩蛋)

Service Worker 还能接收服务器推送的消息,即使网站没打开也能弹出通知。这需要用户授权和后台推送服务(比如 Firebase Cloud Messaging)。代码稍复杂,但可以实现"用户关掉浏览器,你也能给他发优惠券提醒"的效果。

七、实测数据:加了 SW 之后

我用一个 React 静态网站测试:

  • 未缓存:首次加载 1.8s,二次无网白屏。
  • 加了 Workbox 预缓存:首次 2.0s(多下载了 SW 和 manifest),二次无网打开 0.3s(完全离线)。
  • 页面切换速度提升明显,因为路由对应的 JS 也被缓存。

用户从"等待加载"变成"秒开",体验提升 5 倍以上。

八、坑点与避坑

  • 更新缓存 :修改文件后,用户可能还是旧版本。需要更新 CACHE_NAME 版本号,或者在预缓存时用 rev(文件 hash)解决。Workbox 会自动处理。
  • localhost 测试:记得勾选 DevTools → Application → Service Workers → Update on reload,否则 SW 缓存会干扰。
  • 作用域 :SW 默认作用域是 sw.js 所在目录,如果放在根目录,可以控制全站。放在 js/ 下就只能控制 js/ 路径。
  • 调试:Chrome DevTools 的 Application 面板可以看到所有缓存、SW 状态、推送通知。

九、总结:PWA 是前端的"离线外挂"

  • Service Worker 是浏览器后台独立线程,能拦截请求、缓存资源、推送通知。
  • 生命周期:注册 → 安装 → 激活 → fetch。
  • 缓存策略根据资源类型选择:Cache First、Network First、Stale-While-Revalidate。
  • 搭配 Workbox 可省去手写复杂缓存逻辑。
  • 加上 manifest.json 就能让网站"安装到桌面"。

下次你坐飞机,打开自己的 PWA 网站,不用网络也能刷内容。同事看了问:"你怎么做到的?" 你就可以把本文甩给他。


评论区聊聊:你的网站支持离线访问吗?遇到过哪些缓存更新问题?

相关推荐
有味道的男人11 分钟前
搜好货平台关键词商品搜索接口:分页容错与商品数据轻量化提取方案
服务器·前端·javascript
程序 代码狂人13 分钟前
数据开发面试【问答完整版|带深挖追问 + 扩展回答】
面试·职场和发展
江华森1 小时前
Web漏洞与中间件框架漏洞实战详解
面试
lovingsoft1 小时前
Chrome Lighthouse 详解
前端·chrome
Qlly1 小时前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝1 小时前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
略略略啦1 小时前
Vibe Coding 基于Spec DD和TDD的实践指南
前端
shmily麻瓜小菜鸡1 小时前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
打工仔折腾 AI2 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
汉堡大王95272 小时前
单次输出拉到100万Token:拆完 Gemini 4 Argon 的续写协议,才看懂谷歌这7个月在憋什么
前端·人工智能·后端