File System Access API:跨源沙箱与用户授权的文件系统操作

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 方法来读取其内容,例如 FileReaderBlob 接口。

/**
 * 读取指定文件句柄的内容。
 * @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, BlobFile
  • 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 的句柄(FileSystemFileHandleFileSystemDirectoryHandle)是可序列化的。这意味着它们可以通过 postMessage 在主线程和 Web Worker 之间传递。这是一个非常重要的特性,因为它允许您在后台线程中执行耗时的文件I/O操作,从而避免阻塞主线程,保持UI的流畅响应。

工作原理:

  1. 主线程获取句柄: 在主线程中,通过 showOpenFilePicker() 等方法获取文件或目录句柄。
  2. 传递句柄到 Worker: 使用 worker.postMessage(handle, [handle]) 将句柄传递给 Worker。第二个参数是 transferList,指示浏览器将句柄的所有权转移给 Worker,而不是复制它。
  3. Worker 中操作句柄: Worker 接收到句柄后,可以在其内部直接调用句柄的方法(如 getFile(), createWritable(), values() 等)执行文件操作。
  4. 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。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注