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

借助 File System Access API,Web 应用可以直接读取用户设备上的文件和文件夹,或将更改保存到其中。

什么是 File System Access API?

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

如果您以前尝试过读取和写入文件,那么我接下来要分享的内容应该比较熟悉。我仍建议您阅读,因为并非所有系统都相同。

目前,Windows、macOS、ChromeOS 和 Linux 上的大多数 Chromium 浏览器均支持 File System Access API。一个值得注意的例外情况是,Brave 目前仅在标志后面可用。从 Chromium 109 开始,Android 支持该 API 的源专用文件系统部分。目前还没有关于选择器方法的计划,但您可以通过为 crbug.com/1011535 加注星标来跟踪潜在进度。

使用 File System Access API

为了展示 File System Access API 的强大功能和实用性,我编写了一个文件文本编辑器。您可以使用它打开文本文件、进行修改、将更改保存回磁盘或者启动新文件并将更改保存到磁盘中。这虽然没有什么特别之处,但足以帮助您理解相关概念。

浏览器支持

浏览器支持

  • 86
  • 86
  • x
  • x

来源

试用

请参阅文本编辑器演示,了解 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() 会返回一个包含 blob 的 File 对象。如需从 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();
}

将数据写入磁盘需要使用 FileSystemWritableFileStream 对象,该对象是 WritableStream 的子类。通过对文件句柄对象调用 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。为此,您可以将 suggestedName 属性作为 showSaveFilePicker 选项的一部分进行传递。

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

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

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/idb-keyval@5.0.2/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' } 传递给该方法。

const butDir = document.getElementById('butDirectory');
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())。

const butDir = document.getElementById('butDirectory');
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() 方法创建或访问文件和文件夹。通过传入键为 create 且布尔值为 truefalse 的可选 options 对象,您可以决定是否应创建新文件或文件夹(如果不存在)。

// 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 });

浏览器支持

  • 86
  • 86
  • 111
  • 15.2

来源

从源私有文件系统访问针对性能进行了优化的文件

源私有文件系统支持选择访问一种经过高度优化的特殊文件,例如提供对文件内容的就地和专属写入访问权限。在 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 });

聚酯纤维

您无法对 File System Access API 方法进行完全 polyfill 操作。

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

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

安全与权限

Chrome 团队按照控制对强大的 Web 平台功能的访问权限中定义的核心原则(包括用户控制和透明度以及用户工效学设计)设计和实现了 File System Access API。

打开文件或保存新文件

用于打开文件进行读取的文件选择器
用于打开现有文件进行读取的文件选择器。

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

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

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

受限文件夹

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

修改现有文件或目录

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

权限提示

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

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

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

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

透明度

多功能框图标
多功能框图标,表明用户已授权网站保存到本地文件。

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

权限保留

在源位置的所有标签页关闭之前,Web 应用可以继续保存对文件的更改,而不会显示提示。标签页关闭后,该网站将失去所有访问权限。当用户下次使用 Web 应用时,系统会重新提示他们是否访问文件。

反馈

我们想要了解您使用 File System Access API 的体验。

向我们介绍 API 设计

是否存在 API 行为不符合您预期的情况?或者说,是否缺少某些方法或属性来实现您的想法?如果您对安全模型有疑问或意见,

实施方面有问题?

您是否发现了 Chrome 实现方面的错误?或者,实现方式是否不同于规范?

  • https://new.crbug.com 上提交 bug。请务必提供尽可能多的详细信息、有关重现的简单说明,并将组件设为 Blink>Storage>FileSystemGlitch 非常适合快速轻松地分享重现的视频。

打算使用该 API?

打算在您的网站上使用 File System Access API?您公开提供的支持有助于我们确定功能的优先级,并向其他浏览器供应商显示支持这些功能的重要性。

实用链接

致谢

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