PWA 渐进式 Web 应用:Service Worker 与离线体验

PWA(Progressive Web App,渐进式 Web 应用)是一种利用现代 Web 技术构建的应用形态,它兼具网页的可达性与原生应用的交互体验。通过 Service Worker、Cache Storage 和 Web App Manifest 三大核心技术,PWA 能够在弱网甚至无网环境

PWA(Progressive Web App,渐进式 Web 应用)是一种利用现代 Web 技术构建的应用形态,它兼具网页的可达性与原生应用的交互体验。通过 Service Worker、Cache Storage 和 Web App Manifest 三大核心技术,PWA 能够在弱网甚至无网环境下保持可用,并支持添加至主屏幕、后台同步和推送通知等功能。

PWA 的核心价值

PWA 并非框架或库,而是一套设计范式与技术标准的组合。它的目标是在不依赖应用商店分发的前提下,让用户获得接近原生应用的体验。其核心优势包括:离线可用、即时加载、可安装、可推送、安全且响应迅速。对于内容型产品或工具类站点,PWA 能以极低的开发成本覆盖移动端用户,同时避免 iOS 与 Android 双端维护的负担。

Twitter Lite 是 PWA 领域的标杆案例。通过 Service Worker 缓存静态资源与推文数据,其页面加载时间减少了 30%,数据流量消耗降低了 70%,用户平均会话时长反而显著提升。这证明了 PWA 在真实业务场景中的可行性。

Service Worker 生命周期

Service Worker 是 PWA 的基石,它是一个运行于浏览器后台的独立线程,能够拦截网络请求、管理缓存、处理推送和后台同步。理解其生命周期是掌握 PWA 开发的第一步。

Service Worker 的生命周期包含四个阶段:

installing:当浏览器未发现当前作用域下已激活的 Service Worker,或检测到脚本内容发生字节级变化时,会触发安装事件。此阶段适合预缓存核心静态资源。如果在 install 事件回调中调用了 event.waitUntil() 并且传入的 Promise 被拒绝,安装将失败,Service Worker 进入 redundant 状态。

waiting:安装成功后,如果当前已有一个激活的 Service Worker 在运行,新安装的版本会进入等待状态。这是为了避免同一时刻存在两个不同版本的 Service Worker 同时处理请求,导致页面状态不一致。当所有受控页面关闭后,新的 Service Worker 才会激活。

activating:旧 Service Worker 退出后,新版本进入激活阶段。此时可以清理旧版本的缓存,确保后续请求使用最新的资源。激活完成后,Service Worker 开始接管其注册作用域内的 fetch 请求。

redundant:当安装失败、被新版本取代,或注册被撤销时,Service Worker 进入废弃状态,不再接收任何事件。

开发者可通过 self.skipWaiting() 在 install 事件中强制新版本立即激活,同时配合 clients.claim() 在 activate 阶段立即接管已有页面。这在开发环境中极为常见,但生产环境需谨慎使用,以免导致正在浏览的页面出现资源版本不匹配的问题。

缓存策略详解

Service Worker 拦截 fetch 请求后,可依据业务场景选择不同的缓存策略。以下是五种经典模式:

Cache First(缓存优先):请求发生时先查询 Cache Storage。若命中则直接返回缓存内容;若未命中再发起网络请求,并将成功响应写入缓存。此策略适用于不经常变动的静态资源,如 Logo、字体文件和框架代码。缺点是一旦缓存命中,用户在缓存过期前始终看不到更新。

Network First(网络优先):优先发起网络请求。若成功则返回响应并更新缓存;若失败则回退到缓存。此策略适合对实时性要求较高的内容,如新闻列表、社交动态。缺点是弱网环境下请求延迟较高,用户体验可能受损。

Stale-While-Revalidate(过时重验证):先返回缓存中的旧内容以保证即时响应,同时在后台发起网络请求更新缓存。下一次访问时用户即可获得最新内容。此策略平衡了加载速度与内容新鲜度,是 PWA 中最推荐的通用策略。

Cache Only:所有请求仅从缓存中响应。适用于需要完全离线运行的应用,或某些明确不需要网络资源的场景。

