vuejs实现多文件断点续传、秒传、分片上传

凡是要知其然知其所以然

文件上传相信很多朋友都有遇到过,那或许你也遇到过当上传大文件时,上传时间较长,且经常失败的困扰,并且失败后,又得重新上传很是烦人。那我们先了解下失败的原因吧!

据我了解大概有以下原因:

  1. 服务器配置:例如在PHP中默认的文件上传大小为8M【post_max_size = 8m】,若你在一个请求体中放入8M以上的内容时,便会出现异常
  2. 请求超时:当你设置了接口的超时时间为10s,那么上传大文件时,一个接口响应时间超过10s,那么便会被Faild掉。
  3. 网络波动:这个就属于不可控因素,也是较常见的问题。

注意文中的代码并非实际代码,请移步至github查看最新代码

https://github.com/pseudo-god...


分片上传

HTML

  <div class="btns">

<el-button-group>

<el-button :disabled="changeDisabled">

<i class="el-icon-upload2 el-icon--left" size="mini"></i>选择文件

<input

v-if="!changeDisabled"

type="file"

:multiple="multiple"

class="select-file-input"

:accept="accept"

@change="handleFileChange"

/>

</el-button>

<el-button :disabled="uploadDisabled" @click="handleUpload()"><i class="el-icon-upload el-icon--left" size="mini"></i>上传</el-button>

<el-button :disabled="pauseDisabled" @click="handlePause"><i class="el-icon-video-pause el-icon--left" size="mini"></i>暂停</el-button>

<el-button :disabled="resumeDisabled" @click="handleResume"><i class="el-icon-video-play el-icon--left" size="mini"></i>恢复</el-button>

<el-button :disabled="clearDisabled" @click="clearFiles"><i class="el-icon-video-play el-icon--left" size="mini"></i>清空</el-button>

</el-button-group>

<slot

//data 数据

var chunkSize = 10 * 1024 * 1024; // 切片大小

var fileIndex = 0; // 当前正在被遍历的文件下标

data: () => ({

container: {

files: null

},

tempFilesArr: [], // 存储files信息

cancels: [], // 存储要取消的请求

tempThreads: 3,

// 默认状态

status: Status.wait

}),

一个稍微好看的UI就出来了。

选择文件

fileIndex 这个很重要,因为是多文件上传,所以定位当前正在被上传的文件就很重要,基本都靠它

handleFileChange(e) {

const files = e.target.files;

if (!files) return;

Object.assign(this.$data, this.$options.data()); // 重置data所有数据

fileIndex = 0; // 重置文件下标

this.container.files = files;

// 判断文件选择的个数

if (this.limit && this.container.files.length > this.limit) {

this.onExceed && this.onExceed(files);

return;

}

// 因filelist不可编辑,故拷贝filelist 对象

var index = 0; // 所选文件的下标,主要用于剔除文件后,原文件list与临时文件list不对应的情况

for (const key in this.container.files) {

if (this.container.files.hasOwnProperty(key)) {

const file = this.container.files[key];

if (this.beforeUpload) {

const before = this.beforeUpload(file);

if (before) {

this.pushTempFile(file, index);

}

}

if (!this.beforeUpload) {

this.pushTempFile(file, index);

}

index++;

}

}

},

// 存入 tempFilesArr,为了上面的钩子,所以将代码做了拆分

pushTempFile(file, index) {

// 额外的初始值

const obj = {

status: fileStatus.wait,

chunkList: [],

uploadProgress: 0,

hashProgress: 0,

index

};

for (const k in file) {

obj[k] = file[k];

}

console.log('pushTempFile -> obj', obj);

this.tempFilesArr.push(obj);

}

分片上传

  • 创建切片,循环分解文件即可

      createFileChunk(file, size = chunkSize) {

    const fileChunkList = [];

    var count = 0;

    while (count < file.size) {

    fileChunkList.push({

    file: file.slice(count, count + size)

    });

    count += size;

    }

    return fileChunkList;

    }

  • 循环创建切片,既然咱们做的是多文件,所以这里就有循环去处理,依次创建文件切片,及切片的上传。

