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

长期以来,浏览器一直能够处理文件和目录。File API 提供了在 Web 应用中表示文件对象以及以编程方式选择文件对象并访问其数据的功能。不过,当你靠近时,就会发现闪烁着的并不是金子。

以前,处理文件的

正在打开文件

作为开发者,您可以通过 <input type="file"> 元素打开和读取文件。打开文件的最简单形式与以下代码示例类似。input 对象会为您提供 FileList,在下面的示例中,它仅包含一个 FileFileBlob 的一种特定类型,可在 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 属性设置为可以从 URL.createObjectURL() 方法获取的 blob: 网址。

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()。在内部,该库功能会检测是否支持 File System Access 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 上开始绘图,将其保存到我的 iPhone 的“下载”文件夹中(从技术层面来讲,就是将其下载,因为 Safari 不支持 File System Access API),在我的桌面上打开该文件(从手机传输后),修改该文件,并使用我的更改覆盖它,甚至将其另存为新文件。

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

实际代码示例

下面,您可以看到一个在 Excalidraw 中使用的 browser-fs-access 实际示例。此摘要摘录自 /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) {}),除了保存按钮之外,您还可以显示另存为按钮。以下屏幕截图显示了 iPhone 和桌面版 Chrome 上 Excalidraw 的自适应主应用工具栏之间的区别。 请注意 iPhone 上的另存为按钮缺失的情况。

iPhone 上的 Excalidraw 应用工具栏,只有一个“Save”按钮。
在 iPhone 上,只有一个 Save 按钮的 Excalidraw 应用工具栏。
Chrome 桌面设备上的 Excalidraw 应用工具栏,其中包含“保存”和“另存为”按钮。
Chrome 上的 Excalidraw 应用工具栏,其中包含一个保存和一个聚焦的另存为按钮。

总结

从技术上来讲,所有现代浏览器都可以使用系统文件。在支持 File System Access API 的浏览器中,您可以通过允许真正保存和覆盖(而不仅仅是下载)文件,并允许用户随时随地创建新文件,同时在不支持 File System Access API 的浏览器上继续正常运行,从而提供更好的体验。browser-fs-access 可处理渐进式增强的细微差别,并尽可能简化代码,让您的工作变得更轻松。

致谢

本文由 Joe MedleyKayce Basques 审核。 感谢 Excalidraw 的贡献者参与此项目以及审核我的拉取请求。主打图片,作者:Ilya Pavlov,发布于 Unsplash。