需求分析和实现比较

做这个功能的原因是参加的一个比赛项目制作的是一个云存储系统,文件上传是其核心功能之一。但是当前主流的UI框架(AntD,TDesign)的Upload文件上传组件都只支持单/多文件的上传。即使使用了react-dropzone这一组件也只能解决文件夹自动展开为其子文件

同时,由于后端提供的接口是一层一层在后台的系统中创建文件夹,直接暴力上传整个文件夹,而不建立树形结构是无法实现层序创建文件夹的。

而且,在现在的开源社区搜索上传文件夹,大多都是简单调用API进行文件夹的拆分,没有实际解决树型结构上传的问题。

如果不自己再造一个轮子,对于大量使用文件操作的存储系统的用户来说是体验很差的,文件夹必须要自己创建,而不能直接创建多层嵌套的文件夹
同时,使用过腾讯云或其他云存储的朋友们可以发现,云存储厂商基本都实现了拖拽上传文件夹的功能,这至少证明了这个功能是可实现的。下面就是腾讯云的实现结果,发现确实展现出了一个文件夹

腾讯云实现.png
我们再与直接用开源组件react-dropzone的情况进行对比。就可以发现它完全没有创建文件夹,而是简单地将文件夹打平,将里面的所有文件同层上传。
文件夹被拆分了.png

阅读提示

在个人平时的实践中,除了云存储系统,大多数项目都是不需要上传整个文件夹的。

而且由于浏览器本就只提供了文件的上传,并没有原生实现文件夹上传的API,使用监听器去监听onDrop事件,也只能得到用户当前拖拽的直接文件是什么,对于文件夹,并不能获取到它内部的文件

即使对于云存储系统,也是需要定制化轮子来适配自己后端的接口,这也注定了上传文件夹功能的强定制化性,所以没有一个易于使用的开源框架进行文件夹上传的实践也是可以理解的。
正因如此,本文也不会提及和后端的实际交互,但会提供一个思路/伪代码帮助读者自己实现定制化的交互。
代码的特别注意点如下

  • 使用React编写,依赖react-dropzone库实现,但上传方法的大多数代码都是原生JS实现,和框架和库基本无关
  • 只在开发环境进行了测试

思路分析和数据结构

这部分是此功能实现的思路和使用的数据结构,希望能为大家阅读代码提供一些帮助,或是为更好的实现提供些许的灵感。
对于文件管理而言,它就是类似DOM的树形结构,根节点即为盘符(存储桶),分支节点即为各个文件夹,叶子节点即为各个单独的文件(js中为File文件对象)
因此,代码的实现就是通过一个数据结构来辅助DFS进行建树,到叶子文件时就进行存储,到分支就进行新文件夹的创建,建好之后就是简单的遍历树进行操作了。
最后笔者通过如下的类定义实现该数据结构

 class StandardDirectory {
	constructor() {
		this.files = [];//存储当前文件夹下的直接叶子,即文件
		this.subDirectories = new Map();//存储子文件夹
    //key为name(File文件的path)作为子文件夹名称,value为新一层的StandardDirectoryMap
		this.size = 0;//记录当前文件夹已经存储的大小
	}
 }

结构.png

(有点丑的结构图)

WEB实现

WEB的实现由于不用处理路径的问题,直接就能得知用户选择的文件夹是什么,File文件的路径就是从选择的文件夹开始的路径,相对比较简单。
web路径.png

这里就可以看到是文件就是直接文件名,文件夹则是相对路径
首先先给出拖拽的实现,其实就是在react-dropzone官方示例的基础上进行了一些修改。
fileList和setFileList用于记录当前上传的所有叶子文件,isElectron 在Web中不会使用
由于数据结构基于Map实现,React的State对Map的处理很差,因此我们直接在开头声明我们自己的数据结构

//在UI组件外部,在文件的开头
const bucketUploadDirectory = new StandardDirectory();

//组件
const MyDragger = ({ fileList, setFileList, isElectron }) => {
  const onDrop=()=>{}
	const { getRootProps, getInputProps } = useDropzone({
		onDrop,
		noClick: true,
		noKeyboard: true,
		multiple: true,
	});
	return (
		<div {...getRootProps()} className="upload-content">
			<input {...getInputProps()} className="drag-upload-form" />
			{fileList.length > 0 ? (
				<FilesTableContent
					fileList={fileList}
					setFileList={setFileList}
				/>
			) : (
				<EmptyContent />
			)}
		</div>
	);
};

