PWA 实战:用 Service Worker 打造离线可用的渐进式 Web 应用

当网络不稳定或彻底离线时,网页就白屏了——这是传统 Web 应用最大的短板。PWA(Progressive Web App,渐进式 Web 应用)用 Service Worker 与 Cache API 把核心资源缓存到本地,让站点像原生 App 一样可离线打开、可添加到主屏、可接收推送。本文从零讲清 PWA 的三大支柱、离线缓存策略与工程化落地,附可直接复用的 Service Worker 代码。

一、什么是 PWA,为什么需要它

PWA 不是某个具体框架,而是一组 Web 标准能力的统称。它让网站具备”可安装、可离线、可推送”的原生体验,同时保留 Web 免安装、即点即用的优势。对于内容站、工具站、后台系统来说,PWA 能显著提升首屏速度与留存——尤其在网络较差的移动端。

核心收益有三点:① 离线可用,断网也能浏览已访问页面;② 秒开,Service Worker 拦截请求走缓存,跳过网络往返;③ 可安装,用户把站点”添加至主屏”,入口与 App 无异。配合前端性能优化手段,PWA 是性价比极高的体验升级。

一个真实场景:地铁进隧道时网络中断,传统网页立刻白屏,用户直接流失;而 PWA 因为 App Shell 已缓存,页面照常渲染、已读文章照常可读,网络恢复后再静默同步新内容。对内容型站点(博客、文档、资讯)而言,这种”弱网不崩”的韧性,往往比首屏快几百毫秒更影响留存。

二、PWA 三大支柱:Manifest、Service Worker、HTTPS

一个合格的 PWA 必须满足三项基础:① Web App Manifest(manifest.json)——定义名称、图标、启动页;② Service Worker——后台脚本,负责缓存与请求拦截;③ HTTPS——Service Worker 仅在安全上下文注册(localhost 开发例外)。三者缺一不可,否则浏览器不会弹出”安装”提示。

三、注册 Service Worker 与生命周期

Service Worker 本质是独立于页面运行的脚本,通过三个事件管理缓存:install(首次安装,预缓存静态资源)、activate(旧缓存清理)、fetch(拦截网络请求决定走缓存还是网络)。在页面入口注册即可:

// main.js —— 在页面入口注册 Service Worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker
      .register('/sw.js')
      .then((reg) => console.log('SW registered:', reg.scope))
      .catch((err) => console.error('SW failed:', err));
  });
}

注册后浏览器会在后台安装 sw.js。注意作用域:注册路径决定它能拦截哪些请求,/sw.js 默认管控整站。开发中可打开 Chrome DevTools 的 Application → Service Workers 面板实时调试,配合VS Code的 Live Server 即可联调。

需要特别留意 skipWaiting 与 clients.claim 是一把双刃剑:自动激活能让新版本尽快生效,但可能打断正在进行的请求或让旧页面逻辑错乱。对强一致性的后台系统,更稳妥的做法是监听 controllerchange 事件,在用户空闲时再 location.reload(),而不是强制刷新。调试时记得勾选 DevTools 的”Update on reload”,否则每次刷新用的仍是旧缓存。

四、离线缓存策略:Cache API 实战

install 阶段预缓存 App Shell(HTML/CSS/JS/图标),fetch 阶段按策略响应请求。下面是一段生产可用的 sw.js:

// sw.js
const CACHE = 'pwa-v1';
const PRECACHE = ['/', '/index.html', '/app.css', '/app.js', '/icon-192.png'];

self.addEventListener('install', (e) => {
  e.waitUntil(
    caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting())
  );
});

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

// 缓存优先(适合静态资源),失败再回源
self.addEventListener('fetch', (e) => {
  if (e.request.method !== 'GET') return;
  e.respondWith(
    caches.match(e.request).then((hit) =>
      hit ||
      fetch(e.request)
        .then((res) => {
          const copy = res.clone();
          caches.open(CACHE).then((c) => c.put(e.request, copy));
          return res;
        })
        .catch(() => caches.match('/index.html')) // 离线兜底
    )
  );
});

不同资源适合不同策略,切勿一刀切:

策略适用资源取舍
Cache First(缓存优先)静态资源、图标、字体最快,但更新有延迟
Network First(网络优先)API、动态数据数据新,断网才走缓存
Stale-While-Revalidate频繁访问的列表页先返缓存、后台更新
Network Only / Cache Only登录请求 / 纯离线包极端场景专用

对 API 与动态数据,更稳妥的是”网络优先、失败回缓存”。下面把 fetch 策略按路径分流:静态资源走缓存优先,接口走网络优先,并给接口加上兜底响应,避免断网时整页报错。

