1
头图

文件系统标准(File System Standard)引入了源私有文件系统(origin private file system,OPFS),作为页面源私有的、用户不可见的存储端点,它提供了对一种特殊文件(a special kind of file)的可选访问,并对性能进行了高度优化。

庆祝
源私有文件系统允许网络应用程序在自己特定的源虚拟文件系统中存储和操作文件,包括低级文件操作、逐字节访问和文件流。所有主要浏览器都支持源私有文件系统。

浏览器支持

源私有文件系统被现代浏览器支持,并由网络超文本应用程序技术工作组(WHATWG)标准化为 File System Living Standard

浏览器兼容性

动机

提到计算机上的文件,你可能会想到这样的文件层次结构:文件被组织在文件夹中——你可以用操作系统的文件资源管理器来查看。例如,在 Windows 上,对于一个名为 Tom 的用户,他的待办事项列表可能位于 C:\Users\Tom\Documents\ToDo.txt。在这个示例中,ToDo.txt 是文件名,UsersTomDocuments 是文件夹名。Windows 上的 C:\ 表示驱动器的根目录。

在本文中,我交替使用文件夹目录这两个术语,而忽略了文件系统概念(目录)和图形用户界面隐喻(文件夹)之间的区别。

在 Web 上处理文件的传统方式

若要在网络应用程序中编辑待办事项列表,这是传统的流程:

  1. 用户将文件上传到服务器,或在客户端使用 <input type="file"> 打开文件。
  2. 用户进行编辑,然后通过 JavaScript 以编程方式对注入的 <a download="ToDo.txt"> 执行 click() 方法来下载生成的文件。
  3. 要打开文件夹,可以使用 <input type="file" webkitdirectory>,尽管该属性的名称具有私有前缀,但实际上得到了浏览器的普遍支持。

在 Web 上处理文件的现代方式

这种流程并不代表用户编辑文件的思维方式,这意味着用户最终只能下载其输入文件的副本。因此,文件系统访问 API(File System Access API)引入了三个选择器方法——showOpenFilePicker()showSaveFilePicker()showDirectoryPicker(),它们的功能和名称一样(译注:它们的功能依次为打开文件、保存文件和打开目录)。通过它们,可以以如下的流程来处理文件:

  1. 使用 showOpenFilePicker() 打开 ToDo.txt,得到一个 FileSystemFileHandle 对象。
  2. 通过调用文件句柄 FileSystemFileHandle 对象的 getFile() 方法获取 File
  3. 编辑文件,然后在句柄上调用 requestPermission({ mode: 'readwrite' })
  4. 如果用户接受权限请求,则将更改保存回原始文件。
  5. 或者,调用 showSaveFilePicker() 让用户选择一个新文件。 (如果用户选择的是之前打开的文件,其内容将被覆盖。)对于重复保存,可以保留文件句柄,这样就不必再次显示文件保存对话框。

在 Web 上处理文件的限制

通过这些方法访问的文件和文件夹位于用户可见文件系统中。从网络上保存的文件,特别是可执行文件,都会被打上网络标记,因此在潜在危险文件被执行之前,操作系统会显示额外的警告。作为一项额外的安全功能,从网络上获取的文件也会受到安全浏览的保护,为简单起见,在本文中,你可以将其视为基于云的病毒扫描。当使用文件系统访问 API 向文件写入数据时,写入不是就地写入,而是会使用临时文件。除非通过所有这些安全检查,否则文件本身不会被修改。可以想象,即使在 macOS 等系统上尽可能地进行了优化,但这些检查还是让文件操作变得相对缓慢。尽管如此,每个 write() 调用都是独立的,因此它在后台会打开文件,查找到给定的偏移量,并最终写入数据。

作为处理工作基础的文件

同时,文件也是记录数据的绝佳方式。例如,SQLite 将整个数据库存储在一个文件中。另一个例子是用于图像处理的 mipmaps。Mipmaps 是经过预先计算和优化的图像序列,每一幅图像都是前一幅图像的分辨率逐渐降低的表示,这使得许多操作(如缩放)变得更快。那么,网络应用程序如何既能获得文件的优势,又能避免传统网络文件处理的性能成本呢?答案是源私有文件系统

