基于 OPFS 的前端缓存实践:图片与点云数据的本地持久化

0 阅读8分钟

前言

在现代 Web 应用中,处理大量图片和三维点云数据时,重复的网络请求会严重影响加载速度和用户体验。浏览器提供的 Origin Private File System (OPFS) 为我们带来了新的解决方案——它允许 Web 应用在用户设备上读写专属于自己的文件系统,且完全隔离于其他源,无需用户授权即可使用。

本文将分享如何利用 OPFS 封装一个缓存管理器,用于缓存图片和点云几何数据。代码基于 TypeScript 编写,适用于需要在浏览器中高效复用资源的场景。

什么是 OPFS?

OPFS 是 File System Access API 的一部分,它为 Web 应用提供了一个私有的、与源绑定的文件系统。与传统的 IndexedDB 或 localStorage 相比,OPFS 支持高性能的文件读写操作,尤其适合存储二进制大对象。它的主要特点包括:

  • 完全隔离:每个源拥有独立的文件系统,互不干扰。
  • 无需用户授权:无需弹出权限请求。
  • 同步访问(在 Worker 中):支持同步 API,可大幅提升性能。
  • 持久化存储:数据会一直保留,除非用户手动清除。

设计目标

我们需要一个缓存系统,能够:

  1. 缓存从网络加载的图片(Blob 格式)。
  2. 缓存点云几何数据,包括位置、法线、颜色、强度、标签等属性(以 TypedArray 形式存储)。
  3. 支持基于 URL 的缓存键,确保同一资源只存一份。
  4. 提供简单的读写接口,屏蔽 OPFS 的复杂操作。

整体架构

缓存目录结构如下:

label-flow-cache/
├── images/
│   ├── <key>.bin          # 图片二进制数据
│   └── <key>.meta.json    # 图片元信息(如 MIME 类型)
└── pointClouds/
    ├── <key>/             # 每个点云数据一个子目录
    │   ├── meta.json      # 点云元信息(包含哪些属性、范围等)
    │   ├── position.bin   # 位置数组(Float32Array)
    │   ├── normal.bin     # 法线数组(可选)
    │   ├── color.bin      # 颜色数组(可选)
    │   ├── intensity.bin  # 强度数组(可选)
    │   └── label.bin      # 标签数组(可选)

缓存键的生成策略:从 URL 中提取 pathname + search + hash,然后计算 SHA-256 哈希作为最终键名。这样可以保证键名长度固定且唯一。

核心代码解析

1. 单例模式

export class OPFSCache {
  private static instance: OPFSCache;
  private constructor() {}

  public static getInstance(): OPFSCache {
    if (!OPFSCache.instance) {
      OPFSCache.instance = new OPFSCache();
    }
    return OPFSCache.instance;
  }
}

确保全局只有一个缓存实例,避免重复初始化。

2. 目录初始化

private async ensureCacheRootDir(): Promise<FileSystemDirectoryHandle | null> {
  if (typeof window === 'undefined') return null;
  const root = await getOPFSRoot(); // 外部提供的获取 OPFS 根句柄的函数
  if (!root) return null;
  try {
    return await root.getDirectoryHandle('label-flow-cache', { create: true });
  } catch {
    return null;
  }
}

递归获取或创建 label-flow-cache 目录,并缓存其句柄。imagespointClouds 子目录类似。

3. 缓存键生成

private async getFileKey(url: string): Promise<string> {
  const rawKey = getOPFScacheKey(url); // 提取 pathname + search + hash
  const hash = await this.sha256Hex(rawKey);
  if (hash) return hash;
  return encodeURIComponent(rawKey).replace(/%/g, '_').slice(0, 120);
}

优先使用 SHA-256 哈希作为文件名,如果浏览器不支持,则降级为编码后的原始键(截取前 120 个字符)。

4. 图片缓存

写入

