前置知识: JavaScript

Service Worker 与 PWA

13 min高级

离线优先:Service Worker 生命周期、缓存策略与 PWA 安装体验。

Service Worker 与 PWA

Service Worker 是一段运行在页面之外的脚本,站在网页与网络之间充当可编程的代理:它拦截本源的所有请求,决定”走网络、读缓存还是返回离线页”。配合 Cache Storage 与 Web App Manifest,站点可以获得离线可用、秒开、可安装的 PWA 体验。本篇覆盖 Service Worker 的生命周期与作用域、三种经典缓存策略、Fetch 拦截与离线回退、Manifest 与安装提示,以及推送与后台同步的概览。

前置知识

学习目标

  • 能描述 Service Worker 的 install、waiting、activate 生命周期与作用域规则。
  • 能针对不同资源类型选择 cache-first、network-first 或 stale-while-revalidate 策略。
  • 能编写 fetch 拦截逻辑实现离线回退页。
  • 能编写 Manifest 并用 beforeinstallprompt 引导用户安装 PWA。
  • 能说出后台同步与推送通知的适用场景与基本事件模型。

一、Service Worker 的定位:页面之外的可编程代理

Service Worker 与普通脚本有三个本质区别:它运行在独立的 Worker 环境中,无法访问 DOM;它常驻后台,即使页面全部关闭也可能被唤醒处理事件;它是事件驱动的,浏览器只在需要时(请求到达、推送到达、同步触发)调用它。使用前提是 HTTPS 或 localhost——因为它权限极大(可以拦截篡改一切请求),必须防传输劫持。

javascript
// 页面入口:注册 Service Worker,路径决定作用域
if ('serviceWorker' in navigator) {
  navigator.serviceWorker
    .register('/sw.js', { scope: '/' }) // 根路径脚本默认作用域为整站
    .then((reg) => console.log('注册成功,作用域:', reg.scope))
    .catch((err) => console.error('注册失败:', err));
}

作用域是理解 SW 的第一道关卡:/sw.js 默认控制整站,而 /js/sw.js 只能控制 /js/ 下的页面。要把作用域提升到根目录,需要响应头 Service-Worker-Allowed: /。这也解释了为什么绝大多数项目把 SW 脚本放在站点根目录。

二、生命周期与作用域

SW 的生命周期是三步状态机:install(下载执行新脚本,预缓存资源)、waiting(新 SW 就绪但旧 SW 仍在控制页面,等待所有标签页关闭)、activate(旧 SW 退出,新 SW 接管,适合清理过期缓存)。一个关键设计是:安装中的新 SW 不会打断旧 SW,避免”半新半旧”的页面状态。

javascript
// sw.js:install -> waiting -> activate 三阶段
const CACHE = 'fandex-live-v2'; // 版本号一变,旧缓存即可被识别并清理

self.addEventListener('install', (event) => {
  event.waitUntil(
    // waitUntil 保证异步任务完成后才算安装成功
    caches.open(CACHE).then((cache) => cache.addAll(['/offline.html']))
  );
  // self.skipWaiting(); // 可选:跳过等待立即接管(新版本差异大时才用)
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      // 删除所有非当前版本的旧缓存
      Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
    )
  );
});

两个 API 必须理解:event.waitUntil(promise) 告诉浏览器”事件没结束,别杀我”,Promise 兑现前 SW 保持存活;clients.claim() 让刚 activate 的 SW 立即控制已打开的页面(默认只控制之后打开的页面)。缓存清单中的版本号是整个方案的安全阀:只要版本一变,新 SW 就会被检测到并走一遍完整生命周期。

更新检测的时机也有讲究:浏览器会在每次页面导航时重新拉取 sw.js 做字节级比对(并且至少每 24 小时强制检查一次),内容一致则直接复用旧脚本。因此发布时无需任何手动刷新机制,只要新脚本内容有差异,新 SW 就会自动进入 install 阶段排队等待接管。

与页面通信:postMessage 双通道

本节承接自网络请求 API 拆分(其原第 11.4 节);该篇第 11.1~11.3 节与本篇第一、二节重复,已去重。SW 与页面是两个执行环境,数据靠 postMessage 双向传递:

javascript
// 主线程 → Service Worker
navigator.serviceWorker.controller.postMessage({
  type: 'CACHE_URL',
  url: '/api/data',
});

// Service Worker 接收
self.addEventListener('message', (event) => {
  if (event.data.type === 'CACHE_URL') {
    caches.open('v1').then((cache) => cache.add(event.data.url));
  }
});

// Service Worker → 主线程(通过 Client)
self.addEventListener('message', (event) => {
  event.source.postMessage({ type: 'REPLY', data: 'ok' });
});

// 主线程接收
navigator.serviceWorker.addEventListener('message', (event) => {
  console.log('收到 SW 消息:', event.data);
});

易错点:两侧的 message 事件监听写在不同环境——self.addEventListener('message', ...) 在 sw.js 里,navigator.serviceWorker.addEventListener('message', ...) 在页面里;把页面侧的监听误挂到 window 上收不到 SW 的回信。

三、Cache Storage 与三种缓存策略

Cache Storage 是按名字分组的 Request 到 Response 的缓存表,与 HTTP 缓存相互独立、由代码全权控制。不同资源的新鲜度要求不同,对应的策略也不同:

javascript
// 三种经典缓存策略
const CACHE = 'fandex-live-v2';

async function cacheFirst(request) {
  // 缓存优先:应援色 CSS、字体、图标这类几乎不变的静态资源
  const hit = await caches.match(request);
  return hit ?? fetch(request);
}

async function networkFirst(request) {
  // 网络优先:余票数、排期这类必须新鲜的数据,失败才回退缓存
  try {
    const fresh = await fetch(request);
    const cache = await caches.open(CACHE);
    cache.put(request, fresh.clone()); // body 只能读一次,缓存前必须 clone
    return fresh;
  } catch {
    return (await caches.match(request)) ?? Response.error();
  }
}

async function staleWhileRevalidate(request) {
  // 先用缓存立刻响应,后台悄悄更新,下次访问即是新数据
  const cache = await caches.open(CACHE);
  const cached = await caches.match(request);
  const fresh = fetch(request)
    .then((res) => {
      cache.put(request, res.clone());
      return res;
    })
    .catch(() => cached);
  return cached ?? fresh;
}

选择原则可以概括为一句话:按”用户能容忍多旧”来分配资源。静态资产容忍极旧,用 cache-first;业务数据不能旧,用 network-first;歌单封面、艺人介绍这类介于两者之间的内容,用 stale-while-revalidate 兼顾速度与新鲜度。策略本身只是十几行代码,价值在于把它们按资源类型路由到正确的位置。

存储配额是另一个必须正视的现实:Cache Storage 的配额由浏览器按整站统筹,navigator.storage.estimate() 可以查询已用空间与上限;配额紧张时浏览器可能整体回收站点存储。因此缓存列表要有条目上限与淘汰策略,而真正重要的数据(例如待重发的购票单)应存入 IndexedDB,不要依赖缓存的长久性。

Cache API 基础操作

承接自网络请求篇拆分(原第 12.1、12.2.4 节);原第 12.2.1~12.2.3 的三种策略实现与本篇第三节重复,已去重。上一节的三种策略都是这几个原语的不同排列:

javascript
// 打开一个缓存
const cache = await caches.open('my-cache');

// 添加(Request 或 URL)
await cache.add('/api/data');
await cache.addAll(['/api/users', '/api/posts']);

// 手动 put
const response = await fetch('/api/data');
await cache.put('/api/data', response.clone());

// 读取
const cached = await cache.match('/api/data');
if (cached) {
  const data = await cached.json();
}

// 删除
await cache.delete('/api/data');

// 查询所有键
const keys = await cache.keys();

12.2.4 Network Only / Cache Only

javascript
// Network Only:强制网络
async function networkOnly(request) {
  return fetch(request);
}

// Cache Only:仅缓存(离线场景)
async function cacheOnly(request) {
  const cached = await caches.match(request);
  return cached || Response.error();
}

速查卡(承接自 Web 存储篇)

基本写法:打开缓存 caches.open(<名称>)

javascript
// 用于 Service Worker 缓存
caches.open("v1").then(cache => {});