async handleUpload(resume) {

if (!this.container.files) return;

this.status = Status.uploading;

const filesArr = this.container.files;

var tempFilesArr = this.tempFilesArr;

for (let i = 0; i < tempFilesArr.length; i++) {

fileIndex = i;

//创建切片

const fileChunkList = this.createFileChunk(

filesArr[tempFilesArr[i].index]

);

tempFilesArr[i].fileHash ='xxxx'; // 先不用看这个,后面会讲,占个位置

tempFilesArr[i].chunkList = fileChunkList.map(({ file }, index) => ({

fileHash: tempFilesArr[i].hash,

fileName: tempFilesArr[i].name,

index,

hash: tempFilesArr[i].hash + '-' + index,

chunk: file,

size: file.size,

uploaded: false,

progress: 0, // 每个块的上传进度

status: 'wait' // 上传状态,用作进度状态显示

}));

//上传切片

await this.uploadChunks(this.tempFilesArr[i]);

}

}

  • 上传切片,这个里需要考虑的问题较多,也算是核心吧,uploadChunks方法只负责构造传递给后端的数据,核心上传功能放到sendRequest方法中

 async uploadChunks(data) {

var chunkData = data.chunkList;

const requestDataList = chunkData

.map(({ fileHash, chunk, fileName, index }) => {

const formData = new FormData();

formData.append('md5', fileHash);

formData.append('file', chunk);

formData.append('fileName', index); // 文件名使用切片的下标

return { formData, index, fileName };

});

try {

await this.sendRequest(requestDataList, chunkData);

} catch (error) {

// 上传有被reject的

this.$message.error('亲 上传失败了,考虑重试下呦' + error);

return;

}

// 合并切片

const isUpload = chunkData.some(item => item.uploaded === false);

console.log('created -> isUpload', isUpload);

if (isUpload) {

alert('存在失败的切片');

} else {

// 执行合并

await this.mergeRequest(data);

}

}

  • sendReques。上传这是最重要的地方,也是容易失败的地方,假设有10个分片,那我们若是直接发10个请求的话,很容易达到浏览器的瓶颈,所以需要对请求进行并发处理。

    • 并发处理:这里我使用for循环控制并发的初始并发数,然后在 handler 函数里调用自己,这样就控制了并发。在handler中,通过数组API.shift模拟队列的效果,来上传切片。
    • 重试: retryArr 数组存储每个切片文件请求的重试次数,做累加。比如[1,0,2],就是第0个文件切片报错1次,第2个报错2次。为保证能与文件做对应,const index = formInfo.index; 我们直接从数据中拿之前定义好的index。 若失败后,将失败的请求重新加入队列即可。

      • 关于并发及重试我写了一个小Demo,若不理解可以自己在研究下,文件地址:https://github.com/pseudo-god... , 重试代码好像被我弄丢了,大家要是有需求,我再补吧!

    // 并发处理

sendRequest(forms, chunkData) {

var finished = 0;

const total = forms.length;

const that = this;

const retryArr = []; // 数组存储每个文件hash请求的重试次数,做累加 比如[1,0,2],就是第0个文件切片报错1次,第2个报错2次

return new Promise((resolve, reject) => {

const handler = () => {

if (forms.length) {

// 出栈

const formInfo = forms.shift();

const formData = formInfo.formData;

const index = formInfo.index;

instance.post('fileChunk', formData, {

onUploadProgress: that.createProgresshandler(chunkData[index]),

cancelToken: new CancelToken(c => this.cancels.push(c)),

timeout: 0

}).then(res => {

console.log('handler -> res', res);

// 更改状态

chunkData[index].uploaded = true;

chunkData[index].status = 'success';

finished++;

handler();

})

.catch(e => {

// 若暂停,则禁止重试

if (this.status === Status.pause) return;

if (typeof retryArr[index] !== 'number') {

retryArr[index] = 0;

}

// 更新状态

chunkData[index].status = 'warning';

// 累加错误次数

retryArr[index]++;

// 重试3次

if (retryArr[index] >= this.chunkRetry) {

return reject('重试失败', retryArr);

}

this.tempThreads++; // 释放当前占用的通道

// 将失败的重新加入队列

forms.push(formInfo);

handler();

});

}

if (finished >= total) {

resolve('done');

}

};

// 控制并发

for (let i = 0; i < this.tempThreads; i++) {

handler();

}

});

}

  • 切片的上传进度,通过axios的onUploadProgress事件,结合createProgresshandler方法进行维护

