使用 browser-fs-access 库读取和写入文件和目录

浏览器已经能够处理文件和目录很长时间了。File API 提供了用于在 Web 应用中表示文件对象的功能,以及以编程方式选择文件对象和访问其数据的功能。不过,仔细观察后,您会发现并非所有闪光的东西都是金子。

传统的文件处理方式

打开文件

作为开发者,您可以通过 <input type="file"> 元素打开和读取文件。在最简单的情况下,打开文件的代码可能如以下代码示例所示。input 对象会为您提供 FileList,在以下示例中,它仅包含一个 FileFile 是一种特定的 Blob,可在 Blob 可用的任何上下文中使用。

const openFile = async () => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.addEventListener('change', () => {
      resolve(input.files[0]);
    });
    input.click();
  });
};

打开目录

如需打开文件夹(或目录),您可以设置 <input webkitdirectory> 属性。除此之外,其他所有操作与上述操作相同。尽管 webkitdirectory 带有供应商前缀,但它不仅可在 Chromium 和 WebKit 浏览器中使用,还可在基于旧版 EdgeHTML 的 Edge 以及 Firefox 中使用。

保存(即下载)文件

传统上,如需保存文件,您只能下载文件,这得益于 <a download> 属性。给定 Blob 后,您可以将锚点的 href 属性设置为 blob: 网址,该网址可从 URL.createObjectURL() 方法获取。

const saveFile = async (blob) => {
  const a = document.createElement('a');
  a.download = 'my-file.txt';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', (e) => {
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  a.click();
};

问题

下载方法的一个重大缺点是,无法实现传统的“打开”→“修改”→“保存”流程,也就是说,无法覆盖原始文件。相反,每次您“保存”时,系统都会在操作系统的默认“下载内容”文件夹中创建原始文件的新副本

File System Access API

File System Access API 可简化打开和保存这两项操作。它还支持真正的保存,也就是说,您不仅可以选择文件的保存位置,还可以覆盖现有文件。

打开文件

借助 File System Access API,只需调用一次 window.showOpenFilePicker() 方法即可打开文件。此调用会返回一个文件句柄,您可以通过 getFile() 方法从中获取实际的 File

