React 与 IndexedDB:在 React 中构建离线优先(Offline-first)的本地存储同步链路

各位好,各位前端工程师。

今天我们要聊一个有点“硬核”,但绝对能让你的应用在用户心中封神的话题:如何用 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 会负责:

  1. 初始化时从 IndexedDB 读取数据。
  2. 当数据变化时,先写本地,再尝试同步。
  3. 监听网络状态。
// 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.onsuccesstransaction.onerror 里处理结果。或者使用 async/await 配合我们封装的 _transaction 方法。

2. 数据类型陷阱

IndexedDB 只支持几种基本的数据类型:string, number, boolean, Date, Array, Object

如果你存了 undefined,它会变成 undefined
如果你存了 function,它会直接报错。
如果你存了 MapSet,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. 合并:如果是列表,服务器有 {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 响应,我们构建了一个完整的闭环。

核心要点回顾:

  1. 不要直接用:原生 API 太繁琐,封装它。
  2. 异步是常态:IndexedDB 是异步的,React State 是同步的,用 useEffectasync/await 桥接它们。
  3. 乐观更新:让用户感觉不到网络延迟,这是离线应用体验好坏的分水岭。
  4. 队列与重试:网络是不可靠的,你的代码必须要有“重试”机制和“队列”机制。

最后一点建议:

离线优先不仅仅是技术选型,更是一种产品思维。它意味着你的应用在任何时候都是有价值的,哪怕是在深山老林里,或者是在飞机上。

当你看着你的应用在断网状态下依然流畅运行,看着数据在后台默默同步,那种成就感,比发一个 5 分的 PR 要爽得多。

好了,今天的讲座就到这里。去写代码吧,让用户彻底离不开你的应用!

发表回复

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