File System Access API 让 Web 应用可以直接读取用户设备上的文件和文件夹内容,或者保存对这些内容的更改。
发布时间:2024 年 8 月 19 日
借助 File System Access API,开发者可以构建与用户本地设备上的文件进行交互的强大 Web 应用,例如 IDE、照片和视频编辑器、文本编辑器等。用户向 Web 应用授予访问权限后,此 API 让 Web 应用可以直接读取用户设备上的文件和文件夹内容,或者保存对这些内容的更改。除了读取和写入文件之外,File System Access API 还提供打开目录并枚举其内容的功能。
如果您之前处理过文件的读取和写入,那么我接下来要分享的内容对您来说会非常熟悉。不过,我还是建议您阅读一下,因为并非所有系统都一样。
Windows、macOS、ChromeOS、Linux 和 Android 设备上的大多数 Chromium 浏览器都支持文件系统访问 API。一个值得注意的例外情况是 Brave,该浏览器目前仅在标志后提供。
使用 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。为此,您可以将 suggestedName 属性作为 showSaveFilePicker 选项的一部分进行传递。
const fileHandle = await self.showSaveFilePicker({
suggestedName: 'Untitled Text.txt',
types: [{
description: 'Text documents',
accept: {
'text/plain': ['.txt'],
},
}],
});
默认起始目录也是如此。如果您要构建文本编辑器,可能希望在默认 documents 文件夹中启动文件保存或文件打开对话框;而对于图片编辑器,可能希望在默认 pictures 文件夹中启动。您可以通过向 showSaveFilePicker、showDirectoryPicker() 或 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' } 传递给该方法。
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",而文件系统访问 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">元素进行近似处理。showSaveFilePicker()方法可使用<a download="file_name">元素进行模拟,但会触发程序化下载,并且不允许覆盖现有文件。- 可以使用非标准
<input type="file" webkitdirectory>元素在一定程度上模拟showDirectoryPicker()方法。
我们开发了一个名为 browser-fs-access 的库,该库尽可能使用 File System Access API,并在所有其他情况下回退到这些次优选项。
安全与权限
Chrome 团队在设计和实现 File System Access API 时,遵循了控制对强大的 Web 平台功能的访问权限中定义的核心原则,包括用户控制和透明度以及用户人机工程学。
打开文件或保存新文件
打开文件时,用户使用文件选择器授予读取文件或目录的权限。
只有在从安全上下文提供服务时,才能使用用户手势显示打开的文件选择器。如果用户改变主意,可以在文件选择器中取消选择,这样网站就无法访问任何内容。此行为与 <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 组件:
Blink>Storage>FileSystem
致谢
File System Access API 规范由 Marijn Kruisselbrink 撰写。