HTML 离线应用:Service Worker + Cache 缓存策略

shandian
shandian 见习用户见习用户
发布于 2026-10-09 08:07 ·5 浏览 ·2 回复

学完这篇,你能给自己的网页加上 Service Worker,做到断网也能打开、静态资源秒开,并且知道每种缓存策略该用在什么场景。

第一步:确认前提条件

Service Worker 只能在安全上下文里跑,也就是 https:// 或者 http://localhost。用 IP 直连(比如 http://192.168.1.10)在部分浏览器上会被拒绝注册。

注意:Service Worker 作用域(scope)默认是脚本所在目录。把 sw.js 放在根目录,它才能接管全站;放在 /js/sw.js 就只能管 /js/ 下的请求。

目录结构建议这样:

/sw.js
/index.html
/offline.html
/css/style.css
/js/app.js

第二步:在主页面注册

在 index.html 的 </body> 前加:

<script>
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then(reg => console.log('注册成功,范围:', reg.scope))
      .catch(err => console.error('注册失败:', err));
  });
}
</script>

放在 load 之后注册,避免和首屏资源抢带宽。

第三步:预缓存核心资源

新建 sw.js,先做「安装时缓存」:

const VERSION = 'v1';
const STATIC_CACHE = `static-${VERSION}`;
const RUNTIME_CACHE = `runtime-${VERSION}`;

const PRECACHE = ['/', '/index.html', '/offline.html', '/css/style.css'];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then(cache => cache.addAll(PRECACHE))
      .then(() => self.skipWaiting())
  );
});

skipWaiting() 让新版本立即进入激活流程,不用等用户关掉所有标签页。

注意:cache.addAll() 是「全成功才算成功」。列表里任何一个 404,整个安装就会失败,而且报错信息很含糊。先用浏览器逐个打开确认能访问。

第四步:清理旧版本缓存

版本号一改,旧缓存要删掉,否则磁盘里会堆一堆废数据:

self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(
        keys.filter(k => k !== STATIC_CACHE && k !== RUNTIME_CACHE)
            .map(k => caches.delete(k))
      )
    ).then(() => self.clients.claim())
  );
});

以后每次发版,只要把 VERSION 改成 v2,旧缓存自动清空。

第五步:按请求类型分配策略

这一步是重点。别用一种策略打天下,导航请求和静态资源的需求完全不同:

self.addEventListener('fetch', event => {
  const req = event.request;
  if (req.method !== 'GET') return;

  const url = new URL(req.url);
  if (url.origin !== location.origin) return;

  // 策略一:导航请求 —— 网络优先,失败回退缓存,再失败给离线页
  if (req.mode === 'navigate') {
    event.respondWith(
      fetch(req)
        .then(res => {
          const copy = res.clone();
          caches.open(RUNTIME_CACHE).then(c => c.put(req, copy));
          return res;
        })
        .catch(() => caches.match(req).then(r => r || caches.match('/offline.html')))
    );
    return;
  }

  // 策略二:静态资源 —— 缓存优先 + 后台静默更新
  event.respondWith(
    caches.match(req).then(cached => {
      const network = fetch(req)
        .then(res => {
          if (res && res.status === 200 && res.type === 'basic') {
            const copy = res.clone();
            caches.open(RUNTIME_CACHE).then(c => c.put(req, copy));
          }
          return res;
        })
        .catch(() => cached);
      return cached || network;
    })
  );
});

三种常见策略的适用场景,记住这张对照表就够了:

策略行为适合
Cache First先查缓存,没有才联网CSS/JS/图片/字体
Network First先联网,失败读缓存HTML 页面、API 列表
Stale-While-Revalidate先返回缓存,同时后台更新头像、排行榜这类「旧一点没关系」的数据

上面的策略二其实就是 Stale-While-Revalidate 的写法。

第六步:调试与验证

打开 DevTools → Application 面板:

  • Service Workers:勾选 Offline 模拟断网,刷新看是否还能打开
  • Cache Storage:点开能看到缓存了哪些 URL 和响应状态
  • Update on reload:开发时勾上,每次刷新强制更新 SW,省得手动 unregister

注意:普通的「强制刷新(Ctrl+F5)」不会绕过 Service Worker。要彻底清干净,在 Application 面板点 Clear storage。

三个高频坑

  1. 接口别用 Cache First:用户看到的是登录前的旧数据,还查不出原因。API 请求统一走 Network First,或者干脆 return 不拦截。
  2. SW 更新有延迟:浏览器默认在导航后 24 小时内不重复检查 sw.js。想主动触发,调 reg.update()。
  3. 改了代码没生效:99% 是 VERSION 没改。缓存名不变,install 阶段的 addAll 会因为文件已存在而走缓存里的旧文件。

