File System Access API:简化对本地文件的访问

借助 File System Access API,Web 应用可以直接读取用户设备上的文件和文件夹内容,或者保存对这些内容的更改。

什么是 File System Access API?

借助 File System Access API,开发者可以构建强大的 Web 应用,与用户本地设备上的文件进行交互,例如 IDE、照片和视频编辑器、文本编辑器等。用户向 Web 应用授予访问权限后,此 API 让 Web 应用可以直接读取用户设备上的文件和文件夹内容,或者保存对这些内容的更改。除了读取和写入文件之外,File System Access API 还提供打开目录和枚举其内容的功能。

如果您之前使用过文件读写功能,那么我接下来要分享的大部分内容对您来说应该并不陌生。不过,我还是建议您阅读该文档,因为并非所有系统都是一样的。

Windows、macOS、ChromeOS、Linux 和 Android 上的大多数 Chromium 浏览器都支持 File System Access API。值得注意的例外情况是 Brave,目前仅在启用标志后可用

使用 File System Access API

为了展示 File System Access API 的强大功能和实用性,我编写了一个单文件文本编辑器。您可以使用该命令打开文本文档、对其进行修改、将更改保存回磁盘,或者创建新文件并将更改保存到磁盘。它并不复杂,但足以帮助您理解相关概念。

浏览器支持

Browser Support

  • Chrome: 86.
  • Edge: 86.
  • Firefox: not supported.
  • Safari: not supported.

Source

功能检测

如需了解设备是否支持 File System Access API,请检查您感兴趣的选择器方法是否存在。

if ('showOpenFilePicker' in self) {
  // The `showOpenFilePicker()` method of the File System Access API is supported.
}

试试看

如需了解 File System Access API 的实际运作方式,请参阅文本编辑器演示。

从本地文件系统读取文件

我要解决的第一个用例是,让用户选择一个文件,然后从磁盘打开并读取该文件。

让用户选择要读取的文件

File System Access API 的入口点是 window.showOpenFilePicker()。调用此方法时,系统会显示文件选择器对话框,并提示用户选择文件。用户选择文件后,该 API 会返回一个文件句柄数组。借助可选的 options 参数,您可以影响文件选择器的行为,例如允许用户选择多个文件、目录或不同类型的文件。如果未指定任何选项,文件选择器会允许用户选择单个文件。这非常适合文本编辑器。

与许多其他强大的 API 一样,调用 showOpenFilePicker() 必须在安全上下文中进行,并且必须从用户手势中调用。

let fileHandle;
butOpenFile.addEventListener('click', async () => {
  // Destructure the one-element array.
  [fileHandle] = await window.showOpenFilePicker();
  // Do something with the file handle.
});

用户选择文件后,showOpenFilePicker() 会返回一个句柄数组,在本例中,是一个包含一个 FileSystemFileHandle 的一元素数组,其中包含与文件互动所需的属性和方法。

保留对文件句柄的引用非常有用,因为日后可以使用该引用。您需要使用该权限才能保存对文件所做的更改,或执行任何其他文件操作。

从文件系统读取文件

现在,您已经有了文件的句柄,可以获取文件的属性,或访问文件本身。 现在,我会朗读其内容。调用 handle.getFile() 会返回一个 File 对象,其中包含一个 blob。如需从 blob 中获取数据,请调用 其方法之一(slice()stream()text()arrayBuffer())。

const file = await fileHandle.getFile();
const contents = await file.text();

只要磁盘上的底层文件未更改,FileSystemFileHandle.getFile() 返回的 File 对象便可读取。如果磁盘上的文件被修改,File 对象将变为不可读,您需要再次调用 getFile() 以获取新的 File 对象来读取已更改的数据。

综合应用

当用户点击打开按钮时,浏览器会显示文件选择器。用户选择文件后,应用会读取内容并将其放入 <textarea>

let fileHandle;
butOpenFile.addEventListener('click', async () => {
  [fileHandle] = await window.showOpenFilePicker();
  const file = await fileHandle.getFile();
  const contents = await file.text();
  textArea.value = contents;
});

将文件写入本地文件系统

在文本编辑器中,您可以通过两种方式保存文件:保存另存为保存会使用之前检索到的文件句柄将更改写回原始文件。但是,另存为会创建一个新文件,因此需要新的文件句柄。

创建新文件

如需保存文件,请调用 showSaveFilePicker(),这会以“保存”模式显示文件选择器,以便用户选择要用于保存的新文件。对于文本编辑器,我还希望它自动添加 .txt 扩展程序,因此提供了一些额外的参数。

