阅读视图

发现新文章,点击刷新页面。

IndexedDB 增量更新:实现精准的字段级“补丁”

在 SQL 中,我们可以执行 UPDATE table SET field = 'val' WHERE id = 1。但在 IndexedDB 的世界里,原生 API 只提供了 put()put 的本质是 “全量覆盖” :如果你只传一个字段,整个对象剩下的部分都会丢失。

处理这种“增量更新”时,必须保证原子性(Atomicity) ,尤其是在处理长篇 AI Prompt 时,防止数据在“读取-修改-写入”的过程中被并发操作篡改。


1. 核心原理:Get-Modify-Put 模式

由于 IndexedDB 不支持部分更新,我们必须手动实现一个 patch 方法。关键点在于:获取数据、合并字段、重新存入这三个步骤必须在**同一个事务(Transaction)**中完成。

2. 代码实现:给 PromptDB 增加 patch 方法

我们在之前封装的 PromptDB 基础上,新增一个智能增量更新函数:

JavaScript

/**
 * 增量更新指定 ID 的数据
 * @param {string} id - 主键
 * @param {Object} changes - 需要修改的字段,例如 { title: '新标题' }
 */
async patch(id, changes) {
  await this.init();
  
  return new Promise((resolve, reject) => {
    // 1. 开启读写事务
    const transaction = this.db.transaction(this.storeName, 'readwrite');
    const store = transaction.objectStore(this.storeName);

    // 2. 先读取原始数据
    const getRequest = store.get(id);

    getRequest.onsuccess = () => {
      const data = getRequest.result;
      if (!data) {
        reject(new Error(`ID 为 ${id} 的记录不存在`));
        return;
      }

      // 3. 合并差异 (使用 Object.assign 或 展开运算符)
      const updatedData = { ...data, ...changes, updatedAt: Date.now() };

      // 4. 写回数据库
      const putRequest = store.put(updatedData);
      
      putRequest.onsuccess = () => resolve(updatedData);
      putRequest.onerror = () => reject(new Error('增量更新失败'));
    };

    transaction.onerror = () => reject(new Error('事务异常'));
  });
}

3. 为什么必须在同一个“事务”里?

你肯定关心并发控制

  • 场景:AI 正在流式更新 content 字段,而用户同时在点击“收藏”按钮(修改 isFavorite 字段)。
  • 风险:如果 getput 分属不同事务,可能会发生“写覆盖”:用户读取了旧数据,修改了收藏状态,此时 AI 更新了内容,用户最后写回时,把 AI 刚更新的内容又覆盖回了旧版。
  • 解决:IndexedDB 的事务锁机制能确保在 onsuccess 回调完成前,其他写操作必须排队,从而保证了增量操作的原子性

4. 8 个具体的业务实战场景

场景 patch 传参示例 意义
字数统计 { wordCount: 1500 } 仅更新统计信息,不触碰数万字的 Prompt 正文
收藏状态 { isFavorite: true } 极速切换,不影响复杂的上下文关联
流式更新 { content: currentText } 在 AI 打字过程中,频繁同步内容,避免丢失进度
标签修正 { tags: [...oldTags, '金融'] } 增量添加标签索引
错误标记 { lastError: 'API Timeout' } 记录异常状态,保留原始请求参数
读取计数 { views: data.views + 1 } 简单的原子累加
版本回退 { version: 2, content: history[2] } 部分字段回滚到历史版本
UI 状态记录 { isExpanded: false } 记录卡片折叠状态,不触碰业务数据

5. 性能优化:大数据量下的增量更新

如果你处理的是超过 100KB 的大型 Prompt 对象,频繁的 patch 会产生大量的垃圾回收(GC)压力。

  1. 防抖处理 (Debounce) :对于“用户改了一个词”这种高频操作,建议在内存中缓冲 500ms,再调用一次 patch
  2. 字段拆分:如果某个字段(如 chat_history)会无限增长,建议将其拆分为另一个 Object Store,通过 ID 关联,而不是塞在一个超大的对象里不断 patch
  3. 使用 Proxy:在前端框架中,可以用 Proxy 监听对象变化,自动收集 changes 补丁包,实现自动化增量备份。

浏览器里的 SSD:IndexedDB 极简封装实战

LocalStorage 的 5MB 限制同步阻塞特性简直是生产环境的定时炸弹。当你为 AI Prompt Manager 存储上万条带上下文的模板时,IndexedDB 是浏览器端唯一的“工业级”选择。

