JavaScript 操作文件系统

javascript
Created 1/27/2023
Updated 6/15/2023

JavaScript 操作文件系统

参考

File System Access API 文件系统访问接口让 Web 应用拥有读写、管理本地设备或线上的文件系统的能力,实现与原生应用类似的功能。

注意

大部分功能仅在 secure context 安全上下文,例如使用 HTTPS 协议的网站中可用。

使用该系列的 API 的核心概念是一个名为 FileSystemHandle 句柄的类 class

该类的实例表示文件系统中的一个条目(可能是一个文件或一个目录,以下用 handle 表示),它具有以下属性和方法:

说明

对于文件系统中的同一个 entry 条目,可以有多个句柄与之对应

  • handle.kind(只读)属性:该属性值可能是 filedirectory,句柄所关联的条目的类型。
    如果关联的是文件,则该属性值就是 file;如果关联的是目录,则该属性值就是 directory
  • handle.name(只读)属性:该属性值是一个字符串,句柄所关联的条目的名称
  • handle.isSameEntry(anotherHandle) 方法:返回一个布尔值,以判断两个句柄是否关联到文件系统中的同一个条目
  • handle.queryPermission(permissionDescriptor)(异步)方法:查看该句柄是否已获取授权。
    可选参数 permissionDescriptor 是一个对象,用以设置需要查询该句柄的哪一种权限
    • 如果是参数是 { mode: 'read' } 则只是查看句柄是否具有读取权限
    • 如果参数是 { mode: 'readwrite'} 则查看句柄是否同时具有读写权限

    返回一个对象,其属性 state 用以描述该句柄指定权限的授权状态,属性值可以是以下三个值之一:
    • granted 已经获取相应的权限
    • denied 相应的权限被拒绝
    • prompt 需要询问用户是否赋予相应的权限,这时候应该调用 handle.requestPermission(permissionDescriptor) 方法,以弹出一个对话框获取用户授权。
    提示

    关于句柄的权限相关内容,可以查看本文的 保存句柄与获取授权 部分

  • handle.requestPermission(permissionDescriptor)(异步)方法:为句柄请求特定的权限
    可选参数 permissionDescriptor 是一个对象,用以设置需要为句柄请求哪一种权限
    • 如果是参数是 { mode: 'read' } 则只是为句柄请求读取权限
    • 如果参数是 { mode: 'readwrite'} 则为句柄同时请求读写权限

    返回一个 Promise 如果 resolve 就会得到一个对象,其中 state 属性可以是以下三个值之一,以表示句柄的相应权限信息:
    • granted 已经获取相应的权限
    • denied 相应的权限请求被拒绝
    • prompt 需要询问用户是否赋予相应的权限
  • handle.remove(options) 方法:从文件系统种删除该句柄所关联的条目
    可选参数 options 是一个对象,其属性 recursive 是一个布尔值,以表示是否执行递归删除(如果删除的条目是一个非空文件夹时,是否同时删除文件夹里的内容)
    说明

    该方法可以移除文件系统中与句柄所关联的条目,但是该方法依然在实验中,在某些浏览器版本中可能不可用。

    作为 fallback 替代方法,可以先获取目标条目所在的(父)目录的句柄 FileSystemDirectoryHandle 再调用 directoryHandle.removeEntry(targetEntryName) 来移除目标条目

    注意

    如果 recursive 属性没有设置为 true(默认值为 false),且要删除的条目是一个非空文件夹,则会抛出 InvalidModificationError 错误

句柄 FileSystemHandle 类有两个子类:

TypeScript

因为这个 API 是暴露在 window 对象下的,但是依然在开发中的,所以 VS Code 的内置 TypeScript 插件可能并不能识别,需要在项目中安装相应的类型包

文件句柄

FileSystemFileHandle 该子类的实例表示文件系统中的一个文件。

