各位好!我是你们的资深“代码架构师”兼“化工现场数据救火队员”。
今天我们不聊那些花里胡哨的框架选型,也不谈什么DDD(领域驱动设计)的宏大叙事,我们要聊点硬核的、带点机油味和汗味的——如何在无信号、无网络、像“信号屏蔽室”一样的精细化工现场,搞定数据的离线采集与实时同步。
想象一下这个场景:你正站在一个几百度的反应釜旁边,手里拿着防爆平板,准备录入当班的温度和压力数据。突然,一阵妖风吹过,或者旁边的大功率设备一启动,Wi-Fi 信号瞬间归零。这时候你的 React 应用如果弹出一个“网络错误,请检查您的网线”的弹窗,那你离被现场的大哥扔进反应釜里去“中和一下”也不远了。
所以,今天我们的核心课题就是:React + IndexedDB + 后端同步协议 = 化工现场的“数字堡垒”。
让我们开始吧,别眨眼。
第一部分:为什么 IndexedDB 是你的救命稻草
在深入代码之前,先得聊聊存储。很多新手(甚至有些老手)一提到离线存储,脑子里蹦出来的就是 localStorage。拜托,那是给存个“记住我”或者“用户偏好设置”用的。如果你想在本地存 10 万条化工操作日志,或者几百兆的现场勘查图片,localStorage 会瞬间让你心态崩盘——因为它的空间只有 5MB,而且还是同步阻塞的。
在化工现场,数据是实打实的。一条“反应釜温度超限警报”数据是实打实的,它不能丢,它不能丢,它不能丢(重要的事情说三遍)。
这时候,IndexedDB 闪亮登场。它就像是浏览器里的一个“私人硬盘”。它基于 NoSQL,基于 JavaScript 对象,基于 异步。
- 空间无限: 虽然浏览器厂商会限制你的空间,但那通常是几十兆甚至几百兆起步,够你存几十年的数据了。
- 原生支持 JSON: 精细化工的数据结构通常很复杂,JSON 就是它的天敌,也是它的亲妈。
- 异步非阻塞: 你写入数据时,不需要等它写完才能点下一个按钮,这是用户体验的底线。
第二部分:IndexedDB 的封装——别裸写,太丑了
如果你直接用原生 API 写 db.transaction(['logs'], 'readwrite').objectStore('logs').put(...),不出三天,你的代码就会变成一团意大利面,甚至连你自己都看不懂。
我们要做的是封装。我们要像造火箭一样造一个数据库操作层。
下面是一个基于 Promise 的封装方案,它简单、粗暴、有效。我们把它命名为 OfflineDB。
// db.js
class OfflineDB {
constructor(dbName, storeName) {
this.dbName = dbName;
this.storeName = storeName;
this.version = 1;
this.db = null;
}
// 初始化数据库,如果不存在则创建
async open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
// 数据库版本变更时触发
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 如果没有 'logs' 这个表,就创建它
if (!db.objectStoreNames.contains(this.storeName)) {
const objectStore = db.createObjectStore(this.storeName, { keyPath: 'id', autoIncrement: true });
// 创建索引,方便以后按时间查询
objectStore.createIndex('timestamp', 'timestamp', { unique: false });
objectStore.createIndex('syncStatus', 'syncStatus', { unique: false });
}
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onerror = (event) => {
reject('Database error: ' + event.target.errorCode);
};
});
}
// 增
async add(data) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.add(data);
request.onsuccess = () => resolve(request.result); // 返回新数据的 ID
request.onerror = () => reject(request.error);
});
}
// 改
async put(data) {
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 getAll() {
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);
});
}
// 查(未同步的)
async getPending() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('syncStatus');
const request = index.getAll('pending'); // 我们约定 syncStatus 为 'pending' 的就是未同步的
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 更新状态
async updateStatus(id, status) {
const data = await this.get(id);
if (data) {
data.syncStatus = status;
return this.put(data);
}
}
async get(id) {
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);
});
}
}
// 实例化,我们在 React 的初始化里用
const db = new OfflineDB('ChemicalLogDB', 'logs');
db.open().catch(console.error);
export default db;
这段代码讲了个啥?
它定义了一个 OfflineDB 类。它知道怎么创建表,怎么往表里扔东西,怎么把表里“没穿衣服”(syncStatus 未设置)的东西捞出来。它把那个丑陋的回调地狱(Callback Hell)变成了优雅的 Promise。
第三部分:React 组件——现场作业的交互界面
现在,我们有了底层的数据库。接下来,我们需要一个 React 组件来操作它。
在这个场景下,我们面对的是精细化工现场。数据字段必须严谨:反应釜编号、温度、压力、投料量、操作员 ID、现场照片(Base64 编码)、危险等级。
这里有个技术难点:状态管理。React 组件里不能直接存几万条历史数据,那内存早就爆了。我们只需要存“当前正在编辑的那一条”或者“刚刚提交的那一条”。
让我们设计一个 ChemicalEntryForm 组件。
import React, { useState, useEffect } from 'react';
import db from './db'; // 引入我们封装好的数据库
import './ChemicalEntryForm.css'; // 假设有样式
const ChemicalEntryForm = () => {
const [formData, setFormData] = useState({
reactorId: 'R-102',
temperature: 0,
pressure: 0,
operator: 'Admin',
notes: '',
syncStatus: 'pending', // 关键字段:标记是否已同步
timestamp: new Date().toISOString()
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [lastSyncTime, setLastSyncTime] = useState(null);
// 1. 离线提交逻辑
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
try {
// 将数据写入 IndexedDB
await db.add({
...formData,
timestamp: new Date().toISOString(),
syncStatus: 'pending' // 标记为待同步
});
alert('数据已保存到本地,正在离线队列中等待同步!');
setLastSyncTime(new Date());
// 重置表单,但不重置 reactorId,方便连续录入
setFormData(prev => ({
...prev,
temperature: 0,
pressure: 0,
notes: ''
}));
} catch (error) {
console.error('写入本地数据库失败:', error);
alert('保存失败,请检查数据库权限或刷新重试');
} finally {
setIsSubmitting(false);
}
};
// 2. 监听网络状态变化(可选,用于给用户提示)
useEffect(() => {
const handleOnline = () => setLastSyncTime(new Date());
const handleOffline = () => setLastSyncTime(null);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOnline);
};
}, []);
return (
<div className="form-container">
<h2>精细化工现场录入终端</h2>
<p>网络状态: {navigator.onLine ? '在线' : '离线'} {lastSyncTime && <span>(最后同步于 {new Date(lastSyncTime).toLocaleTimeString()})</span>}</p>
<form onSubmit={handleSubmit}>
<div className="input-group">
<label>反应釜 ID</label>
<input
type="text"
value={formData.reactorId}
readOnly
style={{ backgroundColor: '#f0f0f0' }}
/>
</div>
<div className="input-group">
<label>实时温度 (°C)</label>
<input
type="number"
value={formData.temperature}
onChange={(e) => setFormData({...formData, temperature: Number(e.target.value)})}
required
/>
</div>
<div className="input-group">
<label>实时压力</label>
<input
type="number"
value={formData.pressure}
onChange={(e) => setFormData({...formData, pressure: Number(e.target.value)})}
required
/>
</div>
<div className="input-group">
<label>备注</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({...formData, notes: e.target.value})}
/>
</div>
<button type="submit" disabled={isSubmitting || !navigator.onLine}>
{isSubmitting ? '保存中...' : '保存离线'}
</button>
</form>
</div>
);
};
export default ChemicalEntryForm;
这段代码展示了什么?
- 受控组件: 通过
useState管理表单数据。 - 乐观写入: 我们在调用
db.add后立即清空表单,并给用户反馈。实际上数据还在本地硬盘里,直到同步服务去抓取它。 - 状态标记:
syncStatus: 'pending'是核心。它告诉同步引擎:“嘿,这个数据是我新写的,赶紧发出去!”
第四部分:同步引擎——那个不知疲倦的快递员
有了本地存储,还需要一个机制把数据“吐”出去。这通常需要一个后台服务或者一个定时器。
我们假设我们有一个 syncService,它负责把 pending 的数据推送到后端 API。
这里有一个非常关键的点:并发控制。如果你在同步一个数据的时候,用户又提交了一个,怎么办?我们需要一个 pendingSyncIds 集合来防止重复上传。
// syncService.js
let isSyncing = false;
let pendingSyncIds = new Set();
const SYNC_INTERVAL = 5000; // 每 5 秒尝试同步一次
let syncTimer = null;
const API_BASE_URL = 'https://api.your-company.com/api/logs';
async function syncData() {
// 如果正在同步,或者没有网,或者没有待同步数据,直接返回
if (isSyncing || !navigator.onLine) return;
try {
// 1. 从 IndexedDB 获取所有待同步数据
const pendingLogs = await db.getPending();
if (pendingLogs.length === 0) return;
console.log(`发现 ${pendingLogs.length} 条离线数据,开始同步...`);
// 2. 批量发送
const results = await Promise.allSettled(
pendingLogs.map(log => fetch(API_BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(log)
}))
);
// 3. 处理结果
for (let i = 0; i < results.length; i++) {
const result = results[i];
const originalLog = pendingLogs[i];
if (result.status === 'fulfilled' && result.value.ok) {
// 同步成功,更新本地状态为 'synced'
await db.updateStatus(originalLog.id, 'synced');
console.log(`ID: ${originalLog.id} 同步成功`);
} else {
// 同步失败,保留在 pending 状态,下次再试
console.error(`ID: ${originalLog.id} 同步失败`, result.reason);
// 可以选择在这里增加重试计数器,超过次数则标记为 'failed'
}
}
} catch (error) {
console.error('同步服务发生异常:', error);
} finally {
isSyncing = false;
}
}
// 启动同步服务
export function startSyncService() {
if (syncTimer) clearInterval(syncTimer);
syncTimer = setInterval(syncData, SYNC_INTERVAL);
// 网络恢复时立即同步一次
window.addEventListener('online', () => {
console.log('网络恢复,立即同步...');
syncData();
});
}
export function stopSyncService() {
if (syncTimer) clearInterval(syncTimer);
}
这段代码的逻辑流:
- 轮询:
setInterval每隔 5 秒唤醒一次。这很省电,而且不会因为网络波动导致同步中断后恢复不及时。 - 防抖/互斥:
isSyncing标志位确保了一次只处理一个批次的数据。如果网络很慢,不要一口气塞进 1000 条数据,那样后端会崩的,客户端也会卡死。 - Promise.allSettled: 这是关键。即使第 1 条数据同步成功,第 2 条失败,我们也要继续尝试第 2 条。我们不知道哪一条数据坏了,必须一条条试。
第五部分:冲突解决——当两台电脑同时修改同一条数据
这是最头疼的地方。假设你今天下午 2 点在车间 A 修改了数据,你的同事在车间 B 也修改了同样的数据。两个设备都离线,都存了本地,都试图在 3 点的时候上传。
这时候,你的数据库里有两条 ID 相同(假设 keyPath 是 id,但 id 是 autoIncrement 生成的)或者业务 ID 相同的数据。后端收到两条,会告诉你:“你晚了,我是最新的。”
这时候,前端怎么处理?
策略:时间戳覆盖法
我们在数据库存储时,除了 id,还存了 timestamp。当同步返回冲突(通常是 HTTP 409 Conflict 或者后端返回新数据)时,我们比较时间戳。
// 伪代码演示冲突处理逻辑
async function handleSyncConflict(localLog, serverLog) {
if (new Date(localLog.timestamp) > new Date(serverLog.timestamp)) {
// 我的是新的,用我的覆盖服务器
console.log('我拥有最新数据,正在强制覆盖...');
// 注意:这里可能需要后端提供 update 接口,或者直接重新 put
// 在我们的简单模型里,我们通常是把本地数据重新 put 进去,等下次同步再发一次
// 或者直接忽略服务器的回滚,保持本地数据不变
return localLog;
} else {
// 服务器的是新的,用服务器的覆盖本地
console.log('服务器数据更新,本地数据已回滚');
// 1. 删除本地数据
await db.delete(localLog.id);
// 2. 存入服务器数据
await db.put(serverLog);
return serverLog;
}
}
在精细化工场景中,我们通常遵循“现场操作优先”的原则。因为车间里的设备读数是实时的,哪怕服务器端数据是错的,现场数据也是错的。所以,如果两个版本都离线,通常以最后写入时间(Last Write Wins,简称 LWOW)为准。
第六部分:照片与大数据——IndexedDB 的内存噩梦
很多同学只存文本,觉得没问题。但在化工现场,我们可能要存现场照片。
如果用户拍了一张 5MB 的照片,直接塞进 IndexedDB,然后 getAll() 一次把所有 1000 条记录(包括照片)全部读出来渲染到列表里,浏览器会瞬间崩溃。
优化方案:图片预览 vs 实际存储
- 预览图: 用户上传时,我们用 Canvas 压缩成 100KB 的小图存到 IndexedDB。
- 大图: 只有大图上传成功到服务器后,才存回 IndexedDB。或者,我们根本不把图片存在 IndexedDB 里,只在表单里存一个
photoBlob,同步成功后删除 Blob,只存 URL(如果 URL 是服务器上的)。
代码片段:压缩图片
async function compressImage(file, maxWidth = 800, maxHeight = 600) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target.result;
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 计算缩放比例
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxWidth) {
height *= maxWidth / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width *= maxHeight / height;
height = maxHeight;
}
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/jpeg', 0.7)); // 0.7 是质量系数
};
};
});
}
第七部分:React 中的 useEffect 深度剖析——监听数据变化
既然是离线采集,UI 必须要能反馈“数据是否同步成功”。
我们可以写一个 useEffect 钩子,专门监听 IndexedDB 的变化。
import { useEffect } from 'react';
import db from './db';
function useSyncStatus() {
const [syncStatus, setSyncStatus] = useState('idle'); // idle, syncing, success
useEffect(() => {
const unsubscribe = db.subscribe((logs) => {
// 这里可以做很多事,比如如果有新数据提交了,自动开始同步
if (logs.some(log => log.syncStatus === 'pending')) {
startSyncService(); // 确保同步服务在运行
}
});
return () => unsubscribe();
}, []);
return syncStatus;
}
注:上面的 db.subscribe 是一种假设的观察者模式。原生 IndexedDB 并不支持简单的观察者,通常需要我们自己在封装层维护一个事件监听列表,当数据变动时通知所有监听者。
第八部分:实战中的“坑”与“避坑指南”
作为专家,我必须告诉你们,这行代码写起来容易,用起来全是坑。
- 事务超时: IndexedDB 的事务默认是“自动提交”的,但如果你在一个事务里干了太多事(比如在一个大循环里不断读写),可能会超过时间限制(通常是 60 秒)而抛出错误。解决办法: 拆分事务,别在一个事务里处理几千条数据。
- 同源限制: 如果你的离线 Web App 是通过 PWA (Progressive Web App) 打包的,并且配置了
Cache-Control和 Service Worker,IndexedDB 的路径必须和 HTML 文件的路径完全一致。坑爹的是,如果你在http://localhost开发,IndexedDB 存在了http://localhost,但 Service Worker 缓存了https://api.com,可能会导致数据隔离。解决办法: 开发时统一用 HTTPS 或者仔细配置 Scope。 - 数据迁移: 原生 API 很难删除数据库。如果你想升级 Schema(比如加个新字段),你不能直接改
onupgradeneeded的版本号并修改现有字段,你必须先deleteDatabase。这在生产环境是噩梦。解决办法: 做好版本检查逻辑,如果版本不匹配,引导用户升级应用。 - UI 卡顿:
getAll()一次读取几万条记录,前端渲染不了。解决办法: 使用游标(Cursor),分页读取。
第九部分:总结——从“能用”到“好用”
通过这套架构,我们实现了一个非常稳固的离线系统。
- React 提供了丝般顺滑的用户交互体验,让录入操作像在玩手游一样爽快。
- IndexedDB 充当了那个沉默寡言但能力超群的硬盘管家,在黑暗中默默守护着每一毫秒的化工数据。
- 后端同步协议 则是连接现实与数字世界的桥梁,确保了数据的最终一致性。
在精细化工现场,数据就是安全,数据就是生命。当一个反应釜的参数出现异常,而平板电脑恰好没网,那一刻的恐慌是难以言喻的。但有了这套组合拳,你可以淡定地关上盖子,告诉老板:“数据已经存在本地了,等连上网,五秒钟就能回传。”
这就是技术的力量。它让你即使在信号屏蔽的角落,也能保持与世界、与安全的连接。
好了,今天的讲座就到这里。如果你们在现场遇到数据库写入失败,别急着砸电脑,检查一下是不是 db.open() 没调成功。我们下次见!