// 切片上传进度

createProgresshandler(item) {

return p => {

item.progress = parseInt(String((p.loaded / p.total) * 100));

this.fileProgress();

};

}

Hash计算

  • 秒传,需要通过MD5值判断文件是否已存在。
  • 续传:需要用到MD5作为key值,当唯一值使用。

执行计算hash

正在上传文件

// 生成文件 hash(web-worker)

calculateHash(fileChunkList) {

return new Promise(resolve => {

this.container.worker = new Worker('./hash.js');

this.container.worker.postMessage({ fileChunkList });

this.container.worker.onmessage = e => {

const { percentage, hash } = e.data;

if (this.tempFilesArr[fileIndex]) {

this.tempFilesArr[fileIndex].hashProgress = Number(

percentage.toFixed(0)

);

}

if (hash) {

resolve(hash);

}

};

});

}

因使用worker,所以我们不能直接使用NPM包方式使用MD5。需要单独去下载spark-md5.js文件,并引入

//hash.js

self.importScripts("/spark-md5.min.js"); // 导入脚本

// 生成文件 hash

self.onmessage = e => {

const { fileChunkList } = e.data;

const spark = new self.SparkMD5.ArrayBuffer();

let percentage = 0;

let count = 0;

const loadNext = index => {

const reader = new FileReader();

reader.readAsArrayBuffer(fileChunkList[index].file);

reader.onload = e => {

count++;

spark.append(e.target.result);

if (count === fileChunkList.length) {

self.postMessage({

percentage: 100,

hash: spark.end()

});

self.close();

} else {

percentage += 100 / fileChunkList.length;

self.postMessage({

percentage

});

loadNext(count);

}

};

};

loadNext(0);

};

文件合并

mergeRequest(data) {

const obj = {

md5: data.fileHash,

fileName: data.name,

fileChunkNum: data.chunkList.length

};

instance.post('fileChunk/merge', obj,

{

timeout: 0

})

.then((res) => {

this.$message.success('上传成功');

});

}


断点续传

缓存处理

在切片上传的axios成功回调中,存储已上传成功的切片

 instance.post('fileChunk', formData, )

.then(res => {

// 存储已上传的切片下标

+ this.addChunkStorage(chunkData[index].fileHash, index);

handler();

})

在切片上传前,先看下localstorage中是否存在已上传的切片,并修改uploaded

    async handleUpload(resume) {

+ const getChunkStorage = this.getChunkStorage(tempFilesArr[i].hash);

tempFilesArr[i].chunkList = fileChunkList.map(({ file }, index) => ({

+ uploaded: getChunkStorage && getChunkStorage.includes(index), // 标识:是否已完成上传

+ progress: getChunkStorage && getChunkStorage.includes(index) ? 100 : 0,

+ status: getChunkStorage && getChunkStorage.includes(index)? 'success'

+ : 'wait' // 上传状态,用作进度状态显示

}));

}

构造切片数据时,过滤掉uploaded为true的

 async uploadChunks(data) {

var chunkData = data.chunkList;

const requestDataList = chunkData

+ .filter(({ uploaded }) => !uploaded)

.map(({ fileHash, chunk, fileName, index }) => {

const formData = new FormData();

formData.append('md5', fileHash);

formData.append('file', chunk);

formData.append('fileName', index); // 文件名使用切片的下标

return { formData, index, fileName };

})

}

