vue实现大文件分片上传 vue-simple-uploader

发布时间 2023-12-08 15:42:33作者: Xproer-松鼠

首先为什么要分片上传?
大部分小白使用element-ui中上传组件,但是直接用它上传大文件会 超时 或者Request Entity Too Large(请求实体太大)这种问题。

1. 使用插件 vue-simple-uploader
我的这个可以自定义样式(没懂的留言给我)

1.1 customUploader封装组件
上代码:


<template>
<div id="global-uploader" :class="{'global-uploader-single': !global}">
<uploader
ref="uploader"
:options="initOptions"
:fileStatusText="fileStatusText"
:autoStart="false"
@file-added="onFileAdded"
@file-success="onUploadSuccess"
@file-progress="onFileProgress"
@file-error="onFileError"
class="uploader-app">

<uploader-unsupport></uploader-unsupport>
<div @click="clickUploader" :style="{width}">
<uploader-drop
class="custom_uploader_drop"
:style="{width}"
v-if="!isUpload"
v-loading="isMd5Upload"
element-loading-text="正在读取中">
<slot name="customContent"></slot>
<uploader-btn ref="uploadBtn" style="display: none" />
</uploader-drop>
<div v-if="isUpload" class="upload_process_box">
<div>文件名:{{fileName}}</div>

<el-progress :percentage="uploadProcessNum"></el-progress>
<!-- <el-progress v-else :percentage="syncUploadProcessNum"></el-progress>-->
<div v-if="isMd5Upload">
正在读取文件中 - {{md5ProgressText}}
</div>
<div v-if="!isSyncUpload&&!isMd5Upload">
正在上传至服务器 - <span>{{uploadSpeed}} M/s</span>
</div>
<div v-if="isSyncUpload">
正在上传至华为云,稍等会儿 (*^<i class="el-icon-loading" />^*)
</div>
</div>
</div>


<!-- <uploader-list v-show="panelShow">-->
<!-- <div class="file-panel" slot-scope="props" :class="{ collapse: collapse }">-->
<!-- <div class="file-title">-->
<!-- <div class="title">文件列表</div>-->
<!-- <div class="operate">-->
<!-- <el-button @click="collapse = !collapse" type="text" :title="collapse ? '展开' : '折叠'">-->
<!-- <i class="iconfont" :class="collapse ? 'el-icon-full-screen' : 'el-icon-minus'"></i>-->
<!-- </el-button>-->
<!-- <el-button @click="close" type="text" title="关闭">-->
<!-- <i class="el-icon-close"></i>-->
<!-- </el-button>-->
<!-- </div>-->
<!-- </div>-->

<!-- <ul class="file-list">-->
<!-- <li-->
<!-- class="file-item"-->
<!-- v-for="file in props.fileList"-->
<!-- :key="file.id">-->
<!-- <uploader-file-->
<!-- :class="['file_' + file.id, customStatus]"-->
<!-- ref="files"-->
<!-- :file="file"-->
<!-- :list="true"-->
<!-- ></uploader-file>-->
<!-- </li>-->
<!-- <div class="no-file" v-if="!props.fileList.length">-->
<!-- <i class="iconfont icon-empty-file"></i> 暂无待上传文件-->
<!-- </div>-->
<!-- </ul>-->
<!-- </div>-->
<!-- </uploader-list>-->
</uploader>
</div>
</template>

<script>
import { ACCEPT_CONFIG } from './js/config'
import Bus from './js/bus'
import SparkMD5 from 'spark-md5'
// import { mergeSimpleUpload } from '@/api'
// 封装的网络请求promise
import { uploadFile,startOriginUploadFile, queryUploadFileProgress,startMergeFile } from './js/service.js'

import { getToken } from "@/utils/auth";

// let urll = process.env.VUE_APP_BASE_API + '/wk-upload/upload/upload';

// let urll = 'http://192.168.1.111:9999/wk-upload/upload/upload';