public async setImage(url: string, blob: Blob): Promise<void> {
  const imagesDir = await this.ensureImagesDir();
  if (!imagesDir) return;
  const key = await this.getFileKey(url);
  await Promise.all([
    this.writeBlobFile(imagesDir, `${key}.bin`, blob),
    this.writeTextFile(imagesDir, `${key}.meta.json`, JSON.stringify({ type: blob.type }))
  ]);
}

将图片二进制数据和 MIME 类型分别存储。

读取

public async getImage(url: string): Promise<Blob | null> {
  const imagesDir = await this.ensureImagesDir();
  if (!imagesDir) return null;
  const key = await this.getFileKey(url);
  const metaText = await this.readTextFile(imagesDir, `${key}.meta.json`);
  const meta = metaText ? JSON.parse(metaText) : null;
  return await this.readFileBlob(imagesDir, `${key}.bin`, meta?.type);
}

读取元数据获取类型,然后读取二进制文件返回 Blob。

5. 点云缓存

数据结构定义

export interface PointCloudGeometryData {
  position?: Float32Array;
  normal?: Float32Array;
  color?: Float32Array;
  intensity?: Float32Array;
  label?: Int32Array;
  boundingSphere?: { center: [number, number, number]; radius: number };
  heightRange?: { min: number; max: number };
  intensityRange?: { min: number; max: number };
}

写入

public async setPointCloudGeometry(url: string, geometryData: PointCloudGeometryData): Promise<void> {
  const pointCloudsDir = await this.ensurePointCloudsDir();
  if (!pointCloudsDir) return;
  const key = await this.getFileKey(url);
  // 先删除旧目录(如果有)
  await pointCloudsDir.removeEntry(key, { recursive: true }).catch(() => {});
  const pcDir = await pointCloudsDir.getDirectoryHandle(key, { create: true });

  const meta: PointCloudMeta = {
    has: {
      position: !!geometryData.position,
      normal: !!geometryData.normal,
      color: !!geometryData.color,
      intensity: !!geometryData.intensity,
      label: !!geometryData.label,
    },
    boundingSphere: geometryData.boundingSphere,
    heightRange: geometryData.heightRange,
    intensityRange: geometryData.intensityRange,
  };

  const tasks: Promise<void>[] = [this.writeTextFile(pcDir, 'meta.json', JSON.stringify(meta))];

  if (geometryData.position) {
    tasks.push(this.writeBufferFile(pcDir, 'position.bin', this.copyViewToArrayBuffer(geometryData.position)));
  }
  // ... 其他属性类似

  await Promise.all(tasks);
}

为每个点云数据创建一个子目录,将元信息和各个属性分别存储为独立文件。注意写入前会删除旧目录,保证数据一致性。

读取

public async getPointCloudGeometry(url: string): Promise<PointCloudGeometryData | null> {
  const pointCloudsDir = await this.ensurePointCloudsDir();
  if (!pointCloudsDir) return null;
  const key = await this.getFileKey(url);
  let pcDir: FileSystemDirectoryHandle;
  try {
    pcDir = await pointCloudsDir.getDirectoryHandle(key);
  } catch {
    return null;
  }

  const metaText = await this.readTextFile(pcDir, 'meta.json');
  if (!metaText) return null;
  const meta = JSON.parse(metaText) as PointCloudMeta;

  const geometryData: PointCloudGeometryData = {
    boundingSphere: meta.boundingSphere,
    heightRange: meta.heightRange,
    intensityRange: meta.intensityRange,
  };

  if (meta.has.position) {
    const buf = await this.readFileBuffer(pcDir, 'position.bin');
    if (!buf) return null;
    geometryData.position = new Float32Array(buf);
  }
  // ... 其他属性类似

  return geometryData;
}

根据元信息动态读取对应文件,还原成 TypedArray。

6. 辅助方法

  • copyViewToArrayBuffer:将 TypedArray 的数据复制到一个新的 ArrayBuffer,避免共享底层内存带来的潜在问题。
  • writeBlobFile / writeTextFile / writeBufferFile:封装 OPFS 的写入操作。
  • readFileBlob / readTextFile / readFileBuffer:封装 OPFS 的读取操作。

完整代码