async function getNewFileHandle() {
  const options = {
    types: [
      {
        description: 'Text Files',
        accept: {
          'text/plain': ['.txt'],
        },
      },
    ],
  };
  const handle = await window.showSaveFilePicker(options);
  return handle;
}

将更改保存到磁盘

您可以在 GitHub 上我的文本编辑器演示中找到用于保存对文件所做的更改的所有代码。核心文件系统交互位于 fs-helpers.js 中。最简单的流程如下代码所示。我会逐步演示并说明每个步骤。

// fileHandle is an instance of FileSystemFileHandle..
async function writeFile(fileHandle, contents) {
  // Create a FileSystemWritableFileStream to write to.
  const writable = await fileHandle.createWritable();
  // Write the contents of the file to the stream.
  await writable.write(contents);
  // Close the file and write the contents to disk.
  await writable.close();
}

将数据写入磁盘时,使用 WritableStream 的子类 FileSystemWritableFileStream 对象。通过对文件句柄对象调用 createWritable() 来创建数据流。调用 createWritable() 时,浏览器会先检查用户是否已向文件授予写入权限。如果未授予写入权限,浏览器会提示用户授予权限。如果未授予权限,createWritable() 会抛出 DOMException,并且应用将无法写入文件。在文本编辑器中,DOMException 对象在 saveFile() 方法中进行处理。

write() 方法采用字符串,这是文本编辑器所需的。但它也可以接受 BufferSourceBlob。例如,您可以将数据流直接管道到它:

async function writeURLToFile(fileHandle, url) {
  // Create a FileSystemWritableFileStream to write to.
  const writable = await fileHandle.createWritable();
  // Make an HTTP request for the contents.
  const response = await fetch(url);
  // Stream the response into the file.
  await response.body.pipeTo(writable);
  // pipeTo() closes the destination pipe by default, no need to close it.
}

您还可以在数据流中执行 seek()truncate() 操作,以更新特定位置的文件或调整文件大小。

指定建议的文件名和起始目录

在许多情况下,您可能希望应用建议默认文件名或位置。例如,文本编辑器可能希望建议默认文件名为 Untitled Text.txt,而不是 Untitled。您可以通过在 showSaveFilePicker 选项中传递 suggestedName 属性来实现此目的。

const fileHandle = await self.showSaveFilePicker({
  suggestedName: 'Untitled Text.txt',
  types: [{
    description: 'Text documents',
    accept: {
      'text/plain': ['.txt'],
    },
  }],
});

默认的起始目录也是如此。如果您要构建文本编辑器,则可能需要在默认的 documents 文件夹中启动文件保存或文件打开对话框;如果您要构建图片编辑器,则可能需要在默认的 pictures 文件夹中启动该对话框。您可以通过将 startIn 属性传递给 showSaveFilePickershowDirectoryPicker()showOpenFilePicker 方法来建议默认的起始目录,如下所示。

const fileHandle = await self.showOpenFilePicker({
  startIn: 'pictures'
});

知名系统目录列表如下:

  • desktop:用户的桌面目录(如果有)。
  • documents:通常用于存储用户创建的文档的目录。
  • downloads:下载的文件通常存储的目录。
  • music:通常存储音频文件的目录。
  • pictures:照片和其他静态图片通常存储的目录。
  • videos:通常存储视频或电影的目录。

除了众所周知的系统目录之外,您还可以将现有文件或目录句柄作为 startIn 的值传递。然后,系统会在同一目录中打开对话框。

// Assume `directoryHandle` is a handle to a previously opened directory.
const fileHandle = await self.showOpenFilePicker({
  startIn: directoryHandle
});

指定不同文件选择器的用途

有时,应用会出于不同目的而使用不同的选择器。例如,富文本编辑器可能允许用户打开文本文件,但也允许用户导入图片。默认情况下,每个文件选择器都会在上次记忆的位置打开。您可以通过为每种类型的选择器存储 id 值来规避此问题。如果指定了 id,文件选择器实现会记住该 id 的最后一次使用的单独目录。

const fileHandle1 = await self.showSaveFilePicker({
  id: 'openText',
});

const fileHandle2 = await self.showSaveFilePicker({
  id: 'importImage',
});

在 IndexedDB 中存储文件句柄或目录句柄

文件句柄和目录句柄是可序列化的,这意味着您可以将文件或目录句柄保存到 IndexedDB,或调用 postMessage() 在同一顶级源之间发送它们。

