capacitor-offline-first
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseOffline-First Capacitor Apps
离线优先的Capacitor应用
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 │ │
│ │ (SQLite / IndexedDB) │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘┌─────────────────────────────────────────┐
│ UI Layer │
├─────────────────────────────────────────┤
│ Service Layer │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ Online Mode │ │ Offline Mode │ │
│ └──────┬──────┘ └────────┬────────┘ │
├─────────┼──────────────────┼────────────┤
│ │ Sync Manager │ │
│ └────────┬─────────┘ │
├──────────────────┼──────────────────────┤
│ ┌───────────────┴───────────────────┐ │
│ │ Local Database │ │
│ │ (SQLite / IndexedDB) │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘Network Detection
网络检测
Using Capacitor Network Plugin
使用Capacitor Network插件
bash
bun add @capacitor/network
bunx cap synctypescript
import { Network } from '@capacitor/network';
// Check current status
const status = await Network.getStatus();
console.log('Connected:', status.connected);
console.log('Connection type:', status.connectionType);
// Listen for changes
Network.addListener('networkStatusChange', (status) => {
console.log('Network status changed:', status.connected);
if (status.connected) {
// Back online - sync data
syncManager.syncPendingChanges();
} else {
// Offline - show indicator
showOfflineIndicator();
}
});bash
bun add @capacitor/network
bunx cap synctypescript
import { Network } from '@capacitor/network';
// 检查当前状态
const status = await Network.getStatus();
console.log('已连接:', status.connected);
console.log('连接类型:', status.connectionType);
// 监听状态变化
Network.addListener('networkStatusChange', (status) => {
console.log('网络状态已更改:', status.connected);
if (status.connected) {
// 重新联网 - 同步数据
syncManager.syncPendingChanges();
} else {
// 离线 - 显示提示
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);
}
}
}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 this.getCachedData(url);
}
try {
const response = await fetch(url, options);
const data = await response.json();
// 缓存响应结果
await this.cacheData(url, data);
return data;
} catch (error) {
// 网络错误 - 尝试读取缓存
return this.getCachedData(url);
}
}
}Local Database with SQLite
基于SQLite的本地数据库
Installation
安装
bash
bun add @capgo/capacitor-data-storage-sqlite
bunx cap syncbash
bun add @capgo/capacitor-data-storage-sqlite
bunx cap syncDatabase Setup
数据库设置
typescript
import { CapacitorDataStorageSqlite } from '@capgo/capacitor-data-storage-sqlite';
class Database {
private db = CapacitorDataStorageSqlite;
private isOpen = false;
async open() {
if (this.isOpen) return;
await this.db.openStore({
database: 'myapp',
table: 'data',
encrypted: false,
mode: 'no-encryption',
});
this.isOpen = true;
}
async set(key: string, value: any) {
await this.open();
await this.db.set({
key,
value: JSON.stringify(value),
});
}
async get<T>(key: string): Promise<T | null> {
await this.open();
const result = await this.db.get({ key });
return result.value ? JSON.parse(result.value) : null;
}
async remove(key: string) {
await this.open();
await this.db.remove({ key });
}
async keys(): Promise<string[]> {
await this.open();
const result = await this.db.keys();
return result.keys;
}
}typescript
import { CapacitorDataStorageSqlite } from '@capgo/capacitor-data-storage-sqlite';
class Database {
private db = CapacitorDataStorageSqlite;
private isOpen = false;
async open() {
if (this.isOpen) return;
await this.db.openStore({
database: 'myapp',
table: 'data',
encrypted: false,
mode: 'no-encryption',
});
this.isOpen = true;
}
async set(key: string, value: any) {
await this.open();
await this.db.set({
key,
value: JSON.stringify(value),
});
}
async get<T>(key: string): Promise<T | null> {
await this.open();
const result = await this.db.get({ key });
return result.value ? JSON.parse(result.value) : null;
}
async remove(key: string) {
await this.open();
await this.db.remove({ key });
}
async keys(): Promise<string[]> {
await this.open();
const result = await this.db.keys();
return result.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
) {}
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): Promise<void> {
item.updatedAt = Date.now();
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);
}
}
}typescript
interface Entity {
id: string;
updatedAt: number;
syncStatus: 'synced' | 'pending' | 'conflict';
}
class OfflineRepository<T extends Entity> {
constructor(
private db: Database,
private collection: string
) {}
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): Promise<void> {
item.updatedAt = Date.now();
item.syncStatus = 'pending';
await this.db.set(`${this.collection}:${item.id}`, item);
}
async delete(id: string): Promise<void> {
// 软删除 - 标记为待同步
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 response = await fetch(
`/api/${repo.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' });
} else if (localItem.syncStatus === 'synced') {
// No local changes - update from server
await repo.save({ ...remoteItem, syncStatus: 'synced' });
} 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' });
}
}
}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('同步项失败:', item.id, error);
// 保持待同步状态以便重试
}
}
// 拉取远程变更
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 response = await fetch(
`/api/${repo.collection}?since=${lastSync}`
);
const remoteItems = await response.json();
for (const remoteItem of remoteItems) {
const localItem = await repo.getById(remoteItem.id);
if (!localItem) {
// 服务器新增项
await repo.save({ ...remoteItem, syncStatus: 'synced' });
} else if (localItem.syncStatus === 'synced') {
// 本地无变更 - 从服务器更新
await repo.save({ ...remoteItem, syncStatus: 'synced' });
} else {
// 冲突 - 本地有待同步变更
await this.resolveConflict(localItem, remoteItem, repo);
}
}
await this.setLastSyncTime(repo, Date.now());
}
private async resolveConflict(
local: any,
remote: any,
repo: OfflineRepository<any>
) {
// 最后写入获胜策略
if (local.updatedAt > remote.updatedAt) {
// 保留本地数据,重新同步到服务器
local.syncStatus = 'pending';
await repo.save(local);
} else {
// 服务器数据获胜
await repo.save({ ...remote, syncStatus: 'synced' });
}
}
}Service Worker Caching
Service Worker缓存
Register Service Worker
注册Service Worker
typescript
// src/main.ts
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}typescript
// src/main.ts
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}Service Worker with Workbox
基于Workbox的Service Worker
typescript
// public/sw.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';
// Precache static assets
precacheAndRoute(self.__WB_MANIFEST);
// Cache API responses
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 5,
})
);
// Cache images
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'image-cache',
plugins: [
{
expiration: {
maxEntries: 100,
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
},
},
],
})
);
// Cache fonts
registerRoute(
({ request }) => request.destination === 'font',
new CacheFirst({
cacheName: 'font-cache',
})
);typescript
// public/sw.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';
// 预缓存静态资源
precacheAndRoute(self.__WB_MANIFEST);
// 缓存API响应
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 5,
})
);
// 缓存图片
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'image-cache',
plugins: [
{
expiration: {
maxEntries: 100,
maxAgeSeconds: 7 * 24 * 60 * 60, // 1周
},
},
],
})
);
// 缓存字体
registerRoute(
({ request }) => request.destination === 'font',
new CacheFirst({
cacheName: 'font-cache',
})
);Optimistic UI Updates
乐观UI更新
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;
}
}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',
};
// 立即保存到本地
await this.repo.save(todo);
// 在后台触发同步
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.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;
}
}
}
}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) {
// 处理失败时停止
break;
}
}
}
}Best Practices
最佳实践
1. Show Sync Status
1. 显示同步状态
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>;
}tsx
function SyncIndicator() {
const { isOnline, pendingChanges, isSyncing } = useSyncStatus();
if (!isOnline) {
return <Badge color="warning">离线</Badge>;
}
if (isSyncing) {
return <Badge color="info">同步中...</Badge>;
}
if (pendingChanges > 0) {
return <Badge color="warning">{pendingChanges} 项待同步</Badge>;
}
return <Badge color="success">已同步</Badge>;
}2. Handle Conflicts Gracefully
2. 优雅处理冲突
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;
}typescript
async function handleConflict(local: Todo, remote: Todo): Promise<Todo> {
// 选项1:最后写入获胜
return local.updatedAt > remote.updatedAt ? local : remote;
// 选项2:合并变更
return {
...remote,
...local,
updatedAt: Math.max(local.updatedAt, remote.updatedAt),
};
// 选项3:询问用户
const choice = await showConflictDialog(local, remote);
return choice === 'local' ? local : remote;
}3. Validate Before Sync
3. 同步前验证
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
}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('无效的待办事项');
}
// 继续同步
}Resources
参考资源
- Capacitor Network: https://capacitorjs.com/docs/apis/network
- Workbox: https://developer.chrome.com/docs/workbox
- IndexedDB: https://developer.mozilla.org/docs/Web/API/IndexedDB_API
- Offline First Manifesto: http://offlinefirst.org
- Capacitor Network: https://capacitorjs.com/docs/apis/network
- Workbox: https://developer.chrome.com/docs/workbox
- IndexedDB: https://developer.mozilla.org/docs/Web/API/IndexedDB_API
- 离线优先宣言: http://offlinefirst.org