Skip to content

离线支持

概述

离线支持是现代Web应用的重要特性,能够在网络不稳定或离线状态下提供更好的用户体验。TanStack Query通过智能缓存、网络状态检测和持久化存储,提供了强大的离线支持能力。本文将详细介绍如何在React应用中实现离线功能。

网络状态检测

基础网络检测

jsx
import { onlineManager } from '@tanstack/react-query';

function NetworkStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  
  useEffect(() => {
    const unsubscribe = onlineManager.subscribe((online) => {
      setIsOnline(online);
    });
    
    return unsubscribe;
  }, []);
  
  return (
    <div className={`network-status ${isOnline ? 'online' : 'offline'}`}>
      {isOnline ? (
        <>
          <span className="icon">✓</span> Online
        </>
      ) : (
        <>
          <span className="icon">✗</span> Offline
        </>
      )}
    </div>
  );
}

自定义网络检测

jsx
import { onlineManager } from '@tanstack/react-query';

// 自定义网络检测逻辑
onlineManager.setEventListener((setOnline) => {
  return window.addEventListener('online', () => {
    // 验证网络真正可用
    fetch('https://www.google.com/generate_204', {
      mode: 'no-cors',
    })
      .then(() => setOnline(true))
      .catch(() => setOnline(false));
  });
});

// 或使用更复杂的检测
function setupNetworkDetection() {
  onlineManager.setEventListener((setOnline) => {
    const checkNetwork = async () => {
      try {
        const response = await fetch('/api/health');
        setOnline(response.ok);
      } catch {
        setOnline(false);
      }
    };
    
    window.addEventListener('online', checkNetwork);
    window.addEventListener('offline', () => setOnline(false));
    
    // 定期检查
    const interval = setInterval(checkNetwork, 30000);
    
    return () => {
      window.removeEventListener('online', checkNetwork);
      window.removeEventListener('offline', () => setOnline(false));
      clearInterval(interval);
    };
  });
}

离线查询配置

网络模式

jsx
function OfflineQueries() {
  // 1. online模式(默认): 只在在线时获取
  const { data: onlineData } = useQuery({
    queryKey: ['online-data'],
    queryFn: fetchData,
    networkMode: 'online',
  });
  
  // 2. always模式: 无论在线离线都尝试获取
  const { data: alwaysData } = useQuery({
    queryKey: ['always-data'],
    queryFn: fetchData,
    networkMode: 'always',
  });
  
  // 3. offlineFirst模式: 优先使用缓存,离线时不获取
  const { data: offlineFirstData } = useQuery({
    queryKey: ['offline-first-data'],
    queryFn: fetchData,
    networkMode: 'offlineFirst',
  });
  
  return (
    <div>
      <div>Online Data: {JSON.stringify(onlineData)}</div>
      <div>Always Data: {JSON.stringify(alwaysData)}</div>
      <div>Offline First: {JSON.stringify(offlineFirstData)}</div>
    </div>
  );
}

离线时使用缓存

jsx
function OfflineCaching() {
  const { data, isLoading, error, isStale } = useQuery({
    queryKey: ['user-data'],
    queryFn: fetchUserData,
    
    // 缓存配置
    staleTime: Infinity,        // 永不过期
    cacheTime: Infinity,        // 永不清除
    
    // 网络配置
    networkMode: 'offlineFirst', // 优先使用缓存
    
    // 离线时不重新获取
    refetchOnWindowFocus: navigator.onLine,
    refetchOnReconnect: true,
    refetchOnMount: navigator.onLine,
  });
  
  return (
    <div>
      {!navigator.onLine && isStale && (
        <div className="warning">
          Showing cached data (offline)
        </div>
      )}
      
      {isLoading && <div>Loading...</div>}
      {error && <div>Error: {error.message}</div>}
      {data && <div>{JSON.stringify(data)}</div>}
    </div>
  );
}

离线Mutation

暂停Mutation

jsx
import { focusManager } from '@tanstack/react-query';