用户可见性与源私有文件系统

不同于通过操作系统的文件资源管理器浏览的、你可以读取、写入、移动和重命名文件和文件夹的用户可见文件系统,源私有文件系统不会被用户看到。顾名思义,源私有文件系统中的文件和文件夹是私有的,更具体地说,是网站的的私有文件系统。在 DevTools 控制台中输入 location.origin 来查找页面的源。例如,页面 https://developer.chrome.com/articles/ 的源是 https://developer.chrome.com(即 /articles 是源的一部分)。你可以在理解“同站”和“同源”一文中阅读更多关于源理论的内容。共享相同源的所有页面都可以在源私有文件系统中看到相同的数据,因此 https://developer.chrome.com/docs/extensions/mv3/getstarted/extensions-101/ 可以看到与上例相同的信息。每个源都有自己独立的源私有文件系统,这意味着 https://developer.chrome.com 的源私有文件系统与 https://web.dev 的源私有文件系统完全不同。在 Windows 系统中,用户可见文件系统的根目录是 C:\。而源私有文件系统的相似项是通过调用异步方法 navigator.storage.getDirectory() 得到的每个源的初始的空的根目录。关于用户可见文件系统与源私有文件系统的比较,请见下图。从图中可以看出,除了根目录外,其他所有东西在概念上都是一样的,都具有文件和文件夹的层次结构,可以根据数据和存储需要进行组织和排列。

用户可见文件系统和源私有文件系统的示意图,以及两个示例文件层次结构。用户可见文件系统的入口点是一个符号硬盘,源私有文件系统的入口点是调用“navigator.storage.getDirectory”方法。

源私有文件系统的特点

与浏览器中的其他存储机制(如 localStorageIndexedDB)一样,源私有文件系统也受浏览器配额的限制。当用户清除所有浏览数据所有网站数据时,源私有文件系统也将被删除。要了解应用程序已经消耗了多少存储空间,请调用 navigator.storage.estimate(),然后在返回的对象中查看 usage 条目。如果你想要特别查看 fileSystem 条目的信息,请看 usageDetails 对象,它是按存储方式细分的。由于源私有文件系统对用户不可见,因此没有权限提示,也没有安全浏览检查。

访问根目录

要访问根目录,请运行下面的命令。你最后会得到一个空目录句柄,更确切地说,是一个 FileSystemDirectoryHandle

const opfsRoot = await navigator.storage.getDirectory();
// 类型为 "directory"、名称为 "" 的 FileSystemDirectoryHandle。
console.log(opfsRoot);

主线程或 Web Worker

有两种方法使用源私有文件系统:在主线程上或在 Web Worker 中。Web Worker 不会阻塞主线程,这意味着在这里,API 可以是同步的,而在主线程上通常不会允许这种模式。同步 API 可以更快,因为它们可以避免处理 promise,且在 C 语言等可以编译成 WebAssembly 的语言中,文件操作通常是同步的。

// 这是同步的 C 代码。
FILE *f;
f = fopen("example.txt", "w+");
fputs("Some text\n", f);
fclose(f);

如果你需要最快的文件操作和/或需要使用 WebAssembly,请跳至在 Web Worker 中使用源私有文件系统。否则,你可以继续阅读。

在主线程上使用源私有文件系统

创建新文件和文件夹

有了根目录句柄后,分别使用 getFileHandle()getDirectoryHandle() 方法创建文件和文件夹。通过传递 { create: true } 参数,当文件或文件夹不存在时,它将被创建。然后通过在新创建的目录上调用这些函数来建立文件层次结构。

const fileHandle = await opfsRoot.getFileHandle("my first file", {
  create: true,
});
const directoryHandle = await opfsRoot.getDirectoryHandle("my first folder", {
  create: true,
});
const nestedFileHandle = await directoryHandle.getFileHandle(
  "my first nested file",
  { create: true },
);
const nestedDirectoryHandle = await directoryHandle.getDirectoryHandle(
  "my first nested folder",
  { create: true },
);