小结

  • Service Worker 必须跑在 HTTPS 或 localhost,脚本位置决定作用域
  • install 预缓存核心文件,activate 清理旧版本缓存,靠 VERSION 常量驱动
  • 导航请求用 Network First + 离线兜底页,静态资源用 Cache First 或 Stale-While-Revalidate
  • 只处理 GET 同源请求,POST 和跨域接口不要插手
  • 调试去 Application 面板,用 Clear storage 彻底重置
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-759.html
转载请注明出处,版权归原作者所有。

全部回复 2

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 1楼 2026-10-09 08:16

这篇骨架是对的——install 预缓存应用壳、activate 按版本清旧缓存、fetch 分流策略,三件套缺一不可。

补几个实际部署最容易翻车的点。「全成功才算成功」这个坑你点到了,实战里更稳的做法是核心壳用 addAll、非核心资源(字体、非首屏图、次要 JS)用 Promise.allSettled 或逐个 cache.add().catch() 吞错。而且最好给 addAll 加个超时兜底——弱网下它会挂到浏览器自己放弃,用户看到的是「注册成功但离线打不开」。

策略要按资源类型分,别一把梭:带 hash 指纹的 JS/CSS/字体/图片走 cache-first;HTML 导航请求必须 network-first(失败回落 offline.html),否则用户永远看不到新页面;列表数据、头像这类走 stale-while-revalidate;登录态、支付、后台接口一律 network-only,绝不能进缓存。

两个硬约束容易被忽略:SW 只缓存 GET,POST 全部穿透;跨域 no-cors 的资源存进 Cache 后 status 是 0、ok 为 false,你要是按 status 判断会误判成失败——要么走 cors,要么干脆不缓存第三方资源。

更新链路:浏览器只在 sw.js 字节变化时才触发更新,只改资源不改 VERSION 是经典翻车姿势,缓存名没变用户永远吃旧文件。建议在 visibilitychange 或定时 reg.update() 主动探测。

最后 activate 里补一句 self.clients.claim(),配合 skipWaiting() 才能真正让当前页立刻受控,但要留意「新 SW + 旧页面」的版本错位,稳妥些可在 controllerchange 时提示刷新。另外帖子最后 caches.delete 那段像是被截断了,方便的话补全一下。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #578 2楼 2026-10-09 08:19
XiaoC:这篇骨架是对的——install 预缓存应用壳、activate 按版本清旧缓存、fetch 分流策略,三件套缺一不可。 补几个实际部署最容易翻车的点。「全成…

您那段 activate 确实是被截断了,先补全,再顺着您提的几个点各自说下实战写法。

补全的 activate(含 claim):

self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys()
      .then(keys => Promise.all(
        keys
          .filter(k => k !== STATIC_CACHE && k !== RUNTIME_CACHE)
          .map(k => caches.delete(k))
      ))
      .then(() => self.clients.claim())
  );
});

关于 addAll 超时兜底——我建议换成逐个 put + allSettled,比加超时更治本,效果就是您说的「核心壳必须成功、非核心失败不影响安装」:

self.addEventListener('install', event => {
  event.waitUntil((async () => {
    const cache = await caches.open(STATIC_CACHE);
    await Promise.allSettled(PRECACHE.map(url =>
      fetch(url, { cache: 'reload' }).then(r => {
        if (r.ok) return cache.put(url, r);
        throw new Error(`${url} -> ${r.status}`);
      }).catch(e => console.warn('跳过', e))
    ));
    await self.skipWaiting();
  })());
});

fetch(url, { cache: 'reload' }) 是必须的,否则强制通过 HTTP 缓存,用户改完离线包还是旧的。真要给弱网保底,再包一层 Promise.race + 8s 超时,超时也放行——宁可壳子缺一两个次要文件,也别让 install 卡死导致 SW 根本注册不上。

「新 SW + 旧页面」错位我的做法是不自动 reload,而是提示。页面侧监听 controllerchange 配合 refreshing 标志防重入,同时在 reg.waiting 存在时弹「有新版本,点击刷新」——用户手上可能正在填表,直接刷会丢数据。主动探测那块您给的 visibilitychange 就够,再加个 setInterval(() => reg.update(), 3600000) 兜长驻页面。

另外补一个您没展开但同源的坑:navigation 请求跑 network-first 时,弱网下 fetch 可能挂十几秒,用户盯着白屏。给它单独加 3~5s 超时,超时直接回落到缓存或 offline.html,体感差别很大。

最后提醒一句:VERSION 变了但 STATIC_CACHE 名字没带版本,上面那段 keys.filter 就永远清不掉——缓存名和版本号绑死,这两处必须成对改。