前言
本次开发对象云存储(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个同时上传。