从前面的代码示例中得到的文件层次结构。

访问现有文件和文件夹

如果你知道文件和文件夹的名称,就可以通过调用 getFileHandle()getDirectoryHandle() 方法,并传入文件或文件夹的名称来访问以前创建的文件和文件夹。

const existingFileHandle = await opfsRoot.getFileHandle("my first file");
const existingDirectoryHandle =
  await opfsRoot.getDirectoryHandle("my first folder");

读取与文件句柄相关联的文件

FileSystemFileHandle 表示文件系统中的一个文件。要获取关联的 File 对象,请使用 getFile() 方法。 File 对象是 Blob 的一种特殊类型,可以在任何能使用 Blob 的上下文中使用。尤其是 FileReaderURL.createObjectURL()createImageBitmap()XMLHttpRequest.send(),它们都能处理 BlobFile。如果你愿意,从 FileSystemFileHandle 获取 File 来“释放”数据,这样你就可以访问它,并将其提供给用户可见文件系统。

const file = await fileHandle.getFile();
console.log(await file.text());

通过流写入文件

为了将数据流写入文件,你需要通过调用 createWritable() 创建一个 FileSystemWritableFileStream,然后使用 write() 将内容写入文件。最后,需要用 close() 来关闭流。

const contents = "Some text";
// 获取一个可写流。
const writable = await fileHandle.createWritable();
// 将文件内容写入数据流。
await writable.write(contents);
// 关闭数据流,保存内容。
await writable.close();

删除文件和文件夹

通过调用特定文件或目录句柄上的 remove() 方法来删除文件和文件夹。要删除包括所有子文件夹在内的文件夹,请使用 { recursive: true } 选项。

await fileHandle.remove();
await directoryHandle.remove({ recursive: true });
remove() 方法目前只在 Chrome 浏览器中实现。你可以通过 'remove' in FileSystemFileHandle.prototype 来检测浏览器是否支持该功能。

作为替代,如果知道目录中要删除的文件或文件夹的名称,可以使用 removeEntry() 方法。

directoryHandle.removeEntry("my first nested file");
作为快速提示,await (await navigator.storage.getDirectory()).remove({ recursive: true }) 是清除整个源私有文件系统的最快方法。

移动和重命名文件和文件夹

使用 move() 方法重命名或移动文件和文件夹。移动和重命名可以同时进行,也可以单独进行。

// 重命名文件。
await fileHandle.move("my first renamed file");
// 将文件移动到另一个目录。
await fileHandle.move(nestedDirectoryHandle);
// 将文件移动到另一个目录并重命名。
await fileHandle.move(
  nestedDirectoryHandle,
  "my first renamed and now nested file",
);
重命名和移动文件夹在 Chrome 浏览器中尚未实现。你也不能将文件从源私有文件系统移动到用户可见文件系统。不过你可以复制它们。

解析文件或文件夹的路径

要了解给定文件或文件夹相对于参照目录(reference directory)的位置,请使用 resolve() 方法,并将 FileSystemHandle 作为参数传递给该方法。要获取源私有文件系统中文件或文件夹的完整路径,请将通过 navigator.storage.getDirectory() 获得的根目录作为参照目录。

const relativePath = await opfsRoot.resolve(nestedDirectoryHandle);
// `relativePath` 为 `['my first folder', 'my first nested folder']`。

检查两个文件或文件夹句柄是否指向同一个文件或文件夹

有时,你有两个句柄,却不知道它们是否指向同一个文件或文件夹。对于这种情况,可以使用 isSameEntry() 方法。

fileHandle.isSameEntry(nestedFileHandle);
// 返回 `false`。

列出文件夹的内容

FileSystemDirectoryHandle 是一个异步迭代器,可通过 for await…of 循环遍历。作为异步迭代器,它还支持 entries()values()keys() 方法,你可以根据自己需要的信息从中进行选择:

for await (let [name, handle] of directoryHandle) {}
for await (let [name, handle] of directoryHandle.entries()) {}
for await (let handle of directoryHandle.values()) {}
for await (let name of directoryHandle.keys()) {}

递归列出文件夹和所有子文件夹的内容

处理与递归搭配的异步循环和函数很容易出错。下面的函数可以作为一个起点,用于列出文件夹及其所有子文件夹的内容,包括所有文件及其大小。如果你不需要文件大小,可以简化函数:在 directoryEntryPromises.push 处不推送 handle.getFile() 的 promise,而是直接推送 handle

const getDirectoryEntriesRecursive = async (
  directoryHandle,
  relativePath = ".",
) => {
  const fileHandles = [];
  const directoryHandles = [];
  const entries = {};
  // 获取目录中文件和文件夹的迭代器。
  const directoryIterator = directoryHandle.values();
  const directoryEntryPromises = [];
  for await (const handle of directoryIterator) {
    const nestedPath = `${relativePath}/${handle.name}`;
    if (handle.kind === "file") {
      fileHandles.push({ handle, nestedPath });
      directoryEntryPromises.push(
        handle.getFile().then((file) => {
          return {
            name: handle.name,
            kind: handle.kind,
            size: file.size,
            type: file.type,
            lastModified: file.lastModified,
            relativePath: nestedPath,
            handle,
          };
        }),
      );
    } else if (handle.kind === "directory") {
      directoryHandles.push({ handle, nestedPath });
      directoryEntryPromises.push(
        (async () => {
          return {
            name: handle.name,
            kind: handle.kind,
            relativePath: nestedPath,
            entries: await getDirectoryEntriesRecursive(handle, nestedPath),
            handle,
          };
        })(),
      );
    }
  }
  const directoryEntries = await Promise.all(directoryEntryPromises);
  directoryEntries.forEach((directoryEntry) => {
    entries[directoryEntry.name] = directoryEntry;
  });
  return entries;
};

在 Web Worker 中使用源私有文件系统

如前所述,Web Worker 不能阻塞主线程,因此在这种情况下允许使用同步方法。

获取同步访问句柄

最快的文件操作入口点是 FileSystemSyncAccessHandle,可以通过在一个常规的 FileSystemFileHandle 上调用 createSyncAccessHandle() 获取。

const fileHandle = await opfsRoot.getFileHandle("my highspeed file.txt", {
  create: true,
});
const syncAccessHandle = await fileHandle.createSyncAccessHandle();
这可能会令人困惑,但实际上,你可以从常规的 FileSystemFileHandle 上获得同步的 FileSystemSyncAccessHandle。还要注意的是,尽管方法 createSyncAccessHandle() 的名称中包含 Sync,但该方法是异步的

同步就地文件方法

一旦拥有了同步访问句柄,你就可以访问所有快速的同步就地文件方法。

  • getSize():以字节为单位返回文件大小。
  • write():将缓冲区的内容写入文件,可选择写入到指定偏移量,并返回写入的字节数。通过检查返回的字节数,调用者可以检测并处理错误和部分写入。
  • read():将文件内容读入缓冲区,可选择从指定偏移量读起。
  • truncate():将文件调整为给定大小。
  • flush():确保文件内容包含通过 write() 所做的所有修改。
  • close():关闭访问句柄。

下面是一个使用上述所有方法的示例。

const opfsRoot = await navigator.storage.getDirectory();
const fileHandle = await opfsRoot.getFileHandle("fast", { create: true });
const accessHandle = await fileHandle.createSyncAccessHandle();

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

// 根据文件大小初始化该变量。
let size;
// 文件的当前大小,最初为 `0`。
size = accessHandle.getSize();
// 编码要写入文件的内容。
const content = textEncoder.encode("Some text");
// 将内容写入文件开头。
accessHandle.write(content, { at: size });
// 刷新(flush)更改。
accessHandle.flush();
// 文件的当前大小,现在是 `9`("Some text"的长度)。
size = accessHandle.getSize();

