前言
本次开发对象云存储(OSS)运用到了文件上传,文件夹上传和拖拽上传这三个功能。开发基于VUE,但是其他框架的需要应用到的都可以应用。然后由于操作流程不一样需要自己去完成一个拖拽上传组件,但是网上查询的其他同学写的代码,2w-3w文件读取需要40s-60s 甚至更长,原因是递归方式的写法上有问题,关键是什么时候读取结束也是不可掌控的。这样的话做读取的loading状态也不方便。所以自己用了一段时间去完善上传。最终效果是2w-3w读取效果在4-6s
文件上传就是 input[file] 类型。
文件夹上传是在input[file]类型的基础上添加了webkitdirectory属性。
文件拖拽上传基于 webkitGetAsEntry + createReader原生API实现文件夹内列表的读取。
说明:优先考虑需求实现再去谈兼容,拖拽使用webkitGetAsEntry 和 createReader都有兼容性得,但是目前浏览器都在尽量的适配这些API,因为这也是前端开发常用的需求。
注意:createReader 这个API Opera目前任然不支持,webkitGetAsEntry 这个API各大版本均有支持。
实现的效果如下

一、什么场景使用拖拽上传?
如果有10个文件夹,使用原生的input[file] + webkitdirectory 去上传文件夹只能一个一个的选择。此时拖拽就派上用场了。
操作的易用性,我们都知道点击文件夹上传会发现文件夹下不展示文件列表,因为webkitdirectory 属性会过滤掉文件,只剩下文件夹,而拖拽会清晰的展示结构。如果使用input 上传会发现,上传文件就只能选择文件类型。上传文件夹就只能选择文件夹。拖拽上传不用管这些,拖什么就上传什么。
二、实现方式 - JS版本
1.HTML 部分-js
<body> <button class="upload_file">上传文件</button> <button class="upload_dir">上传文件夹</button> <input type="file" class="file" hidden> <input type="file" webkitdirectory class="dir" hidden> <div class="drag_upload"> <div class="tip">拖拽上传或者点击 <button>上传文件</button></div> </div> </body>
复制
2. JS 部分
<script> const upload_file = document.querySelector('.upload_file') const upload_dir = document.querySelector('.upload_dir') const drag_upload = document.querySelector('.drag_upload') const file_input = document.querySelector('.file') const dir_input = document.querySelector('.dir') // 点击上传文件按钮 upload_file.addEventListener('click', () => { file_input.click() }) // 点击上传文件夹按钮 upload_dir.addEventListener('click', () => { dir_input.click() }) drag_upload.addEventListener('dragenter', dragEnter) drag_upload.addEventListener('dragover', dragOver) drag_upload.addEventListener('drop', drop) function dragEnter(e) { e.preventDefault() } function dragOver(e) { e.preventDefault() } function drop(e) { e.preventDefault() const dataTransfer = e.dataTransfer if ( dataTransfer.items && dataTransfer.items[0] && dataTransfer.items[0].webkitGetAsEntry ) { webkitReadDataTransfer(dataTransfer) } } function webkitReadDataTransfer(dataTransfer) { var fileNum = dataTransfer.items.length var files = [] const items = dataTransfer.items // 根据拖拽得数量去遍历每一项 for (var i = 0; i < items.length; i++) { var entry = items[i].webkitGetAsEntry() if (!entry) { decrement() return } console.log(entry) if (entry.isFile) { readFiles(items[i].getAsFile(), entry.fullPath) } else { readDirectory(entry.createReader()) } } function readDirectory(reader) { // readEntries() 方法用于检索正在读取的目录中的目录条目,并将它们以数组的形式传递给提供的回调函数。 reader.readEntries((entries) => { if (entries.length) { fileNum += entries.length entries.forEach((entry) => { if (entry.isFile) { entry.file((file) => { readFiles(file, entry.fullPath) }, readError) } else if (entry.isDirectory) { readDirectory(entry.createReader()) } }) readDirectory(reader) } else { decrement() } }, readError) } function readFiles(file, fullPath) { file.relativePath = fullPath.substring(1) files.push(file) decrement() } function readError(fileError) { console.log(fileError) throw fileError } function decrement() { if (--fileNum === 0) { console.log(files, 123) } } } </script>
复制
注意事项
注意点:需要注我用的是HTML文件实现的。如果您也应用的是HTML,直接打开上传会报错。需要以项目方式启动。这里推荐vscode使用 Go Live 插件去启动这个html。上面这个示例只是一个小demo,input 没有注册事件,可以自行完善。可以参考下面完全版本。
三、VUE 版本 - 完全版本,也是上面示例动图的版本
<template> <div class="upload_demo"> <div class="table_info"> <div> <el-button @click="clearList">清空列表</el-button> <el-button @click="uploadFile('file')" :disabled="tableData.length >= 100" >上传文件</el-button > <el-button @click="uploadFile('folder')" :disabled="tableData.length >= 100" >上传文件夹</el-button > </div> <div class="info"> <span>{{ tableData.length }}/ 100文件</span> <span>大小 {{ handleStorage(totalSize) }}</span> </div> </div> <div ref="drag" draggable="true" class="drag tableBox" @dragover="dragover" :class="{ drag_border: tableData.length }" @drop="onDrop" > <div class="el-upload__text" v-show="!tableData.length"> <i class="el-icon-upload" style="margin-right: 6px"></i >拖拽文件/文件夹到此处或者 <el-button type="text" @click="addFiles">添加文件</el-button> </div> <div v-show="!tableData.length" class="el-upload__text"> 文件上传数量不能超过100个,总大小不超过5GB </div> <el-table :data="tableData" v-show="tableData.length" class="table_Height" stripe height="240px" > <el-table-column prop="name" label="名称"> <template v-slot="{ row }"> <span v-if="row.isFolder" style="margin-right: 6px"> <i class="el-icon-folder-opened"></i> </span> <span v-else style="margin-right: 6px"> <i class="el-icon-document"></i> </span> <span>{{ row.name }}</span> </template> </el-table-column> <el-table-column prop="size" label="文件大小" width="200px"> <template v-slot="{ row }"> {{ handleStorage(row.size) }} </template> </el-table-column> <el-table-column label="操作" width="100px"> <template v-slot="{ row, $index }"> <el-button type="text" :disabled="!!row.loaded" @click.stop="deleteFile(row, $index)" >删除</el-button > </template> </el-table-column> </el-table> </div> <!-- 文件读取中不能上传,文件上传数量不能超过100,总大小不能超过 5 GB --> <div class="flex_center" style="margin-top: 20px"> <el-button type="primary" :loading="loading" :disabled=" loading || tableData.length > 100 || totalSize > 5 * 1024 * 1024 * 1024 " @click="uploadFileList" >{{ loading ? "文件读取中" : "上传" }}</el-button > </div> <!-- 上传文件 --> <input type="file" ref="file" hidden multiple @change="uploadeFile" /> <!-- 上传文件夹--> <input type="file" ref="folder" hidden @change="folderChange" multiple webkitdirectory /> </div> </template> <script> export default { data() { return { tableData: [], uploading: false, time: 0, loading: false, }; }, computed: { totalSize() { let total = 0; this.tableData.forEach((item) => { total += item.size; }); return total; }, }, methods: { addFiles() { this.$nextTick(() => { this.$refs["file"].click(); }); }, dragover(e) { e.preventDefault(); }, onDrop(e) { e.preventDefault(); const dataTransfer = e.dataTransfer; if ( dataTransfer.items && dataTransfer.items[0] && dataTransfer.items[0].webkitGetAsEntry ) { this.webkitReadDataTransfer(dataTransfer, e); } }, // 拖拽文件处理 webkitReadDataTransfer(dataTransfer) { let fileNum = dataTransfer.items.length; let files = []; this.loading = true; // 递减计数,当fileNum为0,说明读取文件完毕 const decrement = () => { if (--fileNum === 0) { this.handleFiles(files); this.loading = false; } }; // 递归读取文件方法 const readDirectory = (reader) => { // readEntries() 方法用于检索正在读取的目录中的目录条目,并将它们以数组的形式传递给提供的回调函数。 reader.readEntries((entries) => { if (entries.length) { fileNum += entries.length; entries.forEach((entry) => { if (entry.isFile) { entry.file((file) => { readFiles(file, entry.fullPath); }, readError); } else if (entry.isDirectory) { readDirectory(entry.createReader()); } }); readDirectory(reader); } else { decrement(); } }, readError); }; // 文件对象 const items = dataTransfer.items; // 拖拽文件遍历读取 for (var i = 0; i < items.length; i++) { var entry = items[i].webkitGetAsEntry(); if (!entry) { decrement(); return; } if (entry.isFile) { readFiles(items[i].getAsFile(), entry.fullPath, "file"); } else { // entry.createReader() 读取目录。 readDirectory(entry.createReader()); } } function readFiles(file, fullPath) { file.relativePath = fullPath.substring(1); files.push(file); decrement(); } function readError(fileError) { throw fileError; } }, handleFiles(files) { // 按文件名称去存储列表,考虑到批量拖拽不会有同名文件出现 const dirObj = {}; files.forEach((item) => { // relativePath 和 name 一致表示上传的为文件,不一致为文件夹 // 文件直接放入table表格中 if (item.relativePath === item.name) { this.tableData.push({ name: item.name, filesList: [item.file], isFolder: false, size: item.size, }); } // 文件夹,需要处理后放在表格中 if (item.relativePath !== item.name) { const filderName = item.relativePath.split("/")[0]; if (dirObj[filderName]) { // 放入文件夹下的列表内 let dirList = dirObj[filderName].filesList || []; dirList.push(item); dirObj[filderName].filesList = dirList; // 统计文件大小 let dirSize = dirObj[filderName].size; dirObj[filderName].size = dirSize ? dirSize + item.size : item.size; } else { dirObj[filderName] = { filesList: [item], size: item.size, }; } } }); // 放入tableData Object.keys(dirObj).forEach((key) => { this.tableData.push({ name: key, filesList: dirObj[key].filesList, isFolder: true, size: dirObj[key].size, }); }); }, // input选择文件夹只能单选 folderChange(e) { const filesList = e.target.files; let size = 0; const fileName = filesList[0].webkitRelativePath.split("/")[0]; Array.from(filesList).forEach((item) => { item.relativePath = item.webkitRelativePath; size += item.size; }); const fileObj = { name: fileName, filesList, isFolder: true, size, }; this.tableData.push(fileObj); }, deleteFile(row, index) { // 至于为什么不用filter,而是通过下标删除,需要考虑文件同名同样大小问题。 // 当然通过index去删除也不是最好办法,最好办法是生成为一hash,可以通过md5去计算。大批量文件md5也比较耗费时间 this.tableData.splice(index, index + 1); }, uploadFile(ref, e) { if (e) e.stopPropagation(); this.$refs[ref].click(); }, // 清空文件 clearList() { this.tableData = []; }, // 选择文档后的处理 async uploadeFile() { try { const dom = this.$refs["file"]; const files = dom.files; if (files.length > 200) { this.$message.warning("每次最多上传200个文件"); return; } Array.from(files).forEach((file) => { this.tableData.push({ name: file.name, filesList: [file], isFolder: false, size: file.size, }); }); dom.value = ""; } catch (error) { console.log(error); } }, // 文件上传,需要注意浏览器不同允许并发的数量也不同大多在4-8个区间 edeg 和 chrome 允许6个同时发送 async uploadFileList() {}, handleStorage(value) { let size = ""; if (value / Math.pow(1024, 3) > 1024) { size = (value / Math.pow(1024, 4)).toFixed(2) + " TB"; } else if (value / Math.pow(1024, 2) > 1024) { size = (value / Math.pow(1024, 3)).toFixed(2) + " GB"; } else if (value / 1024 > 1024) { size = (value / Math.pow(1024, 2)).toFixed(2) + " MB"; } else if (value > 1024) { size = (value / 1024).toFixed(2) + " KB"; } else { size = value + " B"; } return size; }, }, }; </script> <style lang="scss" scoped> .upload_demo { width: 600px; margin: 100px auto; } .flex_center { display: flex; align-items: center; justify-content: center; } .el-upload__text { display: flex; align-items: center; justify-content: center; font-size: 12px; line-height: 20px; text-align: center; color: #999999; } .table_info { display: flex; justify-content: space-between; align-items: center; .info { font-size: 12px; color: #999999; & span:first-child { margin-right: 10px; } } } .el-icon-upload { font-size: 19px; margin: 0; } .el-upload__text:first-child { margin-top: 40px; } .addFiles { color: #337dff; } .drag { width: 100%; height: 240px; margin-top: 10px; border: 2px dashed #edeff3; } .tableBox { height: 100%; min-height: 240px; } .drag_border { border: 2px dashed #dfe1e5; } .el-upload__text:first-child { margin-top: 80px; } </style>
复制
后记
建议拖拽文件后,读取过程中触发loading 后,再次拖拽文件读取,此时注意loading状态。可以记录拖拽次数。比如拖拽第一为1。然后这个文件读取过程中,用户又拖拽了文件进来,此时+1。文件读取完成后就减一。最终这个读取次数为0时 loading 才为false。这个这个loading 读取状态时可掌控的。
拖拽上传中文件有大有小,大的需要切片,小的直接上传,所以计算md5 也是比较重要的。因为md5如果后端有记录的话,说明文件已经上传过,直接标记成功,这就是所谓的秒传。
断点续传也一样,就是计算的MD5传给后端后,后端返回md5的碎片列表hash,把没传的碎片传过去,传过的碎片标记为成功,然后合并碎片。因为我这边是云对象存储,业务上不追求md5实现断点续传妙传,只要求切片上传。所以我觉得是这一个不完美点。后面我会写个小作文去断点续传,秒传这一块逻辑
上传接口如果一样的话浏览器允许同时链接个数为4-8个,这个是因为浏览器而异。也就是说如果同时调用20个上传接口的话,会有12个接口被浏览器挂起。所以控制上传接口个数也是一个计算活唷。我这边的实现是如果有切片和文件一起上传那么切片最大允许2个接口同时上传,文件允许4个同时上传。如果只有切片或者文件就只允许6个同时上传。