基本写法:缓存请求 <cache>.put(<请求>, <响应>)

javascript
// 缓存 fetch 响应
caches.open("v1").then(cache => {
    fetch("/api").then(res => cache.put("/api", res.clone()));
});

基本写法:读取缓存 <cache>.match(<请求>)

javascript
// 从缓存匹配请求
caches.open("v1").then(cache => cache.match("/api"))
    .then(res => {});

四、Fetch 拦截与离线回退

fetch 事件是 SW 的核心:页面发出的每个同源请求都会先经过这里,event.respondWith(promise) 用自定义响应替换默认行为。未被 respondWith 覆盖的请求按浏览器默认流程走,因此”选择性拦截”是完全可行的。

javascript
// fetch 拦截:按资源类型分流,离线时回退
self.addEventListener('fetch', (event) => {
  const { request } = event;

  // 只拦截同源 GET;跨域资源与购票 POST 一律放行
  if (request.method !== 'GET' || new URL(request.url).origin !== self.location.origin) {
    return;
  }

  if (request.mode === 'navigate') {
    // 页面导航:网络优先,彻底离线时展示离线页
    event.respondWith(
      networkFirst(request).catch(() => caches.match('/offline.html'))
    );
  } else if (request.url.includes('/static/')) {
    // 静态资源:缓存优先,命中即零网络成本
    event.respondWith(cacheFirst(request));
  }
});

缓存键的规范化是拦截层容易忽略的一环:同一个资源经由带查询参数与不带查询参数的 URL 访问时,会被缓存成两个条目,命中率随之下降。策略上可以在缓存前用 new URL(request.url) 剥离无关参数,或对带时间戳的请求统一归一到规范地址,让”同一资源只有一份缓存”成为默认。

这套分流逻辑让购票页在地铁里也能打开:导航请求失败时立即呈现缓存的离线页与”恢复网络后自动重试”的提示;脚本、样式全部来自缓存,页面秒开;唯独购票按钮会提示联网。离线体验的边界感由此确立——离线可以浏览,交易必须在线。离线页本身也应该在 install 阶段预缓存(本篇第二节正是这样做的),否则”离线回退页也要联网取”就成了死循环。

分流路由、版本管理与 Workbox

承接自网络请求篇拆分(原第 12.3~12.5 节)。上一节的 fetch 拦截示例是最小骨架,工程里的完整分流、多缓存版本清理与”不想手写策略”的 Workbox 方案如下:

分流路由:按请求类型各就各位

javascript
// 按请求类型路由
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // 1. 仅 GET 请求缓存
  if (request.method !== 'GET') {
    return;
  }

  // 2. 静态资源 → Cache First
  if (request.destination === 'style' ||
      request.destination === 'script' ||
      request.destination === 'font' ||
      request.destination === 'image') {
    event.respondWith(cacheFirst(request));
    return;
  }

  // 3. API 请求 → Network First
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // 4. 导航请求 → Network First,离线时返回缓存 HTML
  if (request.mode === 'navigate') {
    event.respondWith(networkFirst(request).catch(() => caches.match('/offline.html')));
    return;
  }

  // 5. 其他 → Stale-While-Revalidate
  event.respondWith(staleWhileRevalidate(request));
});

多缓存版本管理

javascript
const CACHE_VERSION = 'v3';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
const RUNTIME_CACHE = `runtime-${CACHE_VERSION}`;

self.addEventListener('activate', (event) => {
  event.waitUntil(
    (async () => {
      const keys = await caches.keys();
      await Promise.all(
        keys
          .filter((key) => !key.endsWith(CACHE_VERSION))
          .map((key) => caches.delete(key))
      );
      await self.clients.claim();
    })()
  );
});

用 Workbox 免手写

javascript
// 使用 Google Workbox 简化 Service Worker
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// 静态资源
registerRoute(
  ({ request }) => ['style', 'script', 'font'].includes(request.destination),
  new CacheFirst({
    cacheName: 'static-v1',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 }),
    ],
  })
);

// API
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-v1',
    networkTimeoutSeconds: 3,
  })
);