原生 IndexedDB 的 API 设计充满了 20 年前的“回调地狱”既视感。这一篇我们用现代 Promise 将其封装成一个像 Map 一样简单的工具类。


1. 为什么 LocalStorage 救不了你?

在处理万级数据时,两者的性能表现天差地别:

特性 LocalStorage IndexedDB
容量 ~5MB (固定) 磁盘可用空间的 80% (海量)
读写方式 同步 (阻塞主线程) 异步 (不卡顿)
数据结构 仅字符串 原生支持 JSON 对象、Blob
搜索 全量遍历 支持索引 (Index) 极速查询

2. 极简封装代码:PromptDB

我们不需要引入像 Dexie 这样庞大的库,直接用 50 行代码搞定核心逻辑。

JavaScript

class PromptDB {
  constructor(dbName = 'AIPromptDB', storeName = 'prompts') {
    this.dbName = dbName;
    this.storeName = storeName;
    this.db = null;
  }

  // 1. 初始化数据库
  async init() {
    if (this.db) return;
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1);

      request.onupgradeneeded = (e) => {
        const db = e.target.result;
        if (!db.objectStoreNames.contains(this.storeName)) {
          // 创建存储库,并以 id 作为主键
          const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
          // 为模糊搜索创建索引
          store.createIndex('title_idx', 'title', { unique: false });
        }
      };

      request.onsuccess = (e) => {
        this.db = e.target.result;
        resolve();
      };
      request.onerror = reject;
    });
  }

  // 2. 统一步骤:获取事务存储空间
  _getStore(mode = 'readonly') {
    const transaction = this.db.transaction(this.storeName, mode);
    return transaction.objectStore(this.storeName);
  }

  // 3. 存/取 (就像操作对象一样简单)
  async set(data) {
    await this.init();
    return new Promise((resolve) => {
      const request = this._getStore('readwrite').put(data);
      request.onsuccess = () => resolve(true);
    });
  }

  async get(id) {
    await this.init();
    return new Promise((resolve) => {
      const request = this._getStore().get(id);
      request.onsuccess = () => resolve(request.result);
    });
  }

  // 4. 获取全部数据 (性能关键:流式读取)
  async getAll() {
    await this.init();
    return new Promise((resolve) => {
      const request = this._getStore().getAll();
      request.onsuccess = () => resolve(request.result || []);
    });
  }
}

3. 针对 AI 业务的 4 个高阶用法

① 像 JSON 一样存取万级模板

JavaScript

const db = new PromptDB();
// 直接存入一个复杂的 AI 上下文对象,无需 JSON.stringify
await db.set({
  id: 'p_001',
  title: '金融报告分析师',
  content: '你是一个资深审计师...',
  tags: ['finance', 'audit'],
  updatedAt: Date.now()
});

② 批量导入(防止事务频繁开关)

当你有 1000 条 Prompt 需要初始化时,合并到一个事务里:

JavaScript

async batchSet(list) {
  const store = this._getStore('readwrite');
  list.forEach(item => store.put(item));
}

③ 本地模糊搜索(利用 Index)

利用我们之前创建的 title_idx 索引,避开全量扫描:

JavaScript

async findByTitle(keyword) {
  const index = this._getStore().index('title_idx');
  // 仅演示逻辑:实际可用 IDBKeyRange 进行范围匹配
  const request = index.getAll(); 
  return new Promise(resolve => {
    request.onsuccess = () => {
      const result = request.result.filter(i => i.title.includes(keyword));
      resolve(result);
    };
  });
}

④ 存储配额监控

作为资深开发,你应该让用户知道硬盘快满了:

JavaScript

const checkStorage = async () => {
  if (navigator.storage && navigator.storage.estimate) {
    const { usage, quota } = await navigator.storage.estimate();
    console.log(`已用: ${(usage / 1024 / 1024).toFixed(2)}MB`);
    console.log(`剩余: ${(quota / 1024 / 1024 / 1024).toFixed(2)}GB`);
  }
};

4.总结

  1. 版本管理:修改 createObjectStore 逻辑时,记得提升 indexedDB.open 的版本号,否则 onupgradeneeded 不会触发。
  2. 闭包与内存:虽然 IndexedDB 存数据不占内存,但 getAll() 拿出来的 1 万条数据会占用 JS 堆内存。大数据量建议使用 Cursor(游标) 配合之前聊过的 scheduler.yield 分片处理。
  3. 无痕模式:部分浏览器(如老版本 Safari)在无痕模式下会禁用 IndexedDB,记得加一层 try-catch 降级到内存存储。

❌