用户打开飞行模式都能打开你的网站?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 网站,不用网络也能刷内容。同事看了问:"你怎么做到的?" 你就可以把本文甩给他。


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

相关推荐
我是汪先生1 小时前
学习 day8 memory
前端
栉甜1 小时前
APIs学习
前端·javascript·css·学习·html
运营小白1 小时前
2026 年 Shopify 关键词映射指南:从混乱到有序的实战经验
前端·一人公司·seonib·自动化内容·搜索流量
Dxy12393102162 小时前
HTML的Iframe详解
前端·html
dsyyyyy11012 小时前
CSS定位布局和网格布局
前端·css
huaiixinsi2 小时前
Java 后端面试高频题整理(02)
java·开发语言·spring·面试·职场和发展·架构·maven
码码哈哈0.02 小时前
macos26 Liquid class 示例代码
前端
zithern_juejin2 小时前
ES6——Symbol
javascript