Network Only: bypass 缓存,所有请求直接走网络。适用于隐私敏感或严格依赖实时数据的接口。

在实际项目中,极少只使用单一策略。通常的做法是为主文档、接口数据和静态资源分别配置不同的策略,并通过路由匹配实现精细化控制。

Cache Storage API 的精细管理

Cache Storage 是 Service Worker 中专门用于缓存请求/响应对的存储空间,与浏览器 HTTP 缓存相互独立。通过 caches.open() 打开具名缓存,开发者可以实现多版本共存与按需清理。

缓存版本控制是生产环境必须面对的问题。推荐在缓存名称中加入版本号,如 static-v2api-v1。当 Service Worker 新版本激活时,在 activate 事件中遍历所有缓存键名,删除不再属于当前版本的缓存空间,避免磁盘空间无限增长。

清理策略需要考虑两个维度:过期时间与容量上限。Cache Storage API 本身不提供 TTL 机制,需要开发者自行实现。一种常见做法是在缓存条目中加入时间戳元数据,每次读取时判断是否过期;另一种方案是限制缓存总条目数,采用 LRU 算法淘汰最久未使用的资源。

对于 Cache Storage 的调试,Chrome DevTools 的 Application 面板提供了可视化的缓存浏览和删除功能,极大降低了排错成本。

Web App Manifest

Web App Manifest 是一份 JSON 配置文件,定义了应用的名称、图标、主题色、显示模式等元信息。浏览器依据这份清单生成添加到主屏幕的入口,并决定应用以何种形态启动。

关键字段包括:

  • nameshort_name:分别为完整名称与短名称,后者用于主屏幕图标下方等空间有限的场景。
  • icons:提供多分辨率 PNG 或 SVG 图标,系统会自动选择最合适的尺寸。
  • display:取值包括 standalonefullscreenminimal-uibrowserstandalone 最为常用,会隐藏浏览器地址栏,呈现类似原生应用的沉浸感。
  • start_url:定义从主屏幕启动时打开的入口路径,通常设置为 /?source=pwa 以便分析 PWA 用户来源。
  • theme_colorbackground_color:控制浏览器工具栏和启动画面的颜色,应与品牌形象保持一致。
  • shortcuts:定义长按图标时弹出的快捷菜单,可直达应用的常用功能页。

Manifest 文件必须在 HTML 的 <head> 中通过 <link rel="manifest" href="/manifest.json"> 引入。

Background Sync 后台同步

用户在离线状态下提交的表单、发送的消息或执行的操作,往往需要在网络恢复后自动补发。Background Sync API 正是为此而生。

基本流程是:页面主线程调用 navigator.serviceWorker.ready,获取 Service Worker 注册实例,然后执行 registration.sync.register('sync-tag')。浏览器在检测到网络恢复后,会触发 Service Worker 的 sync 事件,开发者取出待发送队列中的数据,逐一完成网络请求。

此 API 并非定时任务调度器,它只保证在浏览器认为"合适的时候"触发一次。对于需要精确周期执行的后台任务,应使用 Periodic Background Sync,但其兼容性目前仍较有限。

推送通知

Push API 与 Notification API 的组合使 PWA 具备了类似原生应用的消息推送能力。与 WebSocket 等长连接方案不同,浏览器厂商提供统一的推送服务端,即使应用页面完全关闭,只要 Service Worker 存活,依然可以接收推送并弹出通知。

技术流程分为三步:客户端订阅推送服务,获得包含 endpoint 和密钥的 PushSubscription 对象;服务端通过 Web Push 协议向该 endpoint 发送加密消息;Service Worker 收到 push 事件后调用 self.registration.showNotification() 展示系统级通知。
由于推送消息本身不包含可读正文,Service Worker 通常需要在此事件内发一个网络请求,拉取完整的通知内容或跳转目标 URL。

需要注意的是,iOS 直到 Safari 16.4 才开始支持 Web Push,这是 PWA 生态长期受限的重要瓶颈。

添加至主屏幕(A2HS)

