Web端文件存储:IndexedDB与OPFS对比

25 年 7 月 17 日 星期四 (已编辑)
2629 字
14 分钟

随着需要在浏览器内处理大型媒体文件、数据集或复杂项目(如CAD、视频编辑)的应用日益增多,选择正确的存储方案已成为架构设计的关键决策。在众多技术中,IndexedDB和新兴的OPFS(Origin Private File System)是存储二进制数据的主要竞争者。

从根本上说,IndexedDB是一个为结构化数据设计的对象存储数据库,而OPFS则是一个为高性能二进制I/O设计的底层文件系统。这一设计哲学的差异决定了它们在文件处理上的能力不同。

IndexedDB: 数据库优先

背景:IndexedDB的诞生是为了替代Web SQL,为Web应用提供一个标准的、强大的、事务性的客户端数据库。它的核心目标是高效地存储、索引和查询大量的结构化JavaScript对象

文件存储的实现:当一个FileBlob对象被存入IndexedDB时,它被视为一个完整的、不透明的数据块。浏览器内部会处理其序列化和持久化,但对于开发者来说,它就是一个“值”。这种模型符合数据库的CRUD(创建、读取、更新、删除)范式:你操作的是整个记录(record),而不是记录中某个值的内部字节。

OPFS: 文件系统优先

背景:OPFS是File System Access API的一部分,其直接动机是弥补Web平台在高性能文件I/O上的短板,让Web应用获得接近原生应用的文件处理能力。它从设计之初就不是为了存储结构化数据,而是为了高效管理二进制内容。

文件存储的实现:OPFS暴露了更底层的抽象——文件句柄(FileSystemFileHandle)和可写流(FileSystemWritableFileStream)。这套API模仿了操作系统层面的文件操作,允许开发者控制文件指针、进行随机访问(random access)和流式写入。它将文件视为一个可寻址的字节序列,而非一个原子性的对象。

核心技术差异对比

