关键要点
- 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:
- Dexie.js 简化 IndexedDB 操作
- Workbox 自动化 Service Worker 管理
- 实现乐观更新和后台同步
- 网络状态监测和处理
扩展建议
- 添加推送通知功能
- 实现数据冲突解决
- 添加用户认证
- 支持多设备同步