function OfflineMutation() {
  const queryClient = useQueryClient();
  
  const { mutate, isPaused } = useMutation({
    mutationFn: updateData,
    networkMode: 'offlineFirst',
    
    // Mutation会在离线时暂停
    onMutate: async (newData) => {
      if (!navigator.onLine) {
        toast.info('Changes will be synced when online');
      }
      
      // 乐观更新
      await queryClient.cancelQueries({ queryKey: ['data'] });
      const previousData = queryClient.getQueryData(['data']);
      
      queryClient.setQueryData(['data'], (old) => ({
        ...old,
        ...newData,
      }));
      
      return { previousData };
    },
    
    onError: (err, newData, context) => {
      queryClient.setQueryData(['data'], context.previousData);
    },
    
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['data'] });
    },
  });
  
  return (
    <div>
      <button onClick={() => mutate({ value: 'new' })}>
        Update
      </button>
      {isPaused && (
        <div className="paused">
          Update paused (offline)
        </div>
      )}
    </div>
  );
}

Mutation队列

jsx
function MutationQueue() {
  const queryClient = useQueryClient();
  const [pendingMutations, setPendingMutations] = useState([]);
  
  useEffect(() => {
    const mutationCache = queryClient.getMutationCache();
    
    const unsubscribe = mutationCache.subscribe((event) => {
      // 获取所有pending的mutations
      const pending = mutationCache.getAll().filter(
        m => m.state.isPaused
      );
      
      setPendingMutations(pending);
    });
    
    return unsubscribe;
  }, [queryClient]);
  
  useEffect(() => {
    const handleOnline = () => {
      // 网络恢复时,暂停的mutations会自动恢复
      toast.success('Syncing pending changes...');
    };
    
    window.addEventListener('online', handleOnline);
    return () => window.removeEventListener('online', handleOnline);
  }, []);
  
  return (
    <div>
      {pendingMutations.length > 0 && (
        <div className="pending-mutations">
          {pendingMutations.length} changes pending sync
        </div>
      )}
    </div>
  );
}

数据持久化

LocalStorage持久化

jsx
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';

function setupPersistence() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        cacheTime: 1000 * 60 * 60 * 24, // 24小时
        networkMode: 'offlineFirst',
      },
    },
  });
  
  const persister = createSyncStoragePersister({
    storage: window.localStorage,
    key: 'REACT_QUERY_OFFLINE_CACHE',
  });
  
  persistQueryClient({
    queryClient,
    persister,
    maxAge: 1000 * 60 * 60 * 24, // 24小时
    dehydrateOptions: {
      shouldDehydrateQuery: (query) => {
        // 只持久化特定查询
        const queryKey = query.queryKey[0];
        return ['user', 'settings', 'posts'].includes(queryKey);
      },
    },
  });
  
  return queryClient;
}

function App() {
  const queryClient = useMemo(() => setupPersistence(), []);
  
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  );
}

IndexedDB持久化

jsx
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { get, set, del } from 'idb-keyval';

function setupIndexedDBPersistence() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        cacheTime: 1000 * 60 * 60 * 24 * 7, // 7天
      },
    },
  });
  
  const persister = createAsyncStoragePersister({
    storage: {
      getItem: async (key) => await get(key),
      setItem: async (key, value) => await set(key, value),
      removeItem: async (key) => await del(key),
    },
  });
  
  persistQueryClient({
    queryClient,
    persister,
    maxAge: 1000 * 60 * 60 * 24 * 7,
  });
  
  return queryClient;
}

版本控制

jsx
function setupVersionedPersistence() {
  const APP_VERSION = '1.0.0';
  
  const queryClient = new QueryClient();
  
  const persister = createSyncStoragePersister({
    storage: window.localStorage,
    key: `APP_CACHE_${APP_VERSION}`,
  });
  
  persistQueryClient({
    queryClient,
    persister,
    buster: APP_VERSION, // 版本变化时清除缓存
    
    dehydrateOptions: {
      shouldDehydrateMutation: (mutation) => {
        // 不持久化mutations
        return false;
      },
    },
  });
  
  // 清理旧版本缓存
  Object.keys(localStorage).forEach(key => {
    if (key.startsWith('APP_CACHE_') && key !== `APP_CACHE_${APP_VERSION}`) {
      localStorage.removeItem(key);
    }
  });
  
  return queryClient;
}

Service Worker集成

