康心伴Logo
康心伴WellAlly
PWA 开发

构建离线 PWA:React + Dexie.js + Workbox 实战指南

全面教程:使用 React、Dexie.js(IndexedDB 封装库)和 Workbox(Google 的 Service Worker 库)构建功能完整的离线优先 PWA。包含数据同步策略和缓存管理。

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

关键要点

  • Dexie.js 简化 IndexedDB 操作:提供类似 MongoDB 的 API,使离线存储更简单
  • Workbox 自动生成 Service Worker:使用 precaching、runtime caching 和 strategies 管理资源
  • 缓存策略选择:Stale While Rehydrate 适合数据,Network First 适合 API,Cache First 适合静态资源
  • 后台同步处理离线操作:使用 Background Sync API 在网络恢复时自动同步
  • 乐观更新提升体验:立即更新 UI,后台同步数据,失败时回滚

离线优先(Offline-First)架构让应用在任何网络条件下都能正常工作。本教程将教你使用 React、Dexie.js 和 Workbox 构建一个功能完整的离线 PWA。

前置条件:

  • React 基础
  • 了解 Service Worker 概念
  • npm/yarn 包管理器

项目初始化

code
npx create-react-app offline-pwa --template typescript
cd offline-pwa
npm install dexie dexie-react-hooks workbox-webpack-plugin
npm install @uiw/react-circular-progress react-icons
npm install -D @types/react-icons
Code collapsed

配置 Workbox

修改 craco.config.js(或使用 react-app-rewired):

code
const { InjectManifest } = require('workbox-webpack-plugin');
const path = require('path');

module.exports = {
  webpack: {
    configure: (webpackConfig) => {
      // 注入 Workbox 插件
      webpackConfig.plugins.push(
        new InjectManifest({
          swSrc: path.join(__dirname, 'src', 'service-worker.ts'),
          swDest: 'service-worker.js',
          exclude: [/\.map$/, /manifest\.json$/, /workbox-(.)*\.js$/],
        })
      );

      return webpackConfig;
    },
  },
};
Code collapsed

配置 Dexie.js

code
// src/db/index.ts
import Dexie, { Table } from 'dexie';

export interface Todo {
  id?: number;
  title: string;
  completed: boolean;
  createdAt: Date;
  synced: boolean;
}

export interface SyncQueueItem {
  id?: number;
  type: 'create' | 'update' | 'delete';
  tableName: string;
  recordId: number;
  data: any;
  timestamp: number;
}

export class AppDatabase extends Dexie {
  todos!: Table<Todo>;
  syncQueue!: Table<SyncQueueItem>;

  constructor() {
    super('OfflinePWA-DB');
    this.version(1).stores({
      todos: '++id, title, completed, createdAt, synced',
      syncQueue: '++id, type, tableName, timestamp',
    });
  }
}

export const db = new AppDatabase();
Code collapsed

创建数据访问层

code
// src/services/todoService.ts
import { db, Todo } from '../db';

export class TodoService {
  async getAllTodos(): Promise<Todo[]> {
    return await db.todos.toArray();
  }

  async getTodoById(id: number): Promise<Todo | undefined> {
    return await db.todos.get(id);
  }

  async addTodo(todo: Omit<Todo, 'id'>): Promise<number> {
    const id = await db.todos.add({ ...todo, synced: false });

    // 添加到同步队列
    await db.syncQueue.add({
      type: 'create',
      tableName: 'todos',
      recordId: id,
      data: todo,
      timestamp: Date.now(),
    });

    return id;
  }

  async updateTodo(id: number, updates: Partial<Todo>): Promise<void> {
    await db.todos.update(id, { ...updates, synced: false });

    await db.syncQueue.add({
      type: 'update',
      tableName: 'todos',
      recordId: id,
      data: updates,
      timestamp: Date.now(),
    });
  }

  async deleteTodo(id: number): Promise<void> {
    await db.todos.delete(id);

    await db.syncQueue.add({
      type: 'delete',
      tableName: 'todos',
      recordId: id,
      data: { id },
      timestamp: Date.now(),
    });
  }

