File System Access API:跨源沙箱与用户授权的文件系统操作
在Web应用发展的历程中,浏览器一直被视为一个高度沙箱化的环境。这种沙箱机制是Web安全的核心基石,它确保了网站无法随意访问用户的本地文件系统、摄像头、麦克风等敏感资源,也防止了不同源(origin)的网站之间进行恶意的数据交互。然而,这种严格的沙箱模式,也曾是Web应用功能拓展的一大瓶颈。传统的Web应用,在文件系统操作方面,其能力被严格限制在以下几个方面:
- 文件上传: 仅能通过
<input type="file">元素,由用户主动选择文件,并上传到服务器或在客户端临时读取。应用无法指定默认路径,无法直接创建新文件,也无法直接写入文件到用户硬盘。 - 文件下载: 通常通过
<a>标签的download属性或 Blob URL 实现,将数据从内存发送给浏览器,由浏览器触发下载,用户选择保存路径。应用无法直接控制下载位置。 - 客户端存储: 提供了如 localStorage、sessionStorage、IndexedDB 等机制,但这些都是浏览器内部的、与特定源绑定的、非用户可见的存储空间。它们与操作系统的文件系统完全隔离。
- WebAssembly/Service Workers: 虽然提供了更强大的计算能力和离线支持,但对文件系统的直接访问能力仍然受限于上述模型。
这些限制使得许多需要深度文件操作能力的Web应用(如在线IDE、图片/视频编辑器、离线数据管理工具等)难以实现与桌面应用相媲美的用户体验。它们要么需要将文件上传到云端处理,要么需要依赖浏览器扩展,或者干脆放弃Web平台。
为了弥补这一鸿沟,W3C 和浏览器厂商共同推出了 File System Access API (前身为 Native File System API)。这项API旨在提供一种安全、高效且用户友好的方式,让Web应用能够直接与用户的本地文件系统进行交互。其核心设计理念在于:将用户授权置于一切文件操作的中心,同时严格遵守并强化现有的浏览器安全模型,包括跨源沙箱机制。
本讲座将深入探讨 File System Access API 的核心概念、工作机制、如何实现用户授权的文件系统操作,以及它如何在不打破浏览器沙箱的前提下,为Web应用开启新的可能性。
File System Access API 核心概念与工作机制
File System Access API 的设计哲学是:一切操作都必须经过用户的明确同意,且同意的粒度是文件或目录级别。 它引入了一系列新的接口和方法,使Web应用能够请求访问用户本地文件系统的特定部分。
1. 句柄 (Handles):文件与目录的抽象引用
API 不直接暴露文件或目录的真实路径,而是通过句柄 (Handles) 来表示对文件或目录的引用。这是一种抽象的、安全的代理对象。主要有两种类型的句柄:
FileSystemFileHandle: 表示一个文件。FileSystemDirectoryHandle: 表示一个目录。
这些句柄具有以下关键特性:
- 安全: 它们是受限的代理对象,不包含文件系统的实际路径信息,也无法被任意修改。所有操作都必须通过句柄提供的方法进行。
- 可序列化: 句柄可以被序列化并通过
postMessage在主线程和 Web Worker 之间传递,这使得文件操作可以在后台线程中执行,从而避免阻塞主线程,提升用户体验。 - 持久性: 句柄本身可以被存储在 IndexedDB 等客户端存储中,以便在用户下次访问应用时重新使用,减少重复授权的次数(但权限状态仍需检查)。
句柄的结构:
句柄通常包含 name 属性(文件或目录的名称)和 kind 属性('file' 或 'directory')。
interface FileSystemHandle {
readonly kind: 'file' | 'directory';
readonly name: string;
isSameEntry(other: FileSystemHandle): Promise<boolean>;
queryPermission(descriptor?: FileSystemPermissionDescriptor): Promise<PermissionState>;
requestPermission(descriptor?: FileSystemPermissionDescriptor): Promise<PermissionState>;
}
interface FileSystemFileHandle extends FileSystemHandle {
readonly kind: 'file';
getFile(): Promise<File>;
createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream>;
}
interface FileSystemDirectoryHandle extends FileSystemHandle {
readonly kind: 'directory';
entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
values(): AsyncIterableIterator<FileSystemHandle>;
keys(): AsyncIterableIterator<string>;
getFileHandle(name: string, options?: FileSystemGetFileHandleOptions): Promise<FileSystemFileHandle>;
getDirectoryHandle(name: string, options?: FileSystemGetDirectoryHandleOptions): Promise<FileSystemDirectoryHandle>;
removeEntry(name: string, options?: FileSystemRemoveEntryOptions): Promise<void>;
resolve(possibleDescendant: FileSystemHandle): Promise<string[] | null>;
}
2. 权限模型 (Permissions Model):用户授权是核心
File System Access API 的核心是其精细的权限模型。任何文件系统操作都需要用户明确授权。这种授权是基于句柄的,并且具有读 ('read') 和读写 ('readwrite') 两种粒度。
- 用户手势触发: 权限请求必须由用户手势(如点击按钮)触发。这意味着你不能在页面加载时就弹出文件选择器或权限请求。
- 一次性或持久性:
- 当用户通过
showOpenFilePicker()或showDirectoryPicker()选择文件/目录时,默认获得的是临时权限。这意味着页面刷新后,如果没有持久化句柄,权限可能会丢失。 - 对于通过
showSaveFilePicker()创建或保存的文件,权限通常是持久的,浏览器会记住用户对该文件的授权。 - 应用可以通过将句柄存储在 IndexedDB 中,并在下次加载时重新获取,然后使用
requestPermission()再次请求权限。如果用户之前已授予持久权限,此操作将静默成功;否则,将再次弹出权限请求。
- 当用户通过
- 权限状态: 可以通过
queryPermission()方法查询句柄的当前权限状态,返回PermissionState类型:'granted': 已授权。'denied': 已拒绝。'prompt': 需要用户授权。
权限描述符 (PermissionDescriptor):
interface FileSystemPermissionDescriptor extends PermissionDescriptor {
query?: boolean; // 默认为 false,表示请求权限,true表示查询权限
mode: 'read' | 'readwrite'; // 请求或查询的权限模式
}
3. 沙箱 (Sandbox) 与跨源安全
尽管 File System Access API 提供了对本地文件系统的访问,但它并没有打破浏览器严格的跨源沙箱 (Cross-Origin Sandbox) 安全模型。理解这一点至关重要。
- 隔离性: 每个Web应用(即每个源)仍然运行在自己的独立沙箱中。一个源无法访问另一个源的 IndexedDB、localStorage 或其他客户端存储。同样,一个源通过 File System Access API 获得的对本地文件系统的访问权限,是针对该特定源和特定用户操作的。
- 用户授权: API 的核心是用户授权。当用户选择一个文件或目录并授权给一个Web应用时,这个授权是:
- 针对该特定源的: 只有发起请求的源才能使用该句柄。
- 针对该特定文件/目录的: 授权范围仅限于用户选择的文件或目录及其子内容(对于目录)。
- 无直接路径访问: 应用始终通过句柄操作,而不是直接操作文件系统的绝对路径。这意味着应用无法通过猜测或构造路径来访问用户未授权的文件。
- 操作系统层面安全: 浏览器仍然依赖操作系统的安全机制。例如,即使授予了文件访问权限,Web应用也无法绕过操作系统的权限限制(如读写系统文件)。
- 恶意行为限制: 如果一个Web应用试图在用户不知情的情况下反复请求权限、滥用文件访问权限进行恶意操作(如删除用户文件),浏览器和操作系统仍然会提供保护机制,用户可以撤销权限,甚至浏览器会标记该网站为不安全。
总结来说,File System Access API 并不是一个“万能钥匙”,它是在现有浏览器沙箱模型之上,通过引入精细的用户授权机制,安全地拓展了Web应用与本地文件系统的交互能力。它不破坏跨源隔离,而是为每个独立的源提供了一个“用户许可的通道”来与本地文件进行交互。
获取文件与目录句柄:用户授权的起点
所有文件系统操作都始于获取一个文件或目录句柄。API 提供了三个主要的全局方法来实现这一点,它们都必须在用户手势(例如点击事件处理器)内部调用。
1. 选择文件 (window.showOpenFilePicker())
这是最常见的方式,用于让用户选择一个或多个现有文件。
/**
* 演示如何使用 showOpenFilePicker 选择文件。
*/
async function selectFile() {
try {
// 配置选项,例如允许选择的MIME类型和是否允许多选
const options = {
// 定义文件类型过滤器
types: [
{
description: '文本文件',
accept: {
'text/plain': ['.txt'],
},
},
{
description: '图片文件',
accept: {
'image/*': ['.png', '.gif', '.jpeg', '.jpg'],
},
},
{
description: 'JSON文件',
accept: {
'application/json': ['.json'],
},
},
],
multiple: false, // 允许选择单个文件
// startIn: 'documents', // 可选:指定文件选择器打开的起始目录
// 'documents', 'downloads', 'desktop', 'music', 'pictures', 'videos'
// 或一个 FileSystemDirectoryHandle
};
// 调用 showOpenFilePicker() 方法,它会返回一个 Promise
// Promise 解决后会得到一个 FileSystemFileHandle 数组
const [fileHandle] = await window.showOpenFilePicker(options);
console.log('用户选择了文件:', fileHandle.name);
// 此时我们有了一个文件句柄,可以进行后续的读写操作
return fileHandle;
} catch (error) {
// 用户取消选择或出现其他错误
if (error.name === 'AbortError') {
console.log('用户取消了文件选择。');
} else {
console.error('选择文件时发生错误:', error);
}
return null;
}
}
// 示例用法:
// document.getElementById('openFileButton').addEventListener('click', async () => {
// const handle = await selectFile();
// if (handle) {
// // 进一步操作,例如读取文件内容
// const file = await handle.getFile();
// const content = await file.text();
// console.log('文件内容:', content.substring(0, 200) + '...'); // 显示前200字符
// }
// });
2. 选择目录 (window.showDirectoryPicker())
允许用户选择一个目录,并授予Web应用访问该目录及其所有子内容的权限。
/**
* 演示如何使用 showDirectoryPicker 选择目录。
*/
async function selectDirectory() {
try {
// 调用 showDirectoryPicker() 方法,它会返回一个 Promise
// Promise 解决后会得到一个 FileSystemDirectoryHandle
const directoryHandle = await window.showDirectoryPicker({
// startIn: 'documents', // 可选:指定目录选择器打开的起始目录
});
console.log('用户选择了目录:', directoryHandle.name);
// 此时我们有了一个目录句柄,可以进行后续的遍历、创建、删除等操作
return directoryHandle;
} catch (error) {
if (error.name === 'AbortError') {
console.log('用户取消了目录选择。');
} else {
console.error('选择目录时发生错误:', error);
}
return null;
}
}
// 示例用法:
// document.getElementById('openDirectoryButton').addEventListener('click', async () => {
// const handle = await selectDirectory();
// if (handle) {
// console.log('已获取目录句柄,可以开始遍历或操作其内容。');
// }
// });
3. 创建新文件或保存文件 (window.showSaveFilePicker())
用于让用户选择一个位置来保存一个新文件,或者覆盖一个现有文件。
/**
* 演示如何使用 showSaveFilePicker 创建或保存文件。
* @param {string} suggestedName - 建议的文件名。
* @param {string} fileContent - 要保存的文件内容。
*/
async function saveFile(suggestedName = 'untitled.txt', fileContent = 'Hello, File System Access API!') {
try {
const options = {
suggestedName: suggestedName, // 建议的文件名
types: [
{
description: '文本文件',
accept: {
'text/plain': ['.txt'],
},
},
{
description: '所有文件',
accept: {
'*/*': ['.*'],
},
},
],
};
// 调用 showSaveFilePicker() 方法
const fileHandle = await window.showSaveFilePicker(options);
console.log('用户选择了保存位置:', fileHandle.name);
// 写入文件内容
const writableStream = await fileHandle.createWritable();
await writableStream.write(fileContent);
await writableStream.close();
console.log('文件保存成功!');
return fileHandle;
} catch (error) {
if (error.name === 'AbortError') {
console.log('用户取消了文件保存。');
} else {
console.error('保存文件时发生错误:', error);
}
return null;
}
}
// 示例用法:
// document.getElementById('saveFileButton').addEventListener('click', async () => {
// await saveFile('my-document.txt', 'This is some content to be saved.');
// });
4. 权限检查与请求 (queryPermission, requestPermission)
当您拥有一个句柄(例如,从 IndexedDB 中恢复的句柄)时,您需要检查其权限状态,并在必要时重新请求权限。
/**
* 检查并请求文件句柄的读写权限。
* @param {FileSystemHandle} handle - 要检查和请求权限的文件或目录句柄。
* @returns {Promise<boolean>} - 如果获得读写权限,则返回 true;否则返回 false。
*/
async function verifyPermission(handle, mode = 'readwrite') {
// 1. 查询当前权限状态
const options = { mode: mode };
let status = await handle.queryPermission(options);
if (status === 'granted') {
console.log(`句柄 ${handle.name} 已经拥有 ${mode} 权限。`);
return true;
}
if (status === 'denied') {
console.log(`句柄 ${handle.name} 的 ${mode} 权限被拒绝。`);
return false;
}
// 2. 如果状态是 'prompt',则请求权限
if (status === 'prompt') {
console.log(`请求句柄 ${handle.name} 的 ${mode} 权限...`);
status = await handle.requestPermission(options);
if (status === 'granted') {
console.log(`已授予句柄 ${handle.name} 的 ${mode} 权限。`);
return true;
} else {
console.log(`用户拒绝了句柄 ${handle.name} 的 ${mode} 权限。`);
return false;
}
}
return false; // 理论上不会达到这里
}
// 示例用法:
// async function performFileOperation(fileHandle) {
// if (!fileHandle) return;
//
// const hasPermission = await verifyPermission(fileHandle, 'readwrite');
// if (hasPermission) {
// console.log('可以进行文件读写操作了!');
// // 例如,写入一些数据
// const writable = await fileHandle.createWritable();
// await writable.write('新的内容。');
// await writable.close();
// console.log('文件写入成功。');
// } else {
// console.log('没有权限,无法操作文件。');
// }
// }
//
// document.getElementById('someActionButton').addEventListener('click', async () => {
// const handle = await selectFile(); // 假设已经通过 showOpenFilePicker 获取了一个句柄
// if (handle) {
// await performFileOperation(handle);
// }
// });
文件操作:读、写与内容管理
一旦获得了 FileSystemFileHandle,就可以对其进行读取、写入等操作。
1. 读取文件 (FileSystemFileHandle.getFile())
getFile() 方法返回一个标准的 File 对象,这个对象与通过 <input type="file"> 元素获取的 File 对象完全相同。这意味着你可以使用所有熟悉的 File API 方法来读取其内容,例如 FileReader 或 Blob 接口。
/**
* 读取指定文件句柄的内容。
* @param {FileSystemFileHandle} fileHandle - 要读取的文件句柄。
* @returns {Promise<string>} - 文件的文本内容。
*/
async function readFileContent(fileHandle) {
if (!fileHandle) {
console.warn('未提供文件句柄。');
return '';
}
// 确保有读取权限
const hasReadPermission = await verifyPermission(fileHandle, 'read');
if (!hasReadPermission) {
console.error('没有读取文件权限。');
return '';
}
try {
// 获取 File 对象
const file = await fileHandle.getFile();
console.log(`正在读取文件: ${file.name} (${file.type}, ${file.size} bytes)`);
// 使用 TextDecoder 或 FileReader 读取文件内容
// 这里使用 Blob.text() 方法,更简洁
const content = await file.text();
return content;
} catch (error) {
console.error('读取文件内容时发生错误:', error);
return '';
}
}
// 示例用法:
// document.getElementById('readSelectedFileButton').addEventListener('click', async () => {
// const fileHandle = await selectFile(); // 假设用户选择了一个文件
// if (fileHandle) {
// const content = await readFileContent(fileHandle);
// console.log('文件前200字符:', content.substring(0, 200) + '...');
// }
// });
2. 写入文件 (FileSystemFileHandle.createWritable())
createWritable() 方法返回一个 FileSystemWritableFileStream 对象,它是一个可写流,支持高效、原子性的文件写入操作。原子性写入意味着即使在写入过程中发生错误(如浏览器崩溃),原始文件也不会被破坏,只会创建或保留一个临时文件,从而保证数据完整性。
FileSystemWritableFileStream 的主要方法:
write(data): 写入数据。data可以是string,ArrayBuffer,TypedArray,DataView,Blob或File。seek(position): 将写入指针移动到指定位置。truncate(size): 将文件截断或扩展到指定大小。close(): 关闭流并完成写入操作。
/**
* 写入内容到指定文件句柄。
* @param {FileSystemFileHandle} fileHandle - 要写入的文件句柄。
* @param {string | Blob | ArrayBufferView} content - 要写入的数据。
* @param {boolean} append - 是否以追加模式写入。
*/
async function writeToFile(fileHandle, content, append = false) {
if (!fileHandle) {
console.warn('未提供文件句柄。');
return;
}
// 确保有写入权限
const hasWritePermission = await verifyPermission(fileHandle, 'readwrite');
if (!hasWritePermission) {
console.error('没有写入文件权限。');
return;
}
try {
// 创建一个可写流
// { keepExistingData: true } 表示在写入时保留现有数据,通常与 seek 配合使用
// 如果不指定,默认会覆盖现有内容
const writableStream = await fileHandle.createWritable({ keepExistingData: append });
if (append) {
// 如果是追加模式,将写入指针移动到文件末尾
const file = await fileHandle.getFile();
await writableStream.seek(file.size);
console.log(`以追加模式写入文件 ${fileHandle.name},当前指针在 ${file.size}。`);
} else {
console.log(`以覆盖模式写入文件 ${fileHandle.name}。`);
}
// 写入数据
await writableStream.write(content);
// 关闭流,完成写入
await writableStream.close();
console.log(`文件 ${fileHandle.name} 写入成功。`);
} catch (error) {
console.error('写入文件时发生错误:', error);
}
}
// 示例用法:
// document.getElementById('saveNewContentButton').addEventListener('click', async () => {
// const fileHandle = await selectFile(); // 假设用户选择了一个文件
// if (fileHandle) {
// await writeToFile(fileHandle, '这是新的文件内容。'); // 覆盖写入
// }
// });
//
// document.getElementById('appendContentButton').addEventListener('click', async () => {
// const fileHandle = await selectFile(); // 假设用户选择了一个文件
// if (fileHandle) {
// await writeToFile(fileHandle, 'n这是追加的内容。', true); // 追加写入
// }
// });
3. 文件元数据 (File 对象属性)
通过 FileSystemFileHandle.getFile() 获取的 File 对象包含了文件的元数据,例如:
name: 文件名。size: 文件大小(字节)。type: 文件的 MIME 类型。lastModified: 文件最后修改时间的 Unix 时间戳。lastModifiedDate: 文件最后修改时间的Date对象(已弃用,推荐使用lastModified)。
/**
* 获取并打印文件元数据。
* @param {FileSystemFileHandle} fileHandle - 文件句柄。
*/
async function getFileMetadata(fileHandle) {
if (!fileHandle) return;
const hasReadPermission = await verifyPermission(fileHandle, 'read');
if (!hasReadPermission) {
console.error('没有读取文件元数据权限。');
return;
}
try {
const file = await fileHandle.getFile();
console.log(`文件名称: ${file.name}`);
console.log(`文件大小: ${file.size} 字节`);
console.log(`文件类型: ${file.type}`);
console.log(`最后修改时间: ${new Date(file.lastModified).toLocaleString()}`);
} catch (error) {
console.error('获取文件元数据时发生错误:', error);
}
}
目录操作:结构化文件管理
FileSystemDirectoryHandle 允许Web应用以结构化的方式管理目录中的文件和子目录。
1. 遍历目录内容 (FileSystemDirectoryHandle.values() / entries() / keys())
FileSystemDirectoryHandle 实现了异步迭代协议,可以通过 for await...of 循环遍历其直接子项。
values(): 返回一个异步迭代器,迭代目录中所有子项(文件和子目录)的句柄。entries(): 返回一个异步迭代器,迭代目录中所有子项的[name, handle]对。keys(): 返回一个异步迭代器,迭代目录中所有子项的名称。
/**
* 遍历指定目录句柄的直接子项。
* @param {FileSystemDirectoryHandle} directoryHandle - 要遍历的目录句柄。
* @param {string} indent - 用于美化输出的缩进字符串。
*/
async function listDirectoryContents(directoryHandle, indent = '') {
if (!directoryHandle) {
console.warn('未提供目录句柄。');
return;
}
const hasReadPermission = await verifyPermission(directoryHandle, 'read');
if (!hasReadPermission) {
console.error('没有读取目录内容权限。');
return;
}
console.log(`${indent}目录: ${directoryHandle.name}`);
// 使用 for await...of 遍历目录内容
for await (const entry of directoryHandle.values()) {
if (entry.kind === 'file') {
const file = await entry.getFile();
console.log(`${indent} 文件: ${entry.name} (大小: ${file.size} bytes, 类型: ${file.type})`);
} else if (entry.kind === 'directory') {
// 递归遍历子目录
await listDirectoryContents(entry, indent + ' ');
}
}
}
// 示例用法:
// document.getElementById('listDirectoryButton').addEventListener('click', async () => {
// const directoryHandle = await selectDirectory(); // 假设用户选择了一个目录
// if (directoryHandle) {
// await listDirectoryContents(directoryHandle);
// }
// });
2. 获取子文件/子目录 (getFileHandle(), getDirectoryHandle())
这些方法允许您通过名称获取目录内的特定文件或子目录的句柄。create 选项非常有用,它可以在文件或目录不存在时自动创建它们。
/**
* 在指定目录中获取或创建文件句柄。
* @param {FileSystemDirectoryHandle} directoryHandle - 父目录句柄。
* @param {string} fileName - 文件名。
* @param {boolean} createIfNotExist - 如果文件不存在是否创建。
* @returns {Promise<FileSystemFileHandle | null>} - 文件句柄或 null。
*/
async function getOrCreateFileInDirectory(directoryHandle, fileName, createIfNotExist = false) {
if (!directoryHandle) return null;
const hasWritePermission = await verifyPermission(directoryHandle, 'readwrite');
if (!hasWritePermission) {
console.error('没有在目录中获取或创建文件的权限。');
return null;
}
try {
const fileHandle = await directoryHandle.getFileHandle(fileName, { create: createIfNotExist });
console.log(`在目录 ${directoryHandle.name} 中获取/创建了文件: ${fileHandle.name}`);
return fileHandle;
} catch (error) {
console.error(`获取/创建文件 ${fileName} 时发生错误:`, error);
return null;
}
}
/**
* 在指定目录中获取或创建子目录句柄。
* @param {FileSystemDirectoryHandle} directoryHandle - 父目录句柄。
* @param {string} subDirectoryName - 子目录名。
* @param {boolean} createIfNotExist - 如果子目录不存在是否创建。
* @returns {Promise<FileSystemDirectoryHandle | null>} - 子目录句柄或 null。
*/
async function getOrCreateDirectoryInDirectory(directoryHandle, subDirectoryName, createIfNotExist = false) {
if (!directoryHandle) return null;
const hasWritePermission = await verifyPermission(directoryHandle, 'readwrite');
if (!hasWritePermission) {
console.error('没有在目录中获取或创建子目录的权限。');
return null;
}
try {
const subDirectoryHandle = await directoryHandle.getDirectoryHandle(subDirectoryName, { create: createIfNotExist });
console.log(`在目录 ${directoryHandle.name} 中获取/创建了子目录: ${subDirectoryHandle.name}`);
return subDirectoryHandle;
} catch (error) {
console.error(`获取/创建子目录 ${subDirectoryName} 时发生错误:`, error);
return null;
}
}
// 示例用法:
// document.getElementById('createFileButton').addEventListener('click', async () => {
// const directoryHandle = await selectDirectory();
// if (directoryHandle) {
// const newFile = await getOrCreateFileInDirectory(directoryHandle, 'my-new-file.txt', true);
// if (newFile) {
// await writeToFile(newFile, '这是一个新创建的文件内容。');
// }
// }
// });
3. 删除文件/目录 (removeEntry())
removeEntry() 方法用于删除目录中的文件或子目录。对于删除非空子目录,需要设置 recursive: true。
/**
* 在指定目录中删除一个文件或子目录。
* @param {FileSystemDirectoryHandle} directoryHandle - 父目录句柄。
* @param {string} entryName - 要删除的文件或子目录的名称。
* @param {boolean} recursive - 如果是目录且非空,是否递归删除。
*/
async function removeEntryInDirectory(directoryHandle, entryName, recursive = false) {
if (!directoryHandle) return;
const hasWritePermission = await verifyPermission(directoryHandle, 'readwrite');
if (!hasWritePermission) {
console.error('没有删除目录内容的权限。');
return;
}
try {
await directoryHandle.removeEntry(entryName, { recursive: recursive });
console.log(`成功删除 ${directoryHandle.name} 中的条目: ${entryName}`);
} catch (error) {
if (error.name === 'NotFoundError') {
console.warn(`要删除的条目 ${entryName} 在目录 ${directoryHandle.name} 中不存在。`);
} else if (error.name === 'InvalidModificationError' && !recursive) {
console.error(`无法删除非空目录 ${entryName}。请尝试设置 recursive: true。`);
} else {
console.error(`删除条目 ${entryName} 时发生错误:`, error);
}
}
}
// 示例用法:
// document.getElementById('deleteFileButton').addEventListener('click', async () => {
// const directoryHandle = await selectDirectory();
// if (directoryHandle) {
// await removeEntryInDirectory(directoryHandle, 'my-new-file.txt');
// }
// });
//
// document.getElementById('deleteDirectoryButton').addEventListener('click', async () => {
// const directoryHandle = await selectDirectory();
// if (directoryHandle) {
// const subDir = await getOrCreateDirectoryInDirectory(directoryHandle, 'empty-sub-dir', true);
// await removeEntryInDirectory(directoryHandle, 'empty-sub-dir'); // 删除空目录
//
// const non_empty_subDir = await getOrCreateDirectoryInDirectory(directoryHandle, 'non-empty-sub-dir', true);
// const file_in_sub = await getOrCreateFileInDirectory(non_empty_subDir, 'file-in-sub.txt', true);
// await writeToFile(file_in_sub, 'some content');
// await removeEntryInDirectory(directoryHandle, 'non-empty-sub-dir', true); // 递归删除非空目录
// }
// });
重命名/移动: File System Access API 本身没有提供直接的重命名或移动文件/目录的方法。你需要通过组合读、写和删除操作来实现。例如,要重命名一个文件,你可以读取其内容,然后将其写入一个新名称的文件,最后删除旧文件。对于目录,这会涉及递归的读写和删除。
持久化权限与句柄:提升用户体验
为了避免用户每次访问应用时都重复授权,File System Access API 提供了机制来持久化文件和目录句柄。最常用的方法是将句柄存储在 IndexedDB 中。
1. 源私有文件系统 (Origin Private File System – OPFS)
在讨论持久化用户授权的句柄之前,值得一提的是 Origin Private File System (OPFS),可以通过 navigator.storage.getDirectory() 访问。OPFS 提供了一个源私有的、沙箱化的、非用户可见的文件系统。它的内容存储在浏览器内部,与特定源绑定,不直接映射到用户的本地文件系统。
OPFS 的特点:
- 隔离性: 每个源都有其独立的 OPFS 实例,不同源之间无法访问。
- 持久性: 数据默认是持久化的,不会随页面关闭而丢失,除非用户清除浏览器数据。
- 非用户可见: 用户无法通过文件管理器直接访问 OPFS 中的文件。
- 同步访问 (仅限 Web Workers): 在 Web Workers 中,OPFS 可以提供同步文件操作接口,这对于性能敏感的场景非常有用。
OPFS 与 File System Access API (用户授权访问本地文件系统) 的区别:
| 特性 | File System Access API (用户授权) | Origin Private File System (OPFS) |
|---|---|---|
| 访问目标 | 用户本地文件系统 (如桌面、下载、文档等) | 浏览器内部的、源隔离的、沙箱化文件系统 |
| 用户可见性 | 用户可见,可通过文件管理器访问 | 用户不可见,仅通过API访问 |
| 授权模式 | 每次访问特定文件/目录都需要用户明确授权 | 首次访问 OPFS 无需额外授权 (已在浏览器沙箱内) |
| 持久性 | 句柄可持久化,权限可能持久(取决于浏览器) | 默认持久化,与源绑定 |
| 使用场景 | 在线IDE、图片编辑器、本地数据管理等 | 应用程序的临时存储、缓存、离线数据、私有配置 |
| 同步API | 仅异步 | 在 Worker 中提供同步 API |
示例:访问 OPFS (仅在需要时了解,与本文主题的“用户授权本地文件系统”略有不同)
// 在主线程或 Worker 中获取 OPFS 的根目录句柄
async function getOPFSRoot() {
try {
const root = await navigator.storage.getDirectory();
console.log('成功获取 OPFS 根目录:', root.name);
return root;
} catch (error) {
console.error('获取 OPFS 根目录失败:', error);
return null;
}
}
// 示例:在 OPFS 中创建文件并写入
// async function createAndWriteToOPFSFile() {
// const opfsRoot = await getOPFSRoot();
// if (opfsRoot) {
// const fileHandle = await opfsRoot.getFileHandle('opfs-data.txt', { create: true });
// const writable = await fileHandle.createWritable();
// await writable.write('Hello from OPFS!');
// await writable.close();
// console.log('OPFS 文件写入成功。');
// }
// }
重点: 本文主要关注的是用户授权的 File System Access API,即与用户本地文件系统交互的部分。OPFS 是一个相关但不同的概念,理解其区别有助于避免混淆。
2. 存储句柄到 IndexedDB
FileSystemHandle 对象是可序列化的,这意味着它们可以被存储在 IndexedDB 中。这样,当用户再次访问您的Web应用时,您可以从 IndexedDB 中恢复这些句柄,然后尝试使用 verifyPermission() 重新获取权限(如果权限已持久化,这将是静默的)。
const DB_NAME = 'FileHandlesDB';
const DB_VERSION = 1;
const STORE_NAME = 'handles';
let db;
/**
* 打开或创建 IndexedDB 数据库。
* @returns {Promise<IDBDatabase>}
*/
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = (event) => {
db = event.target.result;
resolve(db);
};
request.onerror = (event) => {
console.error('IndexedDB 错误:', event.target.errorCode);
reject(event.target.error);
};
});
}
/**
* 将 FileSystemHandle 存储到 IndexedDB。
* @param {string} id - 用于标识句柄的唯一ID。
* @param {FileSystemHandle} handle - 要存储的句柄。
*/
async function saveHandle(id, handle) {
if (!db) db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
// 存储句柄及其名称和类型
const dataToStore = {
id: id,
name: handle.name,
kind: handle.kind,
handle: handle // 存储实际的句柄对象
};
return new Promise((resolve, reject) => {
const request = store.put(dataToStore);
request.onsuccess = () => {
console.log(`句柄 ${id} (${handle.name}) 已存储到 IndexedDB。`);
resolve();
};
request.onerror = (event) => {
console.error(`存储句柄 ${id} 失败:`, event.target.error);
reject(event.target.error);
};
});
}
/**
* 从 IndexedDB 获取 FileSystemHandle。
* @param {string} id - 句柄的ID。
* @returns {Promise<FileSystemHandle | null>} - 恢复的句柄或 null。
*/
async function getHandle(id) {
if (!db) db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = async (event) => {
const data = event.target.result;
if (data && data.handle) {
console.log(`句柄 ${id} (${data.name}) 从 IndexedDB 恢复。`);
// 恢复后需要检查并请求权限
const hasPermission = await verifyPermission(data.handle, 'readwrite');
if (hasPermission) {
resolve(data.handle);
} else {
console.warn(`恢复的句柄 ${id} 权限不足,需要用户重新授权。`);
resolve(null); // 或者可以尝试重新调用 Picker
}
} else {
console.log(`未找到句柄 ${id}。`);
resolve(null);
}
};
request.onerror = (event) => {
console.error(`获取句柄 ${id} 失败:`, event.target.error);
reject(event.target.error);
};
});
}
// 示例:
// async function managePersistentHandle() {
// const savedFileHandle = await getHandle('my-persistent-file');
// if (savedFileHandle) {
// console.log('已恢复并授权的文件句柄:', savedFileHandle.name);
// await readFileContent(savedFileHandle); // 立即使用
// } else {
// console.log('未找到持久化句柄,或权限失效,请用户重新选择。');
// const newFileHandle = await selectFile();
// if (newFileHandle) {
// await saveHandle('my-persistent-file', newFileHandle);
// await readFileContent(newFileHandle);
// }
// }
// }
//
// document.getElementById('managePersistentFileButton').addEventListener('click', managePersistentHandle);
通过这种方式,Web应用可以在用户首次授权后,将句柄持久化,并在后续访问时尝试静默恢复权限,极大提升了用户体验。
Web Workers 中的文件系统操作
File System Access API 的句柄(FileSystemFileHandle 和 FileSystemDirectoryHandle)是可序列化的。这意味着它们可以通过 postMessage 在主线程和 Web Worker 之间传递。这是一个非常重要的特性,因为它允许您在后台线程中执行耗时的文件I/O操作,从而避免阻塞主线程,保持UI的流畅响应。
工作原理:
- 主线程获取句柄: 在主线程中,通过
showOpenFilePicker()等方法获取文件或目录句柄。 - 传递句柄到 Worker: 使用
worker.postMessage(handle, [handle])将句柄传递给 Worker。第二个参数是transferList,指示浏览器将句柄的所有权转移给 Worker,而不是复制它。 - Worker 中操作句柄: Worker 接收到句柄后,可以在其内部直接调用句柄的方法(如
getFile(),createWritable(),values()等)执行文件操作。 - Worker 传回结果: Worker 完成操作后,可以通过
postMessage将结果(例如文件内容、操作成功/失败状态)传回主线程。
// main.js (主线程)
const worker = new Worker('file-worker.js');
/**
* 在主线程中选择文件并将其句柄发送到 Worker 进行处理。
*/
async function processFileInWorker() {
console.log('主线程: 请求用户选择文件...');
const fileHandle = await selectFile(); // 调用前面定义的 selectFile 函数
if (fileHandle) {
console.log('主线程: 用户选择了文件:', fileHandle.name);
console.log('主线程: 将文件句柄发送到 Worker...');
worker.postMessage({ type: 'process-file', fileHandle: fileHandle }, [fileHandle]);
}
}
// 监听 Worker 发送回来的消息
worker.onmessage = (event) => {
if (event.data.type === 'file-processed') {
console.log('主线程: 从 Worker 接收到文件处理结果。');
console.log('文件内容截取:', event.data.content.substring(0, 200) + '...');
} else if (event.data.type === 'error') {
console.error('主线程: 从 Worker 接收到错误:', event.data.message);
}
};
// 示例用法:
// document.getElementById('processFileInWorkerButton').addEventListener('click', processFileInWorker);
// file-worker.js (Web Worker 脚本)
self.onmessage = async (event) => {
if (event.data.type === 'process-file') {
const fileHandle = event.data.fileHandle;
console.log('Worker: 接收到文件句柄:', fileHandle.name);
try {
// 在 Worker 中验证权限 (可选,但推荐)
// 注意:Worker 中无法直接弹出权限请求,如果权限失效,这里会失败
const hasReadPermission = await fileHandle.queryPermission({ mode: 'read' });
if (hasReadPermission !== 'granted') {
throw new Error('Worker 无法在后台请求文件权限,请确保主线程已获取持久化权限。');
}
const file = await fileHandle.getFile();
const content = await file.text();
console.log('Worker: 成功读取文件内容。');
// 将结果发送回主线程
self.postMessage({ type: 'file-processed', content: content });
} catch (error) {
console.error('Worker: 处理文件时发生错误:', error);
self.postMessage({ type: 'error', message: error.message });
}
}
};
通过这种方式,即使是大型文件的读取和写入操作,也不会冻结用户界面,从而提供了更流畅、更响应迅速的Web应用体验。
安全性、隐私与用户体验考量
File System Access API 带来强大能力的同时,也对Web开发者提出了更高的安全和隐私责任要求。
- 用户授权至上: 始终记住,所有文件系统操作都必须经过用户的明确授权。在设计UI时,应清晰地告知用户为什么需要访问其文件系统,以及将对文件进行哪些操作。避免在不必要时请求权限。
- 权限撤销机制: 用户可以随时在浏览器设置中(通常是“站点设置”或“隐私与安全”部分)撤销对特定网站的文件系统权限。开发者应假定权限可能随时被撤销,并在每次操作前检查权限状态。
- 最小权限原则: 仅请求完成任务所需的最小权限。如果只需要读取文件,就请求
'read'权限,而不是'readwrite'。 - 渐进增强: 对于不支持 File System Access API 的浏览器,或者用户拒绝授权的情况,应用应该优雅地降级到传统的
<input type="file">和下载链接方式,确保核心功能仍然可用。 - 安全沙箱不变: 重申 API 不会绕过浏览器的安全沙箱或操作系统的安全机制。应用无法访问系统级的敏感文件,也无法在用户不知情的情况下执行恶意操作。
- 避免滥用: 开发者不应滥用此API来存储无关数据,或者在没有明确用户意图的情况下进行频繁或大规模的文件操作。
实际应用场景
File System Access API 为Web应用开启了许多激动人心的可能性:
- 在线代码编辑器/IDE: 允许开发者直接在浏览器中打开、编辑和保存本地项目文件,无需上传到云端或使用桌面应用。
- 图片/视频编辑器: 直接打开本地图片或视频进行编辑,并将修改后的文件保存回本地,避免了上传下载的延迟和带宽消耗。
- 本地数据管理工具: 如CSV编辑器、Markdown编辑器、JSON格式化工具等,可以直接操作本地文件,实现离线工作和快速处理。
- 文档处理应用: 在线Office套件可以直接编辑本地Word、Excel文档。
- 离线优先应用: 结合 Service Worker 和 IndexedDB,可以实现完全离线的应用,其数据可以直接持久化到用户的本地文件系统。
- PWA (Progressive Web Apps): 进一步模糊了Web应用和原生应用之间的界限,提供更接近原生应用的体验。
未来展望与兼容性
File System Access API 仍在不断发展和完善中。目前,它在 Chromium 系浏览器(Chrome、Edge、Opera)中已经得到了良好的支持。Firefox 和 Safari 团队也正在积极评估和开发相关功能,但尚未全面实现。
开发者在部署应用时,应始终检查 window.showOpenFilePicker 等方法是否存在,并提供回退方案。
if ('showOpenFilePicker' in window) {
// 使用 File System Access API
console.log('浏览器支持 File System Access API。');
} else {
// 提供回退方案,例如使用 <input type="file">
console.warn('浏览器不支持 File System Access API,将使用传统文件操作。');
}
随着Web平台能力的不断增强,我们可以期待未来会有更多与操作系统深度集成的Web API,进一步提升Web应用的潜力和用户体验。
Web应用能力边界的拓展与深思
File System Access API 是Web平台发展中的一个里程碑,它显著提升了Web应用与本地文件系统交互的能力,使得构建功能更强大、体验更流畅的Web应用成为可能。这项API在严格遵守浏览器安全模型和用户授权原则的前提下,为Web应用打开了通往本地文件系统的大门,极大地丰富了Web应用的生态和应用场景。然而,能力越大,责任也越大。开发者必须始终将用户隐私和安全放在首位,以透明、负责任的方式使用这些强大的新API。