// 图片
registerRoute(
  ({ request }) => request.destination === 'image',
  new StaleWhileRevalidate({
    cacheName: 'images-v1',
    plugins: [new ExpirationPlugin({ maxEntries: 50 })],
  })
);

Workbox 的价值不在”能实现”(以上手写版都能实现),而在把过期淘汰、可缓存状态码、网络超时这些工程细节变成插件参数。手写版适合理解原理,生产项目建议从 Workbox 起步。

五、Manifest 与安装提示

PWA 的”可安装”由 Web App Manifest 描述。清单声明应用名、图标、启动 URL、显示模式与主题色;浏览器综合清单与 SW 的活跃情况,判断站点是否满足安装条件。

html
<!-- index.html 中引用清单 -->
<link rel="manifest" href="/manifest.webmanifest" />
<!-- 高质量 PWA 的两大标记:theme_color 让地址栏融入应援色 -->
<meta name="theme-color" content="#39C5BB" />
json
{
  "name": "FANDEX Live",
  "short_name": "FANDEX",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#39C5BB",
  "theme_color": "#39C5BB",
  "icons": [
    { "src": "/icons/icon.svg", "sizes": "any", "type": "image/svg+xml" }
  ]
}

默认情况下浏览器会在条件满足时自动弹出安装横幅,但时机不可控。更专业的做法是拦截 beforeinstallprompt 事件,把安装邀请留到用户表现出兴趣的时刻(例如收藏了某场演唱会之后):

javascript
// 把安装提示留到最合适的时机
let deferredPrompt = null;

window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();   // 阻止浏览器默认横幅
  deferredPrompt = e;   // 暂存事件对象
});

favoriteButton.addEventListener('click', async () => {
  if (!deferredPrompt) return;
  deferredPrompt.prompt(); // 主动触发安装弹窗
  const { outcome } = await deferredPrompt.userChoice;
  console.log(outcome === 'accepted' ? '已添加到主屏幕' : '用户暂不安装');
  deferredPrompt = null;   // 该事件只能消费一次
});

清单中的 display 字段决定安装后的呈现形态:standalone 让应用脱离浏览器 UI 独立成窗,是最常见的 PWA 形态;minimal-ui 保留少量导航控件;fullscreen 则完全沉浸,演唱会直播页常选它。图标建议提供 SVG 矢量格式,一套文件即可适配各分辨率的主屏幕。

六、推送与后台同步概览

两项能力让 PWA 超越”离线网页”。后台同步(Background Sync) 解决”断网时的操作不丢失”:用户在地铁里点击购票,请求先存入 IndexedDB 并注册同步任务,网络恢复后浏览器唤醒 SW 重发。推送(Push) 解决”页面没开也能收到通知”:服务器经推送服务下发消息,SW 的 push 事件被唤醒并展示通知。

javascript
// sw.js:后台同步与推送的事件入口
self.addEventListener('sync', (event) => {
  if (event.tag === 'retry-ticket-order') {
    // 网络恢复后重放:从 IndexedDB 取出未发送的购票单逐个提交
    event.waitUntil(flushPendingOrders());
  }
});

self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};
  event.waitUntil(
    self.registration.showNotification('FANDEX Live', {
      body: data.body ?? '您关注的演唱会已开票',
      icon: '/icons/icon.svg',
      data: { url: '/concerts/42' }, // 点击通知时跳转用
    })
  );
});

推送需要服务端生成 VAPID 密钥、前端用 PushManager 订阅,且通知权限必须由用户显式授予;后台同步则要求请求”可以安全重放”(幂等或带去重 id)。订阅流程的骨架是:页面调用 registration.pushManager.subscribe({ userVisibleKey: ... }) 拿到订阅对象,把其中的 endpoint 上报给业务服务器;服务器之后向该 endpoint 投递加密消息,浏览器负责唤醒 SW。两者共同的工程要点是:把待办数据先落盘(IndexedDB),再依赖浏览器唤醒机制,绝不假设 SW 会一直活着。通知权限的申请时机也要克制:在用户完成一次有价值的操作后再请求授权,拒绝率远低于页面加载即弹窗。

实战项目:离线优先笔记应用(承接自 Web 存储篇)