  async getUnsyncedTodos(): Promise<Todo[]> {
    return await db.todos.where('synced').equals(false).toArray();
  }

  async markAsSynced(id: number): Promise<void> {
    await db.todos.update(id, { synced: true });
  }

  async clearSyncQueue(): Promise<void> {
    await db.syncQueue.clear();
  }
}

export const todoService = new TodoService();
Code collapsed

创建同步服务

code
// src/services/syncService.ts
import { db } from '../db';
import { todoService } from './todoService';

type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';

class SyncService {
  private status: SyncStatus = 'idle';
  private listeners: Set<(status: SyncStatus) => void> = new Set();

  subscribe(listener: (status: SyncStatus) => void) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  private notify(status: SyncStatus) {
    this.status = status;
    this.listeners.forEach((listener) => listener(status));
  }

  async sync(): Promise<void> {
    if (this.status === 'syncing') {
      return;
    }

    try {
      this.notify('syncing');

      // 获取未同步的数据
      const syncQueue = await db.syncQueue.toArray();

      if (syncQueue.length === 0) {
        this.notify('idle');
        return;
      }

      // 批量同步到服务器
      await this.syncToServer(syncQueue);

      // 标记为已同步
      for (const item of syncQueue) {
        if (item.tableName === 'todos' && item.recordId) {
          await todoService.markAsSynced(item.recordId);
        }
      }

      // 清空同步队列
      await db.syncQueue.clear();

      this.notify('success');
      setTimeout(() => this.notify('idle'), 2000);
    } catch (error) {
      console.error('Sync failed:', error);
      this.notify('error');
      setTimeout(() => this.notify('idle'), 3000);
    }
  }

  private async syncToServer(items: any[]): Promise<void> {
    // 按类型分组
    const creates = items.filter((i) => i.type === 'create');
    const updates = items.filter((i) => i.type === 'update');
    const deletes = items.filter((i) => i.type === 'delete');

    // 批量发送到服务器
    await Promise.all([
      this.batchSync('/api/todos/batch', creates),
      this.batchSync('/api/todos/batch', updates),
      this.batchSync('/api/todos/batch', deletes),
    ]);
  }

  private async batchSync(url: string, items: any[]): Promise<void> {
    if (items.length === 0) return;

    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ items }),
    });

    if (!response.ok) {
      throw new Error(`Sync failed: ${response.statusText}`);
    }
  }

  getStatus(): SyncStatus {
    return this.status;
  }
}

export const syncService = new SyncService();
Code collapsed

创建 Service Worker

code
// src/service-worker.ts
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute, NavigationRoute, NetworkFirst, StaleWhileRevalidate, CacheFirst } from 'workbox-routing';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { BackgroundSyncPlugin } from 'workbox-background-sync';

// 预缓存静态资源
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();

// 缓存策略配置

// 1. API 路由 - Network First 失败回退到缓存
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5 分钟
      }),
    ],
  })
);

// 2. 图片资源 - Cache First
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'image-cache',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 天
      }),
    ],
  })
);

// 3. 静态资源 - Stale While Revalidate
registerRoute(
  ({ request }) =>
    request.destination === 'script' ||
    request.destination === 'style',
  new StaleWhileRevalidate({
    cacheName: 'static-resources',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
    ],
  })
);

// 4. 后台同步同步队列
const bgSyncPlugin = new BackgroundSyncPlugin('todo-queue', {
  maxRetentionTime: 24 * 60, // 重试最多 24 小时
  onSync: async ({ queue }) => {
    let entry;
    while ((entry = await queue.shiftRequest())) {
      try {
        await fetch(entry.request);
      } catch (error) {
        await queue.unshiftRequest(entry);
        throw error;
      }
    }
  },
});

// 离线回退页面
const handler = createHandlerBoundToURL('/index.html');
const navigationRoute = new NavigationRoute(handler, {
  allowlist: [/^\/(todos|settings)?$/],
});
registerRoute(navigationRoute);

