PWA 与 Service Worker 实战:给个人站做离线缓存与秒开,附更新失效避坑与 Nginx 配置

为什么个人站也该做 PWA

提到 PWA(Progressive Web App,渐进式网页应用),很多人第一反应是「那是大厂才玩的东西」。其实对个人站长来说,PWA 带来的好处非常实在,而且零成本:一次访问之后,用户再次打开你的站可以秒开(静态资源从本地缓存直接读取,不走网络),弱网甚至离线也能看到内容,手机浏览器还能把站点「添加到主屏幕」,成了一个像 App 一样的图标入口。这些体验提升,对留着用户、提高回访率的作用是实打实的。

PWA 的技术底座核心是两样东西:一个声明文件 manifest.json,和一个跑在后台的脚本 service-worker.js。这篇文章就从零开始,把这两样东西怎么配、缓存策略怎么选、以及最常见的「更新后用户还看到旧版本」这个坑讲透。

三件套:manifest、Service Worker 与 HTTPS

PWA 能成立有三个硬性前提,缺一不可:

  • HTTPS:Service Worker 只能在安全上下文里注册。个人站现在基本都用上了 Let's Encrypt 证书,这一条通常已满足。
  • manifest.json:告诉浏览器这个站叫什么名字、用什么图标、启动时怎么显示。
  • Service Worker:一个独立于页面的后台脚本,负责拦截网络请求、管理缓存,是「离线可用」和「秒开」的实现者。

第一步:写 manifest.json