处理拖拽和建树

建树比较简单的方法就是通过递归DFS进行建树。但首先让我们先获取到我们到底拖拽了什么进来
由于可能会多次拖拽,我们需要将曾经的拖拽和这次的拖拽合并。这里的代码实现是暴力连接,可以根据实际需要加一个通过Set去重的操作
总体而言,这个函数进行了获取文件,并进行了对递归建树的预处理。代码每一行的作用都写在注释里了

const onDrop = (acceptedFiles) => {
		setFileList(fileList.concat(acceptedFiles));//连接旧文件列表和新文件列表
		let relativePaths = acceptedFiles.map((file) => file.path);//构建文件路径数组
		if (isElectron) {
			relativePaths = getRelativePaths(relativePaths);
		}
	
		console.log(acceptedFiles, "acceptedFiles");
		acceptedFiles.forEach((acceptedFile, index) => {
			const path = relativePaths[index];//获取到每个文件的路径字符串
			const pathArray = path.split("/").slice(1); //拆分得到数组,path有一个/开头,需要去掉第一个空元素
			RecursivelyCreateDirectory(
				pathArray,
				bucketUploadDirectory,//根节点
				acceptedFile //同一个对象的引用,性能问题不是很大
			);
		});
	};

递归方法

递归方法可以说是这个实现里最关键的代码了,20行代码写了快一天,个人水平还是有待提升。
这里计算size也有些意思,因为每一个文件找到自己所处的叶子位置必须从根节点开始完整走一遍流程,它经过的每一个分支节点都会直接或间接地存储它,因此可以直接在经过的每一层文件夹,都加上文件的大小。
当路径数组长度等于1,说明文件已经找到了自己的叶子位置。这一层文件夹的files数组接受进这个文件。

const RecursivelyCreateDirectory = (
	pathArray,
	currentDirectory, //可以认为当前在这一层的文件夹中进行操作
	originalFile //传入的是引用
) => {
	currentDirectory.size += originalFile.size; //文件不会被重新访问,所以可以直接在这里加上文件大小
	if (pathArray.length <= 1) {
		//访问到了最后的文件,推入当前文件夹的文件列表中
		currentDirectory.pushFile(originalFile);
		return;
	}
	//仍然有子文件夹,则进行建立,继续递归
	const newFileName = pathArray[0];
	currentDirectory.setSubDirectoryByName(newFileName);
	RecursivelyCreateDirectory(
		pathArray.slice(1),//弹出这一级文件夹的路径,进入下一层
		currentDirectory.getSubDirectoryByName(newFileName),//进入下一层
		originalFile
	);
};



上面的代码有一些类操作,这是为了便于维护而抽象出的类方法。其实就是一些包装的Map和数组方法,代码如下

class StandardDirectory {
	constructor() {
		this.files = [];
		this.subDirectories = new Map();
		this.size = 0;
	}
	pushFile(file) {
		this.files.push(file);
	}
	setSubDirectoryByName(name) {
		if (!this.subDirectories.has(name)) {
			this.subDirectories.set(name, new StandardDirectory());
		}
	}
	getSubDirectoryByName(name) {
		return this.subDirectories.get(name);
	}
}

现在,你只需要对UI组件进行一些设计,通过对bucketUploadDirectory这个我们封装的Map进行一层遍历files和subDirectories,展开成UI组件能够处理的数据列表,对size进行一些标准化计算,就可以达到如下的类似腾讯云的效果了。
web结果.png
size标准化代码如下

function formatFileSize(size) {
	const units = ["B", "KB", "MB", "GB", "TB"];
	let i = 0;
	while (size >= 1024 && i < units.length - 1) {
		size /= 1024;
		i++;
	}
	return `${size.toFixed(2)} ${units[i]}`;
}

总结和反思

作为一个比较冷门的feature,参考资料确实是比较少。

个人代码能力不强,以上的代码对于优秀还要很长一段距离,且确实是以封装react-dropzone为代码编写的目的,可以说是重复造了轮子,实有舍近求远之嫌。

同时,这些代码只是简单地进行了UI界面的展示,还没有和后端打通。不过,既然已经有了这种多叉树的数据结构,只需进行一些层序遍历,就可以完善功能了。

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