将文件或目录句柄保存到 IndexedDB 意味着您可以存储状态,或记住用户正在处理的文件或目录。这样,您就可以保留最近打开或修改的文件的列表、在应用打开时提供重新打开上次文件的选项、恢复上一个工作目录等。在文本编辑器中,我存储了用户最近打开的五个文件的列表,以便用户可以再次访问这些文件。

以下代码示例展示了如何存储和检索文件句柄和目录句柄。您可以在 Glitch 上查看此操作的实际效果。(为简洁起见,我使用 idb-keyval 库。)

import { get, set } from 'https://unpkg.com/[email protected]/dist/esm/index.js';

const pre1 = document.querySelector('pre.file');
const pre2 = document.querySelector('pre.directory');
const button1 = document.querySelector('button.file');
const button2 = document.querySelector('button.directory');

// File handle
button1.addEventListener('click', async () => {
  try {
    const fileHandleOrUndefined = await get('file');
    if (fileHandleOrUndefined) {
      pre1.textContent = `Retrieved file handle "${fileHandleOrUndefined.name}" from IndexedDB.`;
      return;
    }
    const [fileHandle] = await window.showOpenFilePicker();
    await set('file', fileHandle);
    pre1.textContent = `Stored file handle for "${fileHandle.name}" in IndexedDB.`;
  } catch (error) {
    alert(error.name, error.message);
  }
});

// Directory handle
button2.addEventListener('click', async () => {
  try {
    const directoryHandleOrUndefined = await get('directory');
    if (directoryHandleOrUndefined) {
      pre2.textContent = `Retrieved directroy handle "${directoryHandleOrUndefined.name}" from IndexedDB.`;
      return;
    }
    const directoryHandle = await window.showDirectoryPicker();
    await set('directory', directoryHandle);
    pre2.textContent = `Stored directory handle for "${directoryHandle.name}" in IndexedDB.`;
  } catch (error) {
    alert(error.name, error.message);
  }
});

存储的文件或目录句柄和权限

由于权限并不总是在会话之间保留,因此您应使用 queryPermission() 验证用户是否已向文件或目录授予权限。如果尚未完成,请调用 requestPermission() 进行(重新)请求。这对于文件和目录句柄也是一样的。您需要分别运行 fileOrDirectoryHandle.requestPermission(descriptor)fileOrDirectoryHandle.queryPermission(descriptor)

在文本编辑器中,我创建了一个 verifyPermission() 方法,用于检查用户是否已授予权限,并在必要时发出请求。

async function verifyPermission(fileHandle, readWrite) {
  const options = {};
  if (readWrite) {
    options.mode = 'readwrite';
  }
  // Check if permission was already granted. If so, return true.
  if ((await fileHandle.queryPermission(options)) === 'granted') {
    return true;
  }
  // Request permission. If the user grants permission, return true.
  if ((await fileHandle.requestPermission(options)) === 'granted') {
    return true;
  }
  // The user didn't grant permission, so return false.
  return false;
}

通过在读取请求中请求写入权限,我减少了权限提示的数量;用户在打开文件时会看到一条提示,并授予对文件的读取和写入权限。

打开目录并枚举其内容

如需枚举目录中的所有文件,请调用 showDirectoryPicker()。用户在选择器中选择一个目录后,系统会返回 FileSystemDirectoryHandle,以便您枚举和访问目录中的文件。默认情况下,您将有权读取目录中的文件,但如果您需要写入权限,则可以将 { mode: 'readwrite' } 传递给该方法。

butDir.addEventListener('click', async () => {
  const dirHandle = await window.showDirectoryPicker();
  for await (const entry of dirHandle.values()) {
    console.log(entry.kind, entry.name);
  }
});

如果您还需要使用 getFile() 访问每个文件(例如,获取各个文件的大小),请勿对每个结果依序使用 await,而是并行处理所有文件(例如,使用 Promise.all())。

butDir.addEventListener('click', async () => {
  const dirHandle = await window.showDirectoryPicker();
  const promises = [];
  for await (const entry of dirHandle.values()) {
    if (entry.kind !== 'file') {
      continue;
    }
    promises.push(entry.getFile().then((file) => `${file.name} (${file.size})`));
  }
  console.log(await Promise.all(promises));
});

在目录中创建或访问文件和文件夹

您可以在目录中使用 getFileHandle()getDirectoryHandle() 方法创建或访问文件和文件夹。通过传入一个可选的 options 对象(键为 create,布尔值为 truefalse),您可以确定是否应在文件或文件夹不存在时创建新文件或文件夹。

