离线使用实战:PWA与Service Worker核心技术解析

シ馨予 移动 阅读 2,230
赞 13 收藏
二维码
手机扫码查看
反馈

为什么我又在折腾离线方案?

最近一个移动端项目要求“弱网甚至无网也能用”,老板说“用户地铁里也要能操作”。行吧,那就得搞离线。但前端离线这事,水挺深——Service Worker、localStorage、IndexedDB、Cache API、甚至本地 SQLite……方案一堆,选哪个?我踩过不少坑,今天就掏心窝子聊聊几个主流方案的实际体验。

离线使用实战:PWA与Service Worker核心技术解析

谁更灵活?谁更省事?

先说结论:**简单数据用 localStorage,复杂数据上 IndexedDB,全站离线缓存必须 Service Worker + Cache API**。别一上来就堆 IndexedDB,也别以为 localStorage 能扛大梁。

我之前图省事,把整个用户配置塞进 localStorage,结果某天用户导出 500 条记录,直接卡死。localStorage 是同步的,主线程阻塞,页面直接冻住。后来改用 IndexedDB,异步操作,流畅多了。但 IndexedDB 的 API 又臭又长,写起来像在写 Java。

所以我的习惯是:小量、结构简单的数据(比如主题设置、token),用 localStorage;稍微复杂点的(比如草稿、历史记录),用 idb 这个轻量封装库,它把 IndexedDB 的 Promise 化做得挺干净。

// 用 idb 简化 IndexedDB
import { openDB } from 'idb';

const dbPromise = openDB('myApp', 1, {
  upgrade(db) {
    db.createObjectStore('drafts', { keyPath: 'id' });
  }
});

// 存草稿
async function saveDraft(id, content) {
  const db = await dbPromise;
  await db.put('drafts', { id, content });
}

// 读草稿
async function getDraft(id) {
  const db = await dbPromise;
  return await db.get('drafts', id);
}

这比原生 IndexedDB 少写一半代码,亲测有效。

Service Worker:真香,但坑也不少

要做“离线也能打开页面”,绕不开 Service Worker(SW)。很多人以为 SW 就是加个 sw.js 完事,其实细节多到爆炸。

我第一次写 SW,缓存了 HTML、CSS、JS,结果用户更新后还是看到旧页面——因为 SW 默认不会自动更新缓存。后来加上版本号控制和 skipWaiting() + clientsClaim() 才解决。

下面是我现在用的 SW 模板,核心逻辑就这些:

const CACHE_NAME = 'v3';
const urlsToCache = [
  '/',
  '/styles/main.css',
  '/scripts/app.js',
  '/offline.html'
];

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

self.addEventListener('fetch', (event) => {
  // 跳过非 GET 请求和跨域
  if (event.request.method !== 'GET' || !event.request.url.startsWith(self.location.origin)) {
    return;
  }

  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        // 有缓存就返回,没有就走网络
        return response || fetch(event.request).then((networkRes) => {
          // 克隆响应,因为流只能读一次
          const cloned = networkRes.clone();
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, cloned);
          });
          return networkRes;
        }).catch(() => {
          // 网络失败,返回离线页
          return caches.match('/offline.html');
        });
      })
  );
});

// 更新时删旧缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((name) => {
          if (name !== CACHE_NAME) {
            return caches.delete(name);
          }
        })
      );
    })
  );
});

这里注意我踩过好几次坑:一定要 clone 响应,不然你缓存完,主页面拿不到 body;离线页必须提前缓存,否则 offline.html 自己都加载不了;还有,不要缓存带时间戳的 API 接口,比如 /api/data?t=12345,这种动态 URL 会撑爆缓存。

API 数据怎么离线?

静态资源缓存好办,但用户数据怎么办?比如从 https://jztheme.com/api/posts 拉的文章列表。

我试过两种方式:

  • 方案A:SW 里拦截 API 请求,存到 Cache API
  • 方案B:业务层用 IndexedDB 存原始数据,SW 不管 API

实测下来,我更倾向方案B。原因很简单:Cache API 是按 URL 存的,没法做复杂的查询或更新。而用户可能要“标记已读”“收藏某条”,这些操作需要修改数据,Cache API 做不到。

所以我现在的做法是:首次加载时,把 API 数据存 IndexedDB;后续请求先查 DB,同时后台静默拉新数据更新 DB。离线时直接读 DB,体验无缝。

// 业务层处理 API 离线
async function fetchPosts() {
  const cached = await getPostsFromDB(); // 先读本地
  if (cached) render(cached);

  try {
    const res = await fetch('https://jztheme.com/api/posts');
    const data = await res.json();
    await savePostsToDB(data); // 更新本地
    render(data);
  } catch (err) {
    // 网络失败,已用 cached 渲染,无需额外处理
  }
}

这样虽然多写点代码,但灵活性高得多。SW 只负责静态资源,数据交给业务层,职责分明。

我的选型逻辑

总结一下我的实战选型逻辑:

  • 如果只是“离线能打开首页”,用 SW + Cache API,100 行代码搞定
  • 如果有用户生成内容(草稿、笔记等),必须上 IndexedDB(配合 idb 库)
  • API 数据不要扔给 SW 缓存,自己用 DB 管理更可控
  • localStorage 只用于小量配置,别贪多

别被“PWA 一键离线”忽悠了。真实项目里,离线不是开关,而是一堆细节拼起来的体验。比如:离线时按钮要置灰、提示“当前无网络”、同步队列管理……这些比技术选型更耗时间。

另外,iOS 对 SW 支持一直半吊子,Safari 时不时抽风。上线前务必真机测,别只看 Chrome DevTools 的“Offline”勾选框——那玩意儿太理想化了。

最后一点真心话

离线方案没有银弹。我见过团队为了“完美离线”花两周重构,结果用户根本不在意——他们只是偶尔断网几秒。所以先问清楚需求:是要“完全离线可用”,还是“弱网下不白屏”?前者成本高,后者用 SW 缓存关键资源就行。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的 IndexedDB 封装技巧,或者 SW 避坑经验,欢迎评论区交流。这个话题水太深,一个人折腾容易翻车。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论