Capacitor Offline First

Cap-go/capgo-skills/plugins/capacitor-features/skills/capacitor-offline-first

作者 Cap-go2cfb277a148832bdb1a835b01e4de323687b498b無授權條款72 個星標收錄於 2026年10月9日更新於 2026年10月9日儲存庫今天更新

Guide to building offline-first Capacitor apps with data synchronization, caching strategies, and conflict resolution. Covers Fast SQL, service workers, and network detection. Use this skill when users need their app to work without internet.

AI 產生的概覽

指導建置離線優先的 Capacitor 應用,涵蓋本機儲存、同步、快取與衝突解決。

功能
此技能為建置離線優先的 Capacitor 應用提供參考指引與程式碼模式。內容涵蓋使用 Capacitor Network 外掛進行網路偵測、透過 Fast SQL 鍵值儲存實現本機持久化、具待同步變更追蹤與最後寫入優先衝突解決的同步管理器、以 Workbox 為基礎的 Service Worker 快取、樂觀 UI 更新,以及失敗請求佇列。它產出架構圖、TypeScript 範例與最佳實務建議,而非可直接執行的指令碼。
適用情境
當使用者需要應用在沒有網路連線時仍可運作、詢問資料同步、快取策略、本機資料庫,或在 Capacitor 應用中遇到連線問題時使用。
執行需求
不隨附指令碼,僅為說明文件。依範例操作需要 Capacitor 專案與 Node/npm、@capacitor/network 外掛,可選用 @capgo/capacitor-fast-sql 及其平台設定(iOS 本機網路、Android 明文例外、Web 端 sql.js),以及 Workbox 等 Service Worker 工具鏈。

Offline-First Capacitor Apps

Build apps that work seamlessly with or without internet connectivity.

When to Use This Skill

  • User needs offline support
  • User asks about data sync
  • User wants caching
  • User needs local database
  • User has connectivity issues

Offline-First Architecture

┌─────────────────────────────────────────┐│              UI Layer                    │├─────────────────────────────────────────┤│           Service Layer                  ││  ┌─────────────┐  ┌─────────────────┐   ││  │ Online Mode │  │ Offline Mode    │   ││  └──────┬──────┘  └────────┬────────┘   │├─────────┼──────────────────┼────────────┤│         │    Sync Manager  │            ││         └────────┬─────────┘            │├──────────────────┼──────────────────────┤│  ┌───────────────┴───────────────────┐  ││  │         Local Database            │  ││  │   (Fast SQL / IndexedDB)          │  ││  └───────────────────────────────────┘  │└─────────────────────────────────────────┘

Network Detection

Using Capacitor Network Plugin

