React 应用的离线采集协同:利用 IndexedDB 结合后端同步协议实现精细化工现场的离线录入

各位好!我是你们的资深“代码架构师”兼“化工现场数据救火队员”。

今天我们不聊那些花里胡哨的框架选型,也不谈什么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;

这段代码展示了什么?

  1. 受控组件: 通过 useState 管理表单数据。
  2. 乐观写入: 我们在调用 db.add 后立即清空表单,并给用户反馈。实际上数据还在本地硬盘里,直到同步服务去抓取它。
  3. 状态标记: 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);
}

这段代码的逻辑流:

  1. 轮询: setInterval 每隔 5 秒唤醒一次。这很省电,而且不会因为网络波动导致同步中断后恢复不及时。
  2. 防抖/互斥: isSyncing 标志位确保了一次只处理一个批次的数据。如果网络很慢,不要一口气塞进 1000 条数据,那样后端会崩的,客户端也会卡死。
  3. 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 实际存储

  1. 预览图: 用户上传时,我们用 Canvas 压缩成 100KB 的小图存到 IndexedDB。
  2. 大图: 只有大图上传成功到服务器后,才存回 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 并不支持简单的观察者,通常需要我们自己在封装层维护一个事件监听列表,当数据变动时通知所有监听者。


第八部分:实战中的“坑”与“避坑指南”

作为专家,我必须告诉你们,这行代码写起来容易,用起来全是坑。

  1. 事务超时: IndexedDB 的事务默认是“自动提交”的,但如果你在一个事务里干了太多事(比如在一个大循环里不断读写),可能会超过时间限制(通常是 60 秒)而抛出错误。解决办法: 拆分事务,别在一个事务里处理几千条数据。
  2. 同源限制: 如果你的离线 Web App 是通过 PWA (Progressive Web App) 打包的,并且配置了 Cache-Control 和 Service Worker,IndexedDB 的路径必须和 HTML 文件的路径完全一致。坑爹的是,如果你在 http://localhost 开发,IndexedDB 存在了 http://localhost,但 Service Worker 缓存了 https://api.com,可能会导致数据隔离。解决办法: 开发时统一用 HTTPS 或者仔细配置 Scope。
  3. 数据迁移: 原生 API 很难删除数据库。如果你想升级 Schema(比如加个新字段),你不能直接改 onupgradeneeded 的版本号并修改现有字段,你必须先 deleteDatabase。这在生产环境是噩梦。解决办法: 做好版本检查逻辑,如果版本不匹配,引导用户升级应用。
  4. UI 卡顿: getAll() 一次读取几万条记录,前端渲染不了。解决办法: 使用游标(Cursor),分页读取。

第九部分:总结——从“能用”到“好用”

通过这套架构,我们实现了一个非常稳固的离线系统。

  • React 提供了丝般顺滑的用户交互体验,让录入操作像在玩手游一样爽快。
  • IndexedDB 充当了那个沉默寡言但能力超群的硬盘管家,在黑暗中默默守护着每一毫秒的化工数据。
  • 后端同步协议 则是连接现实与数字世界的桥梁,确保了数据的最终一致性。

在精细化工现场,数据就是安全,数据就是生命。当一个反应釜的参数出现异常,而平板电脑恰好没网,那一刻的恐慌是难以言喻的。但有了这套组合拳,你可以淡定地关上盖子,告诉老板:“数据已经存在本地了,等连上网,五秒钟就能回传。”

这就是技术的力量。它让你即使在信号屏蔽的角落,也能保持与世界、与安全的连接。

好了,今天的讲座就到这里。如果你们在现场遇到数据库写入失败,别急着砸电脑,检查一下是不是 db.open() 没调成功。我们下次见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注