读取文件内容、修改文件内容、识别文件夹目录(Web操作系统文件/文件夹详解)

前言

因 Unicode IDE 编辑器导入文件、文件夹需要,研究了下导入文件/文件夹的功能实现,发现目前相关文章有点少,故而记录下过程,如果有误,还望指正。(API的兼容性及相关属性、接口定义,请自行查看文件系统 API)

文件 API

文件 API 使得 web 应用可以访问文件和其中的内容,当我们想要访问一个文件,可以通过<input type="file"> 或者 拖放 来实现。以这种方式提供的文件集被表示为 FileList 对象,这使得 web 应用能够检索单个 File 对象。

TypeScript 复制代码
  const input = document.createElement("input");
  input.type = "file";
  input.multiple = true;
  input.addEventListener("change", () => {
    // 当然,此处也可以使用事件源 e 来获取 FileList 对象哈,e.target 指向 input
    const fileList = input.files;
    console.log("==> ", fileList);
  });
  input.click();

Blob

Blob 代表"二进制大对象"(类似于文件的不可变的原始数据对象);Blob 可以作为文本或二进制数据被读取,或者转换为 ReadableStream。我们常用来处理大文件数据分片,里面有一个 Blob.slice() 方法,用于获取指定字节范围内的数据。

TypeScript 复制代码
  input.addEventListener("change", () => {
    // 当然,此处也可以使用事件源 e 来获取 FileList 对象哈,e.target 指向 input
    const fileList = input.files;
    // 以第一个 File 对象为例,获取 Blob
    const file = fileList![0];
    const blob = new Blob([file], { type: file.type });
    console.log("==> ", blob);
  });

File

File 对象提供对元数据的访问,如文件的名称、大小、类型和最后修改日期提供文件的信息,并允许网页中的 JavaScript 代码访问其中的内容,通常利用File进行内容读取、进行网络传输等。

FileReader

使 web 应用能够异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 FileBlob 对象来指定要读取的文件或数据。

通常,FileReader 是异步的,因为读取文件内容是需要时间的,因此,可以通过监听 load 实现读取成功完成时回调处理。读取文件还能将文件读成多种结果类型:

TypeScript 复制代码
  input.addEventListener("change", () => {
    // 当然,此处也可以使用事件源 e 来获取 FileList 对象哈,e.target 指向 input
    const fileList = input.files;
    // 以第一个 File 对象为例,获取 Blob
    const file = fileList![0];
    const fileReader = new FileReader();
    fileReader.onload = () => {
      console.log(fileReader.result);
    };
    fileReader.readAsText(file);
  });

Tips:关于上诉 FileReader、Blob、File等常见数据类型的定义、转换不在本篇的讨论范围哈,网上也有很详细的说明,大家搜索一下就可以了,我推荐两篇文章,大家可以看看~

谈谈JS二进制:File、Blob、FileReader、ArrayBuffer、Base64https://juejin.cn/post/7148254347401363463?searchId=20241113152541CCE5E6FEB74927A4CFEBjs二进制及其相关转换全总结(File、Blob、FileReader、ArrayBuffer、Base64、Object URL、DataURL...)https://juejin.cn/post/7395866692798201871?searchId=20241113152055D9FE2B4CE63230A35055

文件系统 API

用户直接操作系统文件是非常危险的行为,因此,此API要求在安全上下文可用。

File System API 允许程序与用户本地设备上的或是用户能够访问的网络文件系统上的文件进行交互。此 API 的核心功能包括读取文件、写入或保存文件以及访问目录结构。大多数与文件和目录的交互都通过句柄来完成:

句柄是指在Web API中用于操作文件系统的接口或对象。

通过这些句柄,Web应用可以实现对本地文件系统的读取、写入、导航等操作。

File System API允许Web应用在用户的设备上创建、读取、导航用户本地文件系统中的沙盒部分,并向其中写入数据‌。

FileSystemHandle 接口是代表一个文件或一个目录的对象,在大多数情况下,你不会直接使用 FileSystemHandle,而是会用到它的 FileSystemFileHandleFileSystemDirectoryHandle 子接口。

文件处理

