文件上传之剪切板上传及大文件分片上传和断点续传

前言

文件上传是开发中经常遇到的,市面上也有很多插件。直接封装了上传的方法,使用起来很简单。使用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);
    }

服务端代码基本不变。
写到这里,基本把断点续传和大文件上传都写了。