自学内容网 自学内容网

文件系统设计 - 开发文件系统Store (下篇)

对文件系统实现增删改查

回归上节内容我们知道,文件系统最主要的目的就是对内存中的文件数据进行管理,管理的基本内容就是增删改查几个操作,下面我们依次对这些方法进行实现。

创建文件/文件夹

按照上篇文章对文件/文件夹创建的定义:

  /**
   * 创建文件
   * @param path 文件路径 uri
   * @param content 文件内容
   * @param readonly 是否只读
   * @param visible 是否可见
   */
  createFile(
    path: Uri,
    content: string,
    readonly?: boolean,
    visible?: boolean
  ): IFileSystemItem;
  
  /**
   * 创建目录
   * @param path 目录 Uri
   * @param readonly 是否只读
   * @param visible 是否可见
   */
  createDirectory(
    path: Uri,
    readonly?: boolean,
    visible?: boolean
  ): IDirectoryItem;

以创建文件为例,创建一个文件需要给定文件的路径,文件的内容,以及文件的一些扩展配置.

在创建文件时,我们需要先定位到文件的路径,如需要创建 src/views/index.vue 这样一个文件,需要先定位到 src/views 目录,然后在这个目录下创建对应的文件 index.vue, 当目录不存在时,还需要对齐目录进行创建,保证文件创建的正常运行。

按照以上思路,我们先来实现两个基础的方法,用于生成文件/文件夹的数据结构。

这里Uri类型来自于monaco-editor,前期我们以一个string替代

class FileSystemStore extends Store<FileSystemState> implements FileSystemProvider {
  private _createFile(
    name: string, /* 文件名*/
    path: string, /* 文件路径 */
    content: string,/* 文件内容 */
    readonly: boolean = false,  /* 是否只读 */
    visible: boolean = true    /* 是否可见 */
  ): IFileSystemItem {
    return {
      filename: name,
      type: FileType.File,
      ext: getFileExtension(name),
      code: content,
      fullPath: path,
      language: getFileLanguage(path),
      readonly,
      visible,
      status: 0,
      cacheBuffer: null,
    };
  }

  private _createDirectory(
    name: string, /* 文件夹名称 */
    path: string, /* 文件夹路径 */
    readonly: boolean = false,  /* 是否只读 */
    visible: boolean = true    /* 是否可见 */
  ): IDirectoryItem {
    return {
      filename: name,
      type: FileType.Directory,
      fullPath: path,
      children: [],
      status: 0,
      readonly,
      visible,
    };
  }
}

接下来我们需要实现一个查找文件父级目录的API,用于根据文件路径查找到对应的父级文件夹,当文件夹不存在时需要创建对应的文件夹。

  /**
   * 查找父目录,当目录不存在会自动创建
   * @param path 目录路径数组
   * @returns
   */
  private _findParent(path: string[]) {
    // 记录当前查找到的文件
    let current: IFileSystemItem | IDirectoryItem = this.state.files;
    let index = 0;
    let currentFilename = path[index];

    while (index < path.length - 1) {
      if (
        current.filename === currentFilename &&
        (current as IDirectoryItem).children
      ) {
      // 更新当前正在处理的文件名
        currentFilename = path[++index];
        // 从父级目录中找到下一级的子文件夹
        let child: IFileSystemItem | IDirectoryItem | undefined = (
          current as IDirectoryItem
        ).children.find((child) => child.filename === currentFilename);
        // 查找失败时执行创建
        if (!child) {
          const currentPath = path.slice(0, index + 1).join("/");
          child = this._createDirectory(currentFilename, currentPath);
          // 将创建的文件加入到Map缓存中,方便后续的查找
          this.state.fileMap.set(child.fullPath, child);
          (current as IDirectoryItem).children.push(child);
        }
        current = child;
      } else {
        return undefined;
      }
    }
    return current as IDirectoryItem;
  }

最后我们开始实现文件的创建操,具体步骤如下:

  1. 将路径分割为路径数组的形式,方便查找其父级路径
  2. 父级文件夹查找失败,抛出错误
  3. 查找到父级文件夹后,创建一个新的文件,插入到父级文件的chidlren数组中;
  createFile(
    path: string,  /* 前期路径我们使用string替代 */
    content: string,
    readonly?: boolean,
    visible?: boolean
  ): IFileSystemItem {
  /**
   * trimArrStart Api的作用是去除掉文件路径数组最前面的空串
   * 如 "/src/views/index.vue" 路径分割后,得到的路径数组是: ['', 'src', 'views', 'index.vue']
   * 数组第一个位置的空串会影响后续的路径查找
   */
    const pathArr = trimArrStart(path.split("/"));
    const parent = this._findParent(pathArr.slice(0, -1));
    if (!parent) {
      throw new Error(`file system item not found: ${path.path}`);
    }
    const filename = pathArr[pathArr.length - 1];
    const fileReadonly = readonly || parent.readonly;
    const fileVisible = visible ?? parent.visible;
    const newFile = this._createFile(
      filename,
      path.path,
      content,
      fileReadonly,
      fileVisible
    );
    this.state.fileMap.set(newFile.fullPath, newFile);
    /**
     * 在创建完成后,插入到父文件夹中时,正常还需要按照文件名的字符序进行整理
     * 这里简单做直接将新的文件插入到文件夹的最前面
     */
    parent.children.unshift(newFile);
    return newFile;
  }