本节整体承接自 Web 存储 原第 15 节:项目的四大支柱(SW 拦截、IndexedDB 存储、localStorage 偏好、后台同步)正是本篇前六节的综合演练;IndexedDB 数据层的原理细节见 IndexedDB 第 14 节的同型项目。

项目目标与完整实现

先看目标再读代码:15.1 定义项目的四大支柱,15.2 给出完整实现,15.3 复盘要点。实现代码较长,建议对照 15.1 的清单逐项确认它落在实现的哪个部分,读完后合上文档尝试复述数据流。

15.1 项目目标

构建一个支持离线编辑、跨设备同步、冲突解决的笔记应用:

  1. Service Worker 拦截网络请求,实现离线访问
  2. IndexedDB 存储笔记数据,支持离线编辑
  3. localStorage 存储用户偏好与元数据
  4. 后台同步机制,网络恢复时自动推送

15.2 完整实现

javascript
/**
 * 离线优先笔记应用
 */
class OfflineNotesApp {
  constructor() {
    this.dbName = 'NotesAppDB';
    this.dbVersion = 1;
    this.db = null;
    this.configStorage = new NamespacedStorage('notesConfig');
  }

  async init() {
    await this.initDB();
    await this.registerServiceWorker();
    this.setupOnlineListener();
  }

