欢迎光临
我们一直在努力

前端离线优先架构:Service Worker 缓存策略与数据同步的工程方案

前端离线优先架构:Service Worker 缓存策略与数据同步的工程方案

一、离线优先的架构边界与收益评估

离线优先(Offline-First)不等同于"完全离线使用",而是在网络不可靠(高铁隧道、电梯、弱信号区)的场景下,保障用户不看到空白页面或加载失败提示。架构设计的核心原则是:以本地为真理源(Local-First),网络为同步通道。

架构的适用条件:

  • 应用的核心交互不依赖实时数据(内容类、工具类、表单类)。
  • 用户有一段时间的使用会话(非纯查询型应用)。
  • 可接受秒级的数据一致性延迟(非强一致性场景)。

典型收益数据:

  • 二次加载时间从 2.5s(网络请求)降到 200ms(本地缓存)。
  • 弱网环境下的可用性从 23% 提升到 89%(PWA 实测数据)。
  • 用户留存率提升约 15%(数据来源:Google IO 2024 PWA Case Study)。

flowchart TD
A[用户访问] –> B{Service Worker 激活?}
B –>|否| C[常规网络请求]
B –>|是| D{请求类型判断}
D –>|静态资源| E[Cache-First: 缓存命中直接返回]
D –>|API 数据| F{网络可用?}
F –>|是| G[Network-First: 获取最新数据 + 更新缓存]
F –>|否| H[返回缓存数据,标记为离线状态]
G –> I[渲染页面]
E –> I
H –> I
C –> I
style E fill:#6f6,stroke:#333
style H fill:#fc6,stroke:#333

离线优先的工程成本主要在两方面:请求拦截层的缓存策略实现,以及数据同步层的冲突解决。两者都依赖 Service Worker 的生命周期管理。

二、Service Worker 的分层缓存策略

缓存策略分为四个层级,按请求类型分配不同的策略:

缓存层适用资源策略过期策略最大容量
Shell 层 HTML/JS/CSS 核心资源 Cache-First 版本号控制 10 MB
数据层 API GET 响应 Network-First TTL + 后台更新 50 MB
媒体层 图片/字体/音视频 Stale-While-Revalidate LRU 淘汰 200 MB
动态层 用户上传/表单提交 Network-Only (离线队列) 持久化 5 MB

/**
* Service Worker 多层级缓存策略实现
*/
/// <reference lib="webworker" />

declare const self: ServiceWorkerGlobalScope;

/** 缓存配置 */
const CACHE_CONFIG = {
/** Shell 层缓存名(版本化,用于自动清理旧版本) */
shell: `shell-v${process.env.APP_VERSION || '1'}`,
/** 数据层缓存名 */
data: 'api-data-v1',
/** 媒体层缓存名 */
media: 'media-v1',
/** 各层最大条目数 */
maxItems: {
data: 500,
media: 1000,
},
/** API 响应 TTL(毫秒) */
apiTTL: 5 * 60 * 1000, // 5 分钟
} as const;

/**
* Service Worker 安装事件
* 预缓存 Shell 层关键资源
*/
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_CONFIG.shell);
// 预缓存应用骨架和关键资源
const shellAssets = ['/', '/index.html', '/assets/app.js', '/assets/app.css'];

try {
await cache.addAll(shellAssets);
console.log('[SW] Shell 层预缓存完成');
} catch (error) {
console.error('[SW] Shell 层预缓存失败:', error);
// 预缓存失败不应阻塞 SW 激活
}
})()
);

// 跳过等待,使新 SW 立即激活
self.skipWaiting();
});

/**
* Service Worker 激活事件
* 清理旧版本缓存
*/
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const cacheNames = await caches.keys();

// 保留当前版本的缓存(以 'shell-v' 开头的仅保留当前版本)
const currentVersion = CACHE_CONFIG.shell;
const deletePromises = cacheNames
.filter((name) => name.startsWith('shell-v') && name !== currentVersion)
.map((name) => caches.delete(name));

await Promise.all(deletePromises);

// 立即接管所有页面
await self.clients.claim();

console.log('[SW] 新版本激活,旧缓存已清理');
})()
);
});

/**
* Service Worker 请求拦截
* 根据请求类型应用不同的缓存策略
*/
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);

// 仅处理 GET 请求,非 GET 请求透传给网络
if (request.method !== 'GET') return;

// 跳过 chrome-extension 和非 HTTP 协议
if (!url.protocol.startsWith('http')) return;

// 根据请求类型选择缓存策略
if (isStaticAsset(url)) {
// 静态资源:Cache-First
event.respondWith(cacheFirstStrategy(request));
} else if (isAPIRequest(url)) {
// API 请求:Network-First
event.respondWith(networkFirstStrategy(request));
} else if (isMediaAsset(url)) {
// 媒体资源:Stale-While-Revalidate
event.respondWith(staleWhileRevalidateStrategy(request));
}
});