注册Service Worker

jsx
// sw.js
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('api-cache-v1').then((cache) => {
      return cache.addAll([
        '/api/config',
        '/api/static-data',
      ]);
    })
  );
});

self.addEventListener('fetch', (event) => {
  // 缓存优先策略
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      caches.match(event.request).then((response) => {
        if (response) {
          // 后台更新缓存
          fetch(event.request).then((networkResponse) => {
            caches.open('api-cache-v1').then((cache) => {
              cache.put(event.request, networkResponse);
            });
          });
          
          return response;
        }
        
        return fetch(event.request).then((networkResponse) => {
          return caches.open('api-cache-v1').then((cache) => {
            cache.put(event.request, networkResponse.clone());
            return networkResponse;
          });
        });
      })
    );
  }
});

// React组件
function AppWithServiceWorker() {
  useEffect(() => {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js');
    }
  }, []);
  
  return <App />;
}

后台同步

jsx
// sw.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-mutations') {
    event.waitUntil(syncMutations());
  }
});

async function syncMutations() {
  const mutations = await getStoredMutations();
  
  for (const mutation of mutations) {
    try {
      await fetch(mutation.url, mutation.options);
      await removeMutation(mutation.id);
    } catch (error) {
      console.error('Failed to sync mutation:', error);
    }
  }
}

// React组件
function OfflineSync() {
  const { mutate } = useMutation({
    mutationFn: updateData,
    
    onError: async (error, variables) => {
      if (!navigator.onLine) {
        // 存储mutation供后台同步
        await storeMutation({
          id: Date.now(),
          url: '/api/data',
          options: {
            method: 'POST',
            body: JSON.stringify(variables),
          },
        });
        
        // 注册后台同步
        if ('serviceWorker' in navigator && 'sync' in registration) {
          const registration = await navigator.serviceWorker.ready;
          await registration.sync.register('sync-mutations');
        }
      }
    },
  });
  
  return <UpdateForm onSubmit={mutate} />;
}

离线UI模式

离线指示器

jsx
function OfflineIndicator() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  const [showOfflineBanner, setShowOfflineBanner] = useState(false);
  
  useEffect(() => {
    const handleOnline = () => {
      setIsOnline(true);
      setShowOfflineBanner(false);
      toast.success('Back online');
    };
    
    const handleOffline = () => {
      setIsOnline(false);
      setShowOfflineBanner(true);
      toast.warning('You are offline');
    };
    
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);
  
  if (!showOfflineBanner) return null;
  
  return (
    <div className="offline-banner">
      <span className="icon">⚠️</span>
      You're offline. Some features may be limited.
      <button onClick={() => setShowOfflineBanner(false)}>✕</button>
    </div>
  );
}

数据新鲜度指示

jsx
function DataFreshnessIndicator() {
  const { data, dataUpdatedAt } = useQuery({
    queryKey: ['data'],
    queryFn: fetchData,
    networkMode: 'offlineFirst',
  });
  
  const [timeAgo, setTimeAgo] = useState('');
  
  useEffect(() => {
    const updateTimeAgo = () => {
      if (!dataUpdatedAt) return;
      
      const seconds = Math.floor((Date.now() - dataUpdatedAt) / 1000);
      
      if (seconds < 60) {
        setTimeAgo('just now');
      } else if (seconds < 3600) {
        setTimeAgo(`${Math.floor(seconds / 60)} minutes ago`);
      } else {
        setTimeAgo(`${Math.floor(seconds / 3600)} hours ago`);
      }
    };
    
    updateTimeAgo();
    const interval = setInterval(updateTimeAgo, 10000);
    
    return () => clearInterval(interval);
  }, [dataUpdatedAt]);
  
  return (
    <div className="data-freshness">
      <span>Last updated: {timeAgo}</span>
      {!navigator.onLine && (
        <span className="offline-badge">Offline</span>
      )}
    </div>
  );
}

离线可用功能限制