bash
npm install @capacitor/networknpx cap sync
typescript
import { Network } from '@capacitor/network';
// Check current statusconst status = await Network.getStatus();console.log('Connected:', status.connected);console.log('Connection type:', status.connectionType);
// Listen for changesNetwork.addListener('networkStatusChange', (status) => {  console.log('Network status changed:', status.connected);
  if (status.connected) {    // Back online - sync data    syncManager.syncPendingChanges();  } else {    // Offline - show indicator    showOfflineIndicator();  }});

Network-Aware Service

typescript
import { Network } from '@capacitor/network';
class NetworkAwareService {  private isOnline = true;
  constructor() {    this.init();  }
  private async init() {    const status = await Network.getStatus();    this.isOnline = status.connected;
    Network.addListener('networkStatusChange', (status) => {      this.isOnline = status.connected;    });  }
  async fetch<T>(url: string, options?: RequestInit): Promise<T> {    if (!this.isOnline) {      // Return cached data      return this.getCachedData(url);    }
    try {      const response = await fetch(url, options);      const data = await response.json();
      // Cache the response      await this.cacheData(url, data);
      return data;    } catch (error) {      // Network error - try cache      return this.getCachedData(url);    }  }}

Local Database with Fast SQL

Installation

bash
npm install @capgo/capacitor-fast-sqlnpx cap sync

Before using Fast SQL in production, complete the required platform setup:

  • iOS: allow localhost networking for the plugin transport.
  • Android: add the localhost cleartext exception required by the plugin.
  • Web: install sql.js if the app needs the web fallback.

Use the dedicated sqlite-to-fast-sql skill when you need the full platform checklist.

Database Setup

typescript
import { KeyValueStore } from '@capgo/capacitor-fast-sql';
class Database {  private store: Awaited<ReturnType<typeof KeyValueStore.open>> | null = null;
  async open() {    if (this.store) return;    this.store = await KeyValueStore.open({      database: 'myapp',      store: 'data',      encrypted: false,    });  }
  async set(key: string, value: any) {    await this.open();    await this.store!.set(key, value);  }
  async get<T>(key: string): Promise<T | null> {    await this.open();    return this.store!.get<T>(key);  }
  async remove(key: string) {    await this.open();    await this.store!.remove(key);  }
  async keys(): Promise<string[]> {    await this.open();    return this.store!.keys();  }}

Offline Data Repository

typescript
interface Entity {  id: string;  updatedAt: number;  syncStatus: 'synced' | 'pending' | 'conflict';}
class OfflineRepository<T extends Entity> {  constructor(    private db: Database,    private collection: string  ) {}
  getCollection(): string {    return this.collection;  }
  async getAll(): Promise<T[]> {    const keys = await this.db.keys();    const items: T[] = [];
    for (const key of keys) {      if (key.startsWith(`${this.collection}:`)) {        const item = await this.db.get<T>(key);        if (item) items.push(item);      }    }
    return items;  }
  async getById(id: string): Promise<T | null> {    return this.db.get<T>(`${this.collection}:${id}`);  }
  async save(item: T, options?: { markPending?: boolean }): Promise<void> {    item.updatedAt = Date.now();    if (options?.markPending ?? true) {      item.syncStatus = 'pending';    }    await this.db.set(`${this.collection}:${item.id}`, item);  }
  async delete(id: string): Promise<void> {    // Soft delete - mark for sync    const item = await this.getById(id);    if (item) {      item.syncStatus = 'pending';      (item as any).deleted = true;      await this.db.set(`${this.collection}:${id}`, item);    }  }
  async getPending(): Promise<T[]> {    const all = await this.getAll();    return all.filter((item) => item.syncStatus === 'pending');  }
  async markSynced(id: string): Promise<void> {    const item = await this.getById(id);    if (item) {      item.syncStatus = 'synced';      await this.db.set(`${this.collection}:${id}`, item);    }  }}

Sync Manager

typescript
import { Network } from '@capacitor/network';
class SyncManager {  private isSyncing = false;  private syncQueue: Array<() => Promise<void>> = [];
  constructor(private repositories: OfflineRepository<any>[]) {    this.setupNetworkListener();  }
  private setupNetworkListener() {    Network.addListener('networkStatusChange', async (status) => {      if (status.connected) {        await this.syncAll();      }    });  }
  async syncAll() {    if (this.isSyncing) return;    this.isSyncing = true;
    try {      for (const repo of this.repositories) {        await this.syncRepository(repo);      }    } finally {      this.isSyncing = false;    }  }
  private async syncRepository(repo: OfflineRepository<any>) {    const pending = await repo.getPending();
    for (const item of pending) {      try {        if ((item as any).deleted) {          await this.deleteRemote(item);        } else {          await this.syncToRemote(item);        }        await repo.markSynced(item.id);      } catch (error) {        console.error('Sync failed for item:', item.id, error);        // Keep as pending for retry      }    }
    // Pull remote changes    await this.pullRemoteChanges(repo);  }
  private async syncToRemote(item: any) {    await fetch(`/api/${item.collection}/${item.id}`, {      method: 'PUT',      headers: { 'Content-Type': 'application/json' },      body: JSON.stringify(item),    });  }
  private async deleteRemote(item: any) {    await fetch(`/api/${item.collection}/${item.id}`, {      method: 'DELETE',    });  }
  private async pullRemoteChanges(repo: OfflineRepository<any>) {    const lastSync = await this.getLastSyncTime(repo);    const collection = repo.getCollection();    const response = await fetch(      `/api/${collection}?since=${lastSync}`    );    const remoteItems = await response.json();
    for (const remoteItem of remoteItems) {      const localItem = await repo.getById(remoteItem.id);
      if (!localItem) {        // New item from server        await repo.save({ ...remoteItem, syncStatus: 'synced' }, { markPending: false });      } else if (localItem.syncStatus === 'synced') {        // No local changes - update from server        await repo.save({ ...remoteItem, syncStatus: 'synced' }, { markPending: false });      } else {        // Conflict - local has pending changes        await this.resolveConflict(localItem, remoteItem, repo);      }    }
    await this.setLastSyncTime(repo, Date.now());  }
  private async resolveConflict(    local: any,    remote: any,    repo: OfflineRepository<any>  ) {    // Last-write-wins strategy    if (local.updatedAt > remote.updatedAt) {      // Keep local, re-sync to server      local.syncStatus = 'pending';      await repo.save(local);    } else {      // Server wins      await repo.save({ ...remote, syncStatus: 'synced' }, { markPending: false });    }  }}

Service Worker Caching

Register Service Worker

typescript
// src/main.tsif ('serviceWorker' in navigator) {  navigator.serviceWorker.register('/sw.js');}

Service Worker with Workbox

typescript
// public/sw.jsimport { precacheAndRoute } from 'workbox-precaching';import { registerRoute } from 'workbox-routing';import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';
// Precache static assetsprecacheAndRoute(self.__WB_MANIFEST);
// Cache API responsesregisterRoute(  ({ url }) => url.pathname.startsWith('/api/'),  new NetworkFirst({    cacheName: 'api-cache',    networkTimeoutSeconds: 5,  }));
// Cache imagesregisterRoute(  ({ request }) => request.destination === 'image',  new CacheFirst({    cacheName: 'image-cache',    plugins: [      {        expiration: {          maxEntries: 100,          maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week        },      },    ],  }));
// Cache fontsregisterRoute(  ({ request }) => request.destination === 'font',  new CacheFirst({    cacheName: 'font-cache',  }));

Optimistic UI Updates

typescript
class TodoService {  constructor(    private repo: OfflineRepository<Todo>,    private syncManager: SyncManager  ) {}
  async addTodo(text: string): Promise<Todo> {    const todo: Todo = {      id: crypto.randomUUID(),      text,      completed: false,      updatedAt: Date.now(),      syncStatus: 'pending',    };
    // Save locally immediately    await this.repo.save(todo);
    // Trigger sync in background    this.syncManager.syncAll().catch(console.error);
    return todo;  }
  async toggleComplete(id: string): Promise<Todo> {    const todo = await this.repo.getById(id);    if (!todo) throw new Error('Todo not found');
    todo.completed = !todo.completed;    await this.repo.save(todo);
    this.syncManager.syncAll().catch(console.error);
    return todo;  }}

Queue Failed Requests

typescript
class RequestQueue {  private queue: QueuedRequest[] = [];
  constructor(private storage: Database) {    this.loadQueue();  }
  private async loadQueue() {    this.queue = await this.storage.get<QueuedRequest[]>('requestQueue') || [];  }
  private async saveQueue() {    await this.storage.set('requestQueue', this.queue);  }
  async enqueue(request: QueuedRequest) {    this.queue.push(request);    await this.saveQueue();  }
  async processQueue() {    const status = await Network.getStatus();    if (!status.connected) return;
    while (this.queue.length > 0) {      const request = this.queue[0];
      try {        await fetch(request.url, {          method: request.method,          headers: request.headers,          body: request.body,        });
        this.queue.shift();        await this.saveQueue();      } catch (error) {        // Stop processing on failure        break;      }    }  }}

Best Practices

1. Show Sync Status

tsx
function SyncIndicator() {  const { isOnline, pendingChanges, isSyncing } = useSyncStatus();
  if (!isOnline) {    return <Badge color="warning">Offline</Badge>;  }
  if (isSyncing) {    return <Badge color="info">Syncing...</Badge>;  }
  if (pendingChanges > 0) {    return <Badge color="warning">{pendingChanges} pending</Badge>;  }
  return <Badge color="success">Synced</Badge>;}

2. Handle Conflicts Gracefully

typescript
async function handleConflict(local: Todo, remote: Todo): Promise<Todo> {  // Option 1: Last write wins  return local.updatedAt > remote.updatedAt ? local : remote;
  // Option 2: Merge changes  return {    ...remote,    ...local,    updatedAt: Math.max(local.updatedAt, remote.updatedAt),  };
  // Option 3: Ask user  const choice = await showConflictDialog(local, remote);  return choice === 'local' ? local : remote;}

3. Validate Before Sync

typescript
function validateTodo(todo: Todo): boolean {  if (!todo.id || !todo.text) return false;  if (todo.text.length > 500) return false;  return true;}
async function syncTodo(todo: Todo) {  if (!validateTodo(todo)) {    throw new Error('Invalid todo');  }  // Proceed with sync}

Resources

來源與署名

來源:Cap-go/capgo-skills位於plugins/capacitor-features/skills/capacitor-offline-first提交2cfb277

授權條款: 無授權條款

內容歸原作者所有。SourceWeft 從公開儲存庫中收錄這些內容。

檢舉或申請下架