// In an existing directory, create a new directory named "My Documents".
const newDirectoryHandle = await existingDirectoryHandle.getDirectoryHandle('My Documents', {
  create: true,
});
// In this new directory, create a file named "My Notes.txt".
const newFileHandle = await newDirectoryHandle.getFileHandle('My Notes.txt', { create: true });

解析目录中项的路径

在目录中处理文件或文件夹时,解析相关项的路径会很有用。您可以使用名为 resolve() 的恰当方法来实现这一点。在解析时,该项可以是目录的直接或间接子项。

// Resolve the path of the previously created file called "My Notes.txt".
const path = await newDirectoryHandle.resolve(newFileHandle);
// `path` is now ["My Documents", "My Notes.txt"]

删除目录中的文件和文件夹

如果您已获得对某个目录的访问权限,则可以使用 removeEntry() 方法删除其中包含的文件和文件夹。对于文件夹,删除操作可以选择以递归方式进行,并包括所有子文件夹及其包含的文件。

// Delete a file.
await directoryHandle.removeEntry('Abandoned Projects.txt');
// Recursively delete a folder.
await directoryHandle.removeEntry('Old Stuff', { recursive: true });

直接删除文件或文件夹

如果您有权访问文件或目录句柄,请对 FileSystemFileHandleFileSystemDirectoryHandle 调用 remove() 以将其移除。

// Delete a file.
await fileHandle.remove();
// Delete a directory.
await directoryHandle.remove();

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

您可以通过对 FileSystemHandle 接口调用 move() 来重命名文件和文件夹或将其移至新位置。FileSystemHandle 具有子接口 FileSystemFileHandleFileSystemDirectoryHandlemove() 方法接受一个或两个参数。第一个参数可以是包含新名称的字符串,也可以是指向目标文件夹的 FileSystemDirectoryHandle。在后一种情况下,可选的第二个参数是包含新名称的字符串,因此移动和重命名可以一步完成。

// Rename the file.
await file.move('new_name');
// Move the file to a new directory.
await file.move(directory);
// Move the file to a new directory and rename it.
await file.move(directory, 'newer_name');

拖放式集成

借助 HTML 拖放接口,Web 应用可以接受网页上的拖放文件。在拖放操作期间,拖动的文件和目录项会分别与文件条目和目录条目相关联。如果拖动的项是文件,DataTransferItem.getAsFileSystemHandle() 方法会返回一个包含 FileSystemFileHandle 对象的 promise;如果拖动的项是目录,则会返回一个包含 FileSystemDirectoryHandle 对象的 promise。以下列表展示了此过程的运作方式。请注意,拖放界面的 DataTransferItem.kind 对文件和目录均为 "file",而 File System Access API 的 FileSystemHandle.kind 对文件为 "file",对目录为 "directory"

elem.addEventListener('dragover', (e) => {
  // Prevent navigation.
  e.preventDefault();
});

elem.addEventListener('drop', async (e) => {
  e.preventDefault();

  const fileHandlesPromises = [...e.dataTransfer.items]
    .filter((item) => item.kind === 'file')
    .map((item) => item.getAsFileSystemHandle());

  for await (const handle of fileHandlesPromises) {
    if (handle.kind === 'directory') {
      console.log(`Directory: ${handle.name}`);
    } else {
      console.log(`File: ${handle.name}`);
    }
  }
});

访问源私有文件系统

源私有文件系统是一种存储端点,顾名思义,它仅对网页的来源开放。虽然浏览器通常通过将此来源私有文件系统的内容持久存储到某个位置的磁盘来实现此功能,但打算让用户访问这些内容。同样,应存在名称与源私有文件系统子项名称匹配的文件或目录。虽然浏览器可能会让您认为存在文件,但在内部(由于这是源私有文件系统),浏览器可能会将这些“文件”存储在数据库或任何其他数据结构中。从本质上讲,如果您使用此 API,不要期望在硬盘上的某个位置找到一对一匹配的创建文件。获得对根 FileSystemDirectoryHandle 的访问权限后,您就可以照常在源私有文件系统上操作了。

const root = await navigator.storage.getDirectory();
// Create a new file handle.
const fileHandle = await root.getFileHandle('Untitled.txt', { create: true });
// Create a new directory handle.
const dirHandle = await root.getDirectoryHandle('New Folder', { create: true });
// Recursively remove a directory.
await root.removeEntry('Old Stuff', { recursive: true });

Browser Support

  • Chrome: 86.
  • Edge: 86.
  • Firefox: 111.
  • Safari: 15.2.

Source

