element-plus实现前端分片上传的操作
准备工作
分片上传并不是什么新概念,尤其是大文件传输的处理中经常会被使用,原则就是化整为零,将大文件进行分片处理,切割成若干小文件,随后为每个分片创建一个新的临时文件来保存其内容,待全部分片上传完毕后,后端再按顺序读取所有临时文件的内容,将数据写入新文件中,最后将临时文件再删掉。
其实现在市面上有很多前端的三方库都集成了分片上传的功能,比如百度的WebUploader,遗憾的是它已经淡出历史舞台,无人维护了。现在比较推荐主流的库是vue-simple-uploader,不过饿了么公司开源的elementUI市场占有率还是非常高的。
首先前端需要安装需要的库:
npm install element-plus --save
npm install spark-md5 --save
npm install axios --save
随后在入口文件main.js中进行配置:
import { createApp } from 'vue';
import App from './App.vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
createApp(App)
.use(ElementPlus)
.mount('#app');
配置好之后,设计方案,前端通过elementUI上传时,通过分片大小的阈值对文件进行切割,并且记录每一片文件的切割顺序(chunk),在这个过程中,通过SparkMD5来计算文件的唯一标识(防止多个文件同时上传的覆盖问题identifier),在每一次分片文件的上传中,会将分片文件实体,切割顺序(chunk)以及唯一标识(identifier)异步发送到后端接口(fastapi),后端将chunk和identifier结合在一起作为临时文件写入服务器磁盘中,当前端将所有的分片文件都发送完毕后,最后请求一次后端另外一个接口,后端将所有文件合并。
具体实现
前端实现
根据方案,前端建立chunkupload.js文件:
import SparkMD5 from 'spark-md5'
//错误信息
function getError(action, option, xhr) {
let msg
if (xhr.response) {
msg = `${xhr.response.error || xhr.response}`
} else if (xhr.responseText) {
msg = `${xhr.responseText}`
} else {
msg = `fail to post ${action} ${xhr.status}`
}
const err = new Error(msg)
err.status = xhr.status
err.method = 'post'
err.url = action
return err
}
// 上传成功完成合并之后,获取服务器返回的json
function getBody(xhr) {
const text = xhr.responseText || xhr.response
if (!text) {
return text
}
try {
return JSON.parse(text)
} catch (e) {
return text
}
}
// 分片上传的自定义请求,以下请求会覆盖element的默认上传行为
export default function upload(option) {
if (typeof XMLHttpRequest === 'undefined') {
return
}
const spark = new SparkMD5.ArrayBuffer()// md5的ArrayBuffer加密类
const fileReader = new FileReader()// 文件读取类
const action = option.action // 文件上传上传路径
const chunkSize = 1024 * 1024 * 1 // 单个分片大小,这里测试用1m
let md5 = ''// 文件的唯一标识
const optionFile = option.file // 需要分片的文件
let fileChunkedList = [] // 文件分片完成之后的数组
const percentage = [] // 文件上传进度的数组,单项就是一个分片的进度
// 文件开始分片,push到fileChunkedList数组中, 并用第一个分片去计算文件的md5
for (let i = 0; i < optionFile.size; i = i + chunkSize) {
const tmp = optionFile.slice(i, Math.min((i + chunkSize), optionFile.size))
if (i === 0) {
fileReader.readAsArrayBuffer(tmp)
}
fileChunkedList.push(tmp)
}
// 在文件读取完毕之后,开始计算文件md5,作为文件唯一标识
fileReader.onload = async (e) => {
spark.append(e.target.result)
md5 = spark.end() + new Date().getTime()
console.log('文件唯一标识--------', md5)
// 将fileChunkedList转成FormData对象,并加入上传时需要的数据
fileChunkedList = fileChunkedList.map((item, index) => {
const formData = new FormData()
if (option.data) {
// 额外加入外面传入的data数据
Object.keys(option.data).forEach(key => {
formData.append(key, option.data[key])
})
// 这些字段看后端需要哪些,就传哪些,也可以自己追加额外参数
formData.append(option.filename, item, option.file.name)// 文件
formData.append('chunkNumber', index + 1)// 当前文件块
formData.append('chunkSize', chunkSize)// 单个分块大小
formData.append('currentChunkSize', item.size)// 当前分块大小
formData.append('totalSize', optionFile.size)// 文件总大小
formData.append('identifier', md5)// 文件标识
formData.append('filename', option.file.name)// 文件名
formData.append('totalChunks', fileChunkedList.length)// 总块数
}
return { formData: formData, index: index }
})
// 更新上传进度条百分比的方法
const updataPercentage = (e) => {
let loaded = 0// 当前已经上传文件的总大小
percentage.forEach(item => {
loaded += item
})
e.percent = loaded / optionFile.size * 100
option.onProgress(e)
}
// 创建队列上传任务,limit是上传并发数,默认会用两个并发
function sendRequest(chunks, limit = 2) {
return new Promise((resolve, reject) => {
const len = chunks.length
let counter = 0
let isStop = false
const start = async () => {
if (isStop) {
return
}
const item = chunks.shift()
console.log()
if (item) {
const xhr = new XMLHttpRequest()
const index = item.index
// 分片上传失败回调
xhr.onerror = function error(e) {
isStop = true
reject(e)
}
// 分片上传成功回调
xhr.onload = function onload() {
if (xhr.status < 200 || xhr.status >= 300) {
isStop = true
reject(getError(action, option, xhr))
}
if (counter === len - 1) {
// 最后一个上传完成
resolve()
} else {
counter++
start()
}
}
// 分片上传中回调
if (xhr.upload) {
xhr.upload.onprogress = function progress(e) {
if (e.total > 0) {
e.percent = e.loaded / e.total * 100
}
percentage[index] = e.loaded
console.log(index)
updataPercentage(e)
}
}
xhr.open('post', action, true)
if (option.withCredentials && 'withCredentials' in xhr) {
xhr.withCredentials = true
}
const headers = option.headers || {}
for (const item in headers) {
if (headers.hasOwnProperty(item) && headers[item] !== null) {
xhr.setRequestHeader(item, headers[item])
}
}
// 文件开始上传
xhr.send(item.formData);
}
}
while (limit > 0) {
setTimeout(() => {
start()
}, Math.random() * 1000)
limit -= 1
}
})
}
try {
// 调用上传队列方法 等待所有文件上传完成
await sendRequest(fileChunkedList,2)
// 这里的参数根据自己实际情况写
const data = {
identifier: md5,
filename: option.file.name,
totalSize: optionFile.size
}
// 给后端发送文件合并请求
const fileInfo = await this.axios({
method: 'post',
url: 'http://localhost:8000/mergefile/',
data: this.qs.stringify(data)
}, {
headers: {
"Content-Type": "multipart/form-data"
}
}).catch(error => {
console.log("ERRRR:: ", error.response.data);
});
console.log(fileInfo);
if (fileInfo.data.code === 200) {
const success = getBody(fileInfo.request)
option.onSuccess(success)
return
}
} catch (error) {
option.onError(error)
}
}
}
之后建立upload.vue模板文件,并且引入自定义上传控件:
<template>
<div>
<el-upload
:http-request="chunkUpload"
:ref="chunkUpload"
:action="uploadUrl"
:data="uploadData"
:on-error="onError"
:before-remove="beforeRemove"
name="file" >
<el-button size="small" type="primary">点击上传</el-button>
</el-upload>
</div>
</template>
<script>
//js部分
import chunkUpload from './chunkUpload'
export default {
data() {
return {
uploadData: {
//这里面放额外携带的参数
},
//文件上传的路径
uploadUrl: 'http://localhost:8000/uploadfile/', //文件上传的路径
chunkUpload: chunkUpload // 分片上传自定义方法,在头部引入了
}
},
methods: {
onError(err, file, fileList) {
this.$store.getters.chunkUploadXhr.forEach(item => {
item.abort()
})
this.$alert('文件上传失败,请重试', '错误', {
confirmButtonText: '确定'
})
},
beforeRemove(file) {
// 如果正在分片上传,则取消分片上传
if (file.percentage !== 100) {
this.$store.getters.chunkUploadXhr.forEach(item => {
item.abort()
})
}
}
}
}
</script>
这里注意,请务必使用:http-request="chunkUpload" 覆盖原有的上传方法,http-request作用就是使用自定义的上传方式覆盖默认的。
这里定义的后端上传接口是:http://localhsot:8000/uploadfile/合并文件接口是:http://localhsot:8000/mergefile/
后端实现
前端搞定了,下面我们来编写接口,后端的任务相对简单,接收分片文件、分片顺序以及唯一标识,并且将文件临时写入到服务器中,当最后一个分片文件完成上传后,第二个接口负责按照分片顺序合并所有文件,合并成功后再删除临时文件,用来节约空间。
具体我的实现方法与此处不同,我是采用一个接口,将上传列表一定要改为串行,因为并行可能有后发先至的问题,所以我采用串行,当上传的当前片数=总片数的时候,进行合并。不过我觉得本文的实现方式更加的好。