对于大型文件的上传处理,我们不可能只是单纯的把整个文件,通过一个请求去上传,这样效率很低,且上传速度很慢。所以这种时候就需要前端去对上传的文件做分割处理,将文件分成一小片一小片的,然后再同时发起多个请求去上传这些片段,等文件上传完毕,最后再发起一个合并请求,在服务端上将这些片段合并,形成整个文件保存在服务器上,这就完成整个大型文件快速上传的过程。
那对于文件的分割处理等操作,HTML5 已经提供了一系列的 files API 给我们。但这里我们不去讲,该怎么去用这些 API 做分割上传文件的操作,而是介绍已经基于这些 API 封装的 simple-uploader 插件,一个能帮助我们快速的开发分片上传功能的插件。
文档资料:
摘自文档的第一句话:simple-uploader.js(也称 Uploader) 是一个上传库,支持多并发上传,文件夹、拖拽、可暂停继续、秒传、分块上传、出错自动重传、手工重传、进度、剩余时间、上传速度等特性;该上传库依赖 HTML5 File API。可见该插件已经帮我们封装好了很多功能,只需直接去用即可。
但接下来我细说的过程主要还是基于 vue-simple-uploader 这个插件去见,因为 vue-simple-uploader 插件是基于 simple-uploader 去封装的,所以它的 API 是和 simple-uploader 一模一样,只不过是封装成符合 vue 组件的形式去供我们使用而已。
我会描述 前端 和 后端 在整个分片上传中的大致过程,但具体代码只有前端的。
前端
后端
我所开发的项目是 vue2.0 ,以下代码仅做参考。
1、首先安装插件
安装好 vue-simple-uploader ,它会连带安装 simple-uploader。
npm install vue-simple-uploader --save
因为计算文件 MD5 的时候还需要用到 spark-md5 插件,所以也安装了这个插件
npm install spark-md5 --save
2、引入插件
import uploader from 'vue-simple-uploader'
Vue.use(uploader)
3、使用
在引入 vue-simple-uploader 后,就会全局帮我们注册了 uploader、uploader-unsupport、uploader-btn 等等全局组件,这就是 vue 的开发思想,现成的组件轮子。
template 部分:
<uploader
ref="myUploader"
:fileStatusText="fileStatusText"
:options="options"
:autoStart="false"
@file-added="onFileAdded"
@file-success="onFileSuccess"
@file-error="onFileError"
class="uploader-app">
<uploader-unsupport></uploader-unsupport>
<!-- 这里我把选择上传文件按钮和拖拽组件结合在一起使用了-->
<uploader-btn ref="uploadBtn">
<uploader-drop @click.native="()=>{$refs.uploadBtn.click}">
<p>请点击虚线框选择要上传的文件或拖拽文件到虚线框内</p>
</uploader-drop>
</uploader-btn>
<uploader-list>
<div class="file-panel" slot-scope="props">
<ul class="file-list">
<li v-for="file in props.fileList" :key="file.id">
<uploader-file :class="'file_' + file.id" ref="files" :file="file" :list="true"></uploader-file>
</li>
<div class="no-file" v-if="!props.fileList.length">暂无待上传文件</div>
</ul>
</div>
</uploader-list>
</uploader>
js 部分:
export default {
data() {
const _this = this
return {
// 用于替换组件原来的状态显示文字
filsStatusText: {
success:"成功",
error:"失败",
uploading:"上传中",
paused:"暂停",
waiting:"等待中"
},
// uploader 的主要配置
options: {
chunkSize: 2.5 * 1024 * 1024, // 允许片段最大为5M,因为最后一块默认是可以大于等于这个值,但必须小于它的两倍。
simultaneousUploads: 5, // 最大并发上传数
target:"xxx", // 目标上传 URL,若测试和上传接口不是同个路径,可以用函数模式
permanentErrors:[404,415,500,501,409],// 原来默认是没有409的,但我这接口有409报错,需进入错误回调函数中提示错误信息
// 每次发起测试校验,所有分片都会进入这个回调
checkChunkUploadedByResponse:function (chunk,message) {
// 每次校验,chunk(片段)会不一样,但message(只有一个测试请求)一样
let objMessage = JSON.parse(message);
// 后端认为这个文件上传过,则直接跳过
if(objMessage.skipUpload) return true;
// 若文件检测出现异常,则提示并返回false
if(objMessage.error) {
_this.$message.error(objMessage.message);
return false
}
// 一些校验信息,需要用到的,可以绑定在chunk上
chunk.xxx = objMessage.xxx; // 可写可不写,看自己情况
// chunk的offset就是分块后,该块片段在文件所有模块的位置
return (objMessage.uploadedList||[]).indexOf(chunk.offset+1)>=0
},
// 处理所有请求的参数
processParams:(params,file,chunk) => {
// 这里需要根据后端的要求,处理一些请求参数
params.xxx = chunk.xxx // 比如一些需要在上传时,带上测试校验返回的一些信息字段
return params;
}
}
}
},
methods:{
// 导入文件时
onFileAdded(file) {
// 计算文件 MD5 并标记文件
this.computeMD5(file);
},
// 上传失败
onFileError(rootFile,file,res) {
this.$message.error(JSON.parse(res).message);
},
// 取消文件上传
onFileRemoved(file) {
// 发起取消上传请求给后端
this.$axios.cancelUploadFile({
filename:file.name,
identifier:file.uniqueIdentifer // 文件标记
})
},
// 所有片段上传成功后,进入文件上传成功回调
onFileSuccess(rootFile,file,res) {
res = JSON.parse(res);
// 后端返回成功的字段,插件认为只要所有片段的上传请求都成功了就是上传成功了,而对于其他的错误它是无法处理的
if (!res.result) {
this.$message.error(res.message);
return
}
// 如果后端返回可以合并的字段则发起合并请求
if(res.needMerge) {
// 获取组件的成功状态显示dom节点
const metaDom = document.querySelector(`.uploader-file.file_${file.id} .uploader-file-status`);
// 因为插件在所有片段请求成功后就显示上传成功的状态
// 可合并是否成功却不管了,而插件并未提供处理方式
// 只能通过节点操作修改状态来处理了
metaDom.innerText = "合并中...";
this.$axios.mergeChunk({...}); // 发起合并请求
} else {
// 分片上传成功,但整个文件上传并未结束,不需要合并
console.log("上传成功")
}
},
// 根据文件内容计算 MD5 并标记文件 file
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.options.chunkSize;
let chunks = Math.ceil(file.size / chunkSize); // 总模块数
let spark = new SparkMD5.ArrayBuffer();
// 文件状态设为"计算MD5"
this.statusSet(file.id, 'md5');
file.pause();// 先暂停文件的上传过程
loadNext(); // 开始读取文件内容
// FileReader 加载完成
fileReader.onload = (e => {
// 插入读取的片段内容到 SparkMD5 中
spark.append(e.target.result);
// 按分片顺序读取,小于最后模块位置的就继续读取
if (currentChunk < chunks) {
currentChunk++;
// 实时展示MD5的计算进度
// 对于大型文件读取内容还是会花不少时间
// 所以需要显示读取进度在界面
let dom = document.querySelector(`.uploader-file.file_${file.id} .uploader-file-meta`);
let md5Progress = ((currentChunk/chunks)*100).toFixed(0);
if (md5Progress < 100) {
dom.innerText = "MD5校验:"+md5Progress+"%";
} else if(md5Progress === 100) {
dom.innerText = "MD5校验完成"
}
loadNext();
} else {
// 所有文件分片内容读取完成,生成MD5
let md5 = spark.end();
// 在computeMD5Success中标记文件
this.computeMD5Success(md5, file);
console.log(`MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${new Date().getTime() - time} ms`);
}
});
// FileReader 加载失败
fileReader.onerror = function () {
this.error(`文件${file.name}读取出错,请检查该文件`)
file.cancel();
};
// 分片读取文件内容函数
function loadNext() {
let start = currentChunk * chunkSize;
let end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
// fileReader 读取文件
fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
}
},
computeMD5Success(md5,file) {
file.uniqueIdentifer = md5;// 标记文件file
file.resume();// 继续上传
}
}
}
1、暂停功能
暂停功能无非就是前端取消了已发起但还在pending状态的请求和取消了剩下文件的继续上传(如果这里是要我们自己去写,那就要写很多了,幸好有插件帮我们实现了)
2、续传功能
暂停,刷新页面或在其它页面继续上传,续传功能主要还是依赖于 checkChunkUploadedByResponse 这个函数,其实每次重新上传前,都会执行文件校验,后端返回已接受到的文件片段位置,跳过这些已上传的文件片段达到续传的功能。
3、秒传
对于小文件实现的秒传功能,主要还是因为文件被分片上传了。比起以前的一个上传文件对一个接口,和现在的一个上传文件分片对多个接口,上传速度自然就不一样了,多个请求比一个请求实现上传快很多。
4、分块上传
分块(分片)的核心就是,文件根据内容,生成唯一的MD5标记,根据这个标记划分上传的文件,而分片后的offset即位置,就划分每个文件的片段(块),结合MD5的标记和它在文件中的位置,就可以让每一个片段都是唯一并且可识别的。
5、取消上传
这个几乎没变,取消上传清空组件该文件并请求该文件的清理接口,把该文件已上传的内容清理掉。
1、合并的字段不是在最后上传的片段中返回
这个其实属于后端问题,因为上传文件是并发请求,按理来说,最后一个上传成功的片段,即最后一个响应的上传请求,应该带有需合并的字段才对。但问题是后端在倒数第二个片段就返回合并的字段,可插件并未进入上传成功的函数里,等最后一个也上传成功了,进入了成功回调,却又没有合并的字段。这就导致合并请求发不出去。
有人会说,既然进入插件成功的回调,那就直接合并不行吗?上面说过了,插件判定上传成功,是根据所有片段的上传请求都成功响应来定的。但若出现其它的错误,比如响应的状态码的确是请求成功的一个,可后端却没收到,即接口返回的上传 result 是 false 的,那很明显上传失败,更不应该请求合并。
那前端在请求响应的 processResponse 函数里操作呢? 就是在倒数第二个上传片段的请求响应中得到了可以合并的字段,那这样后端认为可合并,那就直接发起合并请求不行了吗?这个我也试过,但恰好出现虽然倒数第二个片段上传请求响应中出现可以合并的字段,但最后一个片段却上传失败的情况。
无论前端怎么改,到无法做到前后端统一认为可以合并的情况。所以前端还是必须使合并在成功的回调里,而不是写在其它地方。要改的是后端,后端需根据最大并发请求数量,去保证文件上传请求中最后一个片段才能带有可以合并的字段。