关键要点
- IndexedDB 事件驱动架构:理解 onsuccess、onerror、onupgradeneeded 事件的处理方式
- 事务隔离级别:使用 readonly 和 readwrite 事务正确管理并发访问
- 索引优化查询:创建复合索引加速复杂查询,使用 Cursor 遍历大量数据
- 版本迁移策略:安全地升级数据库架构,处理旧数据迁移
- 离线优先设计模式:乐观更新、操作队列、自动重试
离线优先(Offline-First)是现代 Web 应用的重要设计理念。本教程将深入探讨如何使用原生 IndexedDB API 构建可靠的离线数据层。
前置条件:
- Next.js 14 基础
- 了解 IndexedDB 基本概念
- TypeScript 熟悉
IndexedDB 核心概念
数据库结构
code
Database (数据库)
└── ObjectStore (对象表)
├── Index (索引)
└── Data (数据)
Code collapsed
事务类型
| 类型 | 说明 | 用途 |
|---|---|---|
| readonly | 只读事务 | 查询数据 |
| readwrite | 读写事务 | 添加、修改、删除数据 |
创建 IndexedDB 封装类
code
// lib/indexedDB.ts
export interface DBSchema {
todos: {
key: number;
value: {
id: number;
title: string;
completed: boolean;
createdAt: number;
updatedAt: number;
synced: boolean;
};
indexes: {
'by-synced': boolean;
'by-created': number;
};
};
syncQueue: {
key: number;
value: {
id: number;
type: 'create' | 'update' | 'delete';
tableName: string;
recordId: number;
data: any;
timestamp: number;
retryCount: number;
};
indexes: {
'by-timestamp': number;
};
};
}
type TransactionMode = 'readonly' | 'readwrite';
class IndexedDBService {
private db: IDBDatabase | null = null;
private dbName = 'OfflineFirstPWA';
private version = 1;
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => {
reject(new Error('Failed to open database'));
};
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// 创建 todos 表
if (!db.objectStoreNames.contains('todos')) {
const todosStore = db.createObjectStore('todos', {
keyPath: 'id',
autoIncrement: true,
});
// 创建索引
todosStore.createIndex('by-synced', 'synced', { unique: false });
todosStore.createIndex('by-created', 'createdAt', { unique: false });
}
// 创建 syncQueue 表
if (!db.objectStoreNames.contains('syncQueue')) {
const syncStore = db.createObjectStore('syncQueue', {
keyPath: 'id',
autoIncrement: true,
});
syncStore.createIndex('by-timestamp', 'timestamp', { unique: false });
}
};
});
}
async getAll<T>(storeName: string): Promise<T[]> {
return this.transaction(storeName, 'readonly', (store) => {
return store.getAll() as unknown as Promise<T[]>;
});
}
async get<T>(storeName: string, key: IDBValidKey): Promise<T | undefined> {
return this.transaction(storeName, 'readonly', (store) => {
return store.get(key) as unknown as Promise<T>;
});
}
async add<T>(storeName: string, value: T): Promise<IDBValidKey> {
return this.transaction(storeName, 'readwrite', (store) => {
return store.add(value as unknown) as Promise<IDBValidKey>;
});
}
async put<T>(storeName: string, value: T): Promise<IDBValidKey> {
return this.transaction(storeName, 'readwrite', (store) => {
return store.put(value as unknown) as Promise<IDBValidKey>;
});
}
async delete(storeName: string, key: IDBValidKey): Promise<void> {
return this.transaction(storeName, 'readwrite', (store) => {
return store.delete(key);
});
}
async getByIndex<T>(
storeName: string,
indexName: string,
value: IDBValidKey
): Promise<T[]> {
return this.transaction(storeName, 'readonly', (store) => {
const index = store.index(indexName);
return index.getAll(value) as unknown as Promise<T[]>;
});
}
async clear(storeName: string): Promise<void> {
return this.transaction(storeName, 'readwrite', (store) => {
return store.clear();
});
}
private transaction<T>(
storeName: string,
mode: TransactionMode,
callback: (store: IDBObjectStore) => T
): Promise<T> {
return new Promise((resolve, reject) => {
if (!this.db) {
reject(new Error('Database not initialized'));
return;
}
const transaction = this.db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const request = callback(store) as IDBRequest;
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async count(storeName: string): Promise<number> {
return this.transaction(storeName, 'readonly', (store) => {
return store.count();
});
}
// 使用游标遍历大量数据
async *cursor<T>(
storeName: string,
indexName?: string,
range?: IDBKeyRange
): AsyncGenerator<T> {
await this.init();
const transaction = this.db!.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const source = indexName ? store.index(indexName) : store;
const request = source.openCursor(range);
let cursor: IDBCursorWithValue | null = null;
return new Promise(async (resolve, reject) => {
request.onsuccess = (event) => {
cursor = (event.target as IDBRequest).result;
if (cursor) {
resolve(cursor.value as T);
cursor.continue();
} else {
// 遍历完成
return;
}
};
request.onerror = () => reject(request.error);
}) as any;
}
}
export const indexedDB = new IndexedDBService();
Code collapsed
创建数据访问层
code
// lib/repositories/TodoRepository.ts
import { indexedDB } from '../indexedDB';
export interface Todo {
id?: number;
title: string;
description?: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
createdAt: number;
updatedAt: number;
synced: boolean;
}
export type TodoCreateInput = Omit<Todo, 'id' | 'createdAt' | 'updatedAt' | 'synced'>;
export type TodoUpdateInput = Partial<TodoCreateInput>;
class TodoRepository {
private storeName = 'todos';
async findAll(): Promise<Todo[]> {
return indexedDB.getAll<Todo>(this.storeName);
}
async findById(id: number): Promise<Todo | undefined> {
return indexedDB.get<Todo>(this.storeName, id);
}
async create(input: TodoCreateInput): Promise<number> {
const now = Date.now();
const todo: Todo = {
...input,
createdAt: now,
updatedAt: now,
synced: false,
};
const id = (await indexedDB.add<Todo>(this.storeName, todo)) as number;
// 添加到同步队列
await this.addToSyncQueue({
type: 'create',
tableName: this.storeName,
recordId: id,
data: todo,
timestamp: now,
retryCount: 0,
});
return id;
}
async update(id: number, input: TodoUpdateInput): Promise<void> {
const existing = await this.findById(id);
if (!existing) {
throw new Error(`Todo with id ${id} not found`);
}
const updated: Todo = {
...existing,
...input,
updatedAt: Date.now(),
synced: false,
};
await indexedDB.put(this.storeName, updated);
// 添加到同步队列
await this.addToSyncQueue({
type: 'update',
tableName: this.storeName,
recordId: id,
data: { input, updatedAt: updated.updatedAt },
timestamp: Date.now(),
retryCount: 0,
});
}
async delete(id: number): Promise<void> {
await indexedDB.delete(this.storeName, id);
// 添加到同步队列
await this.addToSyncQueue({
type: 'delete',
tableName: this.storeName,
recordId: id,
data: { id },
timestamp: Date.now(),
retryCount: 0,
});
}
async findUnsynced(): Promise<Todo[]> {
return indexedDB.getByIndex<Todo>(this.storeName, 'by-synced', false);
}
async markAsSynced(id: number): Promise<void> {
const todo = await this.findById(id);
if (todo) {
todo.synced = true;
await indexedDB.put(this.storeName, todo);
}
}
async countAll(): Promise<number> {
return indexedDB.count(this.storeName);
}
async findCompleted(): Promise<Todo[]> {
const todos = await this.findAll();
return todos.filter((t) => t.completed);
}
async findPending(): Promise<Todo[]> {
const todos = await this.findAll();
return todos.filter((t) => !t.completed);
}
private async addToSyncQueue(item: any): Promise<void> {
await indexedDB.add('syncQueue', item);
}
}
export const todoRepository = new TodoRepository();
Code collapsed
创建同步管理器
code
// lib/sync/SyncManager.ts
import { todoRepository } from '../repositories/TodoRepository';
type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
interface SyncOptions {
maxRetries?: number;
retryDelay?: number;
batchSize?: number;
}
class SyncManager {
private status: SyncStatus = 'idle';
private statusListeners: Set<(status: SyncStatus) => void> = new Set();
private defaultOptions: SyncOptions = {
maxRetries: 3,
retryDelay: 5000,
batchSize: 10,
};
subscribe(listener: (status: SyncStatus) => void) {
this.statusListeners.add(listener);
return () => this.statusListeners.delete(listener);
}
private setStatus(status: SyncStatus) {
this.status = status;
this.statusListeners.forEach((listener) => listener(status));
}
async sync(options?: SyncOptions): Promise<void> {
if (this.status === 'syncing') {
console.log('Sync already in progress');
return;
}
const opts = { ...this.defaultOptions, ...options };
try {
this.setStatus('syncing');
// 检查网络连接
if (!navigator.onLine) {
throw new Error('Device is offline');
}
// 执行同步
await this.performSync(opts);
this.setStatus('success');
setTimeout(() => this.setStatus('idle'), 2000);
} catch (error) {
console.error('Sync failed:', error);
this.setStatus('error');
setTimeout(() => this.setStatus('idle'), 3000);
}
}
private async performSync(options: SyncOptions): Promise<void> {
// 获取未同步的数据
const unsyncedTodos = await todoRepository.findUnsynced();
if (unsyncedTodos.length === 0) {
console.log('No unsynced data');
return;
}
// 获取同步队列
const syncQueue = await this.getSyncQueue();
// 按操作类型分组
const operations = this.groupOperations(syncQueue);
// 批量同步
await this.syncBatch(operations, options);
// 清理已同步的项目
await this.clearSyncedItems(syncQueue);
// 标记为已同步
for (const todo of unsyncedTodos) {
if (todo.id) {
await todoRepository.markAsSynced(todo.id);
}
}
}
private groupOperations(queue: any[]): Record<string, any[]> {
return queue.reduce(
(acc, item) => {
if (!acc[item.type]) {
acc[item.type] = [];
}
acc[item.type].push(item);
return acc;
},
{} as Record<string, any[]>
);
}
private async syncBatch(
operations: Record<string, any[]>,
options: SyncOptions
): Promise<void> {
// 并行执行不同类型的操作
await Promise.all([
this.syncCreates(operations.create || [], options),
this.syncUpdates(operations.update || [], options),
this.syncDeletes(operations.delete || [], options),
]);
}
private async syncCreates(items: any[], options: SyncOptions): Promise<void> {
if (items.length === 0) return;
const response = await fetch('/api/todos/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
operation: 'create',
items: items.map((i) => i.data),
}),
});
if (!response.ok) {
throw new Error(`Batch create failed: ${response.statusText}`);
}
const result = await response.json();
// 更新本地 ID
for (let i = 0; i < items.length; i++) {
if (result.ids && result.ids[i]) {
items[i].recordId = result.ids[i];
}
}
}
private async syncUpdates(items: any[], options: SyncOptions): Promise<void> {
// 类似实现
}
private async syncDeletes(items: any[], options: SyncOptions): Promise<void> {
// 类似实现
}
private async getSyncQueue(): Promise<any[]> {
// 从 IndexedDB 获取同步队列
return [];
}
private async clearSyncedItems(items: any[]): Promise<void> {
// 从同步队列清除已同步项
}
// 处理后台同步事件
async handleSyncEvent(event: any): Promise<void> {
if (event.tag === 'sync-todos') {
event.waitUntil(this.sync());
}
}
getStatus(): SyncStatus {
return this.status;
}
}
export const syncManager = new SyncManager();
Code collapsed
创建 React Hook
code
// hooks/useIndexedDB.ts
'use client';
import { useEffect, useState, useCallback } from 'react';
import { indexedDB } from '@/lib/indexedDB';
export function useIndexedDB<T>(
storeName: string,
autoInit: boolean = true
) {
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (autoInit) {
init();
}
}, [autoInit]);
const init = useCallback(async () => {
setIsLoading(true);
try {
await indexedDB.init();
setIsInitialized(true);
} catch (err) {
setError(err as Error);
} finally {
setIsLoading(false);
}
}, []);
const getAll = useCallback(async (): Promise<T[]> => {
if (!isInitialized) {
throw new Error('Database not initialized');
}
return indexedDB.getAll<T>(storeName);
}, [storeName, isInitialized]);
const get = useCallback(
async (key: IDBValidKey): Promise<T | undefined> => {
if (!isInitialized) {
throw new Error('Database not initialized');
}
return indexedDB.get<T>(storeName, key);
},
[storeName, isInitialized]
);
const add = useCallback(
async (value: T): Promise<IDBValidKey> => {
if (!isInitialized) {
throw new Error('Database not initialized');
}
return indexedDB.add<T>(storeName, value);
},
[storeName, isInitialized]
);
const put = useCallback(
async (value: T): Promise<IDBValidKey> => {
if (!isInitialized) {
throw new Error('Database not initialized');
}
return indexedDB.put<T>(storeName, value);
},
[storeName, isInitialized]
);
const remove = useCallback(
async (key: IDBValidKey): Promise<void> => {
if (!isInitialized) {
throw new Error('Database not initialized');
}
return indexedDB.delete(storeName, key);
},
[storeName, isInitialized]
);
return {
isInitialized,
isLoading,
error,
init,
getAll,
get,
add,
put,
delete: remove,
};
}
Code collapsed
创建 Service Worker
code
// public/sw.js
const CACHE_NAME = 'offline-pwa-v1';
const urlsToCache = [
'/',
'/offline',
'/manifest.json',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png',
];
// 安装事件
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(urlsToCache);
})
);
self.skipWaiting();
});
// 激活事件
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
self.clients.claim();
});
// 拦截请求
self.addEventListener('fetch', (event) => {
// API 请求 - Network First
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then((response) => {
// 克隆响应
const responseClone = response.clone();
caches.open('api-cache').then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
return;
}
// 静态资源 - Cache First
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
});
})
);
});
// 后台同步
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-todos') {
event.waitUntil(
(async () => {
try {
// 执行同步逻辑
await syncTodos();
} catch (error) {
console.error('Background sync failed:', error);
}
})()
);
}
});
async function syncTodos() {
// 从 IndexedDB 获取未同步数据并同步到服务器
}
Code collapsed
创建离线页面
code
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div className: "min-h-screen flex items-center justify-center bg-gray-50">
<div className: "text-center max-w-md px-4">
<div className: "text-6xl mb-4">📵</div>
<h1 className: "text-2xl font-bold text-gray-900 mb-2">
您当前处于离线状态
</h1>
<p className: "text-gray-600 mb-6">
应用仍可正常使用,您的数据将在网络恢复后自动同步。
</p>
<div className: "space-y-3 text-sm text-gray-500">
<p>✓ 可以查看已加载的内容</p>
<p>✓ 可以添加新的待办事项</p>
<p>✓ 数据将在联网后同步</p>
</div>
</div>
</div>
);
}
Code collapsed
总结
本教程深入探讨了使用 Next.js 和原生 IndexedDB 构建离线优先 PWA:
- IndexedDB 封装类设计
- 数据访问层实现
- 同步管理器架构
- React Hook 集成
- Service Worker 配置
扩展建议
- 实现数据冲突解决
- 添加数据压缩
- 支持多设备同步
- 实现增量同步