源私有文件系统

文件系统标准引入了一个源专用文件系统 (OPFS),作为对页面来源专用且对用户不可见的存储端点,该系统可让您选择访问针对性能进行了高度优化的特殊文件。

浏览器支持

原始私有文件系统受新型浏览器支持,并由网络超文本应用技术工作组 (WHATWG) 在文件系统生活标准中进行了标准化。

浏览器支持

  • Chrome:86。 <ph type="x-smartling-placeholder">
  • Edge:86。 <ph type="x-smartling-placeholder">
  • Firefox:111。 <ph type="x-smartling-placeholder">
  • Safari:15.2. <ph type="x-smartling-placeholder">

来源

设计初衷

想到计算机上的文件时,您可能会想到文件层次结构:以文件夹形式整理的文件,您可以使用操作系统的文件资源管理器浏览这些文件。例如,在 Windows 上,对于名为 Tom 的用户,其待办事项列表可能位于 C:\Users\Tom\Documents\ToDo.txt 中。在此示例中,ToDo.txt 是文件名,UsersTomDocuments 是文件夹名称。Windows 上的“C:”表示驱动器的根目录。

在网络上处理文件的传统方式

要在 Web 应用中修改待办事项列表,通常流程如下:

  1. 用户将文件上传到服务器或使用 <input type="file"> 在客户端打开该文件。
  2. 用户进行更改,然后下载生成的文件,其中包含通过 JavaScript 以编程方式click()注入的 <a download="ToDo.txt>
  3. 要打开文件夹,您需要使用 <input type="file" webkitdirectory> 中的一个特殊属性。尽管该属性为专有名称,但实际上它具备通用的浏览器支持。

在网络上处理文件的新方式

此流程并不代表用户对编辑文件的想法,而是意味着用户最终获得下载下来的输入文件副本。因此,File System Access API 引入了三种选择器方法:showOpenFilePicker()showSaveFilePicker()showDirectoryPicker(),它们完全符合其名称提示。它们通过如下方式启用数据流:

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

在网络上使用文件的限制

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

将文件作为处理的基础

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

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

与使用操作系统的文件资源管理器浏览的用户可见文件系统不同,您可以读取、写入、移动和重命名文件和文件夹,而原始私有文件系统不会被用户看到。顾名思义,源专用文件系统中的文件和文件夹是不公开的,更具体地说,对网站的来源是不公开的。通过在开发者工具控制台中输入 location.origin 来了解页面的来源。例如,网页 https://developer.chrome.com/articles/ 的来源为 https://developer.chrome.com(即部分 /articles 不属于来源)。如需详细了解来源理论,请参阅了解“same-site”和“same-origin”。同一来源的所有网页都可以看到同源私有文件系统数据,因此 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 条目,以了解应用已经消耗了多少存储空间,这按 usageDetails 对象中的存储机制进行了细分,而您在这里专门查看的是 fileSystem 条目。由于用户看不到源专用文件系统,因此系统不会显示权限提示,也不会进行安全浏览检查。

获取根目录的访问权限

如需访问根目录,请运行以下命令。最终您将得到一个空的目录句柄,更具体地说,就是一个 FileSystemDirectoryHandle

const opfsRoot = await navigator.storage.getDirectory();
// A FileSystemDirectoryHandle whose type is "directory"
// and whose name is "".
console.log(opfsRoot);

主线程或 Web Worker

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

// This is synchronous C code.
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() 同时接受 BlobsFiles。如果会,从 FileSystemFileHandle“释放”获取 File这样您便可以访问这些数据,并将其提供给用户可见的文件系统。

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

通过流式传输写入文件

通过调用 createWritable() 将数据流式传输到文件中,该方法会创建一个 FileSystemWritableFileStream,然后write()内容。最后,您需要 close() 该数据流。

const contents = 'Some text';
// Get a writable stream.
const writable = await fileHandle.createWritable();
// Write the contents of the file to the stream.
await writable.write(contents);
// Close the stream, which persists the contents.
await writable.close();

删除文件和文件夹

通过调用文件或文件夹的文件或目录句柄的特定 remove() 方法删除文件和文件夹。如需删除包含所有子文件夹的文件夹,请传递 {recursive: true} 选项。

await fileHandle.remove();
await directoryHandle.remove({recursive: true});

或者,如果您知道目录中要删除的文件或文件夹的名称,请使用 removeEntry() 方法。

directoryHandle.removeEntry('my first nested file');

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

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

// Rename a file.
await fileHandle.move('my first renamed file');
// Move a file to another directory.
await fileHandle.move(nestedDirectoryHandle);
// Move a file to another directory and rename it.
await fileHandle
    .move(nestedDirectoryHandle, 'my first renamed and now nested file');