文件句柄除了继承了 FileSystemHandle 句柄的属性和方法,还具有以下一些特有的方法:

  • fileHandle.getFile()(异步)方法:获取该句柄所关联的文件对象
    File 对象 实际上是一个 Blob 对象(数据以二进制格式表示),可以调用 Blob 对象的一些方法将二进制的数据转换为其他格式
    提示

    blob 对象有一些实用的方法可用于读取其二进制内容,转换为特定的格式,如 text()slice()stream()arrayBuffer()

    注意

    通过方法 fileHandle.getFile() 所获得的一个文件对象,如果在此期间文件被更改了,那么该对象就变得不可读/访问,则需要重新调用 fileHandle.getFile() 方法来获取新的文件对象

  • fileHandle.createSyncAccessHandle()(异步)方法:创建一个 FileSystemSyncAccessHandle 同步访问的文件句柄,具体可参考本文的 同步写入 部分
  • fileHandle.createWritable(options)(异步)方法:创建一个 FileSystemWritableFileStream 写入流(它是 WritableStream 的子类(将流数据写入目标),该子类添加了一些额外的方法以便用于操作文件系统中的单个文件),以便将更改写入到句柄的关联文件中
    可选参数 options 是一个对象,其属性 keepExistingData 是一个布尔值,用以设置写入方式。
    • 如果参数是 { keepExistingData: true} 则保留原有的内容,以追加的方式写入内容
    • 如果参数是 { keepExistingData: false}(默认值)则以覆盖的方式写入内容
    说明

    当调用 fileHandle.createWritable() 方法时,浏览器首先会查看是否已经获得了对于指定文件的写入权限。如果没有获得权限则会弹出一个 prompt 提示框以询问用户是否允许授权,如果没有获得权限就会抛出 DOMException 错误

    只有调用了方法 writableStream.close() 将写入流关闭时,更改的内容才会真的写入到文件中。实际上写入流将更改写入到一个临时文件中,当写入流关闭时,再用临时文件去替代句柄所关联的真实文件


    FileSystemWritableFileStream 该类的实例具有以下方法,以便设置如何将内容写入到文件中:
    • 方法 writableFileStream.write(content) 将内容写入到文件中当前光标所在的位置处
    • 方法 writableFileStream.seek(position) 更新光标位置,以字节 byte 为单位
    • 方法 writableFileStream.truncate(size) 将写入流中的内容截短为指定的字节 bytes 长度

    其中方法 writableStream.write() 可以传入一个对象,包含配置参数和要写入的数据,以实现其他两个方法的作用
    js
    // just pass in the data (no options)
    writableStream.write(data);
    // writes the data to the stream from the determined position
    writableStream.write({ type: "write", position, data });
    // updates the current file cursor offset to the position specified
    writableStream.write({ type: "seek", position });
    // resizes the file to be size bytes long
    writableStream.write({ type: "truncate", size });
注意

如果要使用以上列出的 file handle 文件句柄的相应的方法,请先使用 fileHandle.queryPermission(permissionDescriptor) 确保句柄有相应的权限

如果页面刷新重载,而且浏览器的其他标签页中没有同源的,那么之前所获取的文件句柄的权限将会失效,需要通过 fileHandle.requestPermission(permissionDescriptor) 重新获取

目录句柄

FileSystemDirectoryHandle 该子类的实例表示文件系统中的一个目录。

