首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >H5 加了 Service Worker 反而"更新不出来":缓存策略与版本激活的正确顺序

H5 加了 Service Worker 反而"更新不出来":缓存策略与版本激活的正确顺序

原创
作者头像
用户5598620
发布2026-09-14 09:26:08
发布2026-09-14 09:26:08
10
举报

给 H5 轻应用加离线能力、做资源缓存,本来是为了让二次打开更快、弱网下也能用,但很多团队第一次接入 Service Worker 都会遇到同一个反直觉问题:代码明明发布了,用户看到的却还是旧页面,甚至要手动清缓存、多次刷新才生效。问题几乎都不在缓存本身,而在 Service Worker 的生命周期和版本激活顺序没处理对。这篇把 install、waiting、activate 三个阶段讲透,给出可直接抄走的缓存策略模板和版本更新流程,文末附策略对照表与排查清单。

一、先理解:为什么会"卡在旧版本"

Service Worker(以下简称 SW)有自己独立的生命周期,它和页面是分离的:

  1. 浏览器发现 SW 文件变化,进入 install 阶段预缓存新资源;
  2. 新 SW 安装完成后不会立刻接管页面,而是进入 waiting 状态,因为旧 SW 还控制着当前已经打开的标签页;
  3. 只有当旧 SW 控制的所有标签页都关闭、或新 SW 主动调用 skipWaiting 后,新 SW 才进入 activate;
  4. activate 阶段才会清理旧缓存、真正接管请求。

"更新不出来"最常见的原因就是新 SW 一直停在 waiting:用户的标签页长期开着,旧 SW 不释放,新 SW 永远等不到激活。理解这条链路,后面的方案才有意义。

二、install 阶段:预缓存且任何一个失败都不能算成功

install 里把这一版依赖的静态资源预缓存下来,命名缓存时带上版本号。要用到 cache.addAll,它是原子的——只要列表里有一个资源请求失败,整个 Promise 就 reject,install 失败,避免缓存出一个残缺版本。

代码语言:javascript
复制
const CACHE_VERSION = 'v20260914';
const PRECACHE_URLS = [
  '/',
  '/index.html',
  '/assets/app.css',
  '/assets/app.js',
];

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

这里先放上 skipWaiting,让新 SW 安装完不等旧标签页关闭、直接进入激活。但要注意:skipWaiting 只是让它尽快激活,新 SW 激活的瞬间当前页面已经加载的资源并不会自动替换,所以还需要配合客户端接管,后面会讲。

三、activate 阶段:清掉旧版本缓存

每次版本号变化都会产生一份新缓存,如果不清理旧缓存,缓存会越积越多。activate 里白名单制地删除所有不属于当前版本的缓存:

代码语言:javascript
复制
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => key !== CACHE_VERSION)
          .map((key) => caches.delete(key))
      )
    ).then(() => self.clients.claim())
  );
});

clients.claim 的作用是让激活后的新 SW 立刻接管当前打开的所有页面,而不是等下次刷新。skipWaiting 加 clients.claim 组合起来,才能让新版本"尽快上线";但页面已经渲染出来的 DOM 和已执行的旧脚本不会回滚,所以真正稳妥的做法是检测到新版本后提示用户刷新一次。

四、fetch 阶段:不同资源用不同缓存策略

不是所有请求都该用同一种缓存方式,把策略按资源类型分开,是既快又不出错的关键。下面是一个组合策略示例:带版本哈希的静态资源用缓存优先,HTML 文档用网络优先保证拿到最新,接口请求用网络优先并兜底缓存。

代码语言:javascript
复制
self.addEventListener('fetch', (event) => {
  const { request } = event;
  if (request.method !== 'GET') return;

  // 带哈希的静态资源:缓存优先,长期不变
  if (/\/assets\//.test(request.url)) {
    event.respondWith(cacheFirst(request));
    return;
  }
  // HTML 文档:网络优先,拿不到新的再用缓存(离线兜底)
  if (request.mode === 'navigate') {
    event.respondWith(networkFirst(request));
    return;
  }
});

async function cacheFirst(request) {
  const cached = await caches.match(request);
  return cached || fetch(request).then((resp) => {
    return caches.open(CACHE_VERSION).then((cache) => {
      cache.put(request, resp.clone());
      return resp;
    });
  });
}

async function networkFirst(request) {
  const cache = await caches.open(CACHE_VERSION);
  try {
    const fresh = await fetch(request);
    cache.put(request, fresh.clone());
    return fresh;
  } catch (e) {
    const cached = await caches.match(request);
    if (cached) return cached;
    throw e;
  }
}

关键原则:HTML 入口文档绝不能用 cacheFirst,否则一旦入口被缓存死,后续所有版本更新都进不来,这是"永远更新不出来"的另一大成因。

五、版本更新怎么让用户无感或轻感知