以下是经过适当脱敏(例如将示例中的 URL 处理函数替换为占位符)的完整代码。

export async function getOPFSRoot(): Promise<FileSystemDirectoryHandle | null> {
    const storage: any = navigator.storage
    if (!storage?.getDirectory) return null
    try {
        return (await storage.getDirectory()) as FileSystemDirectoryHandle
    } catch {
        return null
    }
}


export interface PointCloudGeometryData {
  position?: Float32Array;
  normal?: Float32Array;
  color?: Float32Array;
  intensity?: Float32Array;
  label?: Int32Array;
  boundingSphere?: {
    center: [number, number, number];
    radius: number;
  };
  heightRange?: {
    min: number;
    max: number;
  };
  intensityRange?: {
    min: number;
    max: number;
  };
}

export function getOPFScacheKey(src: string) {
  try {
    const url = new URL(src);
    return `${url.pathname}${url.search}${url.hash}`;
  } catch {
    return `${src}`;
  }
}

type ImageMeta = {
  type?: string;
};

type PointCloudMeta = {
  has: {
    position?: boolean;
    normal?: boolean;
    color?: boolean;
    intensity?: boolean;
    label?: boolean;
  };
  boundingSphere?: PointCloudGeometryData['boundingSphere'];
  heightRange?: PointCloudGeometryData['heightRange'];
  intensityRange?: PointCloudGeometryData['intensityRange'];
};

export class OPFSCache {
  private static instance: OPFSCache;

  private constructor() {}

  public static getInstance(): OPFSCache {
    if (!OPFSCache.instance) {
      OPFSCache.instance = new OPFSCache();
    }
    return OPFSCache.instance;
  }

  public async init(): Promise<void> {
    await this.ensureCacheRootDir();
  }

  private async ensureCacheRootDir(): Promise<FileSystemDirectoryHandle | null> {
    if (typeof window === 'undefined') return null;
    const root = await getOPFSRoot();
    if (!root) return null;
    try {
      return await root.getDirectoryHandle('label-flow-cache', { create: true });
    } catch {
      return null;
    }
  }

  private async ensureImagesDir(): Promise<FileSystemDirectoryHandle | null> {
    const root = await this.ensureCacheRootDir();
    if (!root) return null;
    try {
      return await root.getDirectoryHandle('images', { create: true });
    } catch {
      return null;
    }
  }

  private async ensurePointCloudsDir(): Promise<FileSystemDirectoryHandle | null> {
    const root = await this.ensureCacheRootDir();
    if (!root) return null;
    try {
      return await root.getDirectoryHandle('pointClouds', { create: true });
    } catch {
      return null;
    }
  }

  private async sha256Hex(input: string): Promise<string | null> {
    const subtle = globalThis.crypto?.subtle;
    if (!subtle) return null;
    try {
      const data = new TextEncoder().encode(input);
      const digest = await subtle.digest('SHA-256', data);
      return Array.from(new Uint8Array(digest))
        .map((b) => b.toString(16).padStart(2, '0'))
        .join('');
    } catch {
      return null;
    }
  }

  private async getFileKey(url: string): Promise<string> {
    const rawKey = getOPFScacheKey(url);
    const hash = await this.sha256Hex(rawKey);
    if (hash) return hash;
    return encodeURIComponent(rawKey).replace(/%/g, '_').slice(0, 120);
  }

  private async tryGetFileHandle(
    dir: FileSystemDirectoryHandle,
    name: string
  ): Promise<FileSystemFileHandle | null> {
    try {
      return await dir.getFileHandle(name);
    } catch {
      return null;
    }
  }

  private async writeBlobFile(
    dir: FileSystemDirectoryHandle,
    name: string,
    blob: Blob
  ): Promise<void> {
    const handle = await dir.getFileHandle(name, { create: true });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
  }

  private async writeTextFile(
    dir: FileSystemDirectoryHandle,
    name: string,
    text: string
  ): Promise<void> {
    const handle = await dir.getFileHandle(name, { create: true });
    const writable = await handle.createWritable();
    await writable.write(text);
    await writable.close();
  }

