Appearance
离线支持
概述
离线支持是现代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} />;
}总结
离线支持核心要点:
- 网络检测:onlineManager、自定义检测
- 离线查询:networkMode配置、缓存策略
- 离线Mutation:暂停队列、自动恢复
- 数据持久化:LocalStorage、IndexedDB
- Service Worker:后台同步、缓存策略
- 离线UI:指示器、新鲜度、功能限制
- 冲突解决:时间戳检测、自动合并
合理实现离线支持可以显著提升应用在弱网环境下的可用性。
第四部分:高级离线支持策略
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应用的必备特性,能够显著提升用户体验,特别是在网络不稳定的环境下。