访问经过性能优化的源私有文件系统中的文件

源私有文件系统可提供对一种特殊文件的选择性访问权限,这种文件经过高度优化,可提供出色的性能,例如,提供对文件内容的原位和独占写入权限。在 Chromium 102 及更高版本中,源私有文件系统上提供了一种额外的方法来简化文件访问:createSyncAccessHandle()(适用于同步读写操作)。它在 FileSystemFileHandle 上公开,但仅在 Web Worker 中可用。

// (Read and write operations are synchronous,
// but obtaining the handle is asynchronous.)
// Synchronous access exclusively in Worker contexts.
const accessHandle = await fileHandle.createSyncAccessHandle();
const writtenBytes = accessHandle.write(buffer);
const readBytes = accessHandle.read(buffer, { at: 1 });

polyfill

无法完全 polyfill File System Access API 方法。

  • showOpenFilePicker() 方法可以用 <input type="file"> 元素近似表示。
  • 您可以使用 <a download="file_name"> 元素模拟 showSaveFilePicker() 方法,但这会触发程序化下载,并且不允许覆盖现有文件。
  • 您可以使用非标准 <input type="file" webkitdirectory> 元素模拟 showDirectoryPicker() 方法。

我们开发了一个名为 browser-fs-access 的库,该库会尽可能使用文件系统访问 API,并在所有其他情况下回退到这些次优选项。

安全与权限

Chrome 团队使用控制对强大 Web 平台功能的访问权限中定义的核心原则设计和实现了文件系统访问 API,包括用户控制和透明度,以及用户工效学。

打开文件或保存新文件

用于打开文件以供阅读的文件选择器
文件选择器,用于打开现有文件以供读取。

在打开文件时,用户会提供使用文件选择器读取文件或目录的权限。只有在从安全上下文提供时,才能使用用户手势显示打开的文件选择器。如果用户改变主意,可以在文件选择器中取消选择,这样该网站就无法访问任何内容。这与 <input type="file"> 元素的行为相同。

用于将文件保存到磁盘的文件选择器。
用于将文件保存到磁盘的文件选择器。

同样,当 Web 应用想要保存新文件时,浏览器会显示保存文件选择器,以便用户指定新文件的名称和位置。由于用户要将新文件保存到设备上(而不是覆盖现有文件),因此文件选择器会向应用授予写入文件的权限。

受限文件夹

为了帮助保护用户及其数据,浏览器可能会限制用户保存到某些文件夹的权限,例如 Windows、macOS 库文件夹等核心操作系统文件夹。在这种情况下,浏览器会显示提示,要求用户选择其他文件夹。

修改现有文件或目录

Web 应用在未经用户明确许可的情况下,无法修改磁盘上的文件。

权限提示

如果用户想要保存对之前授予读取权限的文件所做的更改,浏览器会显示权限提示,请求网站将更改写入磁盘的权限。权限请求只能由用户手势触发,例如点击“保存”按钮。

保存文件之前显示的权限提示。
在向浏览器授予对现有文件的写入权限之前向用户显示的提示。

或者,编辑多个文件的 Web 应用(例如 IDE)也可以在打开时请求保存更改的权限。

如果用户选择“取消”且未授予写入权限,则 Web 应用无法将对本地文件的更改保存下来。应为用户提供其他方法来保存其数据,例如提供“下载”文件或将数据保存到云端的方法。

透明度

多功能框图标
地址栏图标,表示用户已授予相应网站保存到本地文件的权限。

用户向 Web 应用授予保存本地文件的权限后,浏览器会在地址栏中显示一个图标。点击该图标会打开一个弹出式窗口,其中会显示用户已授予访问权限的文件列表。用户可以随时撤消该访问权限。

权限持久性

在关闭其来源的所有标签页之前,该 Web 应用可以继续保存对文件所做的更改,而无需提示。关闭标签页后,该网站将失去所有访问权限。当用户下次使用该 Web 应用时,系统会再次提示他们访问文件。

反馈

我们希望了解您使用 File System Access API 的体验。

请向我们说明 API 设计

API 是否存在某些方面未按预期运行?或者,您是否缺少实现想法所需的方法或属性?对安全模型有疑问或意见?

实现方面存在问题?

您是否发现了 Chrome 实现中的 bug?或者实现与规范不同?

打算使用该 API?

计划在您的网站上使用 File System Access API?您的公开支持有助于我们确定功能的优先级,并向其他浏览器供应商表明支持这些功能非常重要。

实用链接

致谢

File System Access API 规范由 Marijn Kruisselbrink 编写。