/**
* Cache-First 策略
* 优先使用缓存,缓存未命中时请求网络
*/
async function cacheFirstStrategy(request: Request): Promise<Response> {
const cache = await caches.open(CACHE_CONFIG.shell);

try {
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
} catch {
// 缓存读取失败,降级为网络请求
}

// 缓存未命中或读取失败,请求网络
try {
const networkResponse = await fetch(request);

// 缓存成功的网络响应(仅缓存 200 状态码)
if (networkResponse.ok) {
const clonedResponse = networkResponse.clone();
cache.put(request, clonedResponse).catch(() => {
// 缓存写入失败:可能超出配额,静默处理
});
}

return networkResponse;
} catch (error) {
console.error('[SW] 网络请求失败,无缓存回退:', request.url, error);
// 返回离线提示页面
return new Response('当前处于离线状态,无法加载此页面', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
}

/**
* Network-First 策略
* 优先请求网络,网络失败时使用缓存
*/
async function networkFirstStrategy(
request: Request,
timeout: number = 3000
): Promise<Response> {
const cache = await caches.open(CACHE_CONFIG.data);

try {
// 网络请求,带超时控制
const networkResponse = await fetchWithTimeout(request, timeout);

// 缓存 200 响应
if (networkResponse.ok) {
const clonedResponse = networkResponse.clone();
cache.put(request, clonedResponse);
}

return networkResponse;
} catch {
// 网络失败或超时,降级为缓存
const cachedResponse = await cache.match(request);
if (cachedResponse) {
// 在响应头中标记为离线数据
return addOfflineHeader(cachedResponse);
}

return new Response(
JSON.stringify({ error: '网络不可用,无可用缓存数据' }),
{
status: 503,
headers: { 'Content-Type': 'application/json' },
}
);
}
}

/**
* Stale-While-Revalidate 策略
* 立即返回缓存,同时在后台更新缓存
*/
async function staleWhileRevalidateStrategy(request: Request): Promise<Response> {
const cache = await caches.open(CACHE_CONFIG.media);

// 立即返回缓存版本(不阻塞)
const cachedResponse = await cache.match(request);

// 后台发起网络请求更新缓存
const updatePromise = fetch(request)
.then((networkResponse) => {
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
})
.catch(() => {
// 后台更新失败,静默处理(下次访问时重试)
});

// 在合适的时机等待更新完成(不阻塞本次响应)
event.waitUntil?.(updatePromise);

return cachedResponse || updatePromise;
}

/**
* 带超时的 fetch 封装
*/
async function fetchWithTimeout(
request: Request,
timeout: number
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

try {
const response = await fetch(request, {
signal: controller.signal,
});
return response;
} finally {
clearTimeout(timeoutId);
}
}

/**
* 在响应头添加离线标记
*/
function addOfflineHeader(response: Response): Response {
const headers = new Headers(response.headers);
headers.set('X-Offline-Data', 'true');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}

/** 判断是否为静态资源 */
function isStaticAsset(url: URL): boolean {
return /\\.(js|css|html|json|woff2?)$/.test(url.pathname);
}

/** 判断是否为 API 请求 */
function isAPIRequest(url: URL): boolean {
return url.pathname.startsWith('/api/');
}

/** 判断是否为媒体资源 */
function isMediaAsset(url: URL): boolean {
return /\\.(png|jpg|jpeg|gif|svg|webp|mp4|mp3)$/.test(url.pathname);
}

缓存策略的选择需要根据资源特性调整。对于版本化资源(带 hash 的 JS/CSS),Cache-First 策略最合适,因为文件名变更是版本更新的信号,不存在缓存过期问题。对于 API 响应,Network-First 配合短 TTL 保证数据的时效性。

三、离线数据同步的冲突解决策略

离线优先应用中,用户在离线时产生的写操作(表单提交、内容编辑)需要暂存并在网络恢复后同步到服务端。核心问题在于冲突解决:当多个设备或同一设备离线期间其他用户修改了相同数据时,如何合并变更。

常用的同步方案对比:

方案适用场景冲突策略复杂度
Last-Write-Wins (LWW) 单用户编辑、配置类数据 时间戳比较
CRDT (Conflict-free Replicated Data Type) 协作编辑、多用户在线 自动合并
三路合并 结构化文档编辑 Diff 合并

对于大多数前端表单类应用,LWW + 操作日志的方案足够实用:

/**
* 离线操作队列管理器
* 缓存离线期间的写操作,网络恢复后批量同步
*/
interface OfflineOperation {
/** 操作唯一 ID */
id: string;
/** 操作类型 */
type: 'CREATE' | 'UPDATE' | 'DELETE';
/** 目标资源路径 */
endpoint: string;
/** 操作数据 */
payload: unknown;
/** 创建时间 */
timestamp: number;
/** 重试次数 */
retryCount: number;
/** 状态 */
status: 'pending' | 'syncing' | 'failed';
}

class OfflineSyncQueue {
private readonly STORAGE_KEY = 'offline-sync-queue';
private queue: OfflineOperation[] = [];
private maxRetries = 3;
private syncInProgress = false;

constructor() {
// 从 localStorage 恢复操作队列
this.loadFromStorage();
// 监听网络恢复事件
this.registerNetworkListener();
}

/**
* 入队一个离线操作
*/
async enqueue(
type: OfflineOperation['type'],
endpoint: string,
payload: unknown
): Promise<void> {
const operation: OfflineOperation = {
id: crypto.randomUUID(),
type,
endpoint,
payload,
timestamp: Date.now(),
retryCount: 0,
status: 'pending',
};

this.queue.push(operation);
this.persistToStorage();

// 如果当前网络可用,立即尝试同步
if (navigator.onLine) {
this.sync();
}
}

/**
* 同步所有待处理的离线操作
*/
async sync(): Promise<void> {
if (this.syncInProgress || this.queue.length === 0) return;
this.syncInProgress = true;

const pendingOps = this.queue.filter(
(op) => op.status === 'pending'
);

for (const op of pendingOps) {
try {
op.status = 'syncing';

const response = await fetch(op.endpoint, {
method: this.mapHttpMethod(op.type),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(op.payload),
});

if (response.ok || response.status === 409) {
// 成功或冲突已由服务端处理(409)
this.removeOperation(op.id);
} else if (response.status === 412) {
// 前置条件失败(版本冲突),需用户手动解决
op.status = 'failed';
this.notifyConflict(op);
} else {
op.retryCount++;
if (op.retryCount >= this.maxRetries) {
op.status = 'failed';
this.notifyFailedOperation(op);
} else {
op.status = 'pending'; // 置回 pending 等待下次同步
}
}
} catch (error) {
// 网络错误,不递增重试计数(下次网络恢复时重试)
op.status = 'pending';
console.warn(`离线操作同步失败 (${op.id}):`, error);
break; // 网络错误时停止后续操作,下次一起重试
}
}

this.persistToStorage();
this.syncInProgress = false;
}

/**
* 从队列中移除已成功的操作
*/
private removeOperation(id: string): void {
this.queue = this.queue.filter((op) => op.id !== id);
}

/**
* 持久化队列到 localStorage
*/
private persistToStorage(): void {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.queue));
} catch (error) {
console.error('离线操作队列持久化失败:', error);
}
}