文件夹的创建流程和文件的创建类似,大家可以参考文件创建的实现尝试编写一下。

删除文件/文件夹

文件/文件夹的删除比较简单,只需要根据当前路径查找到父级文件夹,然后将当前文件从父文件夹中删除即可;

首先我们实现一个根据文件路径读取父级文件夹路径的辅助函数

  private _getParentPath(path: string) {
    return path.replace(/\/[^\/]*$/, "");
  }

编写文件/文件夹删除函数

  delete(file: IFileSystemItem | IDirectoryItem) {
  // 获取父文件夹路径
    const parentPath = this._getParentPath(file.fullPath);
    // 从缓存map中根据路径获取到父文件夹对象
    const parentFile = this.state.fileMap.get(parentPath) as IDirectoryItem;
    if (!parentFile) {
      throw new Error(`file system item not found: ${file.fullPath}`);
    }
    // 将当前文件从父级文件夹中删除,并且一处Map中的缓存
    const index = parentFile.children.findIndex((item) => item === file);
    index !== -1 && parentFile.children.splice(index, 1);
    this.state.fileMap.delete(file.fullPath);
  }

查找文件/文件夹

根据文件路径查找文件正常流程需要从文件树中,逐层遍历文件树进行查找;但是因为我们记录文件路径到文件数据的Map映射,所以可以快速的从Map中查找到文件数据:

  readFile(path: string) {
    return this.state.fileMap.get(path) || null;
  }

写入文件数据

文件写入数据主要针对文件结构,从Map中读取到文件数据,让文件内容赋值到文件数据中即可,或者将文件数据写入到缓冲区字段;

  writeFile(path: string, content: string, isBuffer?: boolean) {
    const file = this.state.fileMap.get(path);
    if (file && file.type === FileType.File) {
      if (isBuffer) {
        file.cacheBuffer = content;
        // 为文件添加一个编辑中的状态,后文中会进行实现
        this.addOperator(file, FileOperation.Editing);
      } else {
        file.code = content;
        file.cacheBuffer = null;
      }
    }
  }

重命名文件/文件夹

重命名文件/文件夹我们先实现几个辅助函数:

  • 将原本文件路径的filename 更新为新的文件名
export function updateFileName(filePath: string, newName: string) {
  const pathArr = filePath.split("/");
  pathArr[pathArr.length - 1] = newName;
  return pathArr.join("/");
}
  • 根据文件名获取文件的后缀
export function getFileExtension(filename: string) {
  var ext = filename.split(".").pop();
  return ext || "txt";
}
  • 根据文件路径获取文件的语言
export const FILE_LANGUAGE_MAP: Record<string, RegExp> = {
  typescript: /\.tsx?$/,
  javascript: /\.jsx?$/,
  html: /\.html?$/,
  css: /\.css$/,
  less: /\.less$/,
  json: /\.json$/,
  vue: /\.vue$/,
  yaml: /\.ya?ml$/,
  xml: /\.xml$/,
  md: /\.md$/,
  yml: /\.ya?ml$/,
};
export function getFileLanguage(filePath: string) {
  return Object.entries(FILE_LANGUAGE_MAP).reduce((lan, [language, match]) => {
    if (match.test(filePath)) return language;
    return lan;
  }, "");
}

更新文件名称是比较简单的,直接更新文件的名称和文件的路径,同时刷新fileMap中的映射关系,更新文件后缀,更新文件语言;

  renameFile(file: IFileSystemItem, newName: string) {
    if (newName && newName !== file.filename) {
      // 从Map中移除旧的Path -> 文件 的映射
      this.state.fileMap.delete(file.fullPath);
      // 更新路径上的文件名
      file.fullPath = updateFileName(file.fullPath, newName);
      // 重写Map映射
      this.state.fileMap.set(file.fullPath, file);
      // 更新文件名
      file.filename = newName;
      // 更新文件后缀
      file.ext = getFileExtension(file.filename);
      // 更新文件语言
      file.language = getFileLanguage(file.fullPath);
    }
    this.removeOperator(file, FileOperation.Rename);
  }

更新文件夹名称相对文件名更新会相对复杂,处理对文件夹本身的操作之外,还需要对文件夹下的所有子文件路径进行更新,如 将 src/ 文件夹更新为 src-old ,处理文件夹本身的更新外,还需要将文件夹下的 index.vue 文件的路径也从 src/index.vue 更新为 src-old/index.vue

  renameFolder(folder: IDirectoryItem, newName: string) {
// 更新当前文件夹的内容
    if (newName && newName !== folder.filename) {
      // 记录当前需要处理的文件夹队列
      const currentDirectoryQueue = [folder];
      let currentDirectory: IDirectoryItem | undefined;
      this.state.fileMap.delete(folder.fullPath);
      folder.fullPath = updateFileName(folder.fullPath, newName);
      folder.filename = newName;
      this.state.fileMap.set(folder.fullPath, folder);

      /**
       * tips: 遍历包括当前目录在内的所有子目录,对目录下的所有文件进行路径更新
       */
      while ((currentDirectory = currentDirectoryQueue.shift())) {
        currentDirectory.children.forEach((child) => {
          this.state.fileMap.delete(child.fullPath);
          child.fullPath = join(currentDirectory!.fullPath, child.filename);
          this.state.fileMap.set(child.fullPath, child);
          if (child.type === FileType.Directory) {
            currentDirectoryQueue.push(child);
          }
        });
      }
    }
    // 移除文件夹的重命名状态,后续会做分析
    this.removeOperator(folder, FileOperation.Rename);
  }

