前言
在现代 Web 应用中,处理大量图片和三维点云数据时,重复的网络请求会严重影响加载速度和用户体验。浏览器提供的 Origin Private File System (OPFS) 为我们带来了新的解决方案------它允许 Web 应用在用户设备上读写专属于自己的文件系统,且完全隔离于其他源,无需用户授权即可使用。
本文将分享如何利用 OPFS 封装一个缓存管理器,用于缓存图片和点云几何数据。代码基于 TypeScript 编写,适用于需要在浏览器中高效复用资源的场景。
什么是 OPFS?
OPFS 是 File System Access API 的一部分,它为 Web 应用提供了一个私有的、与源绑定的文件系统。与传统的 IndexedDB 或 localStorage 相比,OPFS 支持高性能的文件读写操作,尤其适合存储二进制大对象。它的主要特点包括:
- 完全隔离:每个源拥有独立的文件系统,互不干扰。
- 无需用户授权:无需弹出权限请求。
- 同步访问(在 Worker 中):支持同步 API,可大幅提升性能。
- 持久化存储:数据会一直保留,除非用户手动清除。
设计目标
我们需要一个缓存系统,能够:
- 缓存从网络加载的图片(
Blob格式)。 - 缓存点云几何数据,包括位置、法线、颜色、强度、标签等属性(以 TypedArray 形式存储)。
- 支持基于 URL 的缓存键,确保同一资源只存一份。
- 提供简单的读写接口,屏蔽 OPFS 的复杂操作。
整体架构
缓存目录结构如下:
python
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. 单例模式
typescript
export class OPFSCache {
private static instance: OPFSCache;
private constructor() {}
public static getInstance(): OPFSCache {
if (!OPFSCache.instance) {
OPFSCache.instance = new OPFSCache();
}
return OPFSCache.instance;
}
}
确保全局只有一个缓存实例,避免重复初始化。
2. 目录初始化
typescript
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 目录,并缓存其句柄。images 和 pointClouds 子目录类似。
3. 缓存键生成
typescript
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. 图片缓存
写入
typescript
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 类型分别存储。
读取
typescript
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. 点云缓存
数据结构定义
typescript
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 };
}
写入
typescript
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);
}
为每个点云数据创建一个子目录,将元信息和各个属性分别存储为独立文件。注意写入前会删除旧目录,保证数据一致性。
读取
typescript
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 处理函数替换为占位符)的完整代码。
typescript
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();
使用示例
typescript
// 初始化(建议在应用启动时调用)
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,我们可以轻松实现前端高性能缓存,为图片密集型和点云应用带来质的飞跃。希望本文能为大家提供一些实用的思路和代码参考。