private loadFromStorage(): void {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
this.queue = JSON.parse(stored);
}
} catch {
this.queue = [];
}
}

private registerNetworkListener(): void {
window.addEventListener('online', () => {
console.log('[OfflineSync] 网络恢复,开始同步');
this.sync();
});
}

private mapHttpMethod(type: OfflineOperation['type']): string {
switch (type) {
case 'CREATE': return 'POST';
case 'UPDATE': return 'PUT';
case 'DELETE': return 'DELETE';
}
}

private notifyConflict(op: OfflineOperation): void {
// 通过 postMessage 或事件通知应用层处理冲突
window.dispatchEvent(
new CustomEvent('sync-conflict', { detail: op })
);
}

private notifyFailedOperation(op: OfflineOperation): void {
window.dispatchEvent(
new CustomEvent('sync-failed', { detail: op })
);
}
}

四、Service Worker 的版本管理与灰度发布

由于 Service Worker 通过 skipWaiting + clients.claim 机制更新后,所有页面立即可用新版本,这使得回滚变得困难。版本管理策略包括:

1. 版本号注入:在构建时将 commit hash 注入 SW 文件,确保新旧 SW 的 URL 不同,浏览器按字节对比检测更新。

2. 优雅降级检查:新 SW 激活后,先发送 postMessage 通知页面应用版本变更。页面在收到消息后展示"发现新版本"提示,而非强制刷新。

3. 紧急回滚:在服务端配置 SW 路由指向旧版本文件,触发浏览器检测到 SW 文件内容变更后重新安装旧版本。

五、总结

前端离线优先架构的工程方案,以 Service Worker 为请求拦截层,以分层缓存策略(Cache-First / Network-First / Stale-While-Revalidate)为数据分发基础,以离线操作队列为写操作暂存机制,构建了一个从展示到交互再到数据同步的完整离线支持框架。

离线优先不是所有应用都需要的架构选择。决策前需要评估收益(可用性提升、加载速度优化)与成本(缓存策略维护、同步冲突处理、SW 更新复杂度)。对于内容消费型应用,仅启用 Shell 层和媒体层缓存即可获得大部分收益;对于表单密集型工具应用,离线操作队列是必要组件。

赞(0)
未经允许不得转载:171主机测评 » 前端离线优先架构:Service Worker 缓存策略与数据同步的工程方案
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址