jsx
function FeatureGating() {
  const isOnline = navigator.onLine;
  
  const { mutate: createPost, isPaused } = useMutation({
    mutationFn: createNewPost,
    networkMode: 'online', // 只在在线时可用
  });
  
  return (
    <div>
      <button
        onClick={() => createPost({ title: 'New Post' })}
        disabled={!isOnline}
        title={isOnline ? '' : 'This feature requires internet connection'}
      >
        Create Post {!isOnline && '(Offline)'}
      </button>
      
      {isPaused && (
        <div className="info">
          Your post will be created when you're back online
        </div>
      )}
    </div>
  );
}

冲突解决

时间戳冲突检测

jsx
function ConflictResolution() {
  const { data, mutate } = useMutation({
    mutationFn: async (updates) => {
      const response = await fetch('/api/data', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          ...updates,
          lastModified: data.lastModified,
        }),
      });
      
      if (response.status === 409) {
        // 冲突
        const serverData = await response.json();
        throw new ConflictError(serverData);
      }
      
      return response.json();
    },
    
    onError: (error) => {
      if (error instanceof ConflictError) {
        // 显示冲突解决UI
        showConflictDialog(error.serverData);
      }
    },
  });
  
  return <UpdateForm onSubmit={mutate} />;
}

class ConflictError extends Error {
  constructor(serverData) {
    super('Conflict detected');
    this.serverData = serverData;
  }
}

自动合并策略

jsx
function AutoMerge() {
  const queryClient = useQueryClient();
  
  const { mutate } = useMutation({
    mutationFn: updateData,
    
    onMutate: async (newData) => {
      const previousData = queryClient.getQueryData(['data']);
      
      // 乐观更新
      queryClient.setQueryData(['data'], (old) => ({
        ...old,
        ...newData,
        _version: (old._version || 0) + 1,
      }));
      
      return { previousData };
    },
    
    onSuccess: (serverData, variables, context) => {
      const localData = queryClient.getQueryData(['data']);
      
      // 检查版本冲突
      if (localData._version > serverData._version) {
        // 本地版本更新,合并变更
        queryClient.setQueryData(['data'], {
          ...serverData,
          ...variables,
          _version: localData._version,
        });
      } else {
        // 使用服务器版本
        queryClient.setQueryData(['data'], serverData);
      }
    },
  });
  
  return <UpdateForm onSubmit={mutate} />;
}

总结

离线支持核心要点:

  1. 网络检测:onlineManager、自定义检测
  2. 离线查询:networkMode配置、缓存策略
  3. 离线Mutation:暂停队列、自动恢复
  4. 数据持久化:LocalStorage、IndexedDB
  5. Service Worker:后台同步、缓存策略
  6. 离线UI:指示器、新鲜度、功能限制
  7. 冲突解决:时间戳检测、自动合并

合理实现离线支持可以显著提升应用在弱网环境下的可用性。

第四部分:高级离线支持策略

4.1 Service Worker集成

jsx
// 1. Service Worker注册
function registerServiceWorker() {
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker
        .register('/sw.js')
        .then(registration => {
          console.log('SW registered:', registration);
        })
        .catch(error => {
          console.log('SW registration failed:', error);
        });
    });
  }
}

// 2. Service Worker文件 (sw.js)
const CACHE_NAME = 'react-app-v1';
const urlsToCache = [
  '/',
  '/static/css/main.css',
  '/static/js/main.js'
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(urlsToCache))
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        if (response) {
          return response;
        }
        return fetch(event.request);
      })
  );
});

// 3. 后台同步
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-mutations') {
    event.waitUntil(syncOfflineMutations());
  }
});

async function syncOfflineMutations() {
  const mutations = await getOfflineMutations();
  
  for (const mutation of mutations) {
    try {
      await fetch(mutation.url, {
        method: mutation.method,
        headers: mutation.headers,
        body: JSON.stringify(mutation.data)
      });
      await removeOfflineMutation(mutation.id);
    } catch (error) {
      console.error('Sync failed:', error);
    }
  }
}

// 4. React中使用Service Worker
function useServiceWorker() {
  const [updateAvailable, setUpdateAvailable] = useState(false);

  useEffect(() => {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.ready.then(registration => {
        registration.addEventListener('updatefound', () => {
          const newWorker = registration.installing;
          
          newWorker.addEventListener('statechange', () => {
            if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
              setUpdateAvailable(true);
            }
          });
        });
      });
    }
  }, []);

  const updateApp = () => {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.ready.then(registration => {
        registration.waiting?.postMessage({ type: 'SKIP_WAITING' });
        window.location.reload();
      });
    }
  };

  return { updateAvailable, updateApp };
}