export default {
props: {
global: {
type: Boolean,
default: false
},
// 发送给服务器的额外参数
params: {
type: Object
},
options: {
type: Object
},
// 宽度
width: {
type: [Number,String],
default: 100,
validator(value) {
return typeof value === 'String' ? value : (value + 'px')
}
}
},

data() {
return {
// actionResourceUrl: process.env.VUE_APP_BASE_API + '/upload/upload',
initOptions: {
target: this.$api.actionBigFileUrl,
headers: {
Authorization: "Bearer " + getToken(),
// 'Content-Type': 'application/json;charset=UTF-8',
},
chunkSize: 1024*1024*10,//10485760 //10000000, //1024 * 1024 * 3, //3MB 10000000
// chunkSize: '2048000',
fileParameterName: 'file', //上传文件时文件的参数名,默认file
singleFile: true, // 启用单个文件上传。上传一个文件后,第二个文件将超过现有文件,第一个文件将被取消。
maxChunkRetries: 3, //最大自动失败重试上传次数
testChunks: false, //是否开启服务器分片校验
// simultaneousUploads: 3, //并发上传数
// 服务器分片校验函数,秒传及断点续传基础
// checkChunkUploadedByResponse: (chunk, message) => {
// let skip = false
// //
// // try {
// // let objMessage = JSON.parse(message)
// // if (objMessage.skipUpload) {
// // skip = true
// // } else {
// // skip = (objMessage.uploaded || []).indexOf(chunk.offset + 1) >= 0
// // }
// // } catch (e) {}
//
// return skip
// },
query: (file, chunk) => {
return {
...file.params
}
}
},
fileStatusText: {
success: '上传成功',
error: '上传失败',
uploading: '上传中',
paused: '已暂停',
waiting: '等待上传'
},
panelShow: false, //选择文件后,展示上传panel
collapse: false,
customParams: {},
customStatus: '',

isUploadOk: false,
isUploadErr: false,
isStartUpload: false, // 开始上传
md5ProgressText: 0,
isMd5Upload: false, // 计算md5状态
isUpload: false, // 正在上传
uploadProcessNum: 0, // 上传进度
uploadSpeed: 0, // 上传速度
fileName: '', // 文件名
isSyncUpload: false, // 是否在同步远程数据
syncUploadProcessNum: 0, // 同步远程数据
response: null, // 上传成功
queryTimer: null, // 轮询计时器

socket: null,
}
},
computed: {
// Uploader实例
uploader() {
return this.$refs.uploader.uploader
}
},

methods: {
// 自定义options
customizeOptions(opts) {
// 自定义上传url
if (opts.target) {
this.uploader.opts.target = opts.target
}

// 是否可以秒传、断点续传
if (opts.testChunks !== undefined) {
this.uploader.opts.testChunks = opts.testChunks
}

// merge 的方法,类型为Function,返回Promise
this.mergeFn = opts.mergeFn || uploadFile

// 自定义文件上传类型
let input = document.querySelector('#global-uploader-btn input')
let accept = opts.accept || ACCEPT_CONFIG.getAll()
input.setAttribute('accept', accept.join())
},
clickUploader(e) {
// console.log(e)
this.$refs.uploadBtn.$el.click()
},
// 上传前
onFileAdded(file) {
// this.panelShow = true
// this.emit('fileAdded')

// 将额外的参数赋值到每个文件上,以不同文件使用不同params的需求
// file.params = this.customParams

// 计算MD5
this.computeMD5(file).then((result) => this.startUpload(result))
},
/**
* 计算md5值,以实现断点续传及秒传
* @param file
* @returns Promise
*/
computeMD5(file) {
let fileReader = new FileReader()
let time = new Date().getTime()
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
let currentChunk = 0
const chunkSize = this.initOptions.chunkSize;

let chunks = Math.ceil(file.size / chunkSize)
let spark = new SparkMD5.ArrayBuffer()
// 获取文件名
const fileInfo = file.uploader.fileList[0]
this.fileName = fileInfo.name

// 文件状态设为"计算MD5"
// this.statusSet(file.id, 'md5')
this.isMd5Upload = true;
this.isUpload = true;
file.pause()
loadNext()

return new Promise((resolve, reject) => {
fileReader.onload = (e) => {
spark.append(e.target.result)
if (currentChunk < chunks) {
currentChunk++
loadNext()

// 实时展示MD5的计算进度
this.$nextTick(() => {
this.md5ProgressText = ((currentChunk/chunks)*100).toFixed(0)+'%'
})

} else {
let md5 = spark.end()

// md5计算完毕
resolve({md5, file})
// console.log(file);
// console.log(
// `MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${
// new Date().getTime() - time
// } ms`
// )
}
}

fileReader.onerror = function () {
this.error(`文件${file.name}读取出错,请检查该文件`)
file.cancel()
reject()
}
})

function loadNext() {
let start = currentChunk * chunkSize
let end = start + chunkSize >= file.size ? file.size : start + chunkSize

fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end))
}
},

// md5计算完毕,开始上传
startUpload({md5, file}) {
file.uniqueIdentifier = md5
file.resume();
this.isMd5Upload = false;
this.isStartUpload = true;
// this.statusRemove(file.id)
},
// 上传中
onFileProgress(rootFile, file, chunk) {
// console.log(
// `上传中 ${file.name},chunk:${chunk.startByte / 1024 / 1024} ~ ${
// chunk.endByte / 1024 / 1024
// }`
// )
// let index = this.findFileById(file.uniqueIdentifier),//通过index来获取对应的文件progress
// p = Math.round(file.progress()*100);
// if(index > -1){
// if(p < 100){
// console.log(p)
// }
// this.fileList[index].status = file.status;
// }
// console.log(rootFile)
// let p = this.$refs.uploader.progress()
// console.log(p)
let uploader = this.$refs.uploader.uploader;
this.uploadProcessNum = Math.floor(uploader.progress() * 100)
this.emit('onUploadProcess',uploader.progress());


// let averageSpeed = uploader.averageSpeed
let averageSpeed = uploader.averageSpeed
// let timeRemaining = uploader.timeRemaining()
// let uploadedSize = uploader.sizeUploaded()
let speed = averageSpeed / 1000 / 10;
this.uploadSpeed = speed.toFixed(2);
// console.log(speed.toFixed(2) + 'M/s')
},
// 上传中转站成功
onUploadSuccess(rootFile, file, response, chunk) {
let res1 = JSON.parse(response);
if(res1.code===200) {
// 开始merge
console.log(rootFile.uniqueIdentifier);
const body = {
totalChunks: rootFile.chunks.length,
md5File: rootFile.uniqueIdentifier,
fileName: rootFile.name
}
console.log(file)
startMergeFile(body).then(res=>{
// 上传到华为云
this.onUploadSuccessFinally({rootFile, file, res, chunk})
})
}else{
this.error('上传失败')
this.$emit('onUploadError',res1)
}

// 服务端自定义的错误(即http状态码为200,但是是错误的情况),这种错误是Uploader无法拦截的
// if (!res.result) {
// this.error(res.message)
// // 文件状态设为“失败”
// this.statusSet(file.id, 'failed')
// return
// }
// return;
// 如果服务端返回了需要合并的参数
// if(res.needMerge) {
// // 文件状态设为“合并中”
// this.statusSet(file.id, 'merging')
//
// this.mergeFn({
// tempName: res.tempName,
// fileName: file.name,
// ...file.params
// })
// .then((res) => {
// // 文件合并成功
// this.emit('fileSuccess')
//
// this.statusRemove(file.id)
// })
// .catch((e) => {})
//
// // 不需要合并
// } else {
// this.emit('fileSuccess')
// console.log('上传成功')
// }
},
// 开始上传至华为云
onUploadSuccessFinally(data) {
// console.log(data)
const {rootFile, file, res, chunk} = data
let body = {
id: res.data.id
}
// try {
// 请求上传远程开始
this.isSyncUpload = true;
startOriginUploadFile(body).then(res1=>{
this.isUpload = false;
this.isSyncUpload = false;
this.$emit('onUploadSuccess',res1)
}).catch(err=>{
this.isUpload = false;
this.$emit('onUploadError',e)
this.error('上传华为云失败')
clearInterval(this.queryTimer)
})
// this.uploadProcessNum = 0;

// this.queryTimer = setInterval(()=>{
// this.queryProcess(body).then(res=>{
// console.log(res);
// this.isSyncUpload = true;
// if(res.data.process&&res.data.process<100) {
// this.syncUploadProcessNum = res.data.process
// }else{
// this.syncUploadProcessNum = 100;
// this.isUploadOk = true;
// this.isUpload = false;
// this.$emit('onUploadSuccess',res)
// clearInterval(this.queryTimer)
// }
// }).catch(err=>{
// this.isUpload = false;
// this.$emit('onUploadError',err)
// this.error('上传华为云失败')
// clearInterval(this.queryTimer)
// })
// },3000)
// } catch (e) {
// this.isUpload = false;
// this.$emit('onUploadError',e)
// this.error('上传华为云失败')
// clearInterval(this.queryTimer)
// }

// console.log('上传成功')
// this.$emit('onUploadSuccess',res);
// setInterval(()=>{
//
// },1000)
// queryUploadFileProgress().then(res=>{})
},
// 获取上传华为云进度
queryProcess(body) {

// return new Promise((resolve,reject)=>{
// queryUploadFileProgress(body).then(res=>{
// if(res.code===200) {
// resolve(res)
// }else{
// reject(res)
// }
// }).catch(err=>{
// reject(err)
// })
// })
},
// 上传失败
onFileError(rootFile, file, response, chunk) {
this.error('上传失败');
this.$emit('onUploadError',response);
this.isUpload = false;
},
// 取消
close() {
this.uploader.cancel()
this.panelShow = false
},
/**
* 新增的自定义的状态: 'md5'、'merging'、'transcoding'、'failed'
* @param id
* @param status
*/
statusSet(id, status) {

let statusMap = {
md5: {
text: '读取文件中',
bgc: '#fff'
},
merging: {
text: '合并中',
bgc: '#e2eeff'
},
transcoding: {
text: '转码中',
bgc: '#e2eeff'
},
failed: {
text: '上传失败',
bgc: '#e2eeff'
}
}

this.customStatus = status
// this.$nextTick(() => {
// const statusTag = document.createElement('p')
// statusTag.className = `custom-status-${id} custom-status`
// statusTag.innerText = statusMap[status].text
// statusTag.style.backgroundColor = statusMap[status].bgc
//
// const statusWrap = document.querySelector(`.file_${id} .uploader-file-status`)
// statusWrap.appendChild(statusTag)
// })
},
// 移除状态
statusRemove(id) {
this.customStatus = ''
// this.$nextTick(() => {
// const statusTag = document.querySelector(`.custom-status-${id}`)
// statusTag.remove()
// })
},