// Skip waiting 立即激活新的 Service Worker
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});
Code collapsed

创建 React Hook

code
// src/hooks/useTodoList.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { todoService } from '../services/todoService';
import { syncService } from '../services/syncService';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '../db';

export function useTodoList() {
  const queryClient = useQueryClient();

  // 使用 Dexie React Hooks 进行实时查询
  const todos = useLiveQuery(() => db.todos.toArray()) || [];

  // 添加 Todo
  const addTodoMutation = useMutation({
    mutationFn: todoService.addTodo,
    onSuccess: () => {
      queryClient.invalidateQueries(['todos']);
    },
  });

  // 更新 Todo
  const updateTodoMutation = useMutation({
    mutationFn: ({ id, updates }: { id: number; updates: any }) =>
      todoService.updateTodo(id, updates),
    onSuccess: () => {
      queryClient.invalidateQueries(['todos']);
    },
  });

  // 删除 Todo
  const deleteTodoMutation = useMutation({
    mutationFn: todoService.deleteTodo,
    onSuccess: () => {
      queryClient.invalidateQueries(['todos']);
    },
  });

  // 同步数据
  const syncMutation = useMutation({
    mutationFn: () => syncService.sync(),
  });

  const stats = {
    total: todos.length,
    completed: todos.filter((t) => t.completed).length,
    pending: todos.filter((t) => !t.completed).length,
  };

  return {
    todos,
    stats,
    addTodo: addTodoMutation.mutate,
    updateTodo: updateTodoMutation.mutate,
    deleteTodo: deleteTodoMutation.mutate,
    sync: syncMutation.mutate,
    isSyncing: syncMutation.isPending,
  };
}
Code collapsed

创建网络状态 Hook

code
// src/hooks/useNetworkStatus.ts
import { useState, useEffect } from 'react';

export function useNetworkStatus() {
  const [isOnline, setIsOnline] = useState(
    typeof navigator !== 'undefined' ? navigator.onLine : true
  );

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}
Code collapsed

创建 UI 组件

code
// src/components/TodoList.tsx
import React, { useState } from 'react';
import { useTodoList } from '../hooks/useTodoList';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { CiCircleCheck, CiCircleRemove, CiTrash } from 'react-icons/ci';
import { IoCloudUploadOutline } from 'react-icons/io5';