4.2 IndexedDB持久化

jsx
// 1. IndexedDB包装器
class IndexedDBStore {
  constructor(dbName, storeName) {
    this.dbName = dbName;
    this.storeName = storeName;
    this.db = null;
  }

  async init() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve(this.db);
      };

      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        if (!db.objectStoreNames.contains(this.storeName)) {
          db.createObjectStore(this.storeName, { keyPath: 'id' });
        }
      };
    });
  }

  async get(id) {
    if (!this.db) await this.init();
    
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([this.storeName], 'readonly');
      const store = transaction.objectStore(this.storeName);
      const request = store.get(id);

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async put(data) {
    if (!this.db) await this.init();
    
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      const request = store.put(data);

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async delete(id) {
    if (!this.db) await this.init();
    
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      const request = store.delete(id);

      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  async getAll() {
    if (!this.db) await this.init();
    
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([this.storeName], 'readonly');
      const store = transaction.objectStore(this.storeName);
      const request = store.getAll();

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
}

// 2. 与TanStack Query集成
const idbStore = new IndexedDBStore('react-cache', 'queries');

function createIDBPersister() {
  return {
    persistClient: async (client) => {
      const queries = client.getQueryCache().getAll();
      const serialized = queries.map(query => ({
        id: JSON.stringify(query.queryKey),
        queryKey: query.queryKey,
        state: query.state,
        timestamp: Date.now()
      }));

      for (const query of serialized) {
        await idbStore.put(query);
      }
    },
    restoreClient: async () => {
      const queries = await idbStore.getAll();
      return {
        clientState: {
          queries: queries.map(q => ({
            queryKey: q.queryKey,
            queryHash: JSON.stringify(q.queryKey),
            state: q.state
          }))
        }
      };
    },
    removeClient: async () => {
      const queries = await idbStore.getAll();
      for (const query of queries) {
        await idbStore.delete(query.id);
      }
    }
  };
}

// 3. 使用持久化
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';

function App() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        gcTime: 1000 * 60 * 60 * 24 // 24 hours
      }
    }
  });

  const persister = createIDBPersister();

  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{ persister }}
    >
      <AppContent />
    </PersistQueryClientProvider>
  );
}

4.3 离线优先架构

jsx
// 1. 离线优先查询策略
function useOfflineFirstQuery(queryKey, queryFn) {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  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 useQuery({
    queryKey,
    queryFn,
    // 离线时只使用缓存
    networkMode: isOnline ? 'online' : 'offlineFirst',
    // 缓存时间设置很长
    staleTime: 1000 * 60 * 60 * 24, // 24小时
    gcTime: 1000 * 60 * 60 * 24 * 7, // 7天
    // 离线时不重新获取
    refetchOnMount: isOnline,
    refetchOnWindowFocus: isOnline,
    refetchOnReconnect: true
  });
}

// 2. 离线Mutation队列
class OfflineMutationManager {
  constructor() {
    this.queue = this.loadQueue();
    this.isProcessing = false;
  }

  loadQueue() {
    const saved = localStorage.getItem('offline-mutation-queue');
    return saved ? JSON.parse(saved) : [];
  }

  saveQueue() {
    localStorage.setItem('offline-mutation-queue', JSON.stringify(this.queue));
  }

  enqueue(mutation) {
    this.queue.push({
      id: Date.now(),
      ...mutation,
      timestamp: new Date().toISOString(),
      retryCount: 0
    });
    this.saveQueue();
  }

  dequeue(id) {
    this.queue = this.queue.filter(m => m.id !== id);
    this.saveQueue();
  }