文件处理最简单的就是 input file了,这里就不演示了, FileSystemFileHandle 接口表示一个指向文件系统条目的句柄。可通过 window.showOpenFilePicker() 方法来访问此接口:

TypeScript 复制代码
  try {
    // 通过文件选择器获取文件
    const file = await window.showOpenFilePicker();
    console.log("==> ", file);
  } catch (error) {
    console.error(error);
  }

如上演示效果,尽管已经可以调用文件,但是 ts 还是报错了,可以通过自定义 Window 的接口来解决这个问题,明确告诉window上存在这个方法即可,还可以拓展一些其他的属性,便于后期的操作哈~

window.showOpenFilePicker() 返回的结果是一个Promise<FileSystemFileHandle[]>是个数组,我们应该如何获取该文件的具体内容呢?

读取文件内容

FileSystemFileHandle有一个getFile方法,用于返回一个 File 对象,有了这个对象,剩下的就是创建 FileReader 读取就可以啦

TypeScript 复制代码
  try {
    // 通过文件选择器获取文件
    const fileListHandle = await window.showOpenFilePicker();
    fileListHandle.forEach(async (fileHandle) => {
      const file = await fileHandle.getFile();
      const fileReader = new FileReader();
      fileReader.readAsText(file);
      fileReader.onload = () => {
        const text = fileReader.result;
        console.log("==> 文件内容", text);
      };
    });
  } catch (error) {
    console.error(error);
  }

写入文件

上面演示了如何读取文件内容,那么,我们该如何修改文件内容呢?createWritable()方法用于创建一个 FileSystemWritableFileStream 对象,可用于写入文件。

TypeScript 复制代码
  try {
    // 通过文件选择器获取文件
    const fileListHandle = await window.showOpenFilePicker();
    //  拿第一个文件做演示哈
    const fileHandle = fileListHandle[0];
    // 创建写入句柄
    const writable = await fileHandle.createWritable();
    // 将文件内容写入到流中。
    await writable.write(`当前时间:${getDate()}`);

    // 关闭文件并将内容写入磁盘。
    await writable.close();
    
    console.log("==> 写入文件成功");
  } catch (error) {
    console.error(error);
  }

还有同步写入的方法createSyncAccessHandle,这里就不演示了哈。如果写入的文件系统有权限要求,可能需要在获取句柄时,查询权限参数。

文件夹处理

FileSystemDirectoryHandle 接口提供指向一个文件系统目录的句柄。这个接口可以通过 window.showDirectoryPicker()

StorageManager.getDirectory()

DataTransferItem.getAsFileSystemHandle()

FileSystemDirectoryHandle.getDirectoryHandle() 这些方法来获取。

因此,文件夹的处理方式要多一些,FileSystemDirectoryHandle.getDirectoryHandle() 与第一个有重叠部分,因此本文仅讲解第一种(点击上传)、第三种(拖拽上传)方式获取文件夹句柄。(第二种是Web Worker)

点击上传文件夹

TypeScript 复制代码
  try {
    //  获取文件夹句柄
    const dirHandle = await window.showDirectoryPicker();
    console.log("==> ", dirHandle);
  } catch (error) {}

window.showDirectoryPicker() 会返回一个文件夹句柄,里面有几个关键的函数:getDirectoryHandle、getFileHandle、迭代器。

TypeScript 复制代码
    //  获取文件夹句柄
    const dirHandle = await window.showDirectoryPicker();
    for await (const item of dirHandle.values()) {
      console.log("==> ", item);
    }

如果是文件的话,就获取文件句柄,如果是文件夹的话,就获取文件夹句柄,递归直到读取完成:

