借助 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 上的大多数 Chromium 浏览器都支持 File System Access API。值得注意的例外情况是 Brave,目前仅在启用标志后可用。我们正在通过 crbug.com/1011535 发布对 Android 的支持。
使用 File System Access API
为了展示 File System Access API 的强大功能和实用性,我编写了一个单文件文本编辑器。您可以使用该命令打开文本文档、对其进行修改,将更改保存回磁盘,或者创建新文件并将更改保存到磁盘。虽然这些概念没有什么妙招,但足以帮助您理解相关概念。
浏览器支持
功能检测
如需确定 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()
方法采用字符串,这是文本编辑器所需的。但它也可以接受 BufferSource 或 Blob。例如,您可以将数据流直接管道传输到它:
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
属性传递给 showSaveFilePicker
、showDirectoryPicker()
或 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/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' }
传递给该方法。
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
,布尔值为 true
或 false
),您可以确定是否应在文件或文件夹不存在时创建新文件或文件夹。
// 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 });
直接删除文件或文件夹
如果您有权访问某个文件或目录句柄,请对 FileSystemFileHandle
或 FileSystemDirectoryHandle
调用 remove()
以将其移除。
// Delete a file.
await fileHandle.remove();
// Delete a directory.
await directoryHandle.remove();
重命名和移动文件和文件夹
您可以通过对 FileSystemHandle
接口调用 move()
来重命名文件和文件夹或将其移至新位置。FileSystemHandle
具有子接口 FileSystemFileHandle
和 FileSystemDirectoryHandle
。move()
方法接受一个或两个参数。第一个参数可以是包含新名称的字符串,也可以是指向目标文件夹的 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 });
访问经过性能优化的源私有文件系统中的文件
源私有文件系统可提供对一种特殊文件的选择性访问权限,这种文件经过高度优化,可提供出色的性能,例如,可提供对文件内容的原位和独占写入权限。在 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
无法完全对 File System Access API 方法执行 polyfill 操作。
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 是否存在某些方面未按预期运行?或者,您是否缺少实现想法所需的方法或属性?对安全模型有疑问或意见?
- 在 WICG 文件系统访问 GitHub 代码库中提交规范问题,或添加您对现有问题的看法。
实现方面存在问题?
您是否发现了 Chrome 实现中的 bug?或者实现与规范不同?
- 请访问 https://new.crbug.com 提交 bug。请务必提供尽可能详细的信息、重现说明,并将组件设置为
Blink>Storage>FileSystem
。故障非常适合分享快速重现步骤。
打算使用该 API?
计划在您的网站上使用 File System Access API?您的公开支持有助于我们确定功能的优先级,并向其他浏览器供应商表明支持这些功能非常重要。
- 在 WICG Discourse 会话中分享您打算如何使用该工具。
- 使用
#FileSystemAccess
标签向 @ChromiumDev 发推文,告诉我们您在哪里以及如何使用该功能。
实用链接
- 公开说明文
- 文件系统访问规范和文件规范
- 跟踪 bug
- ChromeStatus.com 条目
- TypeScript 定义
- File System Access API - Chromium 安全模型
- 闪烁组件:
Blink>Storage>FileSystem
致谢
File System Access API 规范由 Marijn Kruisselbrink 编写。