照片由作者摄于四川黄龙风景区,海拔4000米
前言
最近工作中遇到了一个需求:文件上传,这算是后台系统中很常见的一个功能了。由于项目是React的,我想都没想便用antd的Upload组件很快速的完成了,直到有一天公司服务器崩了,带宽不够,上传大文件时疯狂转圈圈,由此,我才发现之前做的时候考虑得非常片面。 对于大文件的上传,利用spark-md5生成文件的hash值,进行切片上传与断点续传,这也是很常见的一个处理方式。
为什么分片上传
首先,我们得了解一下文件上传中要做的几件事:
- 获取文件
- 判断文件类型及大小
- 获取请求地址
- 往服务器端推送文件
其次,以antd的Upload组件为例,文件的上传只进行了一次请求,请求的主体是FormData。当文件比较小的时候,请求成功率高且速度快;当文件比较大的时候,会导致上传过程中耗时过久,大量占用带宽资源,则很有可能出现上传失败的情况。 所以,对于大文件的上传,我们不能一次性将这些数据全部发送出去,这样会浪费网络资源,且上传速度过于缓慢,影响交互体验,对此,分片上传结合断点续传是一种解决方案。
分片上传的原理
分片上传,也就是将文件切割成若干文件片段,按次序一个个上传,服务器端也是一个个接收,并存储在一个临时文件夹中,等全部分片上传完毕之后,客户端再发起合并分片的请求,由服务器端合并分片,得到一个完整的文件,并删除之前的分片文件夹,返回文件hash值给客户端,以示上传成功。 通常情况下,File对象是由用户在input元素上选择文件后返回的fileList对象 每个file对象包含:
这边穿插一个基于原生JS+Node实现的单文件上传demo,主要为了帮助大家回顾一下基于FormData实现的文件上传,可以直接跳到
如何分片:
<div class="item">
<h3>单一文件上传「FORM-DATA」</h3>
<section class="upload_box" id="upload1">
<!-- accept=".png" 限定上传文件的格式 -->
<input type="file" class="upload_inp" accept=".png,.jpg,.jpeg">
<div class="upload_button_box">
<button class="upload_button select">选择文件</button>
<button class="upload_button upload">上传到服务器</button>
</div>
<div class="upload_tip">只能上传 PNG/JPG/JPEG 格式图片,且大小不能超过2MB</div>
<ul class="upload_list">
<!-- <li>
<span>文件:...</span>
<span><em>移除</em></span>
</li> -->
</ul>
</section>
</div>
//基于FormData实现的文件上传
(function () {
//1.获取要用的DOM元素
let upload = document.querySelector('#upload1'),
upload_inp = upload.querySelector('.upload_inp'),
upload_button_select = upload.querySelector('.upload_button.select'),
upload_button_upload = upload.querySelector('.upload_button.upload'),
upload_tip = upload.querySelector('.upload_tip'),
upload_list = upload.querySelector('.upload_list');
//将第3步中获取到的file对象,提取出来作为该闭包的全局对象
let _file = null;
//2.点击选择文件按钮,触发上传文件的input框选择文件的行为
upload_button_select.addEventListener('click',function(){
//7.如果当前按钮处于不可用状态,则不进行后续处理
if(upload_button_select.classList.contains('disable') || upload_button_upload.classList.contains('loading')){
return;
}
upload_inp.click();
});
//3.监听用户选择文件的操作
upload_inp.addEventListener('change',function(){
//如何获取用户选中的文件:upload_inp.files,此处为单文件上传,所以只获取数组第一项
console.log('fileList',upload_inp.files);
let file = upload_inp.files[0];
if(!file) return;
/**
* file对象:
* -name:文件名
* -size:文件大小
* -type:文件类型
*/
//3.1控制文件上传的格式
//方案一:基于JS->限制文件上传的格式,如果file的type中不包含这几个类型之一,则代表上传的类型不符合这三种
// if(!/(PNG|JPG|JPEG)/i.test(file.type)){
// alert('上传的文件只能是 PNG/JPG/JPEG 三种类型');
// }
//方案二:在input的accept属性里面进行限制,<input type="file" class="upload_inp" accept=".png,.jpg,.jpeg">
//3.2.限制文件上传的大小
if(file.size > 2 * 1024 * 1024){
alert('上传的文件大小不能超过2MB');
return;
}
//确定文件符合规范后,转存到_file变量中,方便其他方法使用
_file = file;
//3.3显示上传的文件
upload_tip.style.display = 'none'; //隐藏文件提示
upload_list.style.display = 'block'; //展示文件列表
upload_list.innerHTML = `<li>
<span>文件:${file.name}</span>
<span><em>移除</em></span>
</li>`;
});
/**
* 4.实现移除方法
* 根据冒泡机制原理,当我们触发移除这个DOM的点击事件时,会逐层冒泡到其祖先元素上
* 为了更便捷的处理,我们可以直接监听其祖先元素file_list的点击事件,这种操作一般我们称为事件委托
* 此处用事件委托进行处理,还有几个原因是:
* -em这个DOM是我们动态添加的,也就是说一开始初始化的时候并没有这个DOM
* -后续对于多个文件上传的功能,会存在多个em,而为每个em都绑定click事件监听并不合理
*/
upload_list.addEventListener('click',function(e){
let target = e.target;
if(target.tagName === 'EM'){ //判断点击的是不是移除按钮
//移除文件并切换DOM显示状态
handleClear();
}
});
//将清空文件,即按需展示元素的功能封装起来,以便移除方法和上传成功与失败时执行
const handleClear = () =>{
_file = null; //清除文件
upload_tip.style.display = 'block'; //显示文件提示
upload_list.style.display = 'none'; //隐藏文件列表
upload_list.innerHTML = ``; //清空列表
}
/**
* 5.使用axios发请求,上传文件到服务器
*/
upload_button_upload.addEventListener('click',function(){
//8.如果当前按钮处于不可用状态,则不进行后续处理
if(upload_button_select.classList.contains('disable') || upload_button_upload.classList.contains('loading')){
return;
}
if(!_file){ //直接点击上传,没有点击上传文件
alert('请先选择要上传的文件');
return;
}
//6.1开始上传,则让按钮变为不可操作的状态
handleDisable(true);
/**
* 把文件传递给服务器的两种格式:
* -formData(此处用formData)
* -Base64
* 结合服务端提供的api文档配置请求:(上传成功的文件在服务器端项目的upload文件夹中可以查看)
* 1.单文件上传处理「FORM-DATA」:由服务器自动生成文件的名字
url:/upload_single
method:POST
params:multipart/form-data
file:文件对象
filename:文件名字
return:application/json
code:0成功 1失败,
codeText:状态描述,
originalFilename:文件原始名称,
servicePath:文件服务器地址
*/
let formData = new FormData();
formData.append('file',_file);
formData.append('filename',_file.name);
//instance是在instance.js文件中通过axios库创建的一个实例对象,进行了二次封装
instance.post('/upload_single',formData).then(data=>{
//此处data即为response.data,因为已提前在instance配置项中使用axios的拦截器interceptors,帮助我们过滤掉不需要的数据,只留下主体信息data
if(+data.code === 0){ //code强制转换为number类型
alert(`上传成功,可在 ${data.servicePath} 中查看 `);
return;
}
return Promise.reject(data.codeText); //返回一个新的Promise对象,状态是rejected,在catch中捕获
}).catch(reason=>{
alert('文件上传失败,请稍后再试');
}).finally(()=>{ //不论成功或者失败,最后都会执行的函数
//移除文件并切换DOM显示状态
handleClear();
//6.2上传结束,恢复按钮的可操作功能
handleDisable(false);
});
});
})();
顺便简单看一下服务器端是如何处理的,不是本文重点,简单了解:
// 延迟函数
const delay = function delay(interval) {
typeof interval !== "number" ? interval = 1000 : null;
return new Promise(resolve => {
setTimeout(() => {
resolve();
}, interval);
});
};
// 基于multiparty插件实现文件上传处理 & form-data解析
const uploadDir = `${__dirname}/upload`;
/**
*
* @param {*} req 客户端传递的信息
* @param {*} auto 是否允许插件自动对文件进行处理
* @returns
* multiparty的缺点:无法对重复图片进行处理
*/
const multiparty_upload = function multiparty_upload(req, auto) {
typeof auto !== "boolean" ? auto = false : null;
let config = {
maxFieldsSize: 200 * 1024 * 1024, //限制文件大小为200MB
};
//如果设为自动处理,会自动把文件存放到uploadDir定义的目录结构下
if (auto) config.uploadDir = uploadDir;
return new Promise(async (resolve, reject) => {
await delay(); //为了测试效果更明显,手动加了个延迟函数,默认值是1s
//multiparty插件提供一个Form方法,用来根据config配置项,把req里面传递过来的formData数据进行解析parse
new multiparty.Form(config)
.parse(req, (err, fields, files) => {
if (err) {
reject(err); //通过reject()方法将Promise状态设置为rejected
return;
}
//解析成功:
resolve({
fields, //传过来的filename
files //传过来的file
});
//通过插件处理,会把传过来的文件名称处理成特殊值,避免多客户端传递同名文件
});
});
};
// 单文件上传处理「FORM-DATA」
/**
* req:request客户端传过来的信息
* res:response对象提供了一些方法让我们可以给客户端返回一些信息
*/
app.post('/upload_single', async (req, res) => {
try {
let {
files
} = await multiparty_upload(req, true);
let file = (files.file && files.file[0]) || {};
res.send({
code: 0,
codeText: 'upload success',
originalFilename: file.originalFilename,
//将服务器中存储文件的地址返回
servicePath: file.path.replace(__dirname, HOSTNAME)
});
} catch (err) {
//上传失败
res.send({
code: 1,
codeText: err
});
}
});
PS:以上源码,已包含在我的git项目中,本文结尾提供了git地址。
回到本文的重点,文件分片的核心是利用:file.prototype.slice()方法 file本身没有slice()方法,熟悉JS的朋友会知道,在对象身上有prototype属性,里面是它继承自父类的东西,file从Blob接口继承了slice()方法。
file.slice(a,b); //代表从a位置切到b位置
而对于文件进行切片,还得思考几个问题:
- 每个文件都需要切片吗?
- 切片的标准是什么?固定大小还是固定数量?
结论是:
- 对于比较小的文件,就没必要分片;超过了某个指定大小才进行分片。
- 切片的标准是:先以固定大小为主,如果获得的切片数量很大,超过了限制的数量,则转为固定数量。
如何分片并赋予分片hash值
举个栗子: 我们要上传一个文件大小为101MB,固定的切片大小为1MB,则按固定大小会被切成101片,而我们要求最多不能超过100片,也就是固定数量为100片,那可以通过判断进行处理,当切片数量>100时,直接用文件大小 / 100,得到每个切片的大小。
let max = 1* 1024 * 1024; //每个切片最大1MB
let count = Math.ceil(file.size / max); //向上取整,获取切片数量
if(count > 100){ //如果切片数量超过100个,则设为100个,固定大小和固定数量的结合
max = file.size / 100;
count = 100;
}
当我们把一个文件切成很多切片,每个切片都需要一个名字。首先,我们根据文件内容生成一个hash值,那每个切片的名字就是hash值_第几个切片.文件后缀,最后合成的文件夹名字就是hash。之后,我们只需要发送hash值给服务端,服务端就知道把哪些切片合成文件。
那么,如何获取文件的hash值呢? 首先我们得获取到这个文件的md5,即通过某种hash算法,得这个文件独一无二的hash值,对于不同名的文件,内容相同的情况下,它们的hash值也是相同的,这个hash值也常用于后端用来判断该文件是否已上传,以减轻服务器端存储资源的压力。
使用js-spark-md5计算文件的md5
npm install --save spark-md5
封装一个方法:该方法传递一个file进去,利用FileReader把获取到的文件数据读取为arrayBuffer字节数组,由于FileReader进行读取和转换的过程是异步的,所以需要在onLoad()中进行后续操作,通过spark.end()方法拿到该hash值。
//file->Buffer
const changeToBuffer = file =>{
return new Promise(resolve =>{
let fileReader = new FileReader();
//读取成Buffer类型
fileReader.readAsArrayBuffer(file);
fileReader.onload = e =>{
let buffer = e.target.result,
spark = new SparkMD5.ArrayBuffer(),
hash;
spark.append(buffer);
hash = spark.end(); //spark拿到buffer处理成hash
suffix = /\.([A-z]+)$/.exec(file.name)[1]; //在文件里面获取文件名,即以.xxx结尾的部分
//最终生成的是hash加上文件名后缀
resolve({
buffer,
hash,
suffix,
filename: `${hash}.${suffix}`
})
}
})
}
在分片过程中,给予每个分片一个唯一的标识,也就是:文件的hash值_index.文件后缀
let index = 0; //初始位置0
let chunks = []; //存放所有的切片
while(index < count){
/**
* index为0:0 ~ max
* index为1:max ~ max+max / max*2
* index为2: max*2 ~ max*3
* ...
* index*max ~ (index+1)*max
*/
chunks.push({
file: file.slice(index*max, (index+1)*max),
filename: `${HASH}_${index+1}.${suffix}`
})
index++;
}
断点续传
- 为什么要断点续传?
比如一开始切成10片,只有7个传成功了,需要服务器提供一个接口,告诉我当前已经上传成功的切片,那我们只需要上传剩下的切片。或者,我们依旧上传全部切片给服务器,由服务器自行判断当前切片是否已经存在,并返回给客户端。 举个栗子: 后端同学提供一个already_upload接口,接收参数为文件的hash值,返回数据类型如下:
data:['xxx_1.png','xxx_2.png','xxx_3.png'...]
前端只需上传剩余切片,已经上传的切片无需再上传
如何上传
循环遍历分片的数组,发起请求:
//把每个切片都上传到服务器上
chunks.forEach(chunk=>{
//已经上传的切片无需再上传
//already为后端返回的已经上传的切片数据
if(already.length > 0 && already.includes(chunk.filename)){
complete(); //另外封装的方法,用来操作进度条,并在全部分片上传完毕后,发起合并分片的请求
return;
}
let fm = new FormData; //以FormData的数据形式进行上传
fm.append('file',chunk.file);
fm.append('filename',chunk.filename);
//instance为二次封装后的axios
instance.post('/upload_chunk',fm).then(data=>{
if(+data.code === 0){
//当前切片上传成功
complete();
return;
}
return Promise.reject(data.codeText); //返回一个失败状态的Promise对象
}).catch(err=>{
alert('当前切片上传失败');
clear(); //另外封装的方法,用来处理页面DOM元素的loading状态及进度条状态等
})
})
总结
- 使用file.slice()对文件进行切片
- 使用spark-md5计算文件hash值,并给予每个切片唯一标识
- 上传切片,并在所有切片上传成功后,发起合并切片的请求
- 可以监听axios的onUploadProgress方法,获取文件上传的进度(loaded/total)
- 如果存在已上传的切片,则进行续传
以上是前端基于原生JS和Node实现的大致思路,主要是为了自己做个记录,也希望能对大家有些帮助。当然,还有很多没有考虑到的方面及更好的解决方案,比如采用Web Workers进行性能优化,对Web Workers感兴趣的小伙伴可以看看阮一峰老师的日志
源码
参考链接: