本节将根据上一节对文件系统的定义,对文件系统进行开发实现
对文件系统实现增删改查
回归上节内容我们知道,文件系统最主要的目的就是对内存中的文件数据进行管理,管理的基本内容就是增删改查几个操作,下面我们依次对这些方法进行实现。
创建文件/文件夹
按照上篇文章对文件/文件夹创建的定义:
/**
* 创建文件
* @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;
}
最后我们开始实现文件的创建操,具体步骤如下:
- 将路径分割为路径数组的形式,方便查找其父级路径
- 父级文件夹查找失败,抛出错误
- 查找到父级文件夹后,创建一个新的文件,插入到父级文件的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 就实现完毕了,如果搭建在阅读文章时,对其中的逻辑有任何问题欢迎留言评论,我将尽快为大家解答;加油!