JavaScript 访问原生文件系统:File System Access API 的权限提升与写入锁机制

各位同仁,各位对前端技术抱有热情的开发者们,大家好。

今天,我们将深入探讨一个在现代Web开发中日益重要,且极具颠覆性的API:File System Access API。长期以来,Web应用程序在与用户本地文件系统交互方面一直受到严格限制,这既是出于安全考虑,也是历史遗留问题。传统的 <input type="file"> 元素虽然允许用户选择文件上传,但其单向性、瞬时性以及无法直接写入的局限性,使得许多需要本地文件操作的富文本编辑器、IDE、图像处理工具等应用难以在浏览器中实现。

然而,随着Web平台能力的不断演进,特别是Progressive Web Apps (PWA) 的兴起,对更深层系统集成能力的需求变得尤为迫切。File System Access API 正是在这样的背景下应运而生,它赋予了Web应用前所未有的能力:直接读取、写入和管理用户本地文件与目录。当然,这种强大的能力必然伴随着严格的安全机制和用户控制。今天,我们的核心将聚焦于该API的两个关键方面:权限提升(Permission Elevation)写入锁机制(Write Lock Mechanism),它们是确保数据安全和完整性的基石。

File System Access API 的核心概念与基石

在深入权限和锁之前,我们首先需要建立对File System Access API基本操作的理解。这个API的核心思想是,Web应用不再直接操作文件路径,而是通过文件句柄(FileSystemFileHandle目录句柄(FileSystemDirectoryHandle 来间接访问用户授权的文件和目录。这些句柄是抽象的引用,它们本身不包含文件内容,但提供了访问文件和目录元数据以及执行读写操作的方法。

用户与API的交互通常始于以下几个入口点:

  1. window.showOpenFilePicker(): 允许用户选择一个或多个文件进行读取。
  2. window.showSaveFilePicker(): 允许用户选择一个位置保存文件,并返回一个可写入的句柄。
  3. window.showDirectoryPicker(): 允许用户选择一个目录,并返回一个可遍历和操作其内容的句柄。

这些方法都必须由用户手势(user gesture) 触发,例如点击按钮。这是一个最基本的安全机制,确保文件系统访问不是在用户不知情的情况下悄悄进行的。

让我们通过一些基本代码示例来感受一下:

// 1. 打开文件
async function openFile() {
  try {
    const [fileHandle] = await window.showOpenFilePicker({
      types: [{
        description: 'Text Files',
        accept: {
          'text/plain': ['.txt'],
        },
      }, {
        description: 'Images',
        accept: {
          'image/*': ['.png', '.gif', '.jpeg', '.jpg'],
        },
      }],
      multiple: false, // 允许选择单个文件
    });

    const file = await fileHandle.getFile(); // 获取 File 对象
    const contents = await file.text(); // 读取文件内容
    console.log(`File Name: ${file.name}`);
    console.log(`File Content:n${contents}`);
    return fileHandle;
  } catch (err) {
    if (err.name === 'AbortError') {
      console.log('User cancelled the file picker.');
    } else {
      console.error('Error opening file:', err);
    }
  }
}

// 2. 保存文件
async function saveFile(content, fileName = 'untitled.txt') {
  try {
    const fileHandle = await window.showSaveFilePicker({
      suggestedName: fileName,
      types: [{
        description: 'Text Files',
        accept: {
          'text/plain': ['.txt'],
        },
      }],
    });

    // 接下来我们将深入讲解写入机制
    // const writableStream = await fileHandle.createWritable();
    // await writableStream.write(content);
    // await writableStream.close();
    // console.log('File saved successfully!');
    return fileHandle;
  } catch (err) {
    if (err.name === 'AbortError') {
      console.log('User cancelled the save file picker.');
    } else {
      console.error('Error saving file:', err);
    }
  }
}

// 3. 选择目录
async function openDirectory() {
  try {
    const directoryHandle = await window.showDirectoryPicker();
    console.log(`Directory Name: ${directoryHandle.name}`);

    // 遍历目录内容
    for await (const entry of directoryHandle.values()) {
      console.log(`  Name: ${entry.name}, Kind: ${entry.kind}`);
    }
    return directoryHandle;
  } catch (err) {
    if (err.name === 'AbortError') {
      console.log('User cancelled the directory picker.');
    } else {
      console.error('Error opening directory:', err);
    }
  }
}

// 示例调用 (需要用户点击事件触发)
// document.getElementById('openFileButton').addEventListener('click', openFile);
// document.getElementById('saveFileButton').addEventListener('click', () => saveFile('Hello, File System Access API!'));
// document.getElementById('openDirButton').addEventListener('click', openDirectory);

权限管理:安全与用户控制的基石

File System Access API 的强大能力必须与严格的权限模型相匹配,以保护用户的隐私和数据安全。权限管理是此API最核心且最复杂的方面之一。

权限类型与模式

每个 FileSystemHandle(无论是 FileSystemFileHandle 还是 FileSystemDirectoryHandle)都关联着一个或多个权限。这些权限是针对特定句柄和特定操作模式的。主要有两种模式:

  • 'read' (读取模式): 允许Web应用读取文件或目录的内容和元数据。
  • 'readwrite' (读写模式): 允许Web应用读取、写入、创建、删除文件或目录。

权限的获取与提升

权限的获取通常有两种方式:隐式获取和显式请求。

1. 隐式权限获取

当用户通过文件选择器与API交互时,权限通常会隐式授予:

  • showOpenFilePicker(): 总是授予选择文件的 'read' 权限。
  • showSaveFilePicker(): 总是授予选择文件的 'readwrite' 权限。这是因为保存操作天然需要写入能力。
  • showDirectoryPicker(): 总是授予选择目录的 'read' 权限。要对目录内容进行写入、创建或删除操作,需要额外的显式权限提升。

重要提示: 隐式获取的权限也需要用户手势。

2. 显式权限请求与提升

对于一个已经获取的句柄,如果其当前权限不足以执行所需操作(例如,一个通过 showDirectoryPicker() 获取的目录句柄只有 'read' 权限,但你想在其中创建新文件),你就需要显式请求权限提升

每个 FileSystemHandle 都继承了 FileSystemHandle 接口的 queryPermission()requestPermission() 方法,用于检查和请求权限。

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 FileSystemPermissionDescriptor extends PermissionDescriptor {
  mode: 'read' | 'readwrite';
}

PermissionState 可以是 'granted' (已授权), 'denied' (已拒绝), 或 'prompt' (需要用户确认)。

示例:检查和请求目录写入权限

假设我们已经通过 showDirectoryPicker() 获取了一个 directoryHandle

async function ensureDirectoryWriteAccess(directoryHandle) {
  // 1. 检查当前写入权限状态
  const options = { mode: 'readwrite' };
  let status = await directoryHandle.queryPermission(options);

  if (status === 'granted') {
    console.log('Directory already has write access.');
    return true;
  }

  if (status === 'prompt') {
    // 2. 如果状态是 'prompt',则显式请求权限
    console.log('Requesting write access for the directory...');
    status = await directoryHandle.requestPermission(options);

    if (status === 'granted') {
      console.log('Write access granted!');
      return true;
    } else {
      console.warn('Write access denied by user.');
      return false;
    }
  }

  if (status === 'denied') {
    console.warn('Write access previously denied. User must manually grant it via browser settings.');
    // 在某些浏览器中,如果用户明确拒绝过一次,后续的 requestPermission() 可能直接返回 'denied'
    // 且不会再次弹出提示。用户需要通过浏览器UI手动更改权限。
    return false;
  }

  return false;
}

// 示例使用
async function createNewFileInDirectory() {
  const directoryHandle = await openDirectory(); // 假设已经获取目录句柄
  if (!directoryHandle) return;

  const hasWriteAccess = await ensureDirectoryWriteAccess(directoryHandle);
  if (hasWriteAccess) {
    try {
      // 现在可以安全地尝试创建文件了
      const newFileHandle = await directoryHandle.getFileHandle('my-new-file.txt', { create: true });
      console.log(`Created new file handle: ${newFileHandle.name}`);

      const writableStream = await newFileHandle.createWritable();
      await writableStream.write('This is content for the new file.');
      await writableStream.close();
      console.log('Content written to new file successfully!');
    } catch (err) {
      console.error('Error creating or writing to file:', err);
    }
  } else {
    console.log('Cannot create file without write access.');
  }
}

// createNewFileInDirectory(); // 需用户手势触发

权限的持久化与撤销

File System Access API 的一个重要特性是权限可以持久化。这意味着,如果用户授予了某个源(origin)对特定文件或目录的访问权限,并且该用户在浏览器中选择了“记住此权限”(通常是弹窗中的一个复选框),那么在后续的会话中,该Web应用可以在没有用户手势的情况下重新获取相同的句柄并执行操作,而无需再次提示用户。这对于构建类似本地IDE或文件管理器这样的应用至关重要。

然而,权限的持久化并非永久。用户随时可以通过浏览器设置来撤销任何已授予的权限。当权限被撤销后,Web应用再次尝试访问时,将需要重新请求用户授权。

权限状态表格总结:

PermissionState 描述 行为
'granted' 用户已授权访问。 应用程序可以执行请求的操作。
'prompt' 用户尚未授权,但可以被提示。 调用 requestPermission() 会触发浏览器权限弹窗。
'denied' 用户明确拒绝了访问,或浏览器因安全策略拒绝。 调用 requestPermission() 通常会直接返回 'denied',不会再次弹出提示。用户需手动在浏览器设置中更改。

最佳实践:

  • 只在需要时请求权限:避免在应用加载时就请求所有权限,这会给用户带来不必要的打扰。
  • 清晰解释请求原因:在请求权限之前,向用户解释为什么需要这些权限,以及它们将如何被使用,这能显著提高用户授权的意愿。
  • 优雅地处理拒绝:如果用户拒绝了权限,应用程序应该能优雅地降级功能,而不是崩溃或显示错误。可以提供替代方案,或指导用户如何在浏览器设置中手动启用权限。
  • 利用持久化权限:如果你的应用需要长期访问文件,利用持久化权限可以极大地提升用户体验。

写入文件:FileSystemWritableFileStream 与写入锁机制

对本地文件进行写入是File System Access API最强大的功能之一,也是最需要谨慎处理的部分。它通过 FileSystemWritableFileStream 对象实现,并引入了严格的写入锁机制来保证数据完整性。

获取 FileSystemWritableFileStream

要写入文件,首先你需要一个 FileSystemFileHandle。这可以通过 showSaveFilePicker()(默认授予读写权限)或 directoryHandle.getFileHandle() 结合 create: true 选项(创建新文件)或 create: false 选项(访问现有文件)获得。

一旦有了 FileSystemFileHandle,你就可以调用其 createWritable() 方法来获取一个 FileSystemWritableFileStream

interface FileSystemFileHandle extends FileSystemHandle {
  getFile(): Promise<File>;
  createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream>;
}

interface FileSystemCreateWritableOptions {
  keepExistingData?: boolean; // 默认为 false,表示截断文件;true 表示保留现有数据并在指定位置写入
}

createWritable() 的关键作用:

  1. 权限检查与提升: 在返回 FileSystemWritableFileStream 之前,createWritable() 会检查当前的 FileSystemFileHandle 是否拥有 'readwrite' 权限。如果没有,它会隐式地触发一个权限请求弹窗(如果状态是 'prompt'),要求用户授予写入权限。如果用户拒绝,或者之前已经拒绝过且状态为 'denied'createWritable() 将抛出 DOMException
  2. 获取写入锁: 这是 createWritable() 的另一个核心功能。当它成功返回一个 FileSystemWritableFileStream 实例时,就意味着你的Web应用已经成功获取了该文件的独占写入锁

FileSystemWritableFileStream 的操作

FileSystemWritableFileStream 是一个 WritableStream 的子类,它提供了一系列写入文件的方法:

  • write(data): 写入数据。data 可以是 stringArrayBufferTypedArrayDataViewBlob
  • seek(position): 移动写入位置。
  • truncate(size): 将文件截断或扩展到指定大小。
  • close(): 至关重要! 关闭流,提交所有挂起的写入操作,并将修改保存到磁盘,同时释放写入锁

示例:写入文件内容

async function writeFile(fileHandle, content) {
  // 确保文件句柄存在且有写入权限
  if (!fileHandle || fileHandle.kind !== 'file') {
    console.error('Invalid file handle provided.');
    return;
  }

  // 1. 检查并请求写入权限 (如果需要)
  const permissionStatus = await fileHandle.requestPermission({ mode: 'readwrite' });
  if (permissionStatus !== 'granted') {
    console.warn('Write permission denied for file:', fileHandle.name);
    return;
  }

  let writableStream;
  try {
    // 2. 获取 FileSystemWritableFileStream,这将获取写入锁
    writableStream = await fileHandle.createWritable();

    // 3. 写入内容
    await writableStream.write(content);

    // 4. 关闭流,提交更改并释放锁
    await writableStream.close();
    console.log(`Content successfully written to ${fileHandle.name}`);
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      console.error('User denied write access or another application holds the lock.', err);
    } else if (err.name === 'AbortError') {
      console.error('Write operation aborted, possibly due to lock contention or browser issues.', err);
    } else {
      console.error('Error writing to file:', err);
    }
  } finally {
    // 确保在任何情况下都尝试关闭流以释放锁
    if (writableStream) {
      // 这里的 close() 可能会再次抛出错误,但通常是为了确保锁被释放
      // 在实际应用中,你可能需要更复杂的错误处理来确保这一点
      try {
        // 如果流因错误而未成功初始化,writableStream可能没有close方法
        // 或者在之前的catch块中已经处理过关闭
        // 这里的finally块是为了捕获那些在try块中没有被捕获的错误
        // 导致流没有被关闭的情况
        // 如果流已经关闭,再次调用 close() 可能会报错,但通常是无害的
        // 或者你可以检查 writableStream.locked 属性 (但 WritableStream 没有直接 locked 属性)
        // 更稳健的做法是在try块中捕获所有错误,并在finally中确保关闭
      } catch (e) {
        console.warn('Error closing writable stream in finally block:', e);
      }
    }
  }
}

// 示例:先选择一个文件,然后写入内容
async function openAndWriteToFile() {
  const fileHandle = await openFile(); // 使用 openFile picker 获取文件句柄
  if (fileHandle) {
    await writeFile(fileHandle, 'New content written at ' + new Date().toLocaleTimeString());
  }
}

// document.getElementById('openAndWriteButton').addEventListener('click', openAndWriteToFile);

写入锁机制:保障数据完整性

现在我们来详细探讨写入锁机制。这是File System Access API在处理并发写入时确保数据完整性的核心设计。

为什么需要写入锁?

想象一下,如果多个Web应用(或者同一个应用的多个标签页、多个Worker)可以同时向同一个文件写入数据,会发生什么?

  • 数据损坏(Race Condition):不同写入操作可能会相互覆盖,导致文件内容混乱不堪,甚至损坏。
  • 不一致状态:文件可能处于一个部分更新、部分旧数据的中间状态。

为了避免这些问题,File System Access API 实现了一个独占写入锁(exclusive write lock)

写入锁的工作原理:

  1. 当你的Web应用调用 fileHandle.createWritable() 时,它会尝试获取该文件的独占写入锁。
  2. 如果文件当前没有被任何其他来源(包括其他Web应用的标签页、Worker,甚至同一个Web应用的另一个标签页)锁定进行写入,那么锁会被成功获取,并返回一个 FileSystemWritableFileStream 实例。
  3. 如果文件已经被另一个来源锁定,createWritable() 将会抛出一个 DOMException,通常是 NotAllowedErrorAbortError 这意味着你无法同时获取写入权限。
  4. 一旦获取了锁,该锁将一直有效,直到 writableStream.close() 被调用,或者持有锁的Web应用标签页/Worker被关闭。

核心要点:

  • 独占性: 在任何给定时刻,只有一个 FileSystemWritableFileStream 实例可以对某个文件进行操作。
  • 跨源/跨标签页: 这个锁是全局的,它不仅阻止同一个源(origin)的多个标签页同时写入,也阻止不同源的Web应用同时写入同一个文件。
  • 原子性(Atomic Writes): 许多文件系统操作(包括 File System Access API 的写入操作)会利用操作系统的原子性特性。在内部,当 createWritable() 被调用时,通常会创建一个临时文件进行写入。只有当 close() 被调用且所有数据都成功写入临时文件后,操作系统才会原子性地将临时文件替换掉原始文件。这确保了即使在写入过程中发生崩溃,原始文件也不会被破坏,从而保证了数据完整性。

处理写入锁冲突:

由于写入锁的独占性,你的应用必须能够优雅地处理锁冲突。当 createWritable() 抛出错误时,你应该告知用户文件当前正在被其他进程使用,并提供相应的处理建议。

// 改进的 writeFile 函数中的错误处理
async function writeFileRobust(fileHandle, content) {
  // ... 权限检查部分同上 ...

  let writableStream;
  try {
    writableStream = await fileHandle.createWritable();
    await writableStream.write(content);
    await writableStream.close();
    console.log(`Content successfully written to ${fileHandle.name}`);
  } catch (err) {
    if (err.name === 'NotAllowedError' || err.name === 'AbortError') {
      console.error(`Error: File "${fileHandle.name}" is currently locked by another process or tab. Please try again later.`);
      // 可以提供用户选项:等待、强制关闭(如果可能且用户授权)、取消
    } else {
      console.error('An unexpected error occurred during file write:', err);
    }
  } finally {
    // 确保流被关闭,即使在写入过程中发生错误
    // 重要的是在try块中捕获所有错误,然后在finally中尝试安全关闭
    // 如果writableStream已经因错误而未成功创建或关闭,这里的close()可能再次失败
    // 但通常我们只是想保证资源释放
    if (writableStream && !writableStream.locked) { // WritableStream 没有直接的 locked 属性
                                                  // 但我们可以假设如果它被成功创建,且没有在try中被关闭,就尝试关闭
      try {
        await writableStream.close(); // 尝试关闭以释放锁
        console.log('Writable stream closed in finally block.');
      } catch (closeErr) {
        console.warn('Error closing writable stream in finally block:', closeErr);
      }
    }
  }
}

最佳实践:

  • 尽快释放锁: 一旦写入操作完成,立即调用 writableStream.close()。不要长时间持有锁,这会阻止其他应用或标签页访问文件。
  • 使用 try...finally 块: 确保 writableStream.close() 即使在写入过程中发生错误也能被调用,这是释放锁的关键。
  • 用户友好的错误信息: 当发生锁冲突时,向用户清晰地解释情况,并建议他们检查是否有其他应用或浏览器标签页正在使用该文件。
  • 避免不必要的写入: 仅在文件内容确实需要更新时才执行写入操作。

目录操作:结构与导航

除了单个文件,File System Access API 还允许我们操作目录,这对于构建文件管理器、项目管理工具等应用至关重要。

获取和遍历目录

通过 showDirectoryPicker() 获取 FileSystemDirectoryHandle

async function listDirectoryContents(directoryHandle) {
  console.log(`Contents of directory: ${directoryHandle.name}`);
  for await (const entry of directoryHandle.values()) {
    console.log(`  Name: ${entry.name}, Kind: ${entry.kind}`); // kind: 'file' or 'directory'
  }
}

// const dirHandle = await openDirectory();
// if (dirHandle) {
//   await listDirectoryContents(dirHandle);
// }

创建、获取和删除目录/文件

FileSystemDirectoryHandle 提供了以下关键方法:

  • getFileHandle(name, options): 获取指定名称的文件句柄。options 可包含 { create: true } 来创建新文件。
  • getDirectoryHandle(name, options): 获取指定名称的子目录句柄。options 可包含 { create: true } 来创建新目录。
  • removeEntry(name, options): 删除指定名称的文件或目录。options 可包含 { recursive: true } 来递归删除非空目录。

重要: 这些操作都需要目标目录句柄拥有 'readwrite' 权限。如果只有 'read' 权限,操作将失败并抛出 NotAllowedError

async function manageDirectory(parentDirectoryHandle) {
  if (!parentDirectoryHandle) return;

  // 1. 确保有写入权限
  const hasWriteAccess = await ensureDirectoryWriteAccess(parentDirectoryHandle);
  if (!hasWriteAccess) {
    console.warn('Cannot manage directory without write access.');
    return;
  }

  try {
    // 2. 创建一个新目录
    const newSubDirHandle = await parentDirectoryHandle.getDirectoryHandle('my-new-folder', { create: true });
    console.log(`Created new directory: ${newSubDirHandle.name}`);

    // 3. 在新目录中创建一个文件
    const newFileInSubDirHandle = await newSubDirHandle.getFileHandle('config.json', { create: true });
    const writable = await newFileInSubDirHandle.createWritable();
    await writable.write(JSON.stringify({ setting: 'value', version: 1 }));
    await writable.close();
    console.log(`Created and wrote to config.json in ${newSubDirHandle.name}`);

    // 4. 获取现有文件(假设它存在)
    // const existingFileHandle = await parentDirectoryHandle.getFileHandle('existing-file.txt', { create: false });
    // console.log(`Found existing file: ${existingFileHandle.name}`);

    // 5. 删除一个文件或空目录
    // await parentDirectoryHandle.removeEntry('my-new-folder/config.json'); // 必须指定完整路径或通过子句柄删除
    // await newSubDirHandle.removeEntry('config.json');
    // console.log('Removed config.json');

    // 6. 删除一个非空目录 (需要 { recursive: true })
    await parentDirectoryHandle.removeEntry('my-new-folder', { recursive: true });
    console.log('Removed my-new-folder recursively.');

  } catch (err) {
    console.error('Error managing directory:', err);
    if (err.name === 'NotAllowedError') {
      console.error('Operation not allowed, likely due to insufficient write permissions.');
    } else if (err.name === 'NotFoundError') {
      console.error('Entry not found.');
    }
  }
}

// 示例:
// async function setupAndManage() {
//   const dirHandle = await openDirectory();
//   if (dirHandle) {
//     await manageDirectory(dirHandle);
//   }
// }
// document.getElementById('manageDirButton').addEventListener('click', setupAndManage);

resolve(possibleDescendant)

这个方法用于确定一个句柄是否是另一个目录句柄的后代,并返回从父目录到后代句柄的相对路径数组。

async function resolvePath(ancestorDirHandle, descendantFileHandle) {
  try {
    const relativePath = await ancestorDirHandle.resolve(descendantFileHandle);
    if (relativePath) {
      console.log(`Relative path from ${ancestorDirHandle.name} to ${descendantFileHandle.name}:`, relativePath.join('/'));
    } else {
      console.log(`${descendantFileHandle.name} is not a descendant of ${ancestorDirHandle.name}.`);
    }
  } catch (err) {
    console.error('Error resolving path:', err);
  }
}

// 示例:
// async function testResolve() {
//   const dirHandle = await openDirectory(); // 用户选择一个目录 A
//   if (!dirHandle) return;

//   // 假设用户在目录 A 中创建了一个文件 B
//   const fileHandle = await dirHandle.getFileHandle('test-file.txt', { create: true });
//   const writable = await fileHandle.createWritable();
//   await writable.write('Test content');
//   await writable.close();
//   console.log('Created test-file.txt');

//   // 再次获取文件句柄 (例如,通过 showOpenFilePicker 选择了 test-file.txt)
//   const [reopenedFileHandle] = await window.showOpenFilePicker();
//   if (reopenedFileHandle) {
//     await resolvePath(dirHandle, reopenedFileHandle); // 应该能解析出 ['test-file.txt']
//   }

//   // 尝试解析一个不相关的句柄
//   const [unrelatedFileHandle] = await window.showOpenFilePicker(); // 用户选择一个不在目录 A 的文件 C
//   if (unrelatedFileHandle) {
//     await resolvePath(dirHandle, unrelatedFileHandle); // 应该返回 null
//   }
// }
// document.getElementById('testResolveButton').addEventListener('click', testResolve);

总结与展望

File System Access API 极大地扩展了Web应用程序的能力边界,使其能够提供与桌面应用相媲美的文件操作体验。通过深入理解其权限提升机制,我们可以构建安全、受控且用户信任的本地文件交互功能。而写入锁机制则是保障文件数据完整性的关键,它强制了独占写入,避免了潜在的数据损坏。

掌握这些核心概念和实践,将使我们能够开发出更加强大、响应迅速且与用户本地环境深度融合的Web应用,真正推动Web平台向更广阔的领域迈进。随着浏览器支持度的不断提高,以及开发者社区的持续探索,我们可以预见,File System Access API 将在未来的Web生态中扮演越来越重要的角色。

发表回复

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