// 按资源类型分流的 fetch 策略
self.addEventListener('fetch', (e) => {
  const url = new URL(e.request.url);
  if (e.request.method !== 'GET') return;

  // 接口:网络优先,失败回缓存
  if (url.pathname.startsWith('/api/')) {
    e.respondWith(
      fetch(e.request)
        .then((res) => {
          const copy = res.clone();
          caches.open(CACHE).then((c) => c.put(e.request, copy));
          return res;
        })
        .catch(() => caches.match(e.request).then((h) => h || new Response('{}', { status: 503 })))
    );
    return;
  }

  // 静态资源:缓存优先,后台更新
  e.respondWith(
    caches.match(e.request).then((hit) =>
      hit || fetch(e.request).then((res) => {
        const copy = res.clone();
        caches.open(CACHE).then((c) => c.put(e.request, copy));
        return res;
      }).catch(() => caches.match('/index.html'))
    )
  );
});

五、Web App Manifest 配置

manifest.json 告诉浏览器”安装后长什么样”。图标需提供 192 与 512 两种尺寸,并声明 purpose,否则部分机型不显示安装横幅。

{
  "name": "FSData 技术站",
  "short_name": "FSData",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2563eb",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
  ]
}

在 HTML 的 <head> 中通过 <link rel="manifest" href="/manifest.json"> 引入。若用Vue 3等框架,可在 public/ 目录放置 manifest 与图标,构建后自动随站点发布。

六、更新与版本管理

Service Worker 的更新是最大的坑:旧 SW 会一直管控页面直到所有标签页关闭。推荐做法是用缓存版本号(如 pwa-v1)做”原子替换”——activate 阶段删除旧版本缓存,配合 skipWaiting + clients.claim 可在新版本发布后立即生效。注意:改了 sw.js 内容,浏览器才会重新安装,单纯更新被缓存的资源不会触发刷新。

七、调试与常见误区

上线前务必在真机与 DevTools 验证。以下是高频踩坑:

现象根因解法
不弹出”安装”缺 manifest 或图标尺寸不全补齐 192/512 图标与 name
更新不生效旧 SW 未 skipWaiting激活阶段清理旧缓存
本地改了资源却没变缓存优先策略改用 Network First 或加版本号
开发时 404非 HTTPS 且非 localhost用Docker起本地 https 或走 localhost
跨域资源缓存失败fetch 未带 credentials对跨域请求单独处理 opaque 响应

八、与构建工具 / 框架集成

现代框架大多有成熟方案:Vue CLI / Vite 可用 vite-plugin-pwa,Next.js 用 next-pwa,都能自动生成 manifest 与 SW。若站点经Nginx 反向代理或 CDN 分发,记得对 /sw.js 关闭缓存(设 Cache-Control: no-cache),否则更新永远到不了用户。静态资源则相反,应设长缓存并带 hash 文件名,由 SW 的版本号控制失效。

以 Vite 为例,配置 vite-plugin-pwa 后几乎零代码量即可获得完整 PWA 能力,构建时自动注入 manifest、生成带预缓存清单的 Service Worker:

// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';
export default {
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',        // 新版本发布后自动激活
      includeAssets: ['favicon.ico', 'icon-192.png', 'icon-512.png'],
      manifest: {
        name: 'FSData 技术站',
        short_name: 'FSData',
        theme_color: '#2563eb',
        icons: [
          { src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }
        ]
      },
      workbox: { globPatterns: ['**/*.{js,css,html,png,svg}'] }
    })
  ]
};

九、生产落地检查清单

检查项是否完成
全站 HTTPS(含子资源)□
manifest.json 含 192/512 图标□
SW 注册成功且 install/activate 无报错□
离线打开已访问页面正常□
/sw.js 关闭浏览器缓存□
Lighthouse PWA 评分 ≥ 90□

把以上清单跑通,你的站点就具备原生级体验了。PWA 改动量小、收益大,配合GitHub Actions做持续部署,每次发布新版本都会自动推送给用户。下一步可接入 Web Push 实现消息推送,让站点真正”活”起来。

验证 PWA 是否达标,最直接的方式是跑 Lighthouse:在 DevTools 的 Lighthouse 面板选”Progressive Web App”模式,得分 ≥ 90 即代表安装能力、离线能力、响应式三项基本合格。若反复卡在”无离线兜底”,通常是因为 fetch 事件没给 caches.match('/index.html') 的兜底分支;若安装横幅不出现,则回到清单图标尺寸与 HTTPS 覆盖度排查。上线后建议用真实弱网(Chrome DevTools 的 Network 面板调成 Offline)实测一次首屏与已访问页回看,确认断网不白屏再对外宣扬”已支持 PWA”。

上一篇 AI 视频生成 2026:可灵、即梦、Sora 怎么选
下一篇 MySQL 在线 DDL 实战:gh-ost、pt-osc 与原生 INSTANT 怎么选
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]