使用 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(),这些方法的名称都非常贴切。 在内部,该库会检测是否支持 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',
  });
})();

演示

您可以在 GitHub 上的演示中看到上述代码的实际效果。 其源代码同样可在该处获取。出于安全考虑,不允许跨源子框架显示文件选择器,因此无法将演示嵌入到本文中。

实际应用中的 browser-fs-access 库

在空闲时间,我会为一款名为 Excalidraw可安装 PWA 贡献一点力量。这款白板工具可让您轻松绘制具有手绘感的图表。 它具有完全的自适应性,可在各种设备上顺畅运行,从小型手机到大屏电脑,不一而足。 这意味着,无论各种平台是否支持 File System Access API,它都需要处理这些平台上的文件。因此,该功能非常适合使用 browser-fs-access 库。

例如,我可以在 iPhone 上开始绘制,将其保存(从技术上讲,由于 Safari 不支持 File System Access API,因此是下载)到 iPhone 的“下载内容”文件夹,在桌面设备上打开该文件(从手机转移后),修改该文件,然后用我的更改覆盖该文件,甚至将其另存为新文件。

iPhone 上的 Excalidraw 绘图。
在不支持 File System Access API 但可以将文件保存(下载)到“下载内容”文件夹的 iPhone 上开始绘制 Excalidraw 图。
桌面版 Chrome 上修改后的 Excalidraw 绘图。
在支持 File System Access API 的桌面设备上打开和修改 Excalidraw 绘图,因此可以通过该 API 访问文件。
使用修改后的文件覆盖原始文件。
使用对原始 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) {}),您可以显示另存为按钮,以及保存按钮。 以下屏幕截图显示了 iPhone 和 Chrome 桌面设备上 Excalidraw 的自适应主应用工具栏之间的区别。 请注意,在 iPhone 上,“另存为”按钮会消失。

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

总结

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

致谢

本文由 Joe MedleyKayce Basques 审核。 感谢 Excalidraw 的贡献者为该项目所做的工作,以及对我的拉取请求的审核。 主打图片,由 Unsplash 用户 Ilya Pavlov 提供。