  private async writeBufferFile(
    dir: FileSystemDirectoryHandle,
    name: string,
    buffer: ArrayBuffer
  ): Promise<void> {
    const handle = await dir.getFileHandle(name, { create: true });
    const writable = await handle.createWritable();
    await writable.write(buffer);
    await writable.close();
  }

  private copyViewToArrayBuffer(view: ArrayBufferView): ArrayBuffer {
    const arrayBuffer = new ArrayBuffer(view.byteLength);
    new Uint8Array(arrayBuffer).set(
      new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
    );
    return arrayBuffer;
  }

  private async readTextFile(
    dir: FileSystemDirectoryHandle,
    name: string
  ): Promise<string | null> {
    const handle = await this.tryGetFileHandle(dir, name);
    if (!handle) return null;
    try {
      const file = await handle.getFile();
      return await file.text();
    } catch {
      return null;
    }
  }

  private async readFileBlob(
    dir: FileSystemDirectoryHandle,
    name: string,
    type?: string
  ): Promise<Blob | null> {
    const handle = await this.tryGetFileHandle(dir, name);
    if (!handle) return null;
    try {
      const file = await handle.getFile();
      const blobType = type || file.type;
      return file.slice(0, file.size, blobType);
    } catch {
      return null;
    }
  }

  private async readFileBuffer(
    dir: FileSystemDirectoryHandle,
    name: string
  ): Promise<ArrayBuffer | null> {
    const handle = await this.tryGetFileHandle(dir, name);
    if (!handle) return null;
    try {
      const file = await handle.getFile();
      return await file.arrayBuffer();
    } catch {
      return null;
    }
  }

  public async getImage(url: string): Promise<Blob | null> {
    try {
      const imagesDir = await this.ensureImagesDir();
      if (!imagesDir) return null;

      const key = await this.getFileKey(url);

      const metaText = await this.readTextFile(imagesDir, `${key}.meta.json`);
      const meta: ImageMeta | null = metaText ? JSON.parse(metaText) : null;

      return await this.readFileBlob(imagesDir, `${key}.bin`, meta?.type);
    } catch (error) {
      console.warn('读取图片缓存失败:', error);
      return null;
    }
  }

  public async setImage(url: string, blob: Blob): Promise<void> {
    try {
      const imagesDir = await this.ensureImagesDir();
      if (!imagesDir) return;

      const key = await this.getFileKey(url);
      await Promise.all([
        this.writeBlobFile(imagesDir, `${key}.bin`, blob),
        this.writeTextFile(
          imagesDir,
          `${key}.meta.json`,
          JSON.stringify({ type: blob.type } satisfies ImageMeta)
        ),
      ]);
    } catch (error) {
      console.warn('写入图片缓存失败:', error);
    }
  }

  public async getPointCloudGeometry(url: string): Promise<PointCloudGeometryData | null> {
    try {
      const pointCloudsDir = await this.ensurePointCloudsDir();
      if (!pointCloudsDir) return null;

      const key = await this.getFileKey(url);
      let pcDir: FileSystemDirectoryHandle;
      try {
        pcDir = await pointCloudsDir.getDirectoryHandle(key);
      } catch {
        return null;
      }

      const metaText = await this.readTextFile(pcDir, 'meta.json');
      if (!metaText) return null;
      const meta = JSON.parse(metaText) as PointCloudMeta;

      const geometryData: PointCloudGeometryData = {
        boundingSphere: meta.boundingSphere,
        heightRange: meta.heightRange,
        intensityRange: meta.intensityRange,
      };

      if (meta.has.position) {
        const buf = await this.readFileBuffer(pcDir, 'position.bin');
        if (!buf) return null;
        geometryData.position = new Float32Array(buf);
      }

      if (meta.has.normal) {
        const buf = await this.readFileBuffer(pcDir, 'normal.bin');
        if (!buf) return null;
        geometryData.normal = new Float32Array(buf);
      }

      if (meta.has.color) {
        const buf = await this.readFileBuffer(pcDir, 'color.bin');
        if (!buf) return null;
        geometryData.color = new Float32Array(buf);
      }

      if (meta.has.intensity) {
        const buf = await this.readFileBuffer(pcDir, 'intensity.bin');
        if (!buf) return null;
        geometryData.intensity = new Float32Array(buf);
      }

      if (meta.has.label) {
        const buf = await this.readFileBuffer(pcDir, 'label.bin');
        if (!buf) return null;
        geometryData.label = new Int32Array(buf);
      }

      return geometryData;
    } catch (error) {
      console.warn('读取点云缓存失败:', error);
      return null;
    }
  }

