各位好,我是你们的老朋友,一个头发日渐稀疏但代码日益精进的 React 资深专家。
今天我们要聊的话题,听起来有点“高大上”,实际上却关乎我们每一个前端工程师的“尊严”和用户体验的“生死存亡”。没错,就是 PWA(Progressive Web App,渐进式 Web 应用)。
为什么我们要聊这个?因为现在的网络环境,就像一个喜怒无常的渣男/渣女。上一秒还在给你发微信,下一秒可能就因为信号不好、基站故障或者你出门进了地下室而直接“失联”。如果你的 React 应用在离线时直接崩溃,或者显示一串令人尴尬的“404 Not Found”,那用户体验简直比没有 PWA 还要糟糕。
所以,今天这场讲座,我们不讲那些花里胡哨的 Hooks,也不讲 Redux 的状态管理精髓。我们要讲的是如何利用 Service Worker (SW) 这个幕后黑手,配合 React 状态管理,打造一套坚不可摧的离线存储策略。
准备好了吗?让我们开始这场关于“离线生存”的实战演练。
第一部分:Service Worker —— 浏览器的“幽灵特工”
首先,我们要搞清楚 Service Worker 是个什么东西。很多新手一听这个名字,觉得它是某种浏览器内置的“服务机器人”。错!大错特错!
Service Worker 本质上就是一个脚本。但它不是一个普通的脚本。它不像你写的 React 组件那样,跟着 React 的生命周期走,也不依赖浏览器的渲染线程。它运行在浏览器的一个独立线程里,甚至可以说,它是一个运行在浏览器里的“幽灵特工”。
它的核心职责只有两个:
- 拦截网络请求:当你的 React 应用想去请求图片、API 数据或者 HTML 文件时,SW 会先过问一声:“嘿,这事儿我能办吗?”
- 缓存资源:如果 SW 觉得这事儿它能办,它就会从自己的“仓库”(Cache Storage)里把东西拿出来给你,根本不需要真的去问网络。
关键点来了: SW 和 React 是完全隔离的。React 在页面里,SW 在后台。它们之间不能直接共享变量。如果 React 想知道 SW 缓存了什么,或者 SW 想通知 React“我抓取失败了”,它们得靠一种叫做 MessageChannel 或者 BroadcastChannel 的通信机制来“传纸条”。
好,概念清楚了,我们来看看怎么把这个“特工”请进我们的项目。
1. 注册 Service Worker
在 React 应用中,你通常在 public/index.html 或者入口文件 main.jsx 里注册它。
// main.jsx (或者 index.js)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW 注册成功:', registration);
// 注册成功后,我们还要监听一下 SW 的状态变化
registration.onupdatefound = () => {
const installingWorker = registration.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 这是一个新版本的 SW 已经安装,但旧版本还在运行
// 此时用户刷新页面后,旧 SW 会“下岗”,新 SW 上岗
console.log('新 SW 已安装,准备更新');
}
};
};
})
.catch(error => {
console.error('SW 注册失败:', error);
});
});
}
2. 编写 Service Worker 代码
这是我们最头疼的部分。SW 的代码不能写 JSX,不能直接用 import(除非用模块化 SW),而且它的生命周期跟 React 完全不一样。
// sw.js
const CACHE_NAME = 'v1.0.0-my-app'; // 版本号很重要,不然你改了代码用户还是用旧缓存
// 安装事件:这是 SW 的“入职仪式”
self.addEventListener('install', (event) => {
console.log('[Service Worker] Installing...');
// event.waitUntil 告诉浏览器:“别急着让我走,我要把东西都缓存好”
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('[Service Worker] Caching app shell and assets');
// 这里我们把 HTML、JS、CSS 都缓存起来
return cache.addAll([
'/',
'/index.html',
'/main.js',
'/styles.css',
'/offline.html', // 我们甚至可以缓存一个专门的离线页面
]);
})
);
// self.skipWaiting() 告诉浏览器:“别管什么等待了,我直接激活!”
// 这通常是为了强制更新,但在生产环境中要小心,可能会导致用户看到旧页面闪烁
self.skipWaiting();
});
// 激活事件:SW 上岗后,清理旧缓存
self.addEventListener('activate', (event) => {
console.log('[Service Worker] Activating...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log('[Service Worker] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
// 确保新 SW 立即接管所有页面
return self.clients.claim();
});
// 拦截请求事件:这是 SW 的“核心业务”
self.addEventListener('fetch', (event) => {
console.log(`[Service Worker] Fetching: ${event.request.url}`);
// 策略 1:对于静态资源(HTML, JS, CSS),使用 CacheFirst(优先读缓存)
// 策略 2:对于 API 请求,使用 NetworkFirst(优先去网络,网络失败再读缓存)
// 策略 3:对于图片,可以使用 StaleWhileRevalidate(先读缓存,后台去更新)
if (event.request.mode === 'navigate') {
// 如果是导航请求(比如用户点击链接),我们尝试从缓存读,读不到就去网络
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
// 如果有缓存,直接返回
if (cachedResponse) {
return cachedResponse;
}
// 没有缓存,去网络抓取
return fetch(event.request).then((networkResponse) => {
// 把抓到的数据存入缓存(可选,看你愿不愿意牺牲一点带宽换速度)
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return networkResponse;
}).catch(() => {
// 网络也挂了,返回离线页面
return caches.match('/offline.html');
});
})
);
} else {
// 对于图片等其他资源,使用 CacheFirst
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request);
})
);
}
});
第二部分:React 状态与 Service Worker 的“异地恋”
好了,SW 已经在后台跑起来了,资源也缓存了。但是,React 应用怎么知道 SW 在干什么呢?
这就好比你的女朋友(React)在客厅看电视,你(SW)在地下室干活。如果她想知道你在干嘛,你不能直接钻进客厅跟她抢电视看,你得喊一声。这个“喊一声”的动作,就是 postMessage。
我们需要在 React 组件中监听 SW 的消息,并根据消息更新状态。同时,我们也需要监听浏览器的 offline 和 online 事件,因为这直接关系到 React 的状态。
1. 创建一个自定义 Hook:useNetworkStatus
这是最基础也最常用的功能。我们要让 React 组件知道现在是连着网还是断网了。
import { useState, useEffect } from 'react';
const useNetworkStatus = () => {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [isCached, setIsCached] = useState(false); // 是否有缓存
const updateOnlineStatus = () => {
setIsOnline(navigator.onLine);
};
useEffect(() => {
// 监听浏览器原生事件
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
return () => {
window.removeEventListener('online', updateOnlineStatus);
window.removeEventListener('offline', updateOnlineStatus);
};
}, []);
// 这里我们还要监听 SW 的状态
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then((registration) => {
// 监听 SW 的消息
registration.active?.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SW_STATUS') {
setIsCached(event.data.isCached);
}
});
});
}
}, []);
return { isOnline, isCached };
};
export default useNetworkStatus;
2. 在组件中使用
import React from 'react';
import useNetworkStatus from './useNetworkStatus';
const NetworkIndicator = () => {
const { isOnline, isCached } = useNetworkStatus();
if (!isOnline) {
return (
<div style={{ background: '#ffcccc', padding: '10px', color: 'red' }}>
⚠️ 您已离线,正在使用缓存内容。
</div>
);
}
return (
<div style={{ background: '#ccffcc', padding: '10px', color: 'green' }}>
🟢 网络连接正常。
</div>
);
};
第三部分:深度策略 —— 缓存与 React 状态的完美同步
光知道离线了还不行。真正的挑战在于:当用户离线时,React 应用应该如何优雅地回退到本地数据?
假设我们有一个“文章列表”页面。用户离线了,我们不能再发请求了。这时候,我们的 React 状态里应该有什么?如果之前已经加载过一次,那 React 状态里应该还有数据。如果没有,那我们得从 SW 的缓存里捞数据出来。
策略设计:Cache-First with React State Hydration
- 首次加载(在线):
- React 发起 API 请求。
- SW 拦截请求,去网络获取数据,存入 Cache,返回数据给 React。
- React 更新 State。
- 再次加载(离线):
- React 发起 API 请求。
- SW 拦截请求,发现网络断了(或者请求被取消)。
- SW 从 Cache 读取数据。
- 关键步骤:SW 通过
postMessage告诉 React:“嘿,我刚才没连上网,但我从硬盘里给你找了点数据。” - React 收到消息,更新 State,显示“已加载本地缓存”。
- 更新数据(离线):
- 用户点击“保存”按钮。
- React 尝试 POST 到 API。SW 拦截,发现网络断了。
- SW 将数据存入 IndexedDB(比 Cache Storage 更适合存大量结构化数据)。
- React 显示“保存成功”(虽然没真正发出去,但给了用户反馈)。
- 当用户恢复网络时,SW 自动发送这些本地数据到服务器。
实战代码:带离线回退的 API 调用 Hook
让我们写一个高级点的 Hook,它不仅能发请求,还能处理离线逻辑。
import { useState, useEffect, useCallback } from 'react';
const useOfflineData = (fetchUrl, options = {}) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isOffline, setIsOffline] = useState(false);
const [isCached, setIsCached] = useState(false);
// 1. 监听网络状态
useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
// 2. 从 SW 或 IndexedDB 获取缓存数据的函数
const getCachedData = async () => {
try {
// 假设我们有一个专门的缓存读取函数
const cached = await window.caches.open('api-cache').then(cache =>
cache.match(fetchUrl)
);
if (cached) {
return await cached.json();
}
} catch (err) {
console.error('读取缓存失败', err);
}
return null;
};
// 3. 核心请求逻辑
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
setIsCached(false);
try {
// 尝试发请求
const response = await fetch(fetchUrl, options);
if (!response.ok) throw new Error('网络请求失败');
const result = await response.json();
setData(result);
// 请求成功,把数据存进缓存(SW 会自动处理)
// 这里我们也可以手动存,或者让 SW 监听这个 fetch 并自动缓存
} catch (err) {
// 请求失败,可能是断网,也可能是 500 错误
console.log('网络请求失败,尝试读取缓存...', err);
setIsOffline(true);
// 尝试从缓存读取
const cachedData = await getCachedData();
if (cachedData) {
setData(cachedData);
setIsCached(true);
// 通知 SW 我们成功读取了缓存,SW 可以做点别的(比如更新 UI 提示)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.controller.postMessage({
type: 'CACHE_HIT',
url: fetchUrl
});
}
} else {
setError('无网络连接且无缓存数据');
}
} finally {
setLoading(false);
}
}, [fetchUrl, options]);
// 4. 初始化时请求一次
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, isOffline, isCached, refetch: fetchData };
};
SW 侧的配合:拦截 API 并处理离线写入
上面的 Hook 只是告诉 React “我有缓存”。但真正的“离线存储”核心在 SW。
我们需要在 SW 里处理 API 请求的失败回退。
// sw.js (再次回顾,但更聚焦 API)
self.addEventListener('fetch', (event) => {
// 只处理 API 请求
if (event.request.url.includes('/api/')) {
event.respondWith(
(async () => {
const cache = await caches.open('api-cache');
// 策略:NetworkFirst (网络优先)
// 先去网络抓取
const networkResponse = await fetch(event.request);
// 无论成功失败,都更新缓存(这是一个比较激进的策略,适合需要最新数据的场景)
// 如果是 POST/PUT/DELETE,我们不缓存响应体,只缓存成功状态
if (event.request.method !== 'GET') {
return networkResponse;
}
const responseToCache = networkResponse.clone();
await cache.put(event.request, responseToCache);
return networkResponse;
})().catch(async () => {
// 网络失败了!
console.log('[SW] Network failed, serving from cache');
// 尝试从缓存读取
const cachedResponse = await caches.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
// 如果连缓存都没有,返回一个 503 Service Unavailable
return new Response(JSON.stringify({ error: 'Offline and no cache' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
})
);
}
});
// 处理离线提交 (POST 请求)
self.addEventListener('fetch', (event) => {
if (event.request.method === 'POST' && event.request.url.includes('/api/submit')) {
event.respondWith(
fetch(event.request).catch(async () => {
console.log('[SW] Network down, saving to IndexedDB');
// 离线提交:把数据存到 IndexedDB,而不是缓存
const body = await event.request.json();
const db = await openDatabase(); // 假设你有一个打开 IndexedDB 的函数
await db.put('offline-queue', body);
// 给用户一个假的成功响应
return new Response(JSON.stringify({ success: true, queued: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
})
);
}
});
// 当网络恢复时,从 IndexedDB 取出数据批量发送给服务器
window.addEventListener('online', async () => {
const db = await openDatabase();
const queuedItems = await db.getAll('offline-queue');
if (queuedItems.length > 0) {
console.log(`[SW] Network recovered, syncing ${queuedItems.length} items...`);
// 批量发送
for (const item of queuedItems) {
try {
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(item),
headers: { 'Content-Type': 'application/json' }
});
await db.delete('offline-queue', item.id);
} catch (e) {
console.error('Sync failed, item stays in queue', e);
}
}
}
});
第四部分:离线 UI 的艺术 —— 情感化设计
技术实现完了,最后一步也是最重要的一步:UI 表现。
如果你的离线页面是一个白屏,上面写着“Error 404”,那用户会觉得这个应用很烂。我们需要用 React 的状态来控制 UI 的渲染,给它加点“人情味”。
场景:一个通用的离线组件
我们可以写一个高阶组件(HOC)或者一个 Provider,包裹整个 App。
import React, { useEffect, useState } from 'react';
const OfflineProvider = ({ children }) => {
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);
};
}, []);
// 如果离线,且没有特定页面(比如我们在一个离线编辑器里),显示全屏遮罩
if (!isOnline) {
return (
<div style={styles.offlineOverlay}>
<div style={styles.offlineCard}>
<h1>🚫 网络已断开</h1>
<p>你正在使用离线模式。你的更改已自动保存到本地。</p>
<p>当网络恢复时,我们会自动同步。</p>
<button onClick={() => window.location.reload()}>刷新页面</button>
</div>
</div>
);
}
return <>{children}</>;
};
const styles = {
offlineOverlay: {
position: 'fixed',
top: 0, left: 0, width: '100%', height: '100%',
background: 'rgba(0,0,0,0.8)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
},
offlineCard: {
background: '#fff',
padding: '40px',
borderRadius: '12px',
textAlign: 'center',
maxWidth: '400px',
}
};
export default OfflineProvider;
场景:数据加载的 Loading 状态
有时候,用户离线了,但数据还在缓存里。这时候应该显示“加载中…”,而不是“无数据”。
const DataLoader = ({ fetchUrl }) => {
const { data, loading, isCached } = useOfflineData(fetchUrl);
if (loading) {
return <div className="spinner">Loading... {isCached && '(从缓存加载)'}</div>;
}
if (!data) return <div>No data available.</div>;
return <div>{/* 渲染数据 */}</div>;
};
第五部分:高级话题 —— 版本控制与更新策略
这是 PWA 开发中最容易“踩坑”的地方。我们写好了 SW,上线了,用户也更新了。但是,用户打开应用时,发现页面还是旧的。为什么?
1. Cache 版本管理
还记得我们在 sw.js 里写的 CACHE_NAME = 'v1.0.0-my-app' 吗?这就是关键。
- Install 事件:SW 下载新的缓存(比如 v1.1.0)。
- Activate 事件:SW 清理旧的缓存(v1.0.0)。
- 问题:即使 SW 已经激活,React 页面还在运行。如果 React 页面请求资源,它可能还在用旧的缓存(因为 SW 的
fetch逻辑可能还没生效,或者 React 的fetch逻辑没变)。
解决方案:强制更新策略。在 activate 事件中,我们调用了 clients.claim()。这会立即让 SW 接管所有打开的页面。但是,为了让页面重新加载(以触发 SW 的 fetch),我们需要强制刷新。
// sw.js
self.addEventListener('activate', (event) => {
// ... 清理缓存代码 ...
event.waitUntil(
self.clients.claim() // 立即接管
);
});
但是,这还不够。为了确保用户总是用最新版本,我们通常会在 React 应用里加一个“检查更新”的逻辑。
2. React 中的强制更新逻辑
当检测到 SW 有新版本时,我们可以弹出一个 Toast 提示:“有新版本可用,正在刷新…”,然后 window.location.reload()。
// 在你的 App.js 或者某个监听器里
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('Controller changed, reloading...');
window.location.reload();
});
}
}, []);
3. 防止 SW 更新过快
有些时候,你改了一个 CSS 文件,SW 就想更新。如果用户正在看那个页面,频繁刷新会让他疯掉。
我们可以使用 skipWaiting 的条件。只有在 CACHE_NAME 变化时才强制更新,或者设置一个冷却期。
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([...]);
})
);
// 只有在强制更新模式下才跳过等待
// self.skipWaiting();
});
第六部分:总结与避坑指南
好了,各位同学,我们已经把 Service Worker、React 状态管理和离线存储策略聊了个遍。现在,让我们像喝完最后一口咖啡一样,总结一下我们学到的“干货”,顺便聊聊那些让人头秃的坑。
核心要点回顾
- SW 是独立的:不要指望在 SW 里直接
import React或者访问window(除非你在模块化 SW 中)。它们是两个世界的人。 - 通信靠传纸条:React 和 SW 之间没有直接的数据绑定。必须通过
postMessage和addEventListener来同步状态。 - 策略决定体验:
- 静态资源(JS/CSS):用 CacheFirst,速度快。
- API 数据:用 NetworkFirst,数据新。
- 图片/字体:用 StaleWhileRevalidate,既快又准。
- 离线不仅仅是加载页面:离线意味着“本地优先”。你的应用应该能够读取本地存储的数据,甚至允许用户在离线时操作,等连网了再同步。
常见“坑爹”问题
- Scope 范围错误:SW 默认只能管理注册它所在目录及其子目录的资源。如果你在
src目录下注册 SW,它可能抓不到根目录的图片。解决方法:确保 SW 文件放在public根目录,并且scope设置正确。 - HTTPS 问题:Service Worker 只能在 HTTPS(或 localhost)环境下运行。这是浏览器的安全机制,为了防止中间人攻击。解决方法:部署时确保使用 HTTPS。
- Scope 限制导致 SW 不工作:如果你把 SW 放在
src/sw.js,而你的 HTML 在index.html,SW 可能会拒绝抓取 HTML。解决方法:把 SW 放在public/sw.js。 - IndexedDB 兼容性:IndexedDB 是异步的,而且 API 比较繁琐(需要版本管理)。如果你不想自己造轮子,可以使用像
idb或Dexie.js这样的库,它们能让你像操作本地数据库一样操作 IndexedDB。 - React 严格模式:React 18 的严格模式会在开发环境下双重渲染组件。这可能会导致 SW 被注册两次,或者
useEffect执行两次。解决方法:在生产构建中,这个问题会消失。
终极建议
PWA 的离线策略不仅仅是技术实现,更是一种思维转变。你不再是单纯地“请求-响应”,而是在构建一个“有记忆”的应用。
当你写下一个 useOfflineData Hook 时,你实际上是在告诉用户:“放心吧,就算你手机没电了,或者飞机要起飞了,我也能让你看个痛快。”
最后,我想说,前端开发是一场修行。Service Worker 是一道坎,跨过去,你的技术栈就又深了一层。离线存储不仅仅是存个文件,它是你与用户之间的一份契约——“无论网络如何,我都在这里。”
好了,今天的讲座就到这里。下课!记得把你的 sw.js 文件保存好,别丢了。