目录句柄除了继承了 FileSystemHandle 句柄的属性和方法,还具有以下一些特有的方法,可用于查看或操作目录中的内容:

  • directoryHandle.entries()(异步)方法:返回一个可迭代对象(列表),其元素是一个二维数组,以 [key, value] 键值对的格式保存着该目录中的(直接)子条目,包括(直接)子目录和文件。
    js
    const dirHandle = await window.showDirectoryPicker()
    for await (const [key, value] of dirHandle.entries()) {
        console.log({ key, value })
    }

    返回的条目称为 FileSystemDirectoryEntry,该对象具有一些实用的方法
    提示

    目录句柄还提供两个相应的方法,可以单独获得该目录内容的键或值:

    • directoryHandle.keys()(异步)方法:返回一个可迭代对象(列表),其元素是该目录下的(直接)文件和子目录的名称
    • directoryHandle.values() (异步)方法:返回一个可迭代对象(列表),其元素是目录下的文件和子目录的内容

    也可以使用 directoryHandle[@@asyncIterator]()(异步)方法:获取生成 directoryHandle.entries() 可迭代对象的方法(相应可迭代函数)

  • directoryHandle.resolve(possibleDescendantEntry)(异步)方法:返回一个数组或 null
    如果返回的是一个数组,则里面的元素是字符串,是从 directoryHandle 所对应的(父)目录,到给定的后代 entry 条目 possibleDescendantEntry 所「经历」的目录的名称,所以该数组记录了从父目录到目标子条目的路径,而且数组的最后一个元素就是该 entry 条目。
    如果没有找到该路径,则返回 null
  • directoryHandle.getFileHandle(fileName[, options])(异步)方法:返回该目录下的一个指定名称 fileName 的文件的句柄
    其中第二个参数 options 是可选参数,它是一个对象,具有 create 属性,该属性值是一个布尔值,如果设置为 true 则会在未找到指定名称的文件时,新建一个相应的文件
  • directoryHandle.getDirectoryHandle(dirName[, options])(异步)方法:返回该目录下的一个指定名称 dirName 的子目录的句柄
    其中第二个参数 options 是可选参数,它是一个对象,具有 create 属性,该属性值是一个布尔值,如果设置为 true 则会在未找到指定名称的子目录时,新建一个相应的子目录
  • directoryHandle.removeEntry(entryName[, options])(异步)方法:移除指定名称的条目(文件或子目录)
    其中第二个参数 options 是可选参数,它是一个对象,具有 recursive 属性。该属性是一个布尔值,如果设置为 true 则会递归地删除指定子目录里的所有内容
    注意

    如果不将属性 recursive 设置为 true,而所需删除的子目录中有内容,则会抛出 InvalidModificationError 错误

推荐

在使用目录句柄时可能需要同时访问其中的多个条目,因为 File System Access API 的大部分方法都是异步的,并不推荐 使用 async-await 代码模式,更推荐使用 Promise.all() 同时进行操作

获取句柄

句柄可以通过很多方法获得,一般从以下这三个(异步)方法开始入手:

  • window.showOpenFilePicker() 以读取 read 模式获取文件句柄
  • window.showSaveFilePicker() 以读写(可保存)readwrite 模式获取文件句柄
  • window.showDirectoryPicker() 获取目录句柄

调用这些方法就会弹出一个文件/目录选择窗口 file or directory picker,让用户在文件系统中选择需要操作的条目,并将操作权限授予给当前的 web 应用/页面。

这个交互比较繁琐,但这是为了确保用户在网络上的安全,Web 应用需要获取用户主动授权确认后,才可以访问文件系统的内容。

提示

除了调用以上的方法通过弹出的对话框来选择文件/目录来获取句柄,还可以将 FileSystemHandle 与 Drag-N-Drop 交互相结合,通过方法 DataTransferItem.getAsFileSystemHandle() 获取相应的句柄。

还可以将已「安装」的 Web 应用(PWA 应用)注册为特定类型的文件的处理/打开程序

以下是一些常见的使用场景和样例代码

读取文件

调用(异步)方法 window.showOpenFilePicker(pickerOpts) 会弹出一个文件选择对话框,称为 file picker 文件选择器,让用户选择一个文件(以读取模式来打开它)。

该方法返回的是 Promise,如果 resolve 就会得到一个文件句柄