  public async setPointCloudGeometry(
    url: string,
    geometryData: PointCloudGeometryData
  ): Promise<void> {
    try {
      const pointCloudsDir = await this.ensurePointCloudsDir();
      if (!pointCloudsDir) return;

      const key = await this.getFileKey(url);
      await pointCloudsDir.removeEntry(key, { recursive: true }).catch(() => {});
      const pcDir = await pointCloudsDir.getDirectoryHandle(key, { create: true });

      const meta: PointCloudMeta = {
        has: {
          position: !!geometryData.position,
          normal: !!geometryData.normal,
          color: !!geometryData.color,
          intensity: !!geometryData.intensity,
          label: !!geometryData.label,
        },
        boundingSphere: geometryData.boundingSphere,
        heightRange: geometryData.heightRange,
        intensityRange: geometryData.intensityRange,
      };

      const tasks: Promise<void>[] = [
        this.writeTextFile(pcDir, 'meta.json', JSON.stringify(meta)),
      ];

      if (geometryData.position) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'position.bin',
            this.copyViewToArrayBuffer(geometryData.position)
          )
        );
      }

      if (geometryData.normal) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'normal.bin',
            this.copyViewToArrayBuffer(geometryData.normal)
          )
        );
      }

      if (geometryData.color) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'color.bin',
            this.copyViewToArrayBuffer(geometryData.color)
          )
        );
      }

      if (geometryData.intensity) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'intensity.bin',
            this.copyViewToArrayBuffer(geometryData.intensity)
          )
        );
      }

      if (geometryData.label) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'label.bin',
            this.copyViewToArrayBuffer(geometryData.label)
          )
        );
      }

      await Promise.all(tasks);
    } catch (error) {
      console.warn('写入点云缓存失败:', error);
    }
  }
}

export const opfsCache = OPFSCache.getInstance();

使用示例

// 初始化(建议在应用启动时调用)
await opfsCache.init();

// 缓存图片
const response = await fetch('https://example.com/image.jpg');
const blob = await response.blob();
await opfsCache.setImage('https://example.com/image.jpg', blob);

// 获取图片
const cachedBlob = await opfsCache.getImage('https://example.com/image.jpg');

// 缓存点云数据
const geometry = {
  position: new Float32Array([...]),
  color: new Float32Array([...]),
  // ...
};
await opfsCache.setPointCloudGeometry('https://example.com/cloud.pcd', geometry);

// 获取点云数据
const cachedGeometry = await opfsCache.getPointCloudGeometry('https://example.com/cloud.pcd');

总结与注意事项

  • 性能优势:OPFS 提供了接近本地文件系统的读写速度,远优于 IndexedDB 的随机访问性能。
  • 存储容量:OPFS 的存储限制通常与浏览器分配给网站的总存储空间一致(一般较大),但具体取决于浏览器实现。
  • 兼容性:OPFS 在现代浏览器(Chrome 86+、Edge 86+、Safari 15.2+)中得到广泛支持,但在旧版本浏览器中需要降级方案。
  • 数据清理:由于数据存储在用户的私密空间中,开发者无需担心隐私问题。但需要注意及时清理无用缓存,避免占用过多磁盘空间。
  • 错误处理:代码中已经添加了 try-catch,保证了缓存操作失败时不会影响主业务流程。

通过 OPFS,我们可以轻松实现前端高性能缓存,为图片密集型和点云应用带来质的飞跃。希望本文能为大家提供一些实用的思路和代码参考。