一、需求
自定义上传组件,实现当附件大小超过10M时分片上传功能,有进度条,有删除功能,可以通过拖拽粘贴点击上传,附件icon根据附件类型进行展示
二、实现过程
- 安装 spark-md5
spark-md5主要用作生成唯一标识使用,可根据项目自行调整
npm i spark-md5 --save
- 效果图如下
- 完整代码如下
GeFile.vue
<template>
<div class="ge-file-contanier">
<div class="upload-box">
<!-- 上传部分 -->
<input
ref="fileInput"
id="id"
type="file"
class="upload-file"
name="ufile"
@change="handleChange($event)"
:disabled="getConfig.disabled"
:multiple="getConfig.multiple"
:accept="getConfig.accept"
:webkitdirectory="getConfig.directory"
/>
<div class="upload-btns">
<div
for="ufile"
:class="[
'ge-file-large-box',
{ 'disabled-file': getConfig.disabled },
]"
@drop.prevent="onDrop"
@paste="handlePaste"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@click="handleClickBtn"
>
<i class="geanicon ge-icon-shangchuan upload-icon"></i>
<p>将文件拖到此处,或<span class="uploadBtn">点击上传</span></p>
</div>
</div>
<!-- 上传的回显 -->
<div class="file-list-box">
<div
class="file-list"
v-for="(item, index) in fileList"
:key="`file_${index}`"
>
<div class="file-left">
<img :src="fileIconBase64(item)" class="file-icon" />
<span class="file-name" :title="item.name">{{ item.name }}</span>
</div>
<div class="file-tools">
<a-icon type="check-circle" class="success-icon" />
<a-icon
type="close"
class="close-icon"
v-if="!getConfig.disabled"
@click="handleRemove(index)"
/>
</div>
</div>
<!-- 进度条 -->
<div class="progress-box" v-if="progressData.showProgress">
<div class="progress-file-list">
<div class="file-left">
<img :src="fileIconBase64(progressData.file)" class="file-icon" />
<span class="file-name" :title="progressData.file.name">{{
progressData.file.name
}}</span>
</div>
</div>
<div class="progress">
<div
class="progress-line"
:style="{ width: progressData.startValue + '%' }"
></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { getFileType, uploadByPieces } from "@/utils/tools.js";
export default {
name: "GeFile",
props: {
value: {
//接收自定义v-model传过来的值
type: [String, Array],
default: null,
},
/* 配置项 */
config: {
type: Object,
default: () => ({}),
},
},
data() {
return {
dragOver: false,
progressData: {
file: null, //当前正在上传的附件
startValue: 0,
showProgress: false, //是否展示进度条
},
privateData: {
disabled: false, //是否可编辑
maxLength: null, //最多可选择几个
minLength: null, //最少可选择几个
multiple: false, //是否支持多选
acquiescence: [], //默认值
accept: null /* 接收上传的类型 */,
directory: false /* 是否支持文件夹上传,默认false不可上传 */,
json: false /* 是否是json类型 */,
},
defaultImg: 'this.src="' + require("@/assets/image/no-pic.png") + '"',
};
},
created() {
if (this.value) {
return;
}
if (this.getConfig.acquiescence.length) {
this.fileList = this.getConfig.acquiescence;
}
},
computed: {
fileList: {
get() {
if (this.getConfig.json && this.value) {
return this.value.reduce((prev, cur) => {
prev.push(JSON.parse(cur));
return prev;
}, []);
}
if (!this.value) {
this.$emit("input", []);
}
return this.value || [];
},
set(e) {
let list = e;
if (this.getConfig.json && e) {
list = e.reduce((prev, cur) => {
prev.push(JSON.stringify(cur));
return prev;
}, []);
}
this.$emit("input", list);
},
},
// 配置项
getConfig() {
return Object.assign(this.privateData, this.config);
},
},
methods: {
// 手动将svg图片转成base64,防止打包后在项目中使用不展示的问题
fileIconBase64(val) {
let n = val.name.lastIndexOf(".");
let name = val.name.slice(n + 1);
let t = getFileType(name);
return require(`@/assets/image/${t}.png`);
},
// 删除已上传附件
handleRemove(index) {
// 为了触发一下计算属性,做了重新赋值
let list = this.fileList;
list.splice(index, 1);
this.fileList = list;
let { minLength } = this.getConfig;
if (this.fileList.length < minLength) {
this.$message.info(`您最少要上传${minLength}个附件`);
}
},
handleChange(e) {
const files = e.target.files;
if (!files) {
return;
}
this.uploadFiles(files);
this.clearInputFile();
},
// 清空上传
clearInputFile() {
this.$refs.fileInput.value = null;
},
// 点击上传按钮
handleClickBtn() {
this.$refs.fileInput.click();
},
// 拖拽松开事件
onDrop(e) {
this.dragOver = false;
this.uploadFiles(e.dataTransfer.files);
},
// 粘贴事件
handlePaste(e) {
this.uploadFiles(e.clipboardData.files);
},
// 多文件上传
uploadFiles(files) {
let postFiles = Array.prototype.slice.call(files);
if (postFiles.length === 0) return;
let { maxLength } = this.getConfig;
let uploadFiles = postFiles;
if (maxLength && this.fileList.length + postFiles.length > maxLength) {
this.$message.info(
`您已超过最大上传附件数,最多可上传${maxLength}个!`
);
uploadFiles = postFiles.slice(0, maxLength - this.fileList.length);
}
uploadFiles.forEach((file) => {
this.upload(file);
});
},
// 上传附件
upload(file) {
let { minLength } = this.getConfig;
if (minLength && this.fileList.length < minLength - 1) {
this.$message.info(`您最少要上传${minLength}个附件`);
}
this.progressData.file = file;
this.uploadLocal(file);
},
// 通过本地服务器上传文件
uploadLocal(file) {
// 分片上传
uploadByPieces({
file, // 文件实体
pieceSize: 10, // 分片大小
success: async (response) => {
this.progressData.showProgress = false;
let list = [...this.fileList];
list.push({
name: response.name,
url: response.url,
size: response.size,
useOss: !this.privateData.upload, // true 代表阿里云 false本地
});
this.fileList = list;
this.$emit("success", list);
},
// 上传进度
uploading: (res) => {
this.progressData = {
file: res.file,
startValue: res.percentage * 100,
showProgress: true,
};
},
error: (e) => {
console.log(e);
this.progressData.showProgress = false;
},
});
},
},
};
</script>
<style lang="less" scoped>
.ge-file-contanier {
width: 100%;
.file-icon {
width: 16px;
height: 16px;
}
.ge-file-large-box {
width: 300px;
height: 150px;
background-color: rgba(189, 93, 93, 0.02);
border-radius: 2px;
border: 1px dashed rgba(0, 0, 0, 0.15);
.flex-align-justify-center;
flex-direction: column;
cursor: pointer;
.upload-icon {
font-size: 30px;
line-height: 2;
}
}
.disabled-file {
cursor: no-drop;
background-color: #f5f5f5;
color: rgba(0, 0, 0, 0.25);
.upload-icon,
.uploadBtn {
color: rgba(0, 0, 0, 0.25);
}
}
.uploadBtn {
color: #0086fb;
}
.upload-icon {
color: #0086fb;
}
.upload-file {
position: absolute;
top: -10000px;
}
.file-list-box {
width: 300px;
max-height: 250px;
overflow-y: auto;
.file-list {
.flex-align-justify-center;
margin-top: 5px;
height: 30px;
line-height: normal;
padding: 0 10px;
&:hover {
background-color: #f9fafc;
.file-tools {
.close-icon {
display: inline-block;
&:hover {
color: #ef3e31;
}
}
.success-icon {
display: none;
}
}
.file-left {
color: #0086fb;
}
}
.file-left {
width: calc(~"100% - 16px");
cursor: pointer;
height: 100%;
display: flex;
align-items: center;
}
.file-name {
display: inline-block;
padding-left: 5px;
width: calc(~"100% - 20px");
.text-ellipsis;
}
.file-tools {
cursor: pointer;
.success-icon {
color: #00c668;
display: inline-block;
font-size: 14px;
}
.close-icon {
display: none;
font-size: 12px;
}
}
}
}
.progress-box {
.progress-file-list {
.flex-justify-between;
margin-bottom: 10px;
height: 22px;
padding: 0 10px;
.file-left {
width: calc(~"100% - 16px");
}
img {
margin-top: -21px;
}
.file-name {
display: inline-block;
padding-left: 5px;
width: calc(~"100% - 20px");
.text-ellipsis;
}
.file-tools {
cursor: pointer;
.close-icon {
font-size: 12px;
&:hover {
color: #ef3e31;
}
}
}
}
.progress {
height: 2px;
background-color: rgba(0, 0, 0, 0.06);
border-radius: 1px;
width: calc(~"100% - 26px");
margin-left: 26px;
.progress-line {
background-color: #0086fb;
border-radius: 1px;
height: 2px;
}
}
}
}
</style>
tools.js
注:文中使用Vue.prototype.$req.uploadAction是因为我的组件是独立出去封装成单独的组件库的用法,如果复制使用时可以根据自己需求替换成自己的请求方式,包括需要的传递的参数,参数名等都可自行修改
import SparkMD5 from 'spark-md5';
// 文件后缀名获取
export const getFileType = (fileName) => {
let suffix = fileName.toLocaleLowerCase();
let result;
// 图片格式
const imglist = ["png", "jpg", "jpeg", "bmp", "gif", "svg"];
// 进行图片匹配
result = imglist.find((item) => item === suffix);
if (result) {
return "png";
}
// 匹配txt
const txtlist = ["txt"];
result = txtlist.find((item) => item === suffix);
if (result) {
return "txt";
}
// 匹配 excel
const excelist = ["xls", "xlsx"];
result = excelist.find((item) => item === suffix);
if (result) {
return "Excel";
}
// 匹配 word
const wordlist = ["doc", "docx"];
result = wordlist.find((item) => item === suffix);
if (result) {
return "word";
}
// 匹配 pdf
const pdflist = ["pdf"];
result = pdflist.find((item) => item === suffix);
if (result) {
return "pdf";
}
// 匹配 ppt
const pptlist = ["ppt", "pptx"];
result = pptlist.find((item) => item === suffix);
if (result) {
return "ppt";
}
// 匹配 视频
const videolist = ["mp4", "m2v", "mkv", "rmvb", "wmv", "avi", "flv", "mov", "m4v"];
result = videolist.find((item) => item === suffix);
if (result) {
return "video";
}
// 匹配 音频
const radiolist = ["mp3", "wav", "wmv"];
result = radiolist.find((item) => item === suffix);
if (result) {
return "radio";
}
const ziplist = ["zip"];
result = ziplist.find((item) => item === suffix);
if (result) {
return "zip";
}
// 其他 文件类型
return "other";
};
/**
* 分片上传处理
* @param options 配置参
* file:上传文件
* pieceSize:每片的大小
* baseURL:分片上传的接口地址
* mergeURL:合并上传接口
* selectedSize:用于抽取文件首尾各多少字节作为md5值
* success:成功后回调
* uploading:分片传输中回调
* error:报错回调
* @return Boolean json格式判断结果,true为JSON格式
* */
export function uploadByPieces({
file,
pieceSize = 1,
baseURL = '/client/file/webUploader',
mergeURL = '/client/file/webMerge',
selectedSize = 1024,
success,
uploading,
error,
}) {
// 上传过程中用到的变量
const randomNumber = Date.now() + String(Math.round(Math.random() * 100000)) // 生成随机数
const fileSize = file.size //附件的大小
const chunkSize = pieceSize * 1024 * 1024; // pieceSize:10 MB一片
const chunkCount = Math.ceil(fileSize / chunkSize); // 总片数
let identifier = ''
// 计算md5的值
const countMd5 = async () => {
return new Promise(function async (resolve) {
const fileReader = new FileReader()
if (fileSize > selectedSize) {
// 文件字节大于分割字节
const md5 = new SparkMD5();
let index = 0;
const loadFile = (start, end) => {
const slice = file.slice(start, end);
fileReader.readAsBinaryString(slice);
}
loadFile(0, selectedSize);
fileReader.onload = e => {
md5.appendBinary(e.target.result);
if (index === 0) {
index += selectedSize;
loadFile(fileSize - selectedSize, fileSize);
} else {
resolve(md5.end())
}
};
} else {
fileReader.readAsBinaryString(file);
fileReader.onload = e => {
resolve(SparkMD5.hashBinary(e.target.result))
}
}
})
}
// 获取file分片
const getChunkInfo = (file, currentChunk, chunkSize) => {
let start = (currentChunk - 1) * chunkSize;
let end = Math.min(fileSize, start + chunkSize);
let chunk = file.slice(start, end);
return {
start,
end,
chunk
};
};
// 调用接口上传文件分片
const uploadChunk = chunkInfo => {
let fetchForm = new FormData();
fetchForm.append("chunk", chunkInfo.currentChunk - 1); //第几分片
fetchForm.append("chunks", chunkInfo.chunkCount); //分片总数
fetchForm.append("guid", identifier); // 文件唯一标识 md5
fetchForm.append("size", fileSize); // 文件大小
fetchForm.append("name", file.name); // 文件名称
fetchForm.append("upload", chunkInfo.chunk, file.name); // 分块文件传输对象
Vue.prototype.$req.uploadAction(baseURL, fetchForm).then(() => {
if (chunkInfo.currentChunk < chunkInfo.chunkCount) {
const {
chunk
} = getChunkInfo(
file,
chunkInfo.currentChunk + 1,
chunkSize
);
uploadChunk({
chunk,
currentChunk: chunkInfo.currentChunk + 1,
chunkCount: chunkInfo.chunkCount
});
uploading && uploading({
file,
percentage: chunkInfo.currentChunk / chunkInfo.chunkCount
});
} else {
// 当总数大于等于分片个数的时候
if (chunkInfo.currentChunk >= chunkInfo.chunkCount - 1) {
let fileInfo = {
chunks: chunkInfo.chunkCount,
guid: identifier,
name: file.name,
}
// 调用合并接口
Vue.prototype.$req.formdataAction(mergeURL, fileInfo).then((res) => {
uploading && uploading({
file,
percentage: 1
});
success && success(res.file);
}).catch(e => {
uploading && uploading({
file,
percentage: 1
});
error && error(e);
})
}
}
}).catch(e => {
uploading && uploading({
file,
percentage: 1
});
error && error(e);
})
};
// 针对每个文件进行chunk处理
const readChunk = () => {
// 针对单个文件进行chunk上传
const {
chunk
} = getChunkInfo(file, 1, chunkSize);
uploadChunk({
chunk,
currentChunk: 1,
chunkCount
});
};
countMd5().then(function (result) {
// 生成的identifier拼接一个随机数,为了防止同文件无法重复上传的问题
identifier = result + "_" + randomNumber
// 附件小于10M时进行直接上传,否则分片上传
if (fileSize < chunkSize) {
uploadFile()
} else {
readChunk(); // 开始执行代码
}
})
// 整片上传
const uploadFile = () => {
let fetchForm = new FormData();
fetchForm.append("guid", identifier); // 文件唯一标识 md5
fetchForm.append("size", fileSize); // 文件大小
fetchForm.append("name", file.name); // 文件名称
fetchForm.append("upload", file, file.name); // 分块文件传输对象
Vue.prototype.$req.uploadAction(baseURL, fetchForm).then((res) => {
uploading && uploading({
file,
percentage: 1
});
success && success(res.file);
}).catch(e => {
uploading && uploading({
file,
percentage: 1
});
error && error(e);
})
}
}
三、可做优化部分(实现断点续传功能)
1、思路:在分片上传时每一片都有传一个利用 spark-md5 库,根据内容生成的唯一标识identifier,前端在重新上传时只需向服务端请求已经上传的切分。
2、难点:
- 在计算
identifier时,如果上传的附件过大,计算量太大,可能出现ui阻塞问题,这时可以使用web-worker开启线程计算 - 这里补充一个文件秒传的概念,所谓文件秒传就是服务端根据前端传的
identifier去查询库中是否已经存在,如果已经存在立即返回上传成功状态,避免重复上传的工作量。 - 暂停上传:可以利用axios中
axios.CancelToken.source的方法生成取消令牌token,设置cancelToken来暂停上传 - 续传:在点击继续上传时,前端需要调用验证接口,后端根据前端传过来的
fileName和identifier来判断是否已经上传过,或者是否已经上传过部分,如果上传部分,需要返回前端已经完成上传的分片