康心伴Logo
康心伴WellAlly
PWA 开发

构建离线优先 PWA:Next.js + 原生 IndexedDB 实战

深入学习如何使用 Next.js 14 和原生 IndexedDB API 构建离线优先的渐进式 Web 应用。包含数据建模、事务管理、索引优化和完整的同步策略。

W
WellAlly 开发团队
2026-03-08
24 分钟阅读

关键要点

  • 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:

  1. IndexedDB 封装类设计
  2. 数据访问层实现
  3. 同步管理器架构
  4. React Hook 集成
  5. Service Worker 配置

扩展建议

  • 实现数据冲突解决
  • 添加数据压缩
  • 支持多设备同步
  • 实现增量同步

参考资料

相关文章

#

文章标签

nextjs
indexeddb
pwa
离线优先
typescript

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