可选参数 pickerOpts 用于设置文件选择器的行为,它是一个对象,支持设置以下的属性:

  • id 属性:一个字符串,作为该文件句柄的唯一标识符,用于区分记录文件选择对话框打开时最后位于哪一个目录(而不必总是打开默认的目录)
  • startIn 属性:可以是 directory handle 目录句柄,也可以是一个表示系统的内置目录的字符串,用于设置打开对话框时默认位于哪一个目录。
    提示

    各系统都常见的一些内置目录如下:

    • desktop 桌面
    • documents 文档
    • downloads 下载
    • music 音乐
    • pictures 图片
    • videos 视频
  • multiple 属性:一个布尔值,用于设置文件选择器是否支持选择多文件。
    默认值为 false,所以文件选择器的默认行为只支持选择单个文件。
  • excludeAcceptAllOption 属性:一个布尔值,默认值为 false。在弹出的文件选择对话框,可选文件格式的下拉菜单中,默认会包含一个「允许所有格式」的选项,以便用户可以选择任何格式的文件。
    如果将该属性设置为 true 则下拉菜单中就没有这个选项,相当于强制用户只能选择预期格式的文件
  • types 属性:一个数组,列出了允许选择的文件格式。数组的每一个元素是一个对象,具有以下两个属性:
    • description(可选)属性:对于该文件类型的可选说明
    • accept 属性:一个对象,用键值对来表示文件格式,键名是一个 MIME 类型,值则是一个数组,其中每个元素都是表示文件格式/拓展名的字符串
js
// 一个 file picker 文件选择器选项的示例
const pickerOpts = {
  types: [
    {
      description: 'Images',
      accept: {
        'image/*': ['.png', '.gif', '.jpeg', '.jpg']
      }
    },
  ],
  excludeAcceptAllOption: true,
  multiple: false
};

该方法的返回值是一个 Promise,如果 resolve 就会得到一个包含文件句柄的数组(如果打开的是一个文件,那么数组中就只有一个元素)

js
let fileHandle;
// 假设 btnOpenFile 是指页面上的一个按钮
btnOpenFile.addEventListener('click', async () => {
  // 该方法是一个异步函数
  // 返回值是一个数组,因为默认只允许选择一个文件
  // 所以通过数组解构,提取出其中的唯一元素
  const fileHandlesArr = await window.showOpenFilePicker();
  console.log(fileHandlesArr);
  // 获取第一个文件句柄
  fileHandle = fileHandlesArr[0]
  console.log(fileHandle);
});

控制台输出的结果
控制台输出的结果

获取了文件句柄后就可以调用相关方法以获取文件内容,例如假设用户选择打开的是一个 hello.txt 的文本文件,通过以下代码查看文本文件的具体内容:

js
// 通过 getFile() 方法获取文件句柄所关联的文件
// 返回一个 File 对象,它是一个 Blob 对象
const file = await fileHandle.getFile()
// 使用 Blob 对象的 text() 方法将二进制的数据解析转换为 UTF-8 格式的字符串
const content = await file.text()
console.log(content); // 将字符串内容打印到控制台

写入文件

调用(异步)方法 window.showSaveFilePicker(options) 会弹出一个文件选择对话框,让用户选择一个已存在的文件,或创建一个新文件(需要输入新文件的名称),以读写/保存模式来打开

可选参数 options 用于设置 file picker 文件选择器 的行为,它是一个对象,支持设置以下的属性:

  • startIn 属性:可以是 directory handle 目录句柄,也可以是一个表示系统的内置目录的字符串,用于设置打开对话框时默认位于哪一个目录。
    提示

    各系统都常见的一些内置目录如下:

    • desktop 桌面
    • documents 文档
    • downloads 下载
    • music 音乐
    • pictures 图片
    • videos 视频
  • id 属性:一个字符串,作为该文件句柄的唯一标识符,用于区分记录文件选择对话框打开时,最后位于哪一个目录。
  • excludeAcceptAllOption 属性:与方法 window.showOpenFilePicker(pickerOpts) 选项中的属性作用一样
  • suggestedName 属性:一个字符串,作为新建文件的建议/默认名称
  • types 属性:与方法 window.showOpenFilePicker(pickerOpts) 选项中的属性作用一样