  async processQueue() {
    if (this.isProcessing || !navigator.onLine) return;
    
    this.isProcessing = true;

    while (this.queue.length > 0 && navigator.onLine) {
      const mutation = this.queue[0];

      try {
        await fetch(mutation.url, {
          method: mutation.method,
          headers: mutation.headers,
          body: JSON.stringify(mutation.data)
        });

        this.dequeue(mutation.id);
      } catch (error) {
        mutation.retryCount++;
        
        if (mutation.retryCount >= 3) {
          this.dequeue(mutation.id);
          console.error('Max retries reached for mutation:', mutation);
        } else {
          this.saveQueue();
        }
        
        break;
      }
    }

    this.isProcessing = false;
  }

  getQueueStatus() {
    return {
      pending: this.queue.length,
      items: this.queue.map(m => ({
        id: m.id,
        type: m.type,
        retryCount: m.retryCount,
        timestamp: m.timestamp
      }))
    };
  }
}

const mutationManager = new OfflineMutationManager();

// 3. 使用离线Mutation
function useOfflineMutation(mutationFn, options = {}) {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  const queryClient = useQueryClient();

  useEffect(() => {
    const handleOnline = () => {
      setIsOnline(true);
      mutationManager.processQueue();
    };
    const handleOffline = () => setIsOnline(false);

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

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

  return useMutation({
    mutationFn: async (variables) => {
      if (!isOnline) {
        // 离线时加入队列
        mutationManager.enqueue({
          type: options.type || 'unknown',
          url: options.url,
          method: options.method || 'POST',
          headers: options.headers || { 'Content-Type': 'application/json' },
          data: variables
        });

        // 执行乐观更新
        if (options.onMutate) {
          await options.onMutate(variables);
        }

        return { offline: true, queued: true };
      }

      return mutationFn(variables);
    },
    ...options
  });
}

4.4 数据同步策略

jsx
// 1. 差异同步
class DiffSync {
  constructor() {
    this.localChanges = new Map();
    this.serverVersion = 0;
  }

  trackChange(key, change) {
    if (!this.localChanges.has(key)) {
      this.localChanges.set(key, []);
    }
    this.localChanges.get(key).push({
      ...change,
      timestamp: Date.now(),
      version: this.serverVersion + this.localChanges.get(key).length + 1
    });
  }

  async sync() {
    if (!navigator.onLine || this.localChanges.size === 0) return;

    const changes = Array.from(this.localChanges.entries()).map(([key, changes]) => ({
      key,
      changes
    }));

    try {
      const response = await fetch('/api/sync', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          clientVersion: this.serverVersion,
          changes
        })
      });

      const result = await response.json();

      // 应用服务器变更
      if (result.serverChanges) {
        this.applyServerChanges(result.serverChanges);
      }

      // 清除已同步的本地变更
      this.localChanges.clear();
      this.serverVersion = result.serverVersion;

      return result;
    } catch (error) {
      console.error('Sync failed:', error);
      throw error;
    }
  }

  applyServerChanges(serverChanges) {
    serverChanges.forEach(change => {
      // 应用服务器变更到本地状态
      console.log('Applying server change:', change);
    });
  }
}

// 2. 冲突解决
class ConflictResolver {
  resolveConflict(local, server) {
    // 使用最后写入时间
    if (local.timestamp > server.timestamp) {
      return local;
    } else if (server.timestamp > local.timestamp) {
      return server;
    }

    // 时间戳相同,尝试合并
    return this.mergeChanges(local, server);
  }

  mergeChanges(local, server) {
    const merged = { ...server };

    Object.keys(local).forEach(key => {
      if (key !== 'timestamp' && key !== 'version') {
        // 如果服务器没有这个字段,使用本地值
        if (!(key in server)) {
          merged[key] = local[key];
        }
        // 如果都有,优先使用服务器值(可根据业务调整)
      }
    });

    merged.timestamp = Math.max(local.timestamp, server.timestamp);
    return merged;
  }

  resolveArrayConflict(local, server) {
    // 数组合并去重
    const merged = [...server];
    
    local.forEach(item => {
      const exists = merged.find(m => m.id === item.id);
      if (!exists) {
        merged.push(item);
      } else if (item.timestamp > exists.timestamp) {
        const index = merged.findIndex(m => m.id === item.id);
        merged[index] = item;
      }
    });

    return merged;
  }
}

