35

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

 4 years ago
source link: http://www.haorooms.com/post/file_image_upload
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

前言

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

选择文件:
        <input type="file" id="haoroomsFileinput"/><br/><br/>
        <div id="progress">
            <span class="red"></span>
        </div>
    <button type="button" id="btn-submit">上 传</button>

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< chunkCount;i++){
        var haoroomsfd = new FormData();   //构造FormData对象
        haoroomsfd.append('token', token);
        haoroomsfd.append('haoroomsFileinput', chunks[i]);
        haoroomsfd.append('index', i);
        xhrSend(haoroomsfd,function () {
            sendChunkCount+=1;
            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);
            }
        });
    }
}
function xhrSend(fd,cb) {
    var xhr = new XMLHttpRequest();   //创建对象
    xhr.open('POST', 'http://haorooms.com:8100/fileupload', true);

    xhr.onreadystatechange = function () {
        console.log('state change', xhr.readyState);
        if (xhr.readyState == 4) {
            console.log(xhr.responseText);
            cb && cb();
        }
    }
    function updateProgress(event) {
        console.log(event);
        if (event.lengthComputable) {
            var completedPercent = (event.loaded / event.total * 100).toFixed(2);
            progressSpan.style.width = completedPercent + '%';
            progressSpan.innerHTML = completedPercent + '%';
            if (completedPercent > 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);
    }

服务端代码基本不变。

写到这里,基本把断点续传和大文件上传都写了。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK