文件上传原生js插件
前言
需求背景:在新疆项目中我们完成了视频上传的定制化开发,前端是基于第三方厂商(阳光云视)提供的代码进行二次封装,视频上传则是直接调用
阳光云视提供的接口。由于业务扩展现在由特定的项目开发转换为产品化开发,原来的视频上传插件不具备通用性无法移植到其他项目。
开发目标:
1.使用方便,通过配置基本配置项就可以达到上传的目的;
2.提供断点续传、文件秒传的功能;
3.可以并发上传;
4.接口调用从对应视频厂商改为调用自己后端接口(无论修改任何视频处理厂商都与前端无关);
5.无论系统采用哪种前端框架完成插件都可以适应。
1 使用
import upload from 'upLoad/upLoad.js';
...
this.SliceUploader = upload.init({
autoUpload: true,
id: 'selecter',
onSuccess: this.onSuccess,
onMD5Progress: this.onMD5Progress,
onUploadProgress: this.onUploadProgress,
onStartUpload: this.startUpload,
onError: this.onError,
onFileinQueue: this.onFileinQueue,
maxSize: 600 * 1024 * 1024,
extensions: 'mp4',
chunkLimit: 1,
chunkSize: 5 * 1024 * 1024,
mimeType: 'video/mp4',
});
1.1 参数说明
参数名 | 描述 | 参数 | 默认值 |
---|---|---|---|
autoUpload | 为true时开启自动上传 | Boolean | true |
id | 挂载事件的input标签id | String | - |
maxSize | 上传文件最大大小 | Number | 300 * 1024 * 1024 |
extensions | 允许上传的文件类型,以任意字符隔开,为空时允许任意格式 | String | - |
chunkLimit | 分片上传请求并发数量 | Number | 2 |
chunkSize | 文件分片大小 | Number | 5 * 1024 * 1024 |
mimeType | 设置input元素的accept属性 | String | - |
1.2 事件说明
如果想阻塞,则在传入的方法中return false
事件 | 参数说明 | 描述 |
---|---|---|
onFileinQueue | *file File对象,对象包含一个uniqueValue参数,是插件类实例的唯一编码 | 文件进入队列时触发 |
onStartUpload | *file File对象 | 当文件开始上传时触发 |
onSuccess | *file File对象,对象包含一个successRes参数,是接口返回信息 | 当前上传文件所有分片上传完成时触发 |
onMD5Progress | *file File对象,对象包含一个MD5Progress参数,表示MD5的进度值 | MD5加密进度改变时触发 |
onUploadProgress | *file File对象,对象包含一个percentage参数,表示上传进度值 | 文件上传进度改变时触发 |
onError | *Object {data:具体错误信息,code:错误代码} | code=1:验证文件是否已上传接口报错 ;code=2:上传文件分片接口报错 ;code=3:手动调用doUpload方法时没有将参数autoUpload设为false;code=4:有文件分片上传失败; code=5:文件过大;code=6:文件类型错误;code=7:找不到相应id的DOM; code=8:网络错误 |
1.3 方法说明
方法 | 参数说明 | 描述 |
---|---|---|
doUpload | - | 手动触发文件上传,通常和autoUpload :false一起使用 |
handlePause | - | 手动暂停文件上传 |
handleResume | - | 手动恢复文件上传 |
1.4 upLoad.js的封装
为了隔离和保护upLoad.js中的变量和方法不被污染,整个文件中的代码包含在一个自执行函数中。
; (function (global) {......})(this);
最前面的分号是为了避免压缩或打包时变为函数。
upLoad的主要任务是执行文件的分片、验证是否已上传、执行上传等一系列操作。
为了完成这些任务,我们定义了一个SliceUpload并向外暴露。
//兼容CommonJs规范
if (typeof module !== 'undefined' && module.exports) {
module.exports = SliceUpload;
}
//兼容AMD/CMD规范
else if (typeof define === 'function') define(function () {
return SliceUpload;
})
//注册全局变量,兼容直接使用script标签引入插件
else global.SliceUpload = SliceUpload;
1.4.1 变量:
一些可以配置的变量或者是能够被获取及修改的变量,都定义在了:
function SliceUpload() {
this.container = {
file: null,
hash: ""
};
this.hashPercentage = 0;
this.percentage = 0;
this.notUploadedIndex = null;
this.data = []; // 上传的数据
this.requestList = []; // xhr数组,传输失败,断点续传
this.chunkLimit = 4;
this.params = {
userId: idStorage.fetch() || "321dsa122121",
tenantId: tenantIdStorage.fetch() || "5",
md5: "",
uid: ""
};
this.md5Obj = {
md5: "",
percent: 0
};
this.chunkSize = 5 * 1024 * 1024;
this.uploadURL = './transfer/aav/video/multipartUpload'
this.checkURL = './transfer/aav/video/multipartUploadCheck'
this.notUploadNumber = 0;
};
1.4.2 回调:
我们在SliceUpload的原型链上定义了所有的回调,并在文件处理或者上传走到既定流程时,调用相应回调。
SliceUpload.prototype = {
onFileinQueue() {/*文件进入队列 */ },
onStartUpload() {/*开始上传 */ },
onUploadProgress() { /*上传进度条改变 */ },
onMD5Progress() { /*MD5加密进度条改变 */ },
onError() {/*报错 */ },
onSuccess() {/*上传完成 */ },
}
...
//执行md5加密和上传前执行onFileinQueue。
if(this.onFileinQueue(this.container.file)===false) return;
1.4.3公有方法:
与回调一样,我们在SliceUpload原型链上定义了一些公有方法,能够通过实例直接调用到。
包括:文件选择事件:handleFileChange、暂停:handlePause、续传/秒传判断:handleResume、手动执行上传:doUpload。
1.4.4 私有方法:
一些我们不希望被外部调用的方法,则定义在了SliceUpload的外部,调用时用call的方式重定向this。
1.5 index.js的封装
index.js的主要任务是创建SliceUpload实例、挂载事件,
为了完成这些任务,我们在其中定义并向外暴露了一个trsUpload对象,挂载都没
init方法
trsUpload 有一个init方法,接收配置项,用这些配置项生成一个SliceUpload实例并返回。
trsUpload.init = function (opt) {
let example = new upLoad()
Object.assign(example, defaultOPT, opt);
eventInt(example);
return example
}
其中的eventInt是挂载input点击事件的方法。
2 vue环境之下使用方法
2.1 引入插件初始化
2.1.1 引入文件
import upload from '@/plugins/upLoad/index';
2.1.2 初始化实例
mounted() {
const opt = {
autoUpload: true,//是否自动上传
id: 'selecter',//绑定的dom元素ID
onSuccess: this.onSuccess,//上传成功的回调方法
onMD5Progress: this.onMD5Progress,//处理MD5进度条过程回调
onUploadProgress: this.onUploadProgress,//上传文件进度条回调
onStartUpload: this.startUpload,//自定义开始上传方法
onError: this.onError,//错误处理回调
onFileinQueue: this.onFileinQueue,//MD5开始之前文件进入队列回调
maxSize: 600 * 1024 * 1024,//文件上传最大限制
extensions: 'mp4',//文件上传后缀
chunkLimit: 1,//上传并发数
chunkSize: 5 * 1024 * 1024,//文件切片(分片)大小
mimeType: 'video/mp4',//息内容类型的因特网标准
};
this.$nextTick(() => {
this.SliceUploader = upload.init(opt);
});
},
注意:
以下代码:
this.$nextTick(() => {
this.SliceUploader = upload.init(opt);
});
使用时指定的dom必须为input元素并且type=‘file’,如果需要用其他元素当做上传按钮,可隐藏input达到效果
在vue的环境下,因为是通过id选择器获取的dom所以要在页面初始化加载完毕之后再进行操作。
在插件的index.js中,init做了哪些事:
总体流程:
① init方法用opt新建并返回了一个upload的实例。
② 为input元素设置了属性,挂载了点击事件。触发点击事件后最终会走到upload的实例的handleFileChange方法。
(1)在index.js里面的init方法:
代码:
upLoad.init = function (opt) {
let example = new upLoad()
Object.assign(example, defaultOPT, opt);
eventInt(example);
return example
}
① 用传入的配置项创建了一个名为example的upLoad实例;
② 执行eventInt(),为指定的dom绑定事件。
③ 返回example。
(2)eventInt方法的定义同样在index.js中
let eventInt = function (example) {
let dom = document.getElementById(example.id);
if (dom) {
dom.setAttribute('accept',example.mimeType)
dom.addEventListener("change", function (e) {
const [file] = e.target.files;
if (file) {
if ( checkSize(example, file.size) && checkExtensions(example, file.type)) example.handleFileChange(e)
}
});
} else {
example.onError({ data: 'The id is not found to be an DOM of ' + example.id, code: 7 })
}
}
2.1.3 用户代码中绑定的回调事件示例
methods: {
onSuccess(res) {
console.log(res);
},
onUploadProgress(file) {
this.uploadProgress = file.percentage;
},
onMD5Progress(file) {
this.md5Progress = file.MD5Progress;
},
onError(err) {
console.log(err);
},
startUpload(file) {
console.log(file);
},
onFileinQueue(file) {
console.log(file);
// return false;
},
},
3 设计流程
总体流程
如流程图所示:
3.1 文件信息校验
这一块的逻辑处理实在index.js文件之中:
主要包括校验文件的基本信息,将本地选择的文件与用户定义的参数要求作比较,通过就可以执行下一步,不通过则回调错误信息。
let checkSize = function (example, size) {
if (size <= example.maxSize) {
return true;
}
example.onError({
data: {
en: 'The file exceeds the maximum limit',
ch: '文件大小超出了最大限制'
},
code: 5
})
}
let checkExtensions = function (example, type) {
if ((example.extensions.includes(type.split('/').slice(-1)) && type) || example.extensions === '') {
return true;
}
example.onError({
data: {
en: 'File type mismatch',
ch: '文件类型不匹配'
},
code: 6
})
}
3.2 用户上传的模式可以分为自动上传与用户点击上传按钮手动触发,两种上传的方式,通过用户初始化实例设置 autoUpload的值来区别:
autoUpload | 效果 |
---|---|
true | 选择文件之后自动调用上传文件的方法 |
false | 调用者需要自己定义一个按钮作为触发文件上传的开关 |
调用者在初始化实例时需要规定上传是哪一种模式
代码示例:
<template>
<div class="main">
<input id="selecter" type="file" />
<el-progress :percentage="md5Progress"></el-progress>
<el-progress :percentage="uploadProgress"></el-progress>
<input id="selecter1" type="file" />
<el-button @click="SliceUploader1.doUpload()">上传</el-button>
<el-progress :percentage="md5Progress1"></el-progress>
<el-progress :percentage="uploadProgress1"></el-progress>
</div>
</template>
<script>
import upload from '@/plugins/upLoad/index';
export default {
name: 'Hello',
data() {
return {
SliceUploader: {},
SliceUploader1: {},
md5Progress: 0,
uploadProgress: 0,
md5Progress1: 0,
uploadProgress1: 0,
};
},
computed: {
state() {
return this.SliceUploader.UploadState;
},
},
mounted() {
const opt = {
autoUpload: true, // 是否自动上传
id: 'selecter', // 绑定的dom元素ID
onSuccess: this.onSuccess, // 上传成功的回调方法
onMD5Progress: this.onMD5Progress, // 处理MD5进度条过程回调
onUploadProgress: this.onUploadProgress, // 上传文件进度条回调
onStartUpload: this.startUpload, // 自定义开始上传方法
onError: this.onError, // 错误处理回调
onFileinQueue: this.onFileinQueue, // MD5开始之前文件进入队列回调
maxSize: 600 * 1024 * 1024, // 文件上传最大限制
extensions: 'mp4', // 文件上传后缀
chunkLimit: 1, // 上传并发数
chunkSize: 5 * 1024 * 1024, // 文件切片(分片)大小
mimeType: 'video/mp4', // 息内容类型的因特网标准
};
const opt1 = {
autoUpload: false,
id: 'selecter1',
onSuccess: this.onSuccess1,
onMD5Progress: this.onMD5Progress1,
onUploadProgress: this.onUploadProgress1,
onStartUpload: this.startUpload1,
onError: this.onError1,
onFileinQueue: this.onFileinQueue1,
maxSize: 600 * 1024 * 1024,
extensions: 'mp4',
chunkLimit: 1,
chunkSize: 5 * 1024 * 1024,
mimeType: 'video/mp4',
};
this.$nextTick(() => {
this.SliceUploader = upload.init(opt);
this.SliceUploader1 = upload.init(opt1);
});
},
methods: {
onSuccess(res) {
console.log(res);
},
onUploadProgress(file) {
this.uploadProgress = file.percentage;
},
onMD5Progress(file) {
this.md5Progress = file.MD5Progress;
},
onError(err) {
console.log(err);
},
startUpload(file) {
console.log(file);
},
onFileinQueue(file) {
console.log(file);
// return false;
},
onSuccess1(res) {
console.log(res);
},
onUploadProgress1(file) {
this.uploadProgress1 = file.percentage;
},
onMD5Progress1(file) {
this.md5Progress1 = file.MD5Progress;
},
onError1(err) {
console.log(err);
},
startUpload1(file) {
console.log(file);
},
onFileinQueue1(file) {
console.log(file);
},
uploadFileFun() {
this.SliceUploader1.doUpload();
},
},
};
</script>
<style lang="less" scoped>
.main {
text-align: center;
}
.fz100 {
font-size: 100px;
}
</style>
效果如下:
3.3 文件上传校验
verifyUpload方法判断本地选择的文件是否再远端已经存在
状态 | 执行 |
---|---|
全部已经上传 | 实现秒传 |
部分已经上传 | 未上传的切片就绪,准备上传 |
全部未上传 | 准备上传所有切片 |
3.4 上传
3.4.1 handleResume
引入promiseLimit,限制并发的数量,notuploadindexsArr存入没有上传的切片,调用uploadChunks方法实现切片上传:
- 初始化limit实例,this.chunkLimit是指系统限制的个数,默认为4,用户也可以自定义
- 首先采用verifyUpload判断本地选择的文件是否再远端已经存在
- 如果已经存在的话,带给用户“秒传”的效果
- 不存在的情况下引入promiseLimit,限制并发的数量,将notuploadindexsArr存入没有上传的切片,调用uploadChunks方法实现切片上传
3.4.2 uploadChunks
async function uploadChunks(notuploadArr = this.data) {
const requestList = notuploadArr
.map(({ file, index }) => {
const formData = new FormData();
formData.append("chunks", this.data.length);
formData.append("file", file, this.container.file.name);
formData.append("currentChunk", index);
formData.append("chunkSize", file.size);
Object.keys(this.params).forEach(key => {
formData.append(key, this.params[key]);
});
return { formData, index };
})
.map(({ formData, index }) => {
return doUploadChunk.call(this, formData, index);
});
Promise.all(requestList).then((res) => {
if (this.notUploadNumber === 0) {
uploadByTempPath.call(this, JSON.parse(res[res.length - 1].data).datas.fullname)
} else {
this.onError({ data: 'Some files failed to upload in pieces', code: 4 })
}
//所有上传完成时进入这里
})
};
- 遍历切片列表,通过doUploadChunk创建发送上传切片请求的Promise实例,组成一个Promise实例列表requestList;
- Promise.all将requestList中的多个Promise实例包装成一个新的Promise。
- 所有请求完成后进入uploadByTempPath方法,后端进行切片合并,并触发onSuccess上传成功的回调
3.4.3 doUploadChunk
function doUploadChunk(formData, index) {
return limit(() => {
return request.call(this, {
url: this.uploadURL,
method: "POST",
data: formData,
onProgress: createProgressHandler.call(this, this.data[index]),
requestList: this.requestList // 重点
});
});
};
① 根据limit的限制,实现多任务批量上传切片,limit是在handleResume中定义
② 调用request创建了调用上传切片的请求的promise实例
3.4.4 request 发送请求
function request({
url,
method = "post",
data,
headers = {},
onProgress = e => e,
requestList
}) {
return new Promise(resolve => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = onProgress;
xhr.open(method, url);
Object.keys(headers).forEach(key => {
xhr.setRequestHeader(key, headers[key]);
});
xhr.send(data);
xhr.onload = e => {
this.onUploadProgress(uploadPercentage.call(this));
if (requestList) {
// xhr 使命完成
const xhrIndex = requestList.findIndex(item => {
return item === xhr;
});
requestList.splice(xhrIndex, 1);
if (e.target.status === 200) this.notUploadNumber--;
}
if (e.target.status !== 200 && !e.target.responseURL.includes(this.checkURL)) {
this.onError({ data: e.target.response, code: 2 });
}
resolve({
data: e.target.response
});
};
if (requestList) {
requestList.push(xhr); // 每个请求
}
});
};
① 创建并返回一个Promise实例。
② 根据传入的参数,创建xhr实例,并将其压入requestList
③ 为xhr.upload.onprogress赋值onProgress,用于创建进度条,以及触发上传进度条值改变时的回调。
④ 请求完成时触发xhr.onload。
3.4.5 成功检验上传
调用接口,如果返回信息显示上传完毕,进度条的展示加载到100(前一步上传完切片最大展示为99%),并且回调onSuccess。
async function uploadByTempPath(fullname) {
const formData = new FormData();
let arr = fullname.split('/')
arr = arr[arr.length - 1].split('.')
arr.pop();
let fileName = arr.join('')
formData.append("tempPath", fullname);
formData.append("fileName", fileName);
formData.append("mrEnable", 0);
let msg = await request.call(this, {
url: './bigdata/mr/personal/2/uploadByTempPath?',
method: "POST",
data: formData,
headers: {
"token": TOKEN.fetch() || ''
}
});
setDatas.call(this, 'percentage', 100)
this.container.file.successRes = msg
if(this.onSuccess(this.container.file)===false) return;
};
4 关键问题
4.1 如何获取进度条数据
(1)MD5处理的进度获取
- 获取MD5值我们使用的是spark-md5资源,通过传入的文件参数以及切片大小的值,遍历文件,最后获得文件的MD5值,这样没遍历一次就会有一条“伪进度”作为回调数值返回给调用者。
- MD5获取完毕之后,赋值到变量,以便后续文件上传接口参数的使用,此时回调进度之为100%
(2)文件上传的进度获取
在拿到 xhr.onload的回调信息之后,执行onUploadProgress回调方法,将进度信息回执给调用者。
给xhr.upload.onprogress经过了onProgress绑定createProgressHandler追踪事件
- 绑定xhr.upload.onprogress
- 得到上传进度的数值方法:
function createProgressHandler(item) {
return e => {
if(this.onUploadProgress(uploadPercentage.call(this))===false) return;
item.percentage = parseInt(String((e.loaded / e.total) * 100));
};
};
4.2 如何实现文件切片
function createFileChunk(file, size = this.chunkSize) {
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
fileChunkList.push({ file: file.slice(cur, cur + size) });
cur += size;
}
return fileChunkList;
};
4.3 如何实现文件切片上传的并发数量限制
这里为了解决文件体积过大的情况下,切片数量过多,会出现并发数目过多而导致浏览器卡顿、甚至浏览器崩溃的情况;所以我们采用了promise-limit依赖包方法来限制并发数量,实现的效果:
- 并发请求限制,n个请求,每次最多只能同时存在limit个请求,剩余的在队列中等待。
- promiseAll 实现并发请求,n个请求,每次最多同时请求limit个,所有请求完成后处理数据。
引入promise-limit
let promiseLimit = require('promise-limit')
初始化定义一个limit变量
var limit = promiseLimit(this.chunkLimit);
ps: chunkLimit是我们在初始化示例的时候定义的。
在执行每一步切片上传的时候
function doUploadChunk(formData, index) {
return limit(() => {
return request.call(this, {
url: this.uploadURL,
method: "POST",
data: formData,
onProgress: createProgressHandler.call(this, this.data[index]),
requestList: this.requestList // 重点
});
});
};
附:
关于更多的promise-limit相关文档参考:
https://www.npmjs.com/package/promise-limit
4.4 判断文件是否已经上传以及秒传的实现
- 首先采用verifyUpload判断本地选择的文件是否再远端已经存在,如果已经存在的话,带给用户“秒传”的效果
- 不存在的情况下引入promiseLimit,限制并发的数量,将notuploadindexsArr代表的没有上传调用uploadChunks实现切片上传
4.5 断点续传
- 断点续传与秒传采用的就是同一个接口,通过接口获得还没有上传的文件切片的“序号”,然后将没有上传的文件切片置入就绪队列,开始上传剩下的切片。
4.6 MD5的作用
前面我们多次提及到MD5,其作用如下:
- 文件分片上传需要一个唯一标识符来判断文件的标志,MD5刚好可以作为这一个参数。对于不同的文件,MD5在几乎都是不同的(不排除一些实在特殊的情况之下)
- 文件上传完毕以后远端会重新将上传的切片拼接,拼接之后再次获得新的MD5值与我们传递的MD5值对比,若是一致的则说明上传正确。
5.效果预览
5.1 git地址(vue示例版本)
https://git.trscd.com.cn/cdtrs/dev/03_super_star/tianMuYun/fileuploadmodule
5.2 在线实装地址(angularjs版本)
目前已将石榴云产品版本的视频上传底层更换为这个插件,在线地址为:
https://sly.trscd.com.cn/xjmediaCloud/#/fusionvideo?iwoplatform=0&wzchannelid=2&wzsiteid=1&wzplatform=0&appchannelid=1&appsiteid=1&appplatform=0&newspapertype=2&paperid=4&issave=0
账号/密码请咨询石榴云开发人员