该方法是一个异步函数,返回值是一个 file handle 文件句柄,获取了支持读写/保存模式的 file handle 文件句柄后,就可以调用其中的相关方法将内容写入到文件中:

js
// fileHandle is an instance of FileSystemFileHandle..
async function writeFile(fileHandle, contents) {
  // 创建一个 FileSystemWritableFileStream 写入流
  const writable = await fileHandle.createWritable();
  // 将给定的内容 content 写入到文件中
  await writable.write(contents);
  // 将写入流关闭,此时才会将内容写入磁盘
  await writable.close();
}
注意

如果选择的是一个已存在的文件,且以上方法都采用默认参数,则在保存文件时,会将文件原有的内容覆盖

内容并不会真正写入磁盘,直至调用了 writableStream.close() 方法将写入流关闭

操作目录内容

调用(异步)方法 window.showDirectoryPicker(options) 会弹出一个目录选择对话框,让用户选择一个目录,以打开该目录

说明

默认情况下,目录句柄可以对该目录里的文件具有读取权限,而没有写入权限。如果需要对该目录里所有文件具有读写权限,需要在在参数 options 中进行配置,将属性 mode 的值设置为 readwrite

可选参数 options 用于设置 directory picker 目录选择器 的行为,它是一个对象,支持设置以下的属性:

  • id 属性:目录句柄的标识符,以便浏览器进行记录区分不同的目录。
    如果新建一个目录句柄,其 id 与已存在的目录句柄相同,则新建的句柄将会打开同一个目录
  • mode 属性:一个字符串,表示目录句柄的权限,该属性值可以是 read 表示对于该目录里所有文件只具有读取权限;如果是 readwrite 表示对该目录里所有文件具有读写权限
  • startIn 属性:可以是 directory handle 目录句柄,也可以是一个表示系统的内置目录的字符串,用于设置打开对话框时默认位于哪一个目录。
    提示

    各系统都常见的一些内置目录如下:

    • desktop 桌面
    • documents 文档
    • downloads 下载
    • music 音乐
    • pictures 图片
    • videos 视频

该方法是一个异步函数,返回值是一个 FileSystemDirectoryHandle 对象,即一个 directory handle 目录句柄

常见的一个场景是遍历目录中的(直接)条目(文件和子目录)

js
btn.addEventListener('click', async () => {
  // 获取 directory handle 目录句柄
  const directoryHandle = await window.showDirectoryPicker();
  // directoryHandle.values() 获取目录中的内容,包含(直接)子目录和文件
  for await (const entry of directoryHandle.values()) {
    // 列出每个条目的类型 entry.kind 和名称 entry.name
    // 类型包含 directory 目录和 file 文件
    // 名称就是文件夹名称或文件名称
    console.log(entry.kind, entry.name);
  }
});

如果要遍历目录中所有的文件(包括嵌套较深的文件)需要使用递归的方法

js
async function* getFilesRecursively (entry) {
  if (entry.kind === 'file') {
    const file = await entry.getFile();
    if (file !== null) {
      file.relativePath = getRelativePath(entry);
      yield file;
    }
  } else if (entry.kind === 'directory') {
    for await (const handle of entry.values()) {
      yield* getFilesRecursively(handle);
    }
  }
}
for await (const fileHandle of getFilesRecursively(directoryHandle)) {
  console.log(fileHandle);
}

另一个常见的场景是获取/创建一个文件/子目录的操作句柄

js
// 在当前目录下,打开或创建一个名为 `My Documents` 的子目录,并返回它的目录句柄
const newDirectoryHandle = await existingDirectoryHandle.getDirectoryHandle('My Documents', {
  create: true,
});
// 在当前目录下,打开或创建一个名为 `My Notes.txt` 的文件,并返回它的文件句柄
const newFileHandle = await newDirectoryHandle.getFileHandle('My Notes.txt', { create: true });
// 以上两个操作都将 create 属性设置为 true,所以在当前目录下没有找到给定名称的条目时,就会创建一个新的

重命名与移到