当站点满足可安装性条件时,浏览器会在地址栏显示安装图标,或触发 beforeinstallprompt 事件。开发者可以监听该事件,保存事件引用,然后在合适的时机(如用户点击自定义的"安装"按钮)调用 prompt() 方法弹出系统级安装对话框。

用户完成安装后,应用图标出现在主屏幕或开始菜单中,启动时以全屏或独立窗口模式运行,不再显示浏览器 UI。

可安装性要求清单

并非所有站点都能被浏览器识别为可安装应用。必须同时满足以下条件:

  1. 站点必须部署在 HTTPS 协议下,Service Worker 和 Push API 均要求安全上下文。
  2. 提供有效的 Web App Manifest 文件,且包含至少一组符合规范的图标。
  3. 注册一个 Service Worker,并且其代码中包含对 fetch 事件的监听逻辑。
  4. Manifest 中的 start_urlname 等核心字段必须合法且非空。

开发者可通过 Chrome DevTools 的 Lighthouse 面板运行"PWA"审计,快速排查不满足的条目。

完整代码示例

以下是一个可直接用于生产环境的 Service Worker 模板,采用 Stale-While-Revalidate 作为默认策略,并为静态资源和 API 请求分别配置了独立缓存。

// /sw.js
const STATIC_CACHE = 'static-v2';
const API_CACHE = 'api-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/icon-192x192.png',
  '/offline.html'
];

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

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

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request, API_CACHE));
  } else if (request.destination === 'image' || request.destination === 'script') {
    event.respondWith(staleWhileRevalidate(request, STATIC_CACHE));
  } else {
    event.respondWith(
      caches.match(request).then((cached) => cached || fetch(request))
    );
  }
});

async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  const fetchPromise = fetch(request).then((response) => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  });
  return cached || fetchPromise;
}

async function networkFirst(request, cacheName) {
  const cache = await caches.open(cacheName);
  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) cache.put(request, networkResponse.clone());
    return networkResponse;
  } catch (error) {
    const cached = await cache.match(request);
    if (cached) return cached;
    throw error;
  }
}

self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};
  event.waitUntil(
    self.registration.showNotification(data.title || '新消息', {
      body: data.body || '',
      icon: '/icon-192x192.png',
      badge: '/badge-72x72.png',
      data: data.url || '/'
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow(event.notification.data));
});

self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-outbox') {
    event.waitUntil(syncOutbox());
  }
});

async function syncOutbox() {
  const requests = await getPendingRequests();
  return Promise.all(requests.map((req) => fetch(req)));
}

页面主线程注册 Service Worker 的代码:

// /app.js
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 registration failed:', err));
  });

  let deferredPrompt;
  window.addEventListener('beforeinstallprompt', (e) => {
    e.preventDefault();
    deferredPrompt = e;
    // 显示自定义安装按钮
    document.getElementById('install-btn').style.display = 'block';
  });

  document.getElementById('install-btn')?.addEventListener('click', async () => {
    if (!deferredPrompt) return;
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    console.log(outcome === 'accepted' ? 'User installed PWA' : 'User dismissed install');
    deferredPrompt = null;
  });
}

Web App Manifest 文件:

{
  "name": "My PWA App",
  "short_name": "MyPWA",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#1a73e8",
  "icons": [
    {
      "src": "/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "shortcuts": [
    {
      "name": "新建笔记",
      "short_name": "笔记",
      "description": "快速创建一条新笔记",
      "url": "/note/new",
      "icons": [{ "src": "/shortcut-note.png", "sizes": "192x192" }]
    }
  ]
}

总结

PWA 通过 Service Worker 赋予 Web 应用离线能力和交互深度,通过 Cache Storage 实现精细化的资源管理,通过 Manifest 完成从网页到"应用"的身份转变。它不是要取代原生应用,而是在 Web 技术的边界内提供最优的用户体验。对于希望快速覆盖多端用户、同时又不愿承受应用商店审核与分发成本的产品团队来说,PWA 仍然是一条值得深入投入的技术路线。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. 前端 CI/CD 最佳实践:从代码提交到自动发布
  2. 前端 Bundle 分析与优化:从体积到执行时长的全链路
  3. 从 Webpack 到 Vite:迁移策略与原理对比