{
  "name": "我的技术博客",
  "short_name": "技术博客",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2b6cb0",
  "icons": [
    { "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

几个字段要记牢:display: standalone 表示从主屏幕打开时不显示浏览器地址栏,像一个原生 App;scope 限定 Service Worker 能控制的范围,一般就写 /;图标至少提供 192 和 512 两个尺寸,Android 的安装提示会校验这些。

然后在每个页面的 <head> 里引用它,并顺手设置主题色:

<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2b6cb0">

第二步:注册 Service Worker

在页面的 JS 里注册(注意浏览器支持性判断):

if ('serviceWorker' in navigator) {
  window.addEventListener('load', function () {
    navigator.serviceWorker.register('/service-worker.js')
      .then(function (reg) { console.log('SW registered:', reg.scope); })
      .catch(function (err) { console.error('SW failed:', err); });
  });
}

把注册放在 load 事件之后,是为了不跟首屏渲染抢资源。Service Worker 文件必须放在站点根目录(或其 scope 覆盖的路径下),否则它默认只能控制自己所在目录及其子目录,放错位置会导致缓存策略对全站失效。

缓存策略:别一股脑全缓存

Service Worker 最大的误区,是把「缓存」当成「把所有请求都存下来」。正确的做法是按资源类型分策略,以下三种最常用:

1. 静态资源:Cache First(缓存优先)

CSS、JS、字体、图片这类带指纹(文件名含 hash)的资源,内容变了文件名就变,可以放心走「缓存优先」——命中缓存直接返回,不访问网络,这就是「秒开」的来源:

const CACHE = 'static-v1';
const ASSETS = ['/', '/static/style.css', '/static/app.js', '/static/icon-192.png'];

self.addEventListener('install', function (e) {
  e.waitUntil(
    caches.open(CACHE).then(function (c) { return c.addAll(ASSETS); })
  );
});

self.addEventListener('fetch', function (e) {
  const url = new URL(e.request.url);
  if (url.pathname.startsWith('/static/')) {
    e.respondWith(
      caches.match(e.request).then(function (hit) {
        return hit || fetch(e.request).then(function (res) {
          const copy = res.clone();
          caches.open(CACHE).then(function (c) { c.put(e.request, copy); });
          return res;
        });
      })
    );
  }
});

关键在于:只对带指纹的静态资源用缓存优先。如果对 HTML 也用缓存优先,用户将长时间看不到新文章,这就是接下来要讲的「更新坑」。

2. HTML 页面:Network First(网络优先)

对 HTML 导航请求,应当「先试网络,拿到最新页面就返回并更新缓存;网络不通时再回退到缓存」:

self.addEventListener('fetch', function (e) {
  if (e.request.mode === 'navigate') {
    e.respondWith(
      fetch(e.request).then(function (res) {
        const copy = res.clone();
        caches.open(CACHE).then(function (c) { c.put(e.request, copy); });
        return res;
      }).catch(function () {
        return caches.match(e.request).then(function (hit) {
          return hit || caches.match('/');
        });
      })
    );
  }
});

这样在线用户永远看到最新内容,离线用户至少能翻到缓存里的旧页面,而不是一片「无法访问」的报错页。

3. 接口数据:Stale-While-Revalidate(陈旧内容先给,后台刷新)

对于站内搜索、评论列表这类「能接受几秒钟延迟」的接口,可以先用缓存里的旧数据快速渲染,同时在后台发请求更新缓存,下次就新了。这是体验与新鲜度之间很好的折中。

最大的坑:更新后用户还看到旧页面

Service Worker 的生命周期有三个状态:install(安装)、waiting(等待)、activate(激活)。默认行为是:新的 Service Worker 装好了,但只要老的那个还控制着页面,新的就会一直卡在 waiting 状态,直到用户关掉所有标签页、重新打开,才会接管。 这就导致你明明发了新版,老用户还一直看到旧缓存。

解决办法有两步。其一,在 install 里调用 skipWaiting(),让新的 Service Worker 立即进入激活:

self.addEventListener('install', function (e) {
  self.skipWaiting();
  e.waitUntil(caches.open(CACHE).then(function (c) { return c.addAll(ASSETS); }));
});

其二,在 activate 里清理掉所有旧版本的缓存(只保留当前版本),防止旧缓存越堆越多还继续被读到:

self.addEventListener('activate', function (e) {
  e.waitUntil(
    caches.keys().then(function (keys) {
      return Promise.all(
        keys.filter(function (k) { return k !== CACHE; })
            .map(function (k) { return caches.delete(k); })
      );
    }).then(function () { return self.clients.claim(); })
  );
});

clients.claim() 让新激活的 Service Worker 立刻接管所有已打开的页面,不用等用户刷新。版本号(static-v1 里的 v1)一定要在每次改缓存内容时手动递增,它是触发更新流程的钥匙——文件名不变,浏览器就认为没变,不会去装新的。

Nginx 配套配置

有两个文件的缓存策略必须特别处理,否则前面的努力会白费。第一,service-worker.js 本身绝不能长缓存,否则浏览器永远拿不到新版本,更新机制失效;第二,manifest.json 也不宜长缓存。在 Nginx 里这样配:

location = /service-worker.js {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    expires -1;
}

location = /manifest.json {
    add_header Cache-Control "no-cache";
    types { application/manifest+json json; }
}

location /static/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

location = /service-worker.js 用了精确匹配(=),确保这个文件不被其他宽泛的 location 规则抢先处理。immutable 告诉浏览器「这个资源一年内绝对不会变,不用再来问」,配合带指纹的静态资源文件名,效果最佳。

验证与调试

Service Worker 的行为很难靠刷新页面察觉,必须借助浏览器开发者工具(Chrome 的 DevTools → Application 面板):

  • Manifest 一节能预览图标、名称,若报错会明确提示缺哪个尺寸。
  • Service Workers 一节能看到当前 SW 的状态(activated / waiting),并提供 Update、Unregister 按钮。
  • Cache Storage 一节能逐个查看缓存里存了哪些请求,验证「秒开」是否真的命中了缓存。
  • 模拟离线:在 Network 面板勾选 Offline,刷新页面,看能否正常显示——这是最直接的 PWA 验收。

手机上则用 Chrome 或 Safari 打开站点,看菜单里有没有「添加到主屏幕」。Android Chrome 如果你满足了安装条件(有 manifest、有图标、注册了带 fetch 处理的 Service Worker),还会主动弹出安装横幅。

再补两处容易翻车的细节

第一处是 Service Worker 的作用域(scope)陷阱。前面提到 service-worker.js 必须放在根目录,原因就在这里:默认情况下,一个 Service Worker 只能控制「它自己所在路径及其子路径」下的页面。如果你的 SW 文件放在 /js/service-worker.js,那么它注册后作用域只有 /js/,站点首页 / 根本不受它控制,「秒开」和离线缓存全部落空。排查方法是在 DevTools 的 Application → Service Workers 里看注册后的 scope 字段是不是 https://example.com/,如果是 /js/ 之类的子路径,就说明位置放错了。如果因为某些原因必须把文件放在子目录,也可以在注册时通过响应头 Service-Worker-Allowed: / 来扩大作用域,但这需要在 Nginx 里为该文件单独加头,不如直接放根目录省事。

第二处是 缓存命中却拿到旧接口数据。Stale-While-Revalidate 策略虽然体验好,但它有一个前提——接口返回的数据要带明确的失效标记。如果接口内容变了却沿用同一个 URL,而缓存策略又过于宽松,用户可能长时间看到过期数据。稳妥的做法是让接口 URL 带上版本或时间戳参数,或者对关键接口改用 Network First,宁可多等两百毫秒,也不要给用户展示陈旧内容。判断原则很简单:「变化后必须马上看到」的内容走 Network First;「晚几秒无所谓」的走缓存优先。

性能上的额外收益

PWA 除了离线和秒开,还有一个常被忽略的收益:它让静态资源真正做到了「一次下载、永久复用」。传统做法下,浏览器虽然也有 HTTP 缓存,但受制于缓存大小和过期策略,用户隔一段时间回来还是要重新下载一部分资源。而 Service Worker 的 Cache Storage 是独立于 HTTP 缓存的一块持久存储,容量更大、控制权完全在你手里。配合带指纹的文件名,你可以理直气壮地把静态资源缓存一年,用户第二次访问时首屏几乎不需要网络请求,感知速度的提升非常明显。

另外,Service Worker 还能充当一个轻量的网络层中间件:你可以用它统一给站内请求加超时、做失败重试、把多个小请求合并,甚至做简单的请求去重。这些能力过去要么写在前端业务代码里,要么依赖服务端,如今在 SW 里集中处理,页面逻辑反而更干净。对于个人站这类没有复杂架构的项目,这种「把横切逻辑收拢到一处」的方式尤其划算。

上线检查清单

  • manifest.json 已引用,图标提供 192 与 512 两个尺寸。
  • Service Worker 文件放在根目录,注册范围正确。
  • 静态资源走 Cache First,HTML 走 Network First,接口走 Stale-While-Revalidate。
  • install 里有 skipWaiting()、activate 里清理旧缓存并 clients.claim()。
  • 缓存版本号(v1)在每次内容变更时递增。
  • Nginx 里 service-worker.js 与 manifest.json 不走长缓存,静态资源走一年 immutable。
  • 在 DevTools 的 Offline 模式实测过离线访问正常。
  • 更新一次内容后,确认老用户刷新即能看到新版本,没有被旧缓存卡住。

PWA 不是玄学,它只是把「缓存」这件老事情,用一套浏览器原生支持的标准重新组织了一遍。对个人站而言,花半天时间配好 Service Worker,换来的是长期的回访体验提升和「像 App 一样」的入口。当你下次在手机上点开自己的站,图标一闪即开、断网也能读,那种流畅感会让你觉得这点投入完全值得。

Last modification:October 11th, 2026 at 09:25 pm

Leave a Comment