文件上传之剪切板上传及大文件分片上传和断点续传
前言
文件上传是开发中经常遇到的,市面上也有很多插件。直接封装了上传的方法,使用起来很简单。使用vue和react技术栈的同学,都使用了element和antd的上传,因此,拖拽上传和一般上传,今天这篇文章不做解释。今天主要总结一下剪切板上传和大文件分片上传及断点续传的内容。
一、剪切板上传
剪切板上传就是复制电脑上的图片或者文件,或者网络中的在线图片,然后粘贴到指定位置上传的方式。
关于剪切板,我之前文章有介绍过: https://www.haorooms.com/post/js_focus_position_copy
假如对光标位置和剪切板复制不清楚的同学,可以看这篇文章。
前台可以这么写:
var haoroomsbox = document.getElementById('haoronms-edit'); haoroomsbox.addEventListener('paste',function (event) { var data = (event.clipboardData || window.clipboardData); var items = data.items; var fileList = [];//存储文件数据 if (items && items.length) { // 检索剪切板items for (var i = 0; i < items.length; i++) { fileList.push(items[i].getAsFile()); } } window.willUploadFileList = fileList; event.preventDefault(); submitUpload(); }); function submitUpload() { var fileList = window.willUploadFileList||[]; if(!fileList.length){ console.log('当前无粘贴文件'); return; } var haoroomsformData = new FormData(); //构造FormData对象 for(var i =0;i<fileList.length;i++){ haoroomsformData.append('filename', fileList[i]);//支持多文件上传 } // http请求,当然你也可以用第三方的axios等 var xhr = new XMLHttpRequest(); //创建对象 xhr.open('POST', 'http://haorooms.com:8100/fileupload', true); xhr.onreadystatechange = function () { if (xhr.readyState === 4) { var obj = JSON.parse(xhr.responseText); //返回值 if(obj.fileUrl.length){ var img = document.createElement('img'); img.src= obj.fileUrl[0]; img.style.width='100px';//这里可以自定义图片宽度,也可以不用写 insertNodeToEditor(box,img); // alert('上传成功'); } } } //注意 send 一定要写在最下面,否则 onprogress 只会执行最后一次 也就是100%的时候 xhr.send(haoroomsformData);//发送时 Content-Type默认就是: multipart/form-data; } //光标处插入 dom 节点 function insertNodeToEditor(editor,ele) { //插入dom 节点 var range;//记录光标位置对象 var node = window.getSelection().anchorNode; // 这里判断是做是否有光标判断,因为弹出框默认是没有的 if (node != null) { range = window.getSelection().getRangeAt(0);// 获取光标起始位置 range.insertNode(ele);// 在光标位置插入该对象 } else { editor.append(ele); } }
后端以Koa为例
var app = new Koa(); var port = process.env.PORT || '8100'; var uploadHost= `http://localhost:${port}/uploads/`; app.use(koaBody({ formidable: { //设置文件的默认保存目录,不设置则保存在系统临时目录下 uploadDir: path.resolve(__dirname, '../static/uploads') }, multipart: true // 支持文件上传 })); app.use(koaStatic( path.resolve(__dirname, '../static') )); //允许跨域 app.use(async (ctx, next) => { ctx.set('Access-Control-Allow-Origin', ctx.headers.origin); ctx.set("Access-Control-Max-Age", 864000); // 设置所允许的HTTP请求方法 ctx.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST"); // 字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段. ctx.set("Access-Control-Allow-Headers", "x-requested-with, accept, origin, content-type"); await next(); }) //二次处理文件,修改名称 app.use((ctx) => { console.log(ctx.request.files); var files = ctx.request.files.f1;//得到上传文件的数组 var result=[]; console.log(files); if(!Array.isArray(files)){//单文件上传容错 files=[files]; } files && files.forEach(item=>{ var path = item.path.replace(/\\/g, '/'); var fname = item.name;//原文件名称 var nextPath = path + fname; if (item.size > 0 && path) { //得到扩展名 var extArr = fname.split('.'); var ext = extArr[extArr.length - 1]; var nextPath = path + '.' + ext; //重命名文件 fs.renameSync(path, nextPath); result.push(uploadHost+ nextPath.slice(nextPath.lastIndexOf('/') + 1)); } }); ctx.body = `{ "fileUrl":${JSON.stringify(result)} }`; })
二、大文件上传
大文件上传其实就是将一个大文件拆分成多个小文件再上传。
我之前文件有讲过Blob,二进制数据,提供了slice,而file继承了Blob的功能,光晕blob相关文章,请看: https://www.haorooms.com/post/js_blobdownload
思路步骤
把大文件进行分段 比如2M,发送到服务器携带一个标志,暂时用当前的时间戳,用于标识一个完整的文件
服务端保存各段文件
浏览器端所有分片上传完成,发送给服务端一个合并文件的请求
服务端根据文件标识、类型、各分片顺序进行文件合并
删除分片文件
例如代码如下:
html
选择文件:
js代码
//思路概括 //把大文件分成每2m 一块进行上传,发送到服务器同时携带一个标志 暂时用当前的时间戳 , //服务端生成临时文件,服务端接受一个文件结束的标志 ,然后将所有的文件进行合并成一个文件,清理临时文件。 返回结果(看情况) function submitUpload() { var chunkSize=2*1024*1024;//2m var progressSpan = document.getElementById('progress').firstElementChild; var file = document.getElementById('haoroomsFileinput').files[0]; var chunks=[], token = (+ new Date()), name =file.name,chunkCount=0,sendChunkCount=0; progressSpan.style.width='0'; progressSpan.classList.remove('green'); if(!file){ alert('请选择文件'); return; } //拆分文件 if(file.size>chunkSize){ //拆分文件 var start=0,end=0; while (true) { end+=chunkSize; var blob = file.slice(start,end); console.log() start+=chunkSize; if(!blob.size){ //拆分结束 break; } chunks.push(blob); } }else{ chunks.push(file.slice(0)); } console.log(chunks); chunkCount=chunks.length; //没有做并发限制,较大文件导致并发过多,tcp 链接被占光 ,需要做下并发控制,比如只有4个在请求在发送 for(var i=0;i 90) {//进度条变色 progressSpan.classList.add('green'); } console.log('已上传', completedPercent); } } //注意 send 一定要写在最下面,否则 onprogress 只会执行最后一次 也就是100%的时候 xhr.send(haoroomsfd);//发送时 Content-Type默认就是: multipart/form-data; } //绑定提交事件 document.getElementById('btn-submit').addEventListener('click',submitUpload);
服务端代码,对上面做了一些改进
//二次处理文件,修改名称 app.use((ctx) => { console.log(ctx.request.files); var body = ctx.request.body; var files = ctx.request.files ? ctx.request.files.haoroomsFileinput:[];//得到上传文件的数组 var result=[]; var fileToken = ctx.request.body.token;// 文件标识 var fileIndex=ctx.request.body.index;//文件顺序 if(files && !Array.isArray(files)){//单文件上传容错 files=[files]; } files && files.forEach(item=>{ var path = item.path.replace(/\\/g, '/'); var fname = item.name;//原文件名称 var nextPath = path.slice(0, path.lastIndexOf('/') + 1) + fileIndex + '-' + fileToken; if (item.size > 0 && path) { //得到扩展名 var extArr = fname.split('.'); var ext = extArr[extArr.length - 1]; //var nextPath = path + '.' + ext; //重命名文件 fs.renameSync(path, nextPath); result.push(uploadHost+ nextPath.slice(nextPath.lastIndexOf('/') + 1)); } }); ctx.body = `{ "fileUrl":${JSON.stringify(result)} }`; if(body.type==='merge'){ //合并文件 var filename = body.filename, chunkCount = body.chunkCount, folder = path.resolve(__dirname, '../static/uploads')+'/'; var writeStream = fs.createWriteStream(`${folder}${filename}`); var cindex=0; //合并文件 function fnMergeFile(){ var fname = `${folder}${cindex}-${fileToken}`; var readStream = fs.createReadStream(fname);// 运用了createReadStream readStream.pipe(writeStream, { end: false }); readStream.on("end", function () { fs.unlink(fname, function (err) { if (err) { throw err; } }); if (cindex+1 < chunkCount){ cindex += 1; fnMergeFile(); } }); } fnMergeFile(); ctx.body='merge ok 200'; } });
三、大文件断点续传
大文件分片上传我们已经实现了,那么断点续传,就是在断网的情况下,继续上传。和大文件分片上传相比,断网下次上传的时候,我们仅仅需要知道哪些上传了,哪些没有上传就可以了,
对于已经上传的文件,我们可以提供2中方式,一种是每个文件生成一个hash,存在本地,另一种是这个hash存在服务端,通过接口请求获取。
为了不出问题,我们可以存放到服务端。
简单起见,我们先讲下如何存在本地。通过获取本地文件hash的方式来续传。
代码如下( 对上面分片上传做了改造):
var saveChunkKey = 'haoroomschunkuploadedObj';//定义 key //获得本地缓存的数据 function getUploadedFromStorage(){ // 服务端存储更安全,可以通过调用接口的方式获取文件key,这里可以写获取接口的方法getUploadedFromServer(fileHash) return JSON.parse( localStorage.getItem(saveChunkKey) || "{}"); } //写入缓存 function setUploadedToStorage(index) { var obj = getUploadedFromStorage(); obj[index]=true; localStorage.setItem(saveChunkKey, JSON.stringify(obj) ); } //分段对比 var uploadedInfo = getUploadedFromStorage();//获得已上传的分段信息 for(var i=0;i< chunkCount;i++){ // 参考上文 大文件分片上传的chunkCount console.log('index',i, uploadedInfo[i]?'已上传过':'未上传'); if(uploadedInfo[i]){//对比分段 sendChunkCount=i+1;//记录已上传的索引 continue;//如果已上传则跳过 } var haoroomsfd = new FormData(); //构造FormData对象 haoroomsfd.append('token', token); haoroomsfd.append('haoroomsFileinput', chunks[i]); haoroomsfd.append('index', i); (function (index) { xhrSend(fd, function () { sendChunkCount += 1; //将成功信息保存到本地 setUploadedToStorage(index); if (sendChunkCount === chunkCount) { console.log('上传完成,发送合并请求'); var formD = new FormData(); formD.append('type', 'merge'); formD.append('token', token); formD.append('chunkCount', chunkCount); formD.append('filename', name); xhrSend(formD); } }); })(i); }
服务端代码基本不变。
写到这里,基本把断点续传和大文件上传都写了。