emit(e) {
// Bus.$emit(e)
// this.$emit(e)
},

error(msg) {
this.$notify({
title: '错误',
message: msg,
type: 'error',
duration: 2000
})
}
},
beforeDestroy() {
clearInterval(this.queryTimer)
// this.socket.onclose = (ee)=>{
//
// }
}
}
</script>

<style lang="scss">
#global-uploader {
&:not(.global-uploader-single) {
position: fixed;
z-index: 20;
right: 15px;
bottom: 15px;
box-sizing: border-box;
}

.uploader-app {
width: 520px;
}

.file-panel {
background-color: #fff;
border: 1px solid #e2e2e2;
border-radius: 7px 7px 0 0;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);

.file-title {
display: flex;
height: 40px;
line-height: 40px;
padding: 0 15px;
border-bottom: 1px solid #ddd;

.operate {
flex: 1;
text-align: right;

i {
font-size: 18px;
}
}
}

.file-list {
position: relative;
height: 240px;
overflow-x: hidden;
overflow-y: auto;
background-color: #fff;
transition: all 0.3s;

.file-item {
background-color: #fff;
}
}

&.collapse {
.file-title {
background-color: #e7ecf2;
}
.file-list {
height: 0;
}
}
}

.no-file {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 16px;
}

.uploader-file {
&.md5 {
.uploader-file-resume {
display: none;
}
}
}

.uploader-file-icon {
&:before {
content: '' !important;
}

//&[icon='image'] {
// background: url(./images/image-icon.png);
//}
//&[icon=audio] {
// background: url(./images/audio-icon.png);
// background-size: contain;
//}
//&[icon='video'] {
// background: url(./images/video-icon.png);
//}
//&[icon='document'] {
// background: url(./images/text-icon.png);
//}
//&[icon=unknown] {
// background: url(./images/zip.png) no-repeat center;
// background-size: contain;
//}
}

.uploader-file-actions > span {
margin-right: 6px;
}

.custom-status {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
}

.custom_uploader_drop {
background-color: rgba(176, 172, 172, 0.1) !important;
cursor: pointer;
border-radius: 5px;
&:hover {
border-color: #409eff !important;
}
}
.upload_process_box {
border-radius: 5px;
border: 1px dashed #409eff;
& > div {
padding: 5px 0;
}
}


/* 隐藏上传按钮 */
#global-uploader-btn {
position: absolute;
clip: rect(0, 0, 0, 0);
}

.global-uploader-single {
#global-uploader-btn {
position: relative;
}
}

//.dot_tran {
// animation-name: dot_tran_keyframe;
// animation-duration: 0.1s;
// animation-iteration-count: infinite;
// .dot_tran_{
// &:after {
// content: '';
// }
// }
//
//}
//@keyframes dot_tran_keyframe {
// 0% {
// .dot_tran_ {
// &:after {
// content: '.';
// }
// }
// }
// 50% {
// .dot_tran_ {
// &:after {
// content: '..';
// }
// }
//
// }
// 100% {
// .dot_tran_ {
// &:after {
// content: '...';
// }
// }
// }
//}

</style>

1.2

<customStardingUploader
v-if="!form.resourceUrl"
width="400px"
@onUploadSuccess="handleAvatarSuccessResource"
@onUploadError="handleVideoErrorResource">
<!-- 自定义内容 -->
<template #customContent>
<div style="margin: 0 auto;width: fit-content;text-align: center">
<div>将文件拖拽到此处</div>
<div>或点击上传</div>
</div>
</template>
</customStardingUploader>

// 上传视频成功
handleAvatarSuccessResource(res, file) {
this.form.resourceUrl = res.data;
this.uploadLoading = false;
},
// 上传视频失败
handleVideoErrorResource(err) {
this.uploadLoading = false;
},

 

参考文章:http://blog.ncmem.com/wordpress/2023/12/08/vue%e5%ae%9e%e7%8e%b0%e5%a4%a7%e6%96%87%e4%bb%b6%e5%88%86%e7%89%87%e4%b8%8a%e4%bc%a0-vue-simple-uploader/

欢迎入群一起讨论