垃圾文件清理

  • 前端在localstorage设置缓存时间,超过时间就发送请求通知后端清理碎片文件,同时前端也要清理缓存。
  • 前后端都约定好,每个缓存从生成开始,只能存储12小时,12小时后自动清理


秒传

async handleUpload(resume) {

if (!this.container.files) return;

const filesArr = this.container.files;

var tempFilesArr = this.tempFilesArr;

for (let i = 0; i < tempFilesArr.length; i++) {

const fileChunkList = this.createFileChunk(

filesArr[tempFilesArr[i].index]

);

// hash校验,是否为秒传

+ tempFilesArr[i].hash = await this.calculateHash(fileChunkList);

+ const verifyRes = await this.verifyUpload(

+ tempFilesArr[i].name,

+ tempFilesArr[i].hash

+ );

+ if (verifyRes.data.presence) {

+ tempFilesArr[i].status = fileStatus.secondPass;

+ tempFilesArr[i].uploadProgress = 100;

+ } else {

console.log('开始上传切片文件----》', tempFilesArr[i].name);

await this.uploadChunks(this.tempFilesArr[i]);

}

}

}

  // 文件上传之前的校验: 校验文件是否已存在

verifyUpload(fileName, fileHash) {

return new Promise(resolve => {

const obj = {

md5: fileHash,

fileName,

...this.uploadArguments //传递其他参数

};

instance

.post('fileChunk/presence', obj)

.then(res => {

resolve(res.data);

})

.catch(err => {

console.log('verifyUpload -> err', err);

});

});

}


后端处理

Node版

JAVA版

PHP版

待完善

  • 切片的大小:这个后面会做出动态计算的。需要根据当前所上传文件的大小,自动计算合适的切片大小。避免出现切片过多的情况。
  • 文件追加:目前上传文件过程中,不能继续选择文件加入队列。(这个没想好应该怎么处理。)

更新记录

预期结果:第一个上传成功后,后面相同的问文件应该直接秒传

实际结果:第一个上传成功后,其余相同的文件都失败,错误信息,块数不对。

原因:当第一个文件块上传完毕后,便立即进行了下一个文件的循环,导致无法及时获取文件是否已秒传的状态,从而导致失败。

解决方案:在当前文件分片上传完毕并且请求合并接口完毕后,再进行下一次循环。

将子方法都改为同步方式,mergeRequest 和 uploadChunks 方法


原因:之前每次选择文件时,没有清空上次所选input文件的数据,相同数据的情况下,是不会触发input的change事件。

解决方案:每次点击input时,清空数据即可。我顺带优化了下其他的代码,具体看提交记录吧。

<input

v-if="!changeDisabled"

type="file"

:multiple="multiple"

class="select-file-input"

:accept="accept"

+ οnclick="f.outerHTML=f.outerHTML"

@change="handleFileChange"/>

封装组件

组件文档

Attribute

参数类型说明默认备注
headersObject设置请求头
before-uploadFunction上传文件前的钩子,返回false则停止上传
acceptString接受上传的文件类型
upload-argumentsObject上传文件时携带的参数
with-credentialsBoolean是否传递Cookiefalse
limitNumber最大允许上传个数00为不限制
on-exceedFunction文件超出个数限制时的钩子
multipleBoolean是否为多选模式true
base-urlString由于本组件为内置的AXIOS,若你需要走代理,可以直接在这里配置你的基础路径
chunk-sizeNumber每个切片的大小10M
threadsNumber请求的并发数3并发数越高,对服务器的性能要求越高,尽可能用默认值即可
chunk-retryNumber错误重试次数3分片请求的错误重试次数

Slot

方法名说明参数备注
header按钮区域
tip提示说明文字

后端接口文档:按文档实现即可

以上是 vuejs实现多文件断点续传、秒传、分片上传 的全部内容, 来源链接: utcz.com/a/36593.html

回到顶部