const openFile = async () => {
  try {
    // Always returns an array.
    const [handle] = await window.showOpenFilePicker();
    return handle.getFile();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

打开目录

通过调用 window.showDirectoryPicker() 打开目录,这样便可以在文件对话框中选择目录。

保存文件

保存文件同样简单。 您可以通过 createWritable() 从文件句柄创建可写入的流,然后调用流的 write() 方法写入 Blob 数据,最后调用其 close() 方法关闭流。

const saveFile = async (blob) => {
  try {
    const handle = await window.showSaveFilePicker({
      types: [{
        accept: {
          // Omitted
        },
      }],
    });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
    return handle;
  } catch (err) {
    console.error(err.name, err.message);
  }
};

推出 browser-fs-access

虽然 File System Access API 非常出色,但尚未广泛提供

File System Access API 的浏览器支持表。所有浏览器都标记为“不支持”或“需要启用标志”。
File System Access API 的浏览器支持表。 (来源

因此,我认为 File System Access API 是一项渐进式增强功能。因此,我想在浏览器支持时使用它,而不支持时使用传统方法;同时,绝不会让用户因下载不受支持的 JavaScript 代码而受到不必要的惩罚。browser-fs-access 库是我为解决此问题而提出的方案。

设计理念

由于 File System Access API 未来可能仍会发生变化,因此 browser-fs-access API 并未以其为蓝本。也就是说,该库不是 polyfill,而是 ponyfill。您可以(静态或动态)专门导入所需的任何功能,以尽可能缩减应用大小。可用的方法分别是名为 fileOpen()directoryOpen()fileSave() 的方法。在内部,该库会检测是否支持文件系统访问 API,然后导入相应的代码路径。

使用 browser-fs-access 库

这三种方法都非常直观易用。 您可以指定应用接受的 mimeTypes 或文件 extensions,并设置 multiple 标志,以允许或禁止选择多个文件或目录。如需了解完整详情,请参阅 browser-fs-access API 文档。以下代码示例展示了如何打开和保存图片文件。

// The imported methods will use the File
// System Access API or a fallback implementation.
import {
  fileOpen,
  directoryOpen,
  fileSave,
} from 'https://unpkg.com/browser-fs-access';

(async () => {
  // Open an image file.
  const blob = await fileOpen({
    mimeTypes: ['image/*'],
  });

  // Open multiple image files.
  const blobs = await fileOpen({
    mimeTypes: ['image/*'],
    multiple: true,
  });

  // Open all files in a directory,
  // recursively including subdirectories.
  const blobsInDirectory = await directoryOpen({
    recursive: true
  });

  // Save a file.
  await fileSave(blob, {
    fileName: 'Untitled.png',
  });
})();

演示

您可以在 Glitch 上的演示中查看上述代码的实际运作情况。您也可以在该处找到其源代码。出于安全考虑,跨源子框架不允许显示文件选择器,因此无法将该演示嵌入到本文中。

实际使用的 browser-fs-access 库

在空闲时间,我会为一个名为 Excalidraw可安装的 PWA 做出一些贡献。这是一个白板工具,可让您轻松绘制具有手绘风格的图表。它完全响应式,可在各种设备(从小屏手机到大屏幕计算机)上正常运行。 这意味着,无论各种平台是否支持 File System Access API,它都需要处理这些平台上的文件。因此,它非常适合 browser-fs-access 库。

例如,我可以在 iPhone 上开始绘制,将其保存(从技术层面讲,是下载,因为 Safari 不支持 File System Access API)到 iPhone 的“下载”文件夹,在桌面上打开该文件(从手机上传输后),修改该文件,并使用我所做的更改覆盖该文件,甚至可以将其另存为新文件。

iPhone 上的 Excalidraw 绘图。
在 iPhone 上启动 Excalidraw 绘图,该 iPhone 不支持 File System Access API,但可以将文件保存(下载)到“下载”文件夹。
桌面版 Chrome 中修改后的 Excalidraw 绘图。
在支持 File System Access API 且可通过该 API 访问文件的桌面设备上打开和修改 Excalidraw 绘图。
使用修改内容覆盖原始文件。
使用对原始 Excalidraw 绘图文件所做的修改覆盖原始文件。浏览器会显示一个对话框,询问我是否可以这样做。
将修改内容保存到新的 Excalidraw 绘图文件。
将修改内容保存到新的 Excalidraw 文件。原始文件保持不变。

真实代码示例

下面,您可以看到 browser-fs-access 在 Excalidraw 中的实际使用示例。此摘录取自 /src/data/json.ts。特别值得注意的是,saveAsJSON() 方法如何将文件句柄或 null 传递给 browser-fs-access 的 fileSave() 方法,这会导致在提供句柄时覆盖,否则保存到新文件。

export const saveAsJSON = async (
  elements: readonly ExcalidrawElement[],
  appState: AppState,
  fileHandle: any,
) => {
  const serialized = serializeAsJSON(elements, appState);
  const blob = new Blob([serialized], {
    type: "application/json",
  });
  const name = `${appState.name}.excalidraw`;
  (window as any).handle = await fileSave(
    blob,
    {
      fileName: name,
      description: "Excalidraw file",
      extensions: ["excalidraw"],
    },
    fileHandle || null,
  );
};

export const loadFromJSON = async () => {
  const blob = await fileOpen({
    description: "Excalidraw files",
    extensions: ["json", "excalidraw"],
    mimeTypes: ["application/json"],
  });
  return loadFromBlob(blob);
};

界面注意事项

无论是在 Excalidraw 中还是在您的应用中,界面都应根据浏览器的支持情况进行调整。如果支持 File System Access API (if ('showOpenFilePicker' in window) {}),除了 Save 按钮之外,您还可以显示 Save As 按钮。以下屏幕截图显示了 Excalidraw 在 iPhone 和 Chrome 桌面版上的自适应主应用工具栏之间的差异。请注意,iPhone 上缺少 Save As 按钮。

iPhone 上的 Excalidraw 应用工具栏,其中只有一个“保存”按钮。
iPhone 上的 Excalidraw 应用工具栏,仅包含一个 Save 按钮。
桌面版 Chrome 上的 Excalidraw 应用工具栏,其中显示了“保存”和“另存为”按钮。
Chrome 上的 Exacalidraw 应用工具栏,其中显示了 Save 和处于聚焦状态的 Save As 按钮。

总结

从技术层面讲,在所有现代浏览器中都可以处理系统文件。在支持 File System Access API 的浏览器上,您可以允许真正保存和覆盖(而不仅仅是下载)文件,并允许用户在任何位置创建新文件,从而改善用户体验,同时在不支持 File System Access API 的浏览器上保持功能正常。browser-fs-access 可处理渐进增强的细微之处,并尽可能简化代码,从而让您的工作更轻松。

致谢

本文由 Joe MedleyKayce Basques 审核。 感谢 Excalidraw 的贡献者参与该项目的工作,并审核我的拉取请求。主打图片:Unsplash 上的 Ilya Pavlov