移动文件/文件夹

文件/文件夹的移动,当在同层进行移动时,只需要改变其在父文件夹中的位置即可(这是因为我们这里没有对文件夹下的文件进行按字母序整理排序,所以可能需要同层排序)。对于不同层级的文件移动,流程如下:

  • 从当前文件的父文件夹中删除当前文件
  • 将当前文件数据插入到目标文件夹中
  • 更新文件的路径信息
  • 如果是文件夹,遍历其子文件,更新对应的文件路径
  • 刷新在 fileMap 中的映射关系
  moveFile(sourcePath: string, targetPath: string) {
// 目标父文件夹路径
    const targetParentPath = this._getParentPath(targetPath);
    // 源父文件夹路径
    const soruceParentPath = this._getParentPath(sourcePath);
    // 目标父文件夹
    const targetParent = this.readFile(targetParentPath);
    // 源文件父文件夹
    const sourceParent = this.readFile(soruceParentPath);
    // 源文件
    const sourceFile = this.readFile(sourcePath);

    if (!sourceParent || !targetParent || !sourceFile) return null;
    // 查找源文件在其父文件夹的位置
    const sourceIndex = (sourceParent as IDirectoryItem).children?.findIndex(
      (item) => item.fullPath === sourcePath
    );
    // 查询目标文件在其父文件的位置 - 源文件插入目标文件夹的相对位置
    const targetIndex = (targetParent as IDirectoryItem).children?.findIndex(
      (item) => item.fullPath === targetPath
    );

    if (sourceParent === targetParent) {
      // 同层交换
      swapArray(
        (sourceParent as IDirectoryItem).children,
        sourceIndex,
        targetIndex
      );
    } else {
      /**
       * tip: 不同层移动,从源位置父节点删除,插入新节点
       * 对于目录的移动,还需要更新子节点的路径
       */
      (sourceParent as IDirectoryItem).children?.splice(sourceIndex, 1);
      // 更新文件路径
      sourceFile.fullPath = join(targetParentPath, sourceFile?.filename!);
      (targetParent as IDirectoryItem).children?.splice(
        targetIndex,
        0,
        sourceFile
      );
      // 更新文件映射
      this.state.fileMap.set(sourceFile.fullPath, sourceFile);
      this.state.fileMap.delete(sourcePath);
      // 如果是文件夹,处理其子文件的路径
      if (sourceFile.type === FileType.Directory) {
        const currentDirectoryQueue = [sourceFile];
        let currentDirectory: IDirectoryItem | undefined;
        while ((currentDirectory = currentDirectoryQueue.shift())) {
          currentDirectory.children.forEach((child) => {
            this.state.fileMap.delete(child.fullPath);
            child.fullPath = join(currentDirectory!.fullPath, child.filename);
            this.state.fileMap.set(child.fullPath, child);
            if (child.type === FileType.Directory) {
              currentDirectoryQueue.push(child);
            }
          });
        }
      }
    }
  }

为执行某个操作的文件添加状态

通过前一篇文章可以知道,我们通过文件的一个Status 字段来记录文件的操作状态,但是同一时刻一个文件可能有多种处理状态,如文件编辑, 文件重命名等,所以我们通过二进制位来记录一个操作状态,举个🌰:

初始化状态为0, 对应的二进制位为 00000000;
此时有两个状态位

Editing = 00000001
Rename = 00000010

当我需要为初始状态添加一个Editing 状态时,只需要将初始状态和Editing 状态为执行 | 操作:

// 这是伪代码
status (00000000) | Editing (00000001) = 00000001

此时判断 status 中是否带有 Editing 状态,只需要将 status & Editing 为truly 变量即可;

当需要从status 中移除对应的状态时,只需要对状态按位取反后和status 进行 & 操作即可完成,及:

// 移除 Editing 状态
Editing 状态按位取反 => 11111110

将status和 Editing的按位取反进行 & 运算:
00000001 & 11111110 => 00000000

由此我们便可以得出为文件添加/删除某个状态的操作方法:

  addOperator(status: number, operator: FileOperation) {
    return status | operator;
  }

  removeOperator(status: number, operator: FileOperation) {
    return status & ~operator;
  }

至此,我们文件系统的基础操作相关的 API 就实现完毕了,如果搭建在阅读文章时,对其中的逻辑有任何问题欢迎留言评论,我将尽快为大家解答;加油!


原文地址:https://blog.csdn.net/qq_44746132/article/details/142434567

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!