TypeScript 复制代码
function directoryPickerHandle(files: FileSystemDirectoryHandle) {
  const reader = new FileReader();

  // 2. 递归解析文件夹内容
  async function entryFolder(handle: FileSystemDirectoryHandle) {
    for await (const item of handle.values()) {
      // 判断文件还是文件夹
      if (item.kind === "file") {
        // 创建文件读取器, 会返回 FileSystemFileHandle
        const fileHandle = await handle.getFileHandle(item.name);
        // 调用 getFile() 获取文件对象(File)
        const file = await fileHandle.getFile();

        const fullpath =
          (handle.parentName || "") + "/" + handle.name + "/" + file.name;
        console.log("==> 文件完整路径:", fullpath);

        // 创建 Reader 读取器,读取文件内容
        reader.onload = () => {};
        reader.readAsText(file, "UTF-8");
      } else if (item.kind === "directory") {
        // 创建文件夹读取器,返回 FolderSystemHandle
        const folderHandle = await handle.getDirectoryHandle(item.name);
        const fullpath = handle.name + "/" + item.name;
        // 记录该路径
        folderHandle.parentName = handle.name;
        console.log("==> 文件夹完整路径:", fullpath);
        entryFolder(folderHandle);
      }
    }
  }

  // 3. 启动entry
  entryFolder(files);
}

这样就能识别文件夹内的所有目录、文件内容啦。这里面有一个注意事项哈:const folderHandle = await handle.getDirectoryHandle(item.name); 这里创建的 getDirectoryHandle 恰好就是 类型四返回的文件夹句柄,又转向递归,实现读取文件夹操作

拖拽上传文件夹

DataTransferItem 接口的 getAsFileSystemHandle() 方法返回一个 FileSystemFileHandle(若拖动的项目是文件),或 FileSystemDirectoryHandle(若拖动的项目是目录)。

拖拽上传需要设置HTML draggable="true",并且监听三个事件:

TypeScript 复制代码
window.onload = () => {
  const box = document.querySelector(".box");
  box?.addEventListener("dragenter", (e) => e.preventDefault());
  box?.addEventListener("dragover", (e) => e.preventDefault());
  box?.addEventListener("drop", (e: Event) => {
    // 阻止导航
    e.preventDefault();
    const event = <DragEvent>e;
    // 获取拖拽的数据
    console.log("==> ", event.dataTransfer);
  });
};

咋一看好像结果为空,明明拖拽文件夹了,结果集还是空的,那是因为你放手的瞬间,数据传输已经完成了。

TypeScript 复制代码
  box?.addEventListener("drop", (e: Event) => {
    // 阻止导航
    e.preventDefault();
    const event = <DragEvent>e;
    const items = event.dataTransfer?.items;
    if (!items) return;

    for (const item of items) {
      // 获取文件系统句柄
      const entry = item.webkitGetAsEntry()!;
      console.log("==> ", entry);
    }
  });

当你拖拽文件和拖拽文件夹时,是不同的数据类型哦,注意一下

拖拽的是文件的话,可以调用 files() 进行文件读取

TypeScript 复制代码
    const fileEntry = <FileSystemFileEntry>entry;

    fileEntry.file((file: File) => {
      // 创建 fileReader
      const reader = new FileReader();
      reader.readAsText(file, "UTF-8");
      reader.onload = async () => {};
    });

拖拽的是文件夹的话,递归继续调用

TypeScript 复制代码
    // 文件夹,创建读取器
    const folderRntry = <FileSystemDirectoryEntry>entry;
    // 记录需要创建的文件夹路径
    console.log("==> 记录文件夹路径", folderRntry.fullPath);
    const reader = folderRntry.createReader();
    reader.readEntries((files) => {
      files.forEach((file) => dataTransferHandle(file));
    });

如果想要实现记录完整文件名称的话,可以跟上诉示例一样,自定义parentName 实现。

总结

我们通过 FileSystemFileHandle 可以进行文件内容读取、修改文件内容等文件相关操作,同时,利用 FileSystemDirectoryHandle 提供的能力,可以实现点击上传并识别文件夹目录,对文件夹内文件进行内容读取,还利用DataTransferItem,实现了拖拽上传文件/文件夹,利用其能力,也能识别文件夹目录及对文件进行读取操作。同时,拖拽上传还支持纯文本哈,const data = e.dataTransfer?.getData("text"); 这也是一些白板、Canvas-Editor、流程图 从外部拖拽上传实现的原理。

再次声明哈,该API具有一定兼容性差异,请查阅MDN后,再决定使用生产,同时,请一定确保使用备用方案,以实现功能的完整性。同时,该文章仅表示本人应用过程实践,可能存在遗漏、错误,烦请指正赐教~