  /**
   * 初始化 IndexedDB
   */
  async initDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.dbVersion);

      request.onupgradeneeded = (e) => {
        const db = e.target.result;

        // 笔记存储
        if (!db.objectStoreNames.contains('notes')) {
          const noteStore = db.createObjectStore('notes', { keyPath: 'id' });
          noteStore.createIndex('updatedAt', 'updatedAt', { unique: false });
          noteStore.createIndex('syncStatus', 'syncStatus', { unique: false });
        }

        // 待同步操作队列
        if (!db.objectStoreNames.contains('pendingOps')) {
          db.createObjectStore('pendingOps', {
            keyPath: 'id',
            autoIncrement: true,
          });
        }
      };

      request.onsuccess = (e) => {
        this.db = e.target.result;
        resolve();
      };

      request.onerror = () => reject(request.error);
    });
  }

  /**
   * 注册 Service Worker
   */
  async registerServiceWorker() {
    if ('serviceWorker' in navigator) {
      try {
        const reg = await navigator.serviceWorker.register('/sw.js');
        console.log('Service Worker registered:', reg.scope);
      } catch (e) {
        console.error('SW registration failed:', e);
      }
    }
  }

  /**
   * 监听网络状态
   */
  setupOnlineListener() {
    window.addEventListener('online', () => {
      console.log('Network restored, syncing...');
      this.syncPendingOps();
    });
  }

  /**
   * 创建笔记
   */
  async createNote(title, content) {
    const note = {
      id: `note_${Date.now()}_${Math.random().toString(36).slice(2)}`,
      title,
      content,
      createdAt: Date.now(),
      updatedAt: Date.now(),
      syncStatus: 'pending',
    };

    await this.saveNote(note);
    await this.queueOp({ type: 'create', noteId: note.id, data: note });

    if (navigator.onLine) {
      await this.syncPendingOps();
    }

    return note;
  }

  /**
   * 更新笔记
   */
  async updateNote(id, updates) {
    const note = await this.getNote(id);
    if (!note) throw new Error('Note not found');

    Object.assign(note, updates, {
      updatedAt: Date.now(),
      syncStatus: 'pending',
    });

    await this.saveNote(note);
    await this.queueOp({ type: 'update', noteId: id, data: updates });

    if (navigator.onLine) {
      await this.syncPendingOps();
    }

    return note;
  }

  /**
   * 删除笔记
   */
  async deleteNote(id) {
    await this.queueOp({ type: 'delete', noteId: id });
    return this.deleteNoteFromDB(id);
  }

  /**
   * 保存笔记到 IndexedDB
   */
  async saveNote(note) {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction('notes', 'readwrite');
      const store = tx.objectStore('notes');
      const request = store.put(note);
      request.onsuccess = () => resolve(note);
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * 获取笔记
   */
  async getNote(id) {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction('notes', 'readonly');
      const store = tx.objectStore('notes');
      const request = store.get(id);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * 获取所有笔记
   */
  async getAllNotes() {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction('notes', 'readonly');
      const store = tx.objectStore('notes');
      const index = store.index('updatedAt');
      const request = index.getAll();
      request.onsuccess = () => resolve(request.result.reverse());
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * 从 IndexedDB 删除笔记
   */
  async deleteNoteFromDB(id) {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction('notes', 'readwrite');
      const store = tx.objectStore('notes');
      const request = store.delete(id);
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * 将操作加入待同步队列
   */
  async queueOp(op) {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction('pendingOps', 'readwrite');
      const store = tx.objectStore('pendingOps');
      const request = store.add({
        ...op,
        timestamp: Date.now(),
      });
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * 同步待处理操作
   */
  async syncPendingOps() {
    const ops = await this.getPendingOps();

    for (const op of ops) {
      try {
        await this.syncOpToServer(op);
        await this.markOpSynced(op.id);
      } catch (e) {
        console.error('Sync failed for op:', op, e);
        break;  // 失败则停止,下次重试
      }
    }
  }

  /**
   * 获取待同步操作
   */
  async getPendingOps() {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction('pendingOps', 'readonly');
      const store = tx.objectStore('pendingOps');
      const request = store.getAll();
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * 同步单个操作到服务器
   */
  async syncOpToServer(op) {
    const response = await fetch('/api/notes/sync', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(op),
    });

    if (!response.ok) {
      throw new Error(`Sync failed: ${response.status}`);
    }

    // 处理冲突
    const result = await response.json();
    if (result.conflict) {
      await this.resolveConflict(op, result.serverVersion);
    }

    // 更新笔记同步状态
    if (op.type !== 'delete') {
      const note = await this.getNote(op.noteId);
      if (note) {
        note.syncStatus = 'synced';
        await this.saveNote(note);
      }
    }
  }

  /**
   * 标记操作已同步
   */
  async markOpSynced(opId) {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction('pendingOps', 'readwrite');
      const store = tx.objectStore('pendingOps');
      const request = store.delete(opId);
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * 冲突解决(简单 LWW 策略)
   */
  async resolveConflict(localOp, serverVersion) {
    const localNote = await this.getNote(localOp.noteId);

    if (serverVersion.updatedAt > localNote.updatedAt) {
      // 服务器版本较新,采用服务器版本
      await this.saveNote({
        ...serverVersion,
        syncStatus: 'synced',
      });
      console.warn('Conflict resolved: server version wins');
    } else {
      // 本地版本较新,重新推送
      console.warn('Conflict resolved: local version wins');
    }
  }

  /**
   * 获取存储使用情况
   */
  async getStorageInfo() {
    if (navigator.storage && navigator.storage.estimate) {
      const estimate = await navigator.storage.estimate();
      return {
        usage: (estimate.usage / 1024 / 1024).toFixed(2) + ' MB',
        quota: (estimate.quota / 1024 / 1024).toFixed(2) + ' MB',
        percentage: (estimate.usage / estimate.quota * 100).toFixed(2) + '%',
      };
    }
    return null;
  }
}

// Service Worker 代码(/sw.js)
/*
const CACHE_NAME = 'notes-app-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/static/css/main.css',
  '/static/js/main.js',
];

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

self.addEventListener('fetch', (event) => {
  // 网络优先,失败时回退缓存
  event.respondWith(
    fetch(event.request).catch(() => caches.match(event.request))
  );
});
*/

// 使用示例
async function main() {
  const app = new OfflineNotesApp();
  await app.init();

  // 创建笔记
  const note = await app.createNote('我的第一篇笔记', 'Hello, offline!');

  // 离线状态下也能编辑
  await app.updateNote(note.id, { content: 'Updated content' });

  // 获取所有笔记
  const allNotes = await app.getAllNotes();
  console.log('All notes:', allNotes);

  // 检查存储使用情况
  const storageInfo = await app.getStorageInfo();
  console.log('Storage:', storageInfo);
}

main();

15.3 项目总结

本项目展示了离线优先应用的完整存储架构:

  1. 分层存储:IndexedDB 存数据、localStorage 存配置、Cache API 存资源
  2. 离线优先:所有操作先写入本地,再异步同步到服务器
  3. 操作队列:pendingOps 存储待同步操作,保证最终一致性
  4. 冲突解决:基于时间戳的 LWW(Last-Write-Wins)策略
  5. 网络感知:online 事件触发自动同步
  6. Service Worker:拦截网络请求,离线时回退缓存

易错点与最佳实践

  1. 修改了 sw.js 但页面毫无变化。SW 脚本本身受 HTTP 缓存影响,旧的 sw.js 可能被缓存数小时。错误示范与修正:
javascript
// 注册时不加任何配置(反例):sw.js 被 HTTP 缓存拖住,更新延迟可达 24 小时
// navigator.serviceWorker.register('/sw.js');

// 修正:注册时声明跳过 HTTP 缓存,更新检测始终直达服务器
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
  1. 缓存 Response 前忘记 clone。cache.put(request, res) 直接把响应体消费掉,返回给页面的将是空 body。修正:cache.put(request, res.clone()),一条给缓存、一条给调用方。

  2. 缓存无限膨胀。activate 时不清旧版本缓存,用户设备会被历次版本缓存占满。修正:以版本号命名缓存,activate 中删除所有非当前版本,必要时用 estimate() 监控配额。

  3. 把非幂等请求交给后台同步重放。POST 购票单被重放两次就会重复扣款。修正:为订单生成客户端唯一 id,服务端幂等去重,或在提交前检查 IndexedDB 中是否已存在该 id。

  4. skipWaiting 与 clients.claim 滥用。新 SW 立即接管会让”页面代码是旧版、缓存是新版”的错位窗口变大。修正:小步发布时保持默认 waiting 行为,仅在关键修复时使用 skipWaiting 并配合页面刷新提示。

本篇小结

  • Service Worker 是可编程的网络代理,独立于页面运行、事件驱动、要求 HTTPS,作用域由脚本路径决定。
  • 生命周期为 install、waiting、activate 三阶段;版本化缓存名是触发更新的安全阀,waitUntil 维持存活。
  • 缓存策略按容忍的新鲜度分配:静态资源 cache-first,业务数据 network-first,中间地带 stale-while-revalidate。
  • Manifest 与 beforeinstallprompt 联合实现可控的安装体验;SW 活跃是安装条件之一,display 模式决定安装后的形态。
  • 后台同步让断网操作可重放,推送让页面未开也能收到通知;两者的前提都是数据先落盘。

动手实践

  1. 离线可用的歌单页:为歌单页实现 network-first 加离线回退,断网时展示最近一次成功的歌单数据与醒目的离线横幅。思路:数据请求与页面导航分别处理;把最近一次 JSON 响应写入缓存;横幅状态由 navigator.onLine 与 fetch 失败共同决定。

  2. 运行时缓存清理器:实现按”最大缓存条目数”的 LRU 清理逻辑,在 activate 与每次 cache.put 后触发。思路:Cache API 没有顺序保证,需要自己维护 key 的时间戳索引(可存于 IndexedDB),超出上限时从最旧开始删除。

  3. 安装转化埋点:统计安装弹窗的曝光、接受与拒绝次数,并验证”收藏行为之后再弹窗”是否提升转化。思路:暂存 beforeinstallprompt,把 prompt() 的调用挂在收藏按钮的点击回调上,用 userChoice 结果作为埋点事件上报。

附录:Service Worker 事件生命周期

承接自网络请求篇附录 E。

注册

fetch / push / sync / message

注册

Installing

Installed

Activating

Activated

运行中

被新版本替换

Redundant

注册

fetch / push / sync / message

注册

Installing

Installed

Activating

Activated

运行中

被新版本替换

Redundant

mermaid diagram

主要事件:

  • install:首次安装或新版本下载后触发
  • activate:新版本接管时触发,适合清理旧缓存
  • fetch:页面发起网络请求时触发
  • push:收到 Push 通知时触发
  • sync:后台同步(网络恢复时)
  • periodicsync:周期性后台同步
  • message:与主线程通信
  • notificationclick:用户点击通知