各位好,我是你们的老朋友。今天咱们不聊那些虚头巴脑的架构图,也不讲什么 SOLID 原则。咱们来聊聊一个在移动端时代几乎等同于“生存技能”的话题——离线优先。
我知道,当你在写代码时,脑海里总是那个完美的场景:Wi-Fi 信号满格,服务器在向你招手,数据像坐火箭一样传输。但现实呢?现实往往是你的用户在地铁里信号忽强忽弱,或者在飞机上想查个单词,结果浏览器弹出一个绿色的“请检查网络连接”。
这时候,如果你写的是“传统的在线优先”应用,用户体验就像是直接被扔进了冰窟窿。而离线优先,就是给用户穿上了一层羽绒服。
今天,咱们就用一场“脱口秀”的风格,把这事儿讲透。我们要讲的是:如何让你的 React 应用像个特工一样,在没有网络的时候依然能优雅地工作,一旦网络恢复,再悄悄地把数据传回服务器。
准备好了吗?咱们开始。
第一章:那个潜伏在你浏览器里的“双面间谍”——Service Worker
首先,咱们得认识一下核心人物。在 React 的世界里,组件渲染在主线程上,JavaScript 运行在主线程上。这就像是一个餐厅,服务员(UI)和厨师(JS逻辑)都在同一个厨房里,忙得不可开交。一旦后厨(服务器)因为网络拥堵或者断电停工,整个餐厅(前端页面)就会陷入死锁。
我们需要一个隔离的线程。这就是 Service Worker (SW)。
SW 是一个运行在浏览器后台的脚本。它不像普通的 JS 那样随着页面刷新而消失。它就像一个住在隔壁、专门管送外卖的“双面间谍”。
1.1 SW 的注册与安装:先发制人
要在 React 中使用 SW,最关键的第一步是在应用的入口文件(通常是 index.js 或 main.jsx)里注册它。这就像是在你出门前,先把家里的钥匙交给那个可靠的老邻居。
// src/index.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js') // 这是一个独立的文件
.then(registration => {
console.log('SW 注册成功: scope 是', registration.scope);
})
.catch(error => {
console.log('SW 注册失败:', error);
});
});
}
注意,SW 的代码必须放在一个独立的文件里(例如 /service-worker.js),React 的 public 目录通常是最佳归宿。
1.2 SW 的生命周期:安装与激活
当你刷新页面时,SW 会被“安装”。这里你可以做一些预缓存的工作,比如把应用用到的 JS、CSS、甚至 SVG 图片提前下载好。这叫“离线时的干粮储备”。
// public/service-worker.js
const CACHE_NAME = 'v1.0.0-my-app';
const ASSETS = [
'/',
'/index.html',
'/styles/main.css',
'/app.js'
];
// 安装事件:先把干粮搬进仓库
self.addEventListener('install', event => {
console.log('SW 正在安装,准备搬砖...');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('SW 打开仓库了,开始存入干粮');
return cache.addAll(ASSETS);
})
);
// 强制激活新版本的 SW
self.skipWaiting();
});
// 激活事件:赶走旧版本
self.addEventListener('activate', event => {
console.log('SW 激活了,开始清理垃圾...');
event.waitUntil(
caches.keys().then(keys => {
// 过滤掉旧版本的缓存
return Promise.all(
keys.map(key => {
if (key !== CACHE_NAME) {
return caches.delete(key);
}
})
);
})
);
});
第二章:当网络消失时,谁在战斗?——缓存策略
SW 最强大的地方在于它能拦截 fetch 请求。想象一下,你的 React 组件正在向服务器要数据,SW 截获了这个请求,问:“兄弟,你要干嘛?”
这时候,你需要决定策略。别担心,别搞得太复杂,咱们只需要掌握两种最常用的策略:Network First 和 Cache First。
2.1 Network First:获取最新鲜的信息
对于文章、新闻、股票这种数据,你希望看到的是最新的。如果离线,你就想看上次缓存的内容。
self.addEventListener('fetch', event => {
// 只处理 GET 请求
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// 如果是 API 请求,使用 Network First 策略
if (url.pathname.startsWith('/api')) {
event.respondWith(
fetch(event.request)
.then(response => {
// 如果网络正常,把响应存入缓存,以便离线使用
const clonedResponse = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, clonedResponse);
});
return response;
})
.catch(() => {
// 网络断了?去缓存里找找看
return caches.match(event.request)
.then(cachedResponse => {
return cachedResponse || new Response('离线且无缓存数据', { status: 503 });
});
})
);
}
// 如果是静态资源(如图片、JS),使用 Cache First
else {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
return cachedResponse || fetch(event.request);
})
);
}
});
这里有个小技巧:fetch 是异步的,而 respondWith 必须在 fetch 完成后返回一个 Response。response.clone() 是必须的,因为响应流只能被消费一次。
第三章:IndexedDB——给数据一个“保险箱”
前面我们说了,SW 可以缓存静态资源,但对于用户的操作数据(比如用户填写的表单、上传的图片),SW 可搞不定。SW 只是个代理,它不负责存结构化数据。
我们需要一个数据库。很多新手会用 localStorage,我劝你打住。localStorage 就像是一个只有 5MB 空间的抽屉,存几个 JSON 都满,更别说存图片或大量文本了。一旦存满,你的应用就挂了。
我们需要 IndexedDB。它是浏览器提供的 NoSQL 数据库,容量无限大(受限于硬盘),支持事务,支持异步操作。
为了不让原生 API 像天书一样难懂,咱们用一个业界流行的库 idb。它把复杂的 Promise 链给封装好了。
// src/db.js
import { openDB } from 'idb';
const DB_NAME = 'MyReactOfflineDB';
const STORE_NAME = 'pending_actions';
const DB_VERSION = 1;
const dbPromise = openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
// 创建一个对象仓库,主键是 id,自增
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
}
},
});
// 保存数据(离线存入本地数据库)
export const saveActionOffline = async (actionData) => {
try {
const db = await dbPromise;
await db.add(STORE_NAME, actionData);
console.log('数据已存入保险箱,网络恢复后会自动发送');
} catch (error) {
console.error('存入数据库失败', error);
}
};
// 获取所有待发送的数据
export const getPendingActions = async () => {
try {
const db = await dbPromise;
return await db.getAll(STORE_NAME);
} catch (error) {
console.error('读取待发送数据失败', error);
}
};
// 删除已发送的数据
export const deleteActionOffline = async (id) => {
try {
const db = await dbPromise;
await db.delete(STORE_NAME, id);
} catch (error) {
console.error('删除数据失败', error);
}
};
第四章:React 的状态同步逻辑——大脑与手脚的配合
好了,现在我们有 SW 这个间谍,有 IndexedDB 这个保险箱。接下来,我们要解决 React 的状态同步问题。这是最难、最烧脑的部分。
核心逻辑是:乐观 UI (Optimistic UI) + 本地队列 + 后台同步。
4.1 乐观 UI:给用户最好的体验
当用户点击“提交订单”时,我们不应该先等网络请求回来。那样太慢了!用户会觉得电脑卡死了。
我们要假设网络一定成功。直接修改 React 的 state,让按钮瞬间变成“提交成功”,然后让 SW 去把请求塞进队列。如果网络真的挂了,再搞回滚。
// src/components/CheckoutForm.js
import React, { useState } from 'react';
import { saveActionOffline } from '../db';
const CheckoutForm = () => {
const [status, setStatus] = useState('idle'); // idle, submitting, success, error
const [order, setOrder] = useState({ items: [], total: 0 });
const handleSubmit = async (e) => {
e.preventDefault();
setStatus('submitting');
// 1. 【乐观更新】直接修改 UI,让用户觉得成功了
setOrder(prev => ({ ...prev, status: 'paid' }));
setStatus('success');
const actionPayload = {
type: 'CREATE_ORDER',
payload: order,
timestamp: Date.now(),
// 模拟网络请求
retryCount: 0
};
try {
// 2. 尝试网络请求
await fetch('https://api.myshop.com/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
});
// 如果这里没抛错,说明成功了,从本地队列里删掉这条数据
// 这里需要一个全局的 ID,实际开发中你会有 ID 生成器
// 这里为了演示,我们假设它总是失败,或者我们故意存进去
console.log('网络请求成功,数据已同步');
} catch (err) {
console.warn('哎呀,网络断了,数据存本地去了', err);
// 3. 【回滚 UI】如果失败了,把状态改回来
setOrder(prev => ({ ...prev, status: 'pending_payment' }));
setStatus('error');
// 4. 【离线存储】把请求丢给 IndexedDB
await saveActionOffline(actionPayload);
}
};
return (
<form onSubmit={handleSubmit}>
<button type="submit" disabled={status === 'submitting'}>
{status === 'submitting' ? '处理中...' : '立即支付'}
</button>
{status === 'success' && <p>支付成功!</p>}
{status === 'error' && <p>支付失败,已保存,网络恢复后重试。</p>}
</form>
);
};
4.2 后台同步:当 SW 意识到“网络来了”
这就是离线优先架构的精髓。当 SW 在后台监听到网络恢复时,它需要从 IndexedDB 里把那些“还没送出去的信”拿出来,一个个塞给服务器。
这需要用到 Service Worker 的 sync 事件。
// public/service-worker.js
self.addEventListener('sync', event => {
if (event.tag === 'sync-pending-orders') {
console.log('后台同步触发!SW 开始干活...');
event.waitUntil(syncPendingOrders());
}
});
async function syncPendingOrders() {
// 1. 从 IndexedDB 获取所有待发送数据
const pendingActions = await getPendingActions();
if (!pendingActions || pendingActions.length === 0) return;
console.log(`还有 ${pendingActions.length} 个订单在排队...`);
// 2. 遍历发送
for (const action of pendingActions) {
try {
// 这里使用你的 API 端点
await fetch('https://api.myshop.com/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.payload)
});
// 发送成功,从 IndexedDB 删掉
await deleteActionOffline(action.id);
console.log(`订单 ${action.id} 发送成功`);
} catch (err) {
console.error(`订单 ${action.id} 发送失败,重试计数: ${action.retryCount}`);
// 可以在这里加入指数退避逻辑,或者把数据放回队列
// 如果重试次数过多,可以写入专门的错误日志表
if (action.retryCount < 5) {
// 更新重试计数
await updateRetryCount(action.id, action.retryCount + 1);
}
}
}
// 3. 通知 React 页面更新 UI(可选)
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({ type: 'SYNC_COMPLETE' });
});
});
}
第五章:React 如何监听 SW 的消息
现在,SW 已经在后台默默地把数据同步回来了。但是,页面的状态还是旧的!用户还在看“支付失败”的提示。
我们需要在 React 组件里监听来自 SW 的 message 事件。
// src/App.js
import React, { useEffect, useState } from 'react';
const App = () => {
const [syncStatus, setSyncStatus] = useState(null);
useEffect(() => {
// 监听 SW 发来的消息
const unsubscribe = navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SYNC_COMPLETE') {
setSyncStatus('数据已同步,页面正在刷新...');
setTimeout(() => {
// 可以在这里刷新页面,或者直接更新 Redux/Zustand store
window.location.reload();
}, 2000);
}
});
return () => unsubscribe();
}, []);
return (
<div>
<h1>我的离线优先应用</h1>
{syncStatus && <p className="success-message">{syncStatus}</p>}
{/* ...其他组件 */}
</div>
);
};
第六章:进阶技巧与“避坑”指南
讲了这么多,你可能觉得这就完了?不,这只是入门。作为资深专家,我得告诉你那些坑,不然你今晚别想睡觉。
6.1 Scope(作用域)错误:为什么我的 SW 注册失败?
这是初学者最头疼的问题。你把 service-worker.js 放在根目录,但是浏览器报错说找不到。
这是因为 Service Worker 的作用域默认是注册它的 HTML 文件的目录。如果你的 HTML 在 /app/index.html,而你在根目录注册 /service-worker.js,浏览器会拒绝。
解决方法:
- 确保注册路径正确。
- 最简单的办法:把你的
public目录结构搞简单点。比如,直接把index.html、service-worker.js、app.js都放在根目录下(Webpack 的 PublicPath 配置得当即可)。
6.2 React Strict Mode 与 SW 的双重渲染
React 18 的 Strict Mode 会故意渲染两次组件以检查副作用。这会导致 Service Worker 注册两次。
如果你在 useEffect 里直接注册 SW,可能会报错。建议将 SW 注册逻辑放在应用的最外层,或者在 index.js 里单独处理,不要让它受 React 生命周期的影响。
6.3 缓存策略的选择:别犯傻
别把所有东西都设为 Cache First。如果你的数据是“实时价格”,你缓存了 10 分钟前的价格,用户付了钱,结果网一恢复,发现价格变了,那你的应用就变成诈骗软件了。
记住规则:
- 用户必须看的东西(HTML, CSS, JS) -> Cache First。
- 用户正在编辑的内容 -> 离线存储,Network First。
- 实时新闻/状态 -> Network First。
6.4 IndexedDB 的复杂性
直接用原生 API 写 IndexedDB,就像是用汇编语言写 JS。你会写出一堆 .then 嵌套地狱。
作为专家,我强烈推荐你使用 Zustand + Immer + idb 的组合。Zustand 负责全局状态管理,Immer 负责简化不可变数据更新,idb 负责数据持久化。
// 使用 idb 的简单示例
const db = await openDB('db', 1, {
upgrade(db) { db.createObjectStore('store'); }
});
// 存
await db.put('store', { name: 'John', age: 30 });
// 取
await db.get('store', 1);
第七章:实战演练——构建一个离线清单应用
为了巩固一下,我们来构建一个非常简化的“离线待办清单”。
核心逻辑:
- 用户添加任务。
- 任务立即显示在 UI 上(乐观 UI)。
- 同时,任务被写入 IndexedDB。
- 页面刷新后,SW 从 IndexedDB 读取数据并渲染(数据持久化)。
Service Worker 代码 (service-worker.js):
const CACHE_NAME = 'v2-todo-app';
// 缓存静态资源
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(['/', '/index.html']))));
});
self.addEventListener('fetch', e => {
// 简单的缓存优先策略
e.respondWith(
caches.match(e.request).then(r => r || fetch(e.request))
);
});
// 监听网络恢复,读取本地数据
self.addEventListener('sync', e => {
if (e.tag === 'sync-todos') {
e.waitUntil(syncTodos());
}
});
React 组件代码:
// src/TodoApp.js
import React, { useEffect, useState } from 'react';
import { openDB } from 'idb';
const TodoApp = () => {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
useEffect(() => {
initDB();
loadTodos();
// 监听 SW 的消息,比如数据同步回来了
navigator.serviceWorker.addEventListener('message', (e) => {
if(e.data.type === 'TODOS_SYNCED') loadTodos();
});
}, []);
const initDB = async () => {
await openDB('todo-db', 1, {
upgrade(db) {
if (!db.objectStoreNames.contains('todos')) {
db.createObjectStore('todos');
}
}
});
};
const loadTodos = async () => {
const db = await openDB('todo-db');
const all = await db.getAll('todos');
setTodos(all);
};
const addTodo = async () => {
if (!input) return;
const newTodo = { id: Date.now(), text: input, done: false };
// 1. 乐观 UI
setTodos(prev => [...prev, newTodo]);
setInput('');
// 2. 持久化到 IndexedDB
const db = await openDB('todo-db');
await db.add('todos', newTodo);
// 3. 尝试网络同步(可选)
try {
await fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo) });
} catch (e) {
console.log('离线模式:数据已保存,等待网络同步');
}
};
return (
<div>
<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={addTodo}>添加</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={async (e) => {
const updatedTodo = { ...todo, done: e.target.checked };
// 更新 UI
setTodos(prev => prev.map(t => t.id === todo.id ? updatedTodo : t));
// 更新数据库
const db = await openDB('todo-db');
await db.put('todos', updatedTodo);
}}
/>
{todo.text}
</li>
))}
</ul>
</div>
);
};
第八章:关于“同步”的误区
很多开发者会问:“我能不能直接用 SW 监听 fetch,然后把响应直接存起来,用户下次刷新页面直接读缓存?”
答案是:别这么做。
fetch 监听是为了缓存网络请求(比如 API 返回的 JSON)。但这不能解决 React 组件需要“重新渲染”的问题。
SW 只是一个搬运工,它不懂 React 的 Virtual DOM。它只懂 Request 和 Response。
React 需要的数据结构必须存储在它自己的“大脑”里(State 或 Redux/Zustand Store)。
正确的流程图是这样的:
- 用户操作 -> React State 变化 -> UI 更新。
- React State 变化 -> 写入 IndexedDB(持久化)。
- 网络恢复 -> SW 监听到 Sync 事件 -> SW 从 IndexedDB 读数据 -> 发送给服务器 -> 服务器回传成功 -> SW 通知 React -> React 读取 IndexedDB -> 更新 State -> UI 更新。
这整个链条,必须清晰、严谨。
第九章:总结(收个尾)
讲了这么多,离线优先到底有什么好?
- 用户体验爆炸提升:你的应用不再是一个“单机游戏”,而是一个有生命力的程序。
- 节省流量:如果用户有缓存,他连网都不用开,直接就能用,不用下载几百 KB 的 JS 和 CSS。
- 容错能力:即使你的后端数据库挂了,只要 SW 缓存了接口,用户还能看,还能用。
当然,这并不意味着你可以完全放弃后端验证。离线优先只是前端策略。你依然需要做后端的幂等性设计。比如用户离线点了两次“提交”,网络恢复后,两次请求都发过去了,后端必须能处理这种情况(比如“只处理最新的订单”)。
最后,别忘了那行最简单的代码:
navigator.serviceWorker.register('/service-worker.js');
写完它,你的 React 应用就拥有了一个忠诚的守护者。它会默默地为你挡住网络的风雨,等待雨过天晴的那一刻,然后悄悄地把你的数据送达彼岸。
技术不仅仅是代码,更是对用户关怀的体现。当你的用户在高铁上兴奋地用离线模式完成了一笔交易时,那种成就感,比任何技术文章的阅读量都要高。
好了,今天的讲座就到这里。记得,别让你的代码“断网”。祝大家都能写出健壮、离线友好的 React 应用!