文件句柄和目录句柄都支持 move 方法,而且可以传递相应的参数,在移动文件或目录的同时实现重命名

js
// 传递一个字符串,重命名文件
await fileHandle.move('new_name');
// 传递一个目录手柄,将文件移动到指定的目录
await fileHandle.move(directoryHandle);
// 依次传递一个目录手柄和字符串,将文件移动到指定的目录,并同时重命名文件
await fileHandle.move(directoryHandle, 'newer_name');

该方法还是实验阶段,可以在该页面查看浏览器是否支持该方法。

注意

FileSystemHandle.move() 该方法目前只在 origin private file system,OPFS,域名私有文件系统中其作用,这和浏览器的其他存储方式一致(例如 Cookie、LocalStorage、SessionStorage、IndexedDB 等,都是域名专属私有的,无法与其他域名的页面共享)

对于其他文件系统的支持计划进度可以关注这个页面

保存句柄与获取授权

将所得的 file handle 文件句柄或 directory handle 目录句柄保存起来以便之后使用是一个好习惯。

因为它们支持 serializable 序列化,所以可以将它们保存到数据库中(例如浏览器的 IndexedDB 数据库,💡 推荐使用一些框架,如 idb-keyvalDexie.js,来操作 IndexedDB 数据库),以便下一次使用。

注意

但是由于目前无法将句柄授权在会话之间持久化,所以在每次关闭网页再重新打开时,都需要重复询问用户授权,以便该句柄获得相关的权限。

文件句柄或目录句柄的权限查询与获取:

  • 调用(异步)方法 fileHandle.queryPermission(permissionDescriptor):查看文件句柄是否已经获得授权,返回一个(只读)字符串以表示句柄当前的权限状态,有以下的的可能值;
    • granted 表示句柄已获取相应的权限
    • denied 表示句柄的相应权限请求被拒绝
    • prompt 表示句柄需要用户授权(一般从数据库,例如 IndexedDB 中取回的句柄的权限状态就是 prompt),此时应该调用 fileHandle.requestPermission() 方法
  • 调用(异步)方法 fileHandle.requestPermission(permissionDescriptor):弹出一个对话框,询问用户是否为文件句柄授予相应的权限

其中(可选)参数 permissionDescriptor 是一个对象,用于描述句柄所需的权限,该对象只有一个属性 mode,而属性值只能是 readreadwrite 分别表示只读权限或读写权限(💡 推荐为句柄请求 readwrite 读写权限,这样在之后如果要将内容写入到文件时,就不必再次去询问用户以获得授权)

这个不太友好的交互方式是否在日后会得到改善,可以关注跟踪这个页面的报道

同步写入

文件句柄有一个方法 fileHandle.createSyncAccessHandle() 可实现高性能的同步写入

该方法本身是一个异步方法,返回的 Promise 如果 resolve 就会获得一个 FileSystemSyncAccessHandle 句柄,它会为关联的文件设置独占锁,调用该句柄的所提供的相关方法来操作文件:

  • fileSyncAccessHandle.read() 读取文件
  • fileSyncAccessHandle.truncate() 截短文件到指定的大小(以 bytes 字节为单位)
  • fileSyncAccessHandle.write() 将指定缓冲区的内容写入到文件
  • fileSyncAccessHandle.flush() 手动将通过 write 方法对文件所做的任何更改保存到磁盘(调用完 write 方法后,也可以不调用该方法,让操作系统进行自动处理)
  • fileSyncAccessHandle.getSize() 获取文件的大小(以 bytes 字节为单位)
  • fileSyncAccessHandle.close() 关闭该同步文件访问句柄,并释放文件的独占锁
说明

这个方法最大的特点是可以实现在页面输入内容的同时,写入到文件系统的文件内容中。

而其他方法一般都是异步的,如 fileHandle.createWritable() 方法,只有在关闭写入流时,才真正将内容写入到磁盘中

注意

这个方法只能用于 Web Workers


Copyright © 2024 Ben

Theme BlogiNote

Icons from Icônes