解析文件或文件夹的路径

如需了解给定文件或文件夹相对于引用目录的位置,请使用 resolve() 方法,并向其传递 FileSystemHandle 作为参数。如需获取源专用文件系统中文件或文件夹的完整路径,请将根目录用作通过 navigator.storage.getDirectory() 获取的引用目录。

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

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

有时,您有两个标识名,但不知道它们是否指向同一个文件或文件夹。如需检查是否属于这种情况,请使用 isSameEntry() 方法。

fileHandle.isSameEntry(nestedFileHandle);
// Returns `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 = {};
    // Get an iterator of the files and folders in the directory.
    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,通过调用 createSyncAccessHandle() 从常规 FileSystemFileHandle 获取。

const fileHandle = await opfsRoot
    .getFileHandle('my highspeed file.txt', {create: true});
const syncAccessHandle = await fileHandle.createSyncAccessHandle();

同步就地文件方法

有了同步访问句柄之后,您便可以访问所有同步的快速就地文件方法。

  • 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();

// Initialize this variable for the size of the file.
let size;
// The current size of the file, initially `0`.
size = accessHandle.getSize();
// Encode content to write to the file.
const content = textEncoder.encode('Some text');
// Write the content at the beginning of the file.
accessHandle.write(content, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `9` (the length of "Some text").
size = accessHandle.getSize();

// Encode more content to write to the file.
const moreContent = textEncoder.encode('More content');
// Write the content at the end of the file.
accessHandle.write(moreContent, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `21` (the length of
// "Some textMore content").
size = accessHandle.getSize();

// Prepare a data view of the length of the file.
const dataView = new DataView(new ArrayBuffer(size));

// Read the entire file into the data view.
accessHandle.read(dataView);
// Logs `"Some textMore content"`.
console.log(textDecoder.decode(dataView));

// Read starting at offset 9 into the data view.
accessHandle.read(dataView, {at: 9});
// Logs `"More content"`.
console.log(textDecoder.decode(dataView));

// Truncate the file after 4 bytes.
accessHandle.truncate(4);

将文件从源专用文件系统复制到用户可见的文件系统

如上所述,您无法将文件从源私有文件系统移至用户可见的文件系统,但可以复制文件。由于 showSaveFilePicker() 仅在主线程上公开,不会在工作器线程中公开,因此请务必在工作器线程中运行代码。

// On the main thread, not in the Worker. This assumes
// `fileHandle` is the `FileSystemFileHandle` you obtained
// the `FileSystemSyncAccessHandle` from in the Worker
// thread. Be sure to close the file in the Worker thread first.
const fileHandle = await opfsRoot.getFileHandle('fast');
try {
  // Obtain a file handle to a new file in the user-visible file system
  // with the same name as the file in the origin private file system.
  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);
}

调试源专用文件系统

在添加内置的开发者工具支持(请参阅 crbug/1284595)之前,请使用 OPFS Explorer Chrome 扩展程序调试源专用文件系统。顺便提一下,上面的新建文件和文件夹部分的屏幕截图是直接从该扩展程序截取的。

Chrome 应用商店中的 OPFS Explorer Chrome 开发者工具扩展程序。

安装扩展程序后,打开 Chrome 开发者工具,选择 OPFS Explorer 标签页,然后即可检查文件层次结构。点击文件名,将文件从源专用文件系统保存到用户可见的文件系统,点击垃圾桶图标删除文件和文件夹。

演示

通过演示查看源专用文件系统的实际运作方式(如果安装了 OPFS Explorer 扩展程序),该演示将该文件用作编译为 WebAssembly 的 SQLite 数据库的后端。请务必查看 Glitch 上的源代码。请注意,下面的嵌入版本如何使用源私有文件系统后端(因为 iframe 是跨源的),但当您在单独的标签页中打开演示时,它会使用。

总结

WHATWG 指定的源私有文件系统塑造了我们在网络上使用文件和与文件交互的方式。它实现了通过用户可见的文件系统无法实现的新用例。所有主要浏览器供应商(Apple、Mozilla 和 Google)都将加入我们,并拥有共同的愿景。原始私有文件系统的开发在很大程度上需要大家通力协作,开发者和用户的反馈对于该系统的开发至关重要。随着我们不断优化和改进该标准,欢迎您以“问题”或“拉取请求”的形式向 whatwg/fs 代码库提供反馈。

致谢

本文由 Austin SullyEtienne NoëlRachel Andrew 审核。主打图片,由 Christina RumpfUnshot 中使用。