export function TodoList() {
  const { todos, stats, addTodo, updateTodo, deleteTodo, sync, isSyncing } =
    useTodoList();
  const [newTodoTitle, setNewTodoTitle] = useState('');
  const isOnline = useNetworkStatus();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (newTodoTitle.trim()) {
      addTodo({
        title: newTodoTitle,
        completed: false,
        createdAt: new Date(),
      });
      setNewTodoTitle('');
    }
  };

  return (
    <div className: "max-w-2xl mx-auto p-4">
      {/* 头部 */}
      <div className: "flex items-center justify-between mb-6">
        <h1 className: "text-3xl font-bold text-gray-900">待办事项</h1>
        <div className: "flex items-center gap-3">
          {/* 网络状态 */}
          <div
            className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm ${
              isOnline
                ? 'bg-green-50 text-green-700'
                : 'bg-yellow-50 text-yellow-700'
            }`}
          >
            <span
              className={`w-2 h-2 rounded-full ${
                isOnline ? 'bg-green-500' : 'bg-yellow-500'
              }`}
            />
            {isOnline ? '在线' : '离线'}
          </div>

          {/* 同步按钮 */}
          <button
            onClick={() => sync()}
            disabled={isSyncing || isOnline}
            className: "flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            <IoCloudUploadOutline
              className={isSyncing ? 'animate-spin' : ''}
            />
            {isSyncing ? '同步中...' : '同步'}
          </button>
        </div>
      </div>

      {/* 统计 */}
      <div className: "grid grid-cols-3 gap-4 mb-6">
        <StatCard label: "总计" value={stats.total} color: "blue" />
        <StatCard label: "已完成" value={stats.completed} color: "green" />
        <StatCard label: "待完成" value={stats.pending} color: "yellow" />
      </div>

      {/* 添加表单 */}
      <form onSubmit={handleSubmit} className: "mb-6">
        <div className: "flex gap-2">
          <input
            type: "text"
            value={newTodoTitle}
            onChange={(e) => setNewTodoTitle(e.target.value)}
            placeholder: "添加新任务..."
            className: "flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
          <button
            type: "submit"
            className: "px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
          >
            添加
          </button>
        </div>
      </form>

      {/* 列表 */}
      <ul className: "space-y-2">
        {todos.map((todo) => (
          <li
            key={todo.id}
            className={`flex items-center gap-3 p-4 bg-white rounded-lg shadow-sm ${
              todo.completed ? 'opacity-60' : ''
            }`}
          >
            <button
              onClick={() => updateTodo({ id: todo.id!, updates: { completed: !todo.completed } })}
              className: "text-2xl"
            >
              {todo.completed ? (
                <CiCircleCheck className: "text-green-500" />
              ) : (
                <CiCircleRemove className: "text-gray-400" />
              )}
            </button>
            <span
              className={`flex-1 ${todo.completed ? 'line-through text-gray-500' : ''}`}
            >
              {todo.title}
            </span>
            {!todo.synced && (
              <span className: "text-xs text-yellow-600 bg-yellow-50 px-2 py-1 rounded">
                待同步
              </span>
            )}
            <button
              onClick={() => deleteTodo(todo.id!)}
              className: "text-red-500 hover:text-red-700"
            >
              <CiTrash size={20} />
            </button>
          </li>
        ))}
      </ul>

      {todos.length === 0 && (
        <div className: "text-center py-12 text-gray-500">
          <p>暂无待办事项</p>
          <p className: "text-sm mt-2">
            {isOnline ? '添加一个任务开始吧!' : '离线模式下也可以添加任务'}
          </p>
        </div>
      )}
    </div>
  );
}

function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
  const colorClasses = {
    blue: 'bg-blue-50 text-blue-700',
    green: 'bg-green-50 text-green-700',
    yellow: 'bg-yellow-50 text-yellow-700',
  };

  return (
    <div className={`p-4 rounded-lg ${colorClasses[color as keyof typeof colorClasses]}`}>
      <p className: "text-sm font-medium">{label}</p>
      <p className: "text-2xl font-bold mt-1">{value}</p>
    </div>
  );
}
Code collapsed

注册 Service Worker

code
// src/serviceWorkerRegistration.ts
export function register() {
  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
    if (publicUrl.origin !== window.location.origin) {
      return;
    }

    window.addEventListener('load', () => {
      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;

      navigator.serviceWorker
        .register(swUrl)
        .then((registration) => {
          console.log('SW registered: ', registration);

          // 检查更新
          registration.onupdatefound = () => {
            const installingWorker = registration.installing;
            if (installingWorker == null) {
              return;
            }
            installingWorker.onstatechange = () => {
              if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) {
                // 新内容可用
                if (window.confirm('新版本可用,是否刷新?')) {
                  window.location.reload();
                }
              }
            };
          };
        })
        .catch((error) => {
          console.error('SW registration failed: ', error);
        });
    });
  }
}

export function unregister() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready
      .then((registration) => {
        registration.unregister();
      })
      .catch((error) => {
        console.error(error.message);
      });
  }
}
Code collapsed

总结

本教程介绍了如何使用 React、Dexie.js 和 Workbox 构建离线优先 PWA:

  1. Dexie.js 简化 IndexedDB 操作
  2. Workbox 自动化 Service Worker 管理
  3. 实现乐观更新和后台同步
  4. 网络状态监测和处理

扩展建议

  • 添加推送通知功能
  • 实现数据冲突解决
  • 添加用户认证
  • 支持多设备同步

参考资料

相关文章

#

文章标签

react
pwa
dexiejs
workbox
离线应用

觉得这篇文章有帮助?

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