// 3. 自动同步Hook
function useAutoSync(interval = 30000) {
  const diffSync = useRef(new DiffSync()).current;
  const [syncStatus, setSyncStatus] = useState('idle');
  const [lastSyncTime, setLastSyncTime] = useState(null);

  const performSync = useCallback(async () => {
    if (!navigator.onLine) {
      setSyncStatus('offline');
      return;
    }

    setSyncStatus('syncing');

    try {
      await diffSync.sync();
      setSyncStatus('success');
      setLastSyncTime(new Date());
    } catch (error) {
      setSyncStatus('error');
      console.error('Auto sync failed:', error);
    }
  }, [diffSync]);

  useEffect(() => {
    const syncInterval = setInterval(performSync, interval);

    const handleOnline = () => {
      performSync();
    };

    window.addEventListener('online', handleOnline);

    return () => {
      clearInterval(syncInterval);
      window.removeEventListener('online', handleOnline);
    };
  }, [performSync, interval]);

  return {
    syncStatus,
    lastSyncTime,
    trackChange: diffSync.trackChange.bind(diffSync),
    manualSync: performSync
  };
}

4.5 离线UI组件

jsx
// 1. 离线指示器
function OfflineIndicator() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  const mutationQueue = mutationManager.getQueueStatus();

  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);
    };
  }, []);

  if (isOnline && mutationQueue.pending === 0) return null;

  return (
    <div style={{
      position: 'fixed',
      top: 0,
      left: 0,
      right: 0,
      padding: '10px',
      background: isOnline ? '#ffa500' : '#f44336',
      color: 'white',
      textAlign: 'center',
      zIndex: 9999
    }}>
      {!isOnline && '⚠️ 离线模式'}
      {isOnline && mutationQueue.pending > 0 && (
        `🔄 正在同步 ${mutationQueue.pending} 个待处理更新...`
      )}
    </div>
  );
}

// 2. 数据新鲜度指示器
function DataFreshnessIndicator({ lastUpdate }) {
  const [freshness, setFreshness] = useState('fresh');

  useEffect(() => {
    const checkFreshness = () => {
      const age = Date.now() - lastUpdate;
      
      if (age < 60000) {
        setFreshness('fresh'); // < 1分钟
      } else if (age < 3600000) {
        setFreshness('stale'); // 1分钟 - 1小时
      } else {
        setFreshness('very-stale'); // > 1小时
      }
    };

    const interval = setInterval(checkFreshness, 10000);
    checkFreshness();

    return () => clearInterval(interval);
  }, [lastUpdate]);

  const colors = {
    fresh: '#4caf50',
    stale: '#ffa500',
    'very-stale': '#f44336'
  };

  return (
    <div style={{
      display: 'inline-block',
      width: '10px',
      height: '10px',
      borderRadius: '50%',
      background: colors[freshness],
      marginLeft: '5px'
    }} />
  );
}

// 3. 离线功能提示
function OfflineFeatureGuard({ children, fallback }) {
  const [isOnline] = useState(navigator.onLine);

  if (!isOnline && fallback) {
    return fallback;
  }

  return children;
}

// 使用示例
function MyComponent() {
  return (
    <>
      <OfflineIndicator />
      
      <div>
        数据更新时间: {new Date().toLocaleString()}
        <DataFreshnessIndicator lastUpdate={Date.now()} />
      </div>

      <OfflineFeatureGuard
        fallback={<div>此功能需要网络连接</div>}
      >
        <OnlineOnlyFeature />
      </OfflineFeatureGuard>
    </>
  );
}

离线支持最佳实践总结

1. Service Worker
   ✅ 缓存静态资源
   ✅ 后台同步
   ✅ 推送通知支持
   ✅ 版本更新管理

2. 数据持久化
   ✅ IndexedDB存储
   ✅ 与查询缓存集成
   ✅ 自动清理策略

3. 离线优先
   ✅ 离线优先查询
   ✅ Mutation队列
   ✅ 乐观更新

4. 数据同步
   ✅ 差异同步
   ✅ 冲突解决
   ✅ 自动同步

5. 用户体验
   ✅ 离线指示器
   ✅ 数据新鲜度
   ✅ 功能降级

完善的离线支持是现代Web应用的必备特性,能够显著提升用户体验,特别是在网络不稳定的环境下。