// 编码更多内容以写入文件。
const moreContent = textEncoder.encode("More content");
// 将内容写入文件末尾。
accessHandle.write(moreContent, { at: size });
// 刷新(flush)更改。
accessHandle.flush();
// 文件的当前大小,现在是 `21`("Some textMore content"的长度)。
size = accessHandle.getSize();

// 准备与文件具有相同长度的 DataView。
const dataView = new DataView(new ArrayBuffer(size));

// 将整个文件读入 DataView。
accessHandle.read(dataView);
// 打印 `"Some textMore content"`。
console.log(textDecoder.decode(dataView));

// 从偏移量 9 开始读入 DataView。
accessHandle.read(dataView, { at: 9 });
// 打印 `"More content"`。
console.log(textDecoder.decode(dataView));

// 在 4 字节后截断文件。
accessHandle.truncate(4);
请注意,read()write() 的第一个参数是 ArrayBufferArrayBufferView(如 DataView)。你不能直接操作 ArrayBuffer 中的内容。相反,你需要创建一个以特定格式表示缓冲区的类型化数组对象(如 Int8ArrayDataView 对象),然后使用它来读写缓冲区中的内容。

从源私有文件系统向用户可见文件系统复制文件

如前所述,无法将文件从源私有文件系统移动到用户可见文件系统,但可以复制文件。因为 showSaveFilePicker() 只在主线程中暴露,而不存在于 Worker 线程中,所以请确保在主线程中运行下面的代码。

// 在主线程上,而不是在 Worker 中。
// 假设 `fileHandle` 是你在 Worker 线程中用于获取的 `FileSystemSyncAccessHandle` 的 `FileSystemFileHandle`。
// 请确保先关闭 Worker 线程中的文件。
const fileHandle = await opfsRoot.getFileHandle("fast");
try {
  // 获取用户可见文件系统中新文件的句柄,该文件与源私有文件系统中的文件名相同。
  const saveHandle = await showSaveFilePicker({
    suggestedName: fileHandle.name || "",
  });
  const writable = await saveHandle.createWritable();
  await writable.write(await fileHandle.getFile());
  await writable.close();
} catch (err) {
  console.error(err.name, err.message);
}

调试源私有文件系统

在内置的 DevTools 支持(参见 crbug/1284595)被添加之前,请使用 OPFS Explorer Chrome 浏览器扩展调试源私有文件系统。上面创建新文件和文件夹部分的截图就是直接从扩展中截取的。

Chrome Web Store 上的 OPFS Explorer Chrome DevTools 扩展。

安装扩展后,打开 Chrome 浏览器的 DevTools,选择 OPFS Explorer 选项卡,然后你就可以检查文件层次结构了。点击文件名可将文件从源私有文件系统保存到用户可见文件系统,点击垃圾桶图标可删除文件或文件夹。

演示

在将源私有文件系统用作编译为 WebAssembly 的 SQLite 数据库后端的演示中,你可以看到源私有文件系统的运行情况(如果你安装了 OPFS Explorer 扩展)。请务必查看 Glitch 上的源代码。请注意,下面的嵌入式版本并不使用源私有文件系统后端(因为 iframe 是跨源的),但当你在单独的标签页中打开演示时,它就会使用。

(译注:这里嵌入不了 iframe,所以请点击 URL 查看演示:https://sqlite-wasm-opfs.glitch.me/

结论

由 WHATWG 制定的源私有文件系统塑造了我们在网络上使用文件和与文件交互的方式。它实现了用户可见文件系统无法实现的新用例。所有主要的浏览器供应商——苹果、Mozilla 和谷歌——都参与其中,并拥有共同的愿景。源私有文件系统的开发在很大程度上是一项协作工作,开发人员和用户的反馈对其进展至关重要。在我们不断完善和改进该标准的过程中,欢迎以议题或拉取请求的方式向 whatwg/fs 存储库提供反馈。

相关链接

致谢

本文由 Austin SullyEtienne NoëlRachel Andrew 审阅。封面图来自 Unsplash 上的 Christina Rumpf


周盛道
1.4k 声望403 粉丝