技术维度IndexedDBOPFS (Origin Private File System)
基本抽象对象存储(Object Store)文件和目录句柄(Handles)
数据模型键值对,值为原子性对象可寻址的字节流(Addressable byte stream)
更新机制替换(Replace)put()操作用新对象完全替换旧对象原地更新(In-place Update)seek() + write()
写入性能良好,但受限于对象序列化开销极高,支持流式写入和底层优化
内存模型可能很高,更新大Blob时需将其完整读入内存极低,流式写入和部分更新不需加载整个文件
API范式数据库事务型(transaction, put, get, index文件I/O流式(createWritable, seek, write, close
并发模型异步事务队列pipeTo原生并发,手动写入需自行管理序列化

为什么存在这些差异?

1. 更新操作的本质区别

这是两者最核心的差异。

  • IndexedDB (替换模型): 当你执行 objectStore.put(newBlob, key) 时,其内部逻辑近似于:

    1. 开启一个事务。
    2. 读取与 key 关联的旧 Blob 的元数据。
    3. 分配新的存储空间以容纳 newBlob
    4. newBlob 的全部内容写入新空间。
    5. 更新数据库的内部指针,将 key 指向新的存储位置。
    6. 将旧 Blob 占用的空间标记为可回收。

    这个过程是破坏性并重建的。对于一个1GB的文件,即使只修改1个字节,也需要重写整个1GB。

  • OPFS (原地更新模型): 当你执行以下操作时:

    javascript
    const writable = await fileHandle.createWritable();
    await writable.seek(1048576); // 移动到第1MB的位置
    await writable.write(smallChunk);
    await writable.close();
    

    其内部逻辑近似于:

    1. 获取对文件描述符的独占写入锁。
    2. 将文件指针移动到指定的字节偏移量(seek)。
    3. 直接在该物理位置上覆写数据(write)。
    4. 释放锁(close)。

    这个过程是外科手术式的,开销仅与写入数据块的大小成正比,与文件总大小无关。

2. 性能与内存

  • pipeTo: 在OPFS中,sourceStream.pipeTo(writableStream) 是一个高度优化的原生实现。浏览器可以绕过JavaScript主线程,在更底层的进程(如网络进程和I/O进程)之间建立数据管道。这可能涉及到**零拷贝(Zero-copy)**技术,即数据从网络套接字缓冲区直接传输到磁盘文件缓冲区,无需在用户空间(尤其是JS堆内存)中创建副本。这是OPFS能够以极低内存占用高效处理GB级文件下载的核心原因。

  • IndexedDB的内存压力: 虽然浏览器对IndexedDB也做了优化,但其“对象原子性”的设计哲学使得在处理大Blob时,不可避免地需要在不同IPC(进程间通信)边界之间传递整个Blob的副本或引用。在更新场景下,将数据读入内存进行操作更是难以避免,从而构成了内存瓶颈。

选型

  • 选择IndexedDB的场景:

    • 元数据存储: 当你需要为文件建立丰富的索引(如按日期、标签、时长查询视频)时,IndexedDB无与伦比。
    • 结构化数据管理: 当你的核心是管理大量相关的JavaScript对象,而文件只是对象的一个属性时。
    • 事务完整性要求高: 当一组操作必须作为一个原子单元提交或回滚时。
  • 选择OPFS的场景:

    • 大型二进制文件存储: 这是OPFS的首要且不可替代的场景。视频、音频、大型图片、数据库文件(如SQLite wasm版)等都应首选OPFS。
    • 需要部分读写或原地修改: 在线编辑器(视频、音频、图片)、大型数据文件的增量同步等。
    • 性能和内存敏感型应用: 需要处理大规模数据流,且必须将主线程阻塞和内存占用降至最低的应用。

对于需要同时兼顾高性能文件I/O和复杂查询能力的应用:

将OPFS用作“数据湖” (Data Lake),将IndexedDB用作“元数据索引/目录” (Metadata Index)。

  1. 文件(二进制实体)通过OPFS的高性能流式API存入,只在IndexedDB中记录其在OPFS中的句柄标识符或路径。
  2. 应用通过IndexedDB强大的查询能力快速定位到目标文件的元数据和句柄标识。
  3. 使用该标识从OPFS中获取文件句柄,进行高效的读写操作。

关注点分离,利用了两种技术的长处,规避了它们的短板,对构建高性能、数据密集型Web应用有很大的帮助。

实践

以下代码实现了OPFS的存取、细粒度的写入控制

ts
const FILE_NAME_PREFIX = '__web-fs__';

// 使用的是OPFS API存取文件
const createContent = async ({ filename }: { filename: string }) => {
  const directoryHandle = await navigator.storage.getDirectory();
  const actualFilename = `${FILE_NAME_PREFIX}:${filename}`;

  const remove = async () => {
    try {
      await directoryHandle.removeEntry(actualFilename, {
        recursive: true,
      });
    } catch {}
  };

  await remove();

  const fileHandle = await directoryHandle.getFileHandle(actualFilename, {
    create: true,
  });
  const writable = await fileHandle.createWritable();

  let written = 0;

  let writPromise = Promise.resolve();

  const write = async (arr: Uint8Array) => {
    await writable.write(arr);
    written += arr.byteLength;
  };

  const updateDataAt = async (position: number, data: Uint8Array) => {
    await writable.seek(position);
    await writable.write(data);
    await writable.seek(written);
  };

  const writer = {
    write: (arr: Uint8Array) => {
      writPromise = writPromise.then(() => write(arr));
      return writPromise;
    },
    finish: async () => {
      await writPromise;

      try {
        await writable.close();
      } catch {
        // Ignore, could already be closed
      }
    },
    async getBlob() {
      // 等待所有挂起的写入操作完成
      await writPromise;

      const newHandle = await directoryHandle.getFileHandle(actualFilename, {
        create: true,
      });
      const newFile = await newHandle.getFile();
      return newFile;
    },
    getWrittenByteCount: () => written,
    updateDataAt: (position: number, data: Uint8Array) => {
      writPromise = writPromise.then(() => updateDataAt(position, data));
      return writPromise;
    },
    remove,
  };

  return writer;
};

const canUseWebFs = async () => {
  if (!('storage' in navigator)) {
    return false;
  }

  if (!('getDirectory' in navigator.storage)) {
    return false;
  }

  try {
    const directoryHandle = await navigator.storage.getDirectory();
    const fileHandle = await directoryHandle.getFileHandle('web-fs-support', {
      create: true,
    });

    const canUse = fileHandle.createWritable !== undefined;
    return canUse;
  } catch {
    return false;
  }
};

const save = async (filename: string, file: Blob): Promise<File> => {
  const root = await navigator.storage.getDirectory();
  const actualFilename = `${FILE_NAME_PREFIX}:${filename}`;

  try {
    // 尝试先删除,确保写入的是一个全新的文件
    // 这与 createContent 的行为保持一致
    await root.removeEntry(actualFilename, { recursive: true }).catch(() => {});

    // 获取文件句柄和可写流
    const fileHandle = await root.getFileHandle(actualFilename, {
      create: true,
    });
    const writable = await fileHandle.createWritable();

    // **核心步骤**: 使用 pipeTo 将文件流直接传输到可写流
    // 这是最高效的方式,由浏览器底层优化
    await file.stream().pipeTo(writable);

    // pipeTo 会在完成后自动关闭 writable 流
    // 现在,我们获取最终写入的文件并返回
    const finalFile = await fileHandle.getFile();
    return finalFile;
  } catch (error) {
    // 如果过程中发生任何错误,尝试清理创建了一半的文件
    await root.removeEntry(actualFilename, { recursive: true }).catch(() => {});
    // 将原始错误向上抛出,以便调用者能捕获到
    throw error;
  }
};

const read = async (filename: string): Promise<File | null> => {
  try {
    const root = await navigator.storage.getDirectory();
    const actualFilename = `${FILE_NAME_PREFIX}:${filename}`;

    // 获取文件句柄
    const fileHandle = await root.getFileHandle(actualFilename);

    // 从句柄获取文件对象并返回
    const file = await fileHandle.getFile();
    return file;
  } catch (error) {
    // 最常见的错误是文件未找到 (NotFoundError)
    // 在这种情况下,我们不应该抛出异常,而是返回 null,这更符合“查找”的语义
    if (error instanceof Error && error.name === 'NotFoundError') {
      return null;
    }
    // 如果是其他类型的错误(如权限问题),则应该向上抛出
    throw error;
  }
};

const remove = async (filename: string): Promise<boolean> => {
  try {
    const root = await navigator.storage.getDirectory();
    const actualFilename = `${FILE_NAME_PREFIX}:${filename}`;

    // 直接调用 removeEntry 删除文件
    await root.removeEntry(actualFilename);

    // 如果没有抛出错误,说明删除成功
    return true;
  } catch (error) {
    // 如果文件未找到,这不是一个程序错误,而是表示文件已经处于“被删除”的状态
    // 在这种情况下返回 false 是一个明确的信号
    if (error instanceof Error && error.name === 'NotFoundError') {
      return false;
    }
    // 其他错误(如权限问题)则应该被抛出
    console.error(`删除文件 "${filename}" 时发生错误:`, error);
    throw error;
  }
};

const clear = async (): Promise<void> => {
  try {
    const root = await navigator.storage.getDirectory();

    // root.values() 返回一个异步迭代器,包含所有文件和目录的句柄
    // 我们使用 for await...of 循环来遍历它们
    const promises: Promise<void>[] = [];
    for await (const handle of root.values()) {
      // 对每个条目调用 removeEntry 进行删除
      // { recursive: true } 对于删除非空目录至关重要
      promises.push(root.removeEntry(handle.name, { recursive: true }));
    }
    // 等待所有删除操作完成
    await Promise.all(promises);
  } catch (error) {
    console.error('清空 OPFS 时发生错误:', error);
    throw error;
  }
};

const webFs = {
  createContent,
  save,
  read,
  remove,
  clear,
  canUseWebFs,
};

export default webFs;

文章标题:Web端文件存储:IndexedDB与OPFS对比

文章作者:shirtiny

文章链接:https://kizamu.anror.com/posts/opfs[复制]

最后修改时间:


商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接,您可以自由地在任何媒体以任何形式复制和分发作品,也可以修改和创作,但是分发衍生作品时必须采用相同的许可协议。
本文采用CC BY-NC-SA 4.0进行许可。