各位好,各位前端工程师。
今天我们要聊一个有点“硬核”,但绝对能让你的应用在用户心中封神的话题:如何用 React 和 IndexedDB 打造一个“离线优先”的本地存储同步链路。
先别急着翻白眼,我知道 IndexedDB 听起来就像是那种“只有 90 年代的老古董才会用的东西”。但听我说完。如果你的应用需要联网才能用,那你就是在玩火。一旦断网,你的应用就变成了一个只能展示静态 HTML 的空壳,用户体验直接从“丝滑”变成“便秘”。
而 IndexedDB,就是那个能让你在断网时依然能像拥有超级计算机一样工作的“秘密武器”。
第一部分:IndexedDB,那个被误解的“巨兽”
首先,我们要给 IndexedDB 正个名。它不是 localStorage,也不是 sessionStorage。
- localStorage:就像你背包里的一个小隔层,大概 5MB。存点 JSON 字符串还行,存图片?存大文件?别做梦了,存多了浏览器会直接给你报错,甚至把你的数据干掉。
- IndexedDB:这是一个真正的数据库。它是基于对象的,支持事务,支持索引,支持海量数据。你可以把它想象成浏览器自带的一个微型 PostgreSQL。它不仅存数据,还给你提供查询能力。虽然它长得丑(API 设计确实有点反人类),但它的能力是毋庸置疑的。
为什么我们需要它?
在 React 中,数据通常是单向流动的。数据从 API 来,存进 State,然后渲染。这很完美,除非……网络断了。
离线优先的核心思想是:先让用户看到东西,再想办法同步。
想象一下,你在写一份长文档。如果每次保存都要联网请求服务器,你的光标就会卡顿,体验极差。离线优先意味着:你敲下的每一个字,都立刻写进浏览器的 IndexedDB,同时尝试发送给服务器。如果服务器挂了,没关系,你的数据还在本地硬盘里,等网好了再发。
第二部分:封装 IndexedDB —— 别直接裸奔
直接在组件里写 window.indexedDB.open?那你会写出像意大利面条一样乱七八糟的代码。React 的哲学是“组件化”,数据库操作也应该封装起来。
我们要创建一个 db.js 文件。这个文件将充当我们应用的“数据库管理员”。
代码示例:构建一个简单的 DB 类
// src/db.js
class IndexedDBManager {
constructor(dbName, version, storeName) {
this.dbName = dbName;
this.version = version;
this.storeName = storeName;
this.db = null;
}
// 初始化数据库,如果不存在就创建
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 创建一个对象仓库,类似于 SQL 的表
if (!db.objectStoreNames.contains(this.storeName)) {
// 指定主键为 id,自增
db.createObjectStore(this.storeName, { keyPath: 'id', autoIncrement: true });
}
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onerror = (event) => {
reject('打开数据库失败: ' + event.target.errorCode);
};
});
}
// 增
async add(data) {
return this._transaction('readwrite', store => store.add(data));
}
// 改
async put(data) {
return this._transaction('readwrite', store => store.put(data));
}
// 删
async delete(id) {
return this._transaction('readwrite', store => store.delete(id));
}
// 查
async getAll() {
return this._transaction('readonly', store => store.getAll());
}
// 私有方法:执行事务
_transaction(mode, callback) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], mode);
const store = transaction.objectStore(this.storeName);
const request = callback(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
export default new IndexedDBManager('OfflineFirstDB', 1, 'tasks');
看,这就是专业。我们封装了增删改查。现在,你的组件只需要导入这个实例,就可以像操作本地变量一样操作数据库了。
第三部分:React Hooks 与 状态管理 —— 数据的“心脏”
现在我们有了数据库,接下来我们要把它塞进 React 的 State 里。但是,IndexedDB 是异步的。React 的 State 更新是同步的。这两者怎么结合?
我们需要一个自定义 Hook。这个 Hook 需要处理两个状态:localData(本地数据)和 syncQueue(同步队列)。
代码示例:useOfflineDB Hook
这个 Hook 会负责:
- 初始化时从 IndexedDB 读取数据。
- 当数据变化时,先写本地,再尝试同步。
- 监听网络状态。
// src/hooks/useOfflineDB.js
import { useState, useEffect, useCallback } from 'react';
import db from '../db';
const useOfflineDB = (endpoint) => {
const [localData, setLocalData] = useState([]);
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [isSyncing, setIsSyncing] = useState(false);
// 1. 初始化加载数据
useEffect(() => {
const loadData = async () => {
try {
const data = await db.getAll();
setLocalData(data);
} catch (error) {
console.error('加载本地数据失败', error);
}
};
loadData();
}, []);
// 2. 监听网络变化
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);
};
}, []);
// 3. 同步逻辑:将本地数据发送到服务器
const syncData = useCallback(async () => {
if (!isOnline || isSyncing) return;
setIsSyncing(true);
try {
// 这里假设你的 API 支持 POST /sync
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(localData),
});
if (!response.ok) throw new Error('Sync failed');
// 同步成功,这里可以做一个清空本地队列的操作(如果有增量同步逻辑)
console.log('数据同步成功!');
} catch (error) {
console.error('同步失败,数据留在本地队列中', error);
} finally {
setIsSyncing(false);
}
}, [isOnline, isSyncing, localData, endpoint]);
// 4. 乐观更新:用户操作 -> 更新本地 -> 更新 UI -> 发送同步请求
const handleAddItem = useCallback(async (item) => {
// A. 立即更新 UI (乐观更新)
setLocalData(prev => [...prev, item]);
// B. 写入 IndexedDB
try {
await db.add(item);
} catch (error) {
console.error('本地写入失败', error);
// 回滚 UI
setLocalData(prev => prev.filter(i => i.id !== item.id));
}
// C. 尝试同步
syncData();
}, [syncData]);
return { localData, handleAddItem, isOnline, isSyncing };
};
export default useOfflineDB;
这就是离线优先的灵魂!
注意 handleAddItem 这一步。我们并没有傻等网络请求回来再更新 UI。我们直接把数据塞进了 useState,然后才去写数据库,最后才去同步。这叫“乐观更新”。用户感觉不到延迟,你的应用瞬间就响应了。
第四部分:实战演练 —— 打造一个“永不丢失”的待办清单
光说不练假把式。我们来做一个真实的场景:一个待办事项应用。
1. 组件结构
我们将创建三个文件:
TodoApp.jsx:主界面。db.js:数据库封装(复用上面的)。syncService.js:专门的同步服务。
2. 组件实现
// src/TodoApp.jsx
import React, { useState, useEffect } from 'react';
import db from './db';
import useOfflineDB from './hooks/useOfflineDB';
const TodoApp = () => {
const [inputValue, setInputValue] = useState('');
const { localData, handleAddItem, isOnline, isSyncing } = useOfflineDB('https://api.myapp.com/sync');
const handleSubmit = async (e) => {
e.preventDefault();
if (!inputValue.trim()) return;
const newItem = {
text: inputValue,
createdAt: Date.now(),
synced: false, // 标记是否已同步
};
await handleAddItem(newItem);
setInputValue('');
};
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif', maxWidth: '600px', margin: '0 auto' }}>
<h1>离线待办清单</h1>
<div style={{ marginBottom: '20px', padding: '10px', background: isOnline ? '#e6fffa' : '#fff5f5', border: `1px solid ${isOnline ? '#38b2ac' : '#fc8181'}` }}>
状态: {isOnline ? '🟢 在线' : '🔴 离线'}
{isSyncing && <span style={{ marginLeft: '10px', color: '#805ad5' }}>🔄 同步中...</span>}
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '10px' }}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="输入任务..."
style={{ flex: 1, padding: '10px' }}
/>
<button type="submit" disabled={!isOnline || isSyncing}>
{isSyncing ? '同步中' : '添加'}
</button>
</form>
<ul style={{ listStyle: 'none', padding: 0, marginTop: '20px' }}>
{localData.map(item => (
<li key={item.id} style={{
padding: '10px',
borderBottom: '1px solid #eee',
display: 'flex',
justifyContent: 'space-between',
opacity: item.synced ? 1 : 0.7
}}>
<span>{item.text}</span>
{!item.synced && <span style={{ fontSize: '12px', color: '#718096' }}>待同步</span>}
</li>
))}
</ul>
</div>
);
};
export default TodoApp;
3. 深入同步服务
上面的 syncData 简单地把所有数据发一遍。但在真实场景中,我们需要更智能的同步策略。
比如,用户添加了 100 条数据,服务器只允许一次发 20 条。或者,我们需要处理冲突(服务器上有这条数据,本地也有,哪个时间戳新?)。
我们写一个 SyncService。
// src/services/syncService.js
// 这是一个高级一点的同步逻辑示例
class SyncService {
constructor(dbInstance, apiEndpoint) {
this.db = dbInstance;
this.endpoint = apiEndpoint;
this.queue = []; // 待同步队列
}
// 处理新增/更新
async queueItem(item) {
// 1. 先写本地
await this.db.put(item);
// 2. 加入队列
this.queue.push(item);
// 3. 触发检查
this.checkSync();
}
// 批量同步
async checkSync() {
if (this.queue.length === 0) return;
// 模拟批量处理:每次取 10 条
const batchSize = 10;
const batch = this.queue.splice(0, batchSize);
try {
const response = await fetch(this.endpoint, {
method: 'POST',
body: JSON.stringify(batch),
});
if (response.ok) {
// 同步成功,更新本地数据的 synced 状态
// 注意:这里需要遍历 batch,找到对应的 id 并标记为 synced
// 这是一个简化版,实际需要复杂的映射逻辑
console.log(`成功同步 ${batch.length} 条数据`);
} else {
// 如果失败,把数据放回队列头部,下次再试
this.queue.unshift(...batch);
throw new Error('Sync failed');
}
} catch (error) {
console.error('同步失败,重试中...', error);
// 也可以在这里加个重试计数器,超过次数就报警
}
}
}
export default SyncService;
第五部分:IndexedDB 的坑与陷阱 —— 警惕那些“看不见的杀手”
虽然 IndexedDB 很强大,但如果你不注意,它也会变成一个巨大的 Bug 来源。
1. 事务的生命周期
IndexedDB 的核心是“事务”。你打开一个事务,做读写操作,然后事务结束。
错误示范:
// 这里的 request 不会执行!
const request = db.add(data);
// 你离开了函数作用域,事务可能已经结束了,或者还没开始,导致请求被取消。
正确示范:
一定要在 transaction.onsuccess 或 transaction.onerror 里处理结果。或者使用 async/await 配合我们封装的 _transaction 方法。
2. 数据类型陷阱
IndexedDB 只支持几种基本的数据类型:string, number, boolean, Date, Array, Object。
如果你存了 undefined,它会变成 undefined。
如果你存了 function,它会直接报错。
如果你存了 Map 或 Set,IndexedDB 会把它们序列化成 JSON 字符串。读取回来时,你需要手动 JSON.parse 或者使用 IDB-Key-Path 库。
3. 版本控制
一旦你改变了数据库结构(比如增加了一个字段 storeName),你必须增加 version 号。如果你用旧版本号去打开一个已经存在的数据库,IndexedDB 会报错,因为旧代码不知道怎么处理新结构。
4. 事件循环
IndexedDB 的操作是在事件循环中进行的。如果你在 useEffect 里调用 db.add(),React 可能会认为组件已经渲染完成了,然后开始卸载。如果这时候数据库还没写完,你的数据就丢了。
解决方案:
确保你的异步操作在组件卸载前完成,或者使用 AbortController(虽然 IndexedDB 原生支持不太好,但你可以通过标记位来停止后续逻辑)。
第六部分:进阶技巧 —— 乐观 UI 与 冲突解决
在 React 中,离线优先不仅仅是“存数据”,更是“骗用户”。
1. 乐观 UI 的极致体验
当用户点击“保存”时,如果网络很慢,他点击了 10 次按钮,那体验就崩了。
我们需要防抖。
import { debounce } from 'lodash';
// 在 useOfflineDB 里
const debouncedSync = debounce(syncData, 1000);
// 修改 handleAddItem
const handleAddItem = async (item) => {
// 1. UI 更新
setLocalData(prev => [...prev, item]);
await db.add(item);
// 2. 防抖同步
debouncedSync();
};
2. 冲突解决
这是最难的部分。用户 A 在家里添加了“买牛奶”,然后网络断开了。用户 B 在公司也添加了“买牛奶”,并同步到了服务器。现在网络连上了。
服务器数据库里现在是两条“买牛奶”的记录。
你的本地数据库也有“买牛奶”。
现在怎么搞?
策略:
- 最后写入胜出:如果服务器有记录,且时间戳比本地新,就用服务器的。
- 客户端优先:如果用户离线很久,他的数据肯定比服务器新,用本地的。
- 合并:如果是列表,服务器有 {1, 2, 3},本地有 {2, 3, 4},合并成 {1, 2, 3, 4}。
这通常需要后端配合(比如给每条数据加一个 updatedAt 时间戳),但前端要做好心理准备。
第七部分:性能优化 —— 别把浏览器搞崩了
IndexedDB 虽然强,但它是基于文件系统的。频繁的打开、关闭事务会非常慢。
1. 批量写入
不要一条条地 db.add()。把 100 个项目放在一个数组里,开启一个事务,一次性 store.add(item) 100 次。虽然还是 100 次调用,但只涉及一次磁盘 IO 事务。
2. 索引
如果你的数据很多,并且经常根据某个字段查询(比如按日期查,或者按用户 ID 查),一定要建索引。
db.createObjectStore('tasks', {
keyPath: 'id',
autoIncrement: true
});
// 在 onupgradeneeded 里
const store = db.createObjectStore('tasks', { keyPath: 'id' });
store.createIndex('createdAt', 'createdAt', { unique: false });
这样查询速度会从 O(N) 变成 O(log N)。
第八部分:总结与展望
好了,各位听众,我们今天把 IndexedDB 和 React 结合的方方面面都聊了一遍。
从最初对浏览器原生数据库的恐惧,到封装出一个健壮的 DB 类,再到通过 Hooks 实现离线优先的 UI 响应,我们构建了一个完整的闭环。
核心要点回顾:
- 不要直接用:原生 API 太繁琐,封装它。
- 异步是常态:IndexedDB 是异步的,React State 是同步的,用
useEffect和async/await桥接它们。 - 乐观更新:让用户感觉不到网络延迟,这是离线应用体验好坏的分水岭。
- 队列与重试:网络是不可靠的,你的代码必须要有“重试”机制和“队列”机制。
最后一点建议:
离线优先不仅仅是技术选型,更是一种产品思维。它意味着你的应用在任何时候都是有价值的,哪怕是在深山老林里,或者是在飞机上。
当你看着你的应用在断网状态下依然流畅运行,看着数据在后台默默同步,那种成就感,比发一个 5 分的 PR 要爽得多。
好了,今天的讲座就到这里。去写代码吧,让用户彻底离不开你的应用!