skipWaiting 会让新 SW 在后台激活,但当前页仍是旧代码渲染的结果。标准做法是在页面里监听 controllerchange,并在发现有 waiting 的新版本时给出一个温和的刷新提示:

代码语言:javascript
复制
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (refreshing) return;
  refreshing = true;
  window.location.reload();
});

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').then((reg) => {
    reg.addEventListener('updatefound', () => {
      const next = reg.installing;
      next.addEventListener('statechange', () => {
        if (next.state === 'installed' && navigator.serviceWorker.controller) {
          // 已有旧 SW 控制,且新版本安装完成,可提示刷新
          console.log('发现新版本,可提示用户刷新');
        }
      });
    });
  });
}

用一个 refreshing 标志位防止重复刷新。是否自动 reload 取决于业务:后台型、表单型页面不宜强制刷新(会丢掉用户正在填的内容),更适合弹一个"有新版本,点击刷新"的轻提示,由用户决定。

六、运行时缓存还要管"新鲜度"和"容量"

预缓存解决的是发版时确定的静态资源,而运行时缓存(用户访问过程中动态写入缓存的资源)如果只进不出,同样会埋下隐患:一是缓存无限膨胀占用存储,二是某些资源被长期缓存后内容过期。对这类资源,更合适的是 stale-while-revalidate 策略——先用缓存快速响应,同时在后台拉取新版本更新缓存,下一次访问就是新的:

代码语言:javascript
复制
async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_VERSION);
  const cached = await cache.match(request);
  const network = fetch(request).then((resp) => {
    cache.put(request, resp.clone());
    return resp;
  }).catch(() => cached);
  return cached || network;
}

同时给缓存设一个容量上限,写入时如果条目数或总大小超过阈值,就按"最久未访问"优先淘汰一批,避免缓存只增不减。对带哈希的静态资源可以放心长缓存,对没有哈希、内容会变的资源,则要带上过期判断,超过一定时长就不再直接用旧缓存,而是优先走网络。把"预缓存、运行时缓存、过期淘汰"三者分开管理,缓存才不会从提速手段变成更新阻碍。

七、可直接抄走的策略对照表

资源类型

推荐策略

理由

带内容哈希的 JS/CSS/图片

缓存优先 cacheFirst

文件名变即新资源,可长期缓存

HTML 入口文档

网络优先 networkFirst

必须优先拿新版,缓存仅离线兜底

数据接口(GET)

网络优先 + 缓存兜底

保证数据新鲜,弱网可回退

字体文件

缓存优先(StaleWhileRevalidate)

基本不变,先用缓存再后台更新

POST/PUT 等写请求

不拦截

SW 不适合缓存非 GET 请求

八、踩坑清单

  • HTML 入口被 cacheFirst 缓存死,导致版本永远更新不进来。
  • install 不用 addAll 或不 return Promise,预缓存没完成就进入下一阶段。
  • activate 不清理旧缓存,版本累积占用存储。
  • 只 skipWaiting 不 clients.claim,新 SW 不接管已开页面。
  • 检测到新版本直接强制 reload,打断用户正在填写的表单。
  • SW 文件本身被强缓存,浏览器根本发现不了 SW 更新——sw.js 要设置为不缓存(Cache-Control: no-cache)。
  • 缓存版本号不随发版变化,新旧资源混在同一个缓存里。

九、工程落地建议

推荐的标准组合是:sw.js 自身禁用缓存、每次发版修改缓存版本号;install 用 addAll 预缓存并 skipWaiting;activate 白名单清理旧缓存并 clients.claim;fetch 按资源类型分流,静态资源缓存优先、HTML 与接口网络优先;页面侧监听 updatefound 与 controllerchange,用轻提示让用户在合适时机刷新。上线后用浏览器的 Application 面板模拟 update、offline 两类场景各走一遍,确认新版本能激活、断网能兜底。以上为通用前端工程实践,具体兼容性以目标浏览器为准。

复盘清单

  • SW 文件本身是否设置为不缓存。
  • HTML 入口是否用了网络优先而非缓存优先。
  • 缓存是否带版本号、activate 是否清理旧版本。
  • skipWaiting 与 clients.claim 是否成对出现。
  • 是否避免了打断用户操作的强制刷新。
  • 是否实测过离线兜底与版本更新两条路径。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、先理解:为什么会"卡在旧版本"
  • 二、install 阶段:预缓存且任何一个失败都不能算成功
  • 三、activate 阶段:清掉旧版本缓存
  • 四、fetch 阶段:不同资源用不同缓存策略
  • 五、版本更新怎么让用户无感或轻感知
  • 六、运行时缓存还要管"新鲜度"和"容量"
  • 七、可直接抄走的策略对照表
  • 八、踩坑清单
  • 九、工程落地建议
  • 复盘清单
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档