上传攻略(转)

作者:zz_jesse
链接:https://juejin.im/post/5da14778f265da5bb628e590
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

原理概述

原理很简单,就是根据 http 协议的规范和定义,完成请求消息体的封装和消息体的解析,然后将二进制内容保存到文件。

我们都知道如果要上传一个文件,需要把 form 标签的enctype设置为multipart/form-data,同时method必须为post方法。

那么multipart/form-data表示什么呢?

multipart互联网上的混合资源,就是资源由多种元素组成,form-data表示可以使用HTML Forms 和 POST 方法上传文件,具体的定义可以参考RFC 7578。

multipart/form-data 结构

看下 http 请求的消息体

上传攻略(转)_第1张图片
image.png

  • 请求头:

Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDCntfiXcSkPhS4PN 表示本次请求要上传文件,其中boundary表示分隔符,如果要上传多个表单项,就要使用boundary分割,每个表单项由———XXX开始,以———XXX结尾。

  • 消息体- Form Data 部分

每一个表单项又由Content-TypeContent-Disposition组成。

Content-Disposition: form-data 为固定值,表示一个表单元素,name 表示表单元素的 名称,回车换行后面就是name的值,如果是上传文件就是文件的二进制内容。

Content-Type:表示当前的内容的 MIME 类型,是图片还是文本还是二进制数据。

解析

客户端发送请求到服务器后,服务器会收到请求的消息体,然后对消息体进行解析,解析出哪是普通表单哪些是附件。

可能大家马上能想到通过正则或者字符串处理分割出内容,不过这样是行不通的,二进制buffer转化为string,对字符串进行截取后,其索引和字符串是不一致的,所以结果就不会正确,除非上传的就是字符串。

不过一般情况下不需要自行解析,目前已经有很成熟的三方库可以使用。

至于如何解析,这个也会占用很大篇幅,后面的文章在详细说。

最原始的文件上传

使用 form 表单上传文件

ie时代,如果实现一个无刷新的文件上传那可是费老劲了,大部分都是用 iframe 来实现局部刷新或者使用 flash 插件来搞定,在那个时代 ie 就是最好用的浏览器(别无选择)。

DEMO

上传攻略(转)_第2张图片
image.png

这种方式上传文件,不需要 js ,而且没有兼容问题,所有浏览器都支持,就是体验很差,导致页面刷新,页面其他数据丢失。

HTML

 
选择文件: input 必须设置 name 属性,否则数据无法发送

标题:


复制代码

文件上传接口

服务端文件的保存基于现有的库koa-body结合 koa2实现服务端文件的保存和数据的返回。

在项目开发中,文件上传本身和业务无关,代码基本上都可通用。

在这里我们使用koa-body库来实现解析和文件的保存。

koa-body 会自动保存文件到系统临时目录下,也可以指定保存的文件路径。

上传攻略(转)_第3张图片
image.png

然后在后续中间件内得到已保存的文件的信息,再做二次处理。

  • ctx.request.files.f1 得到文件信息,f1input file 标签的 name
  • 获得文件的扩展名,重命名文件

NODE

/**
 * 服务入口
 */
var http = require('http');
var koaStatic = require('koa-static');
var path = require('path');
var koaBody = require('koa-body');//文件保存库
var fs = require('fs');
var Koa = require('koa2');

var app = new Koa();
var port = process.env.PORT || '8100';

var uploadHost= `http://localhost:${port}/uploads/`;

app.use(koaBody({
    formidable: {
        //设置文件的默认保存目录,不设置则保存在系统临时目录下  os
        uploadDir: path.resolve(__dirname, '../static/uploads')
    },
    multipart: true // 开启文件上传,默认是关闭
}));

//开启静态文件访问
app.use(koaStatic(
    path.resolve(__dirname, '../static') 
));

//文件二次处理,修改名称
app.use((ctx) => {
    var file = ctx.request.files.f1;//得道文件对象
    var path = file.path;
    var fname = file.name;//原文件名称
    var nextPath = path+fname;
    if(file.size>0 && path){
        //得到扩展名
        var extArr = fname.split('.');
        var ext = extArr[extArr.length-1];
        var nextPath = path+'.'+ext;
        //重命名文件
        fs.renameSync(path, nextPath);
    }
    //以 json 形式输出上传文件地址
    ctx.body = `{
        "fileUrl":"${uploadHost}${nextPath.slice(nextPath.lastIndexOf('/')+1)}"
    }`;
});

/**
 * http server
 */
var server = http.createServer(app.callback());
server.listen(port);
console.log('demo1 server start ......   ');
复制代码

CODE

github.com/Bigerfe/fe-…

多文件上传

ie 时代的多文件上传是需要创建多个 input file 标签,现在 html5只需要一个标签加个属性就搞定了,file 标签开启multiple

DEMO

上传攻略(转)_第4张图片
image.png

HTML

//设置 multiple属性
 
复制代码

NODE

服务端也需要进行简单的调整,由单文件对象变为多文件数组,然后进行遍历处理。

//二次处理文件,修改名称
app.use((ctx) => {

    var files = ctx.request.files.f1;// 多文件, 得到上传文件的数组
    var result=[];

    //遍历处理
    files && files.forEach(item=>{
        var path = item.path;
        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));
        }
    });

    //输出 json 结果
    ctx.body = `{
        "fileUrl":${JSON.stringify(result)}
    }`;
})
复制代码

CODE

github.com/Bigerfe/fe-…

局部刷新 - iframe

这里说的是在 ie 时代的上传文件局部刷新,借助 iframe 实现。

DEMO

上传攻略(转)_第5张图片
image.png
  • 局部刷新

页面内放一个隐藏的 iframe,或者使用 js 动态创建,指定 form 表单的 target 属性值为iframe标签 的 name 属性值,这样 form 表单的 shubmit 行为的跳转就会在 iframe 内完成,整体页面不会刷新。

  • 拿到接口数据

然后为 iframe 添加load事件,得到 iframe 的页面内容,将结果转换为 JSON 对象,这样就拿到了接口的数据

HTML

   
        
选择文件(可多选):
input 必须设置 name 属性,否则数据无法发送

标题:


复制代码

NODE

服务端代码不需要改动,略.

CODE

github.com/Bigerfe/fe-…

无刷新上传

无刷新上传文件肯定要用到XMLHttpRequest,在 ie 时代也有这个对象,单只 支持文本数据的传输,无法用来读取和上传二进制数据。

现在已然升级到了XMLHttpRequest2,较1版本有非常大的升级,首先就是可以读取和上传二进制数据,可以使用·FormData·对象管理表单数据。

当然也可使用 fetch 进行上传。

DEMO

上传攻略(转)_第6张图片
image.png

HTML

 
选择文件(可多选):

复制代码

JS xhr




复制代码

JS Fetch

  fetch('http://localhost:8100/', {
            method: 'POST',
            body: fd
        })
            .then(response => response.json())
            .then(response =>{
                console.log(response);
                if (response.fileUrl.length) {
                    alert('上传成功');
                }
            } )
            .catch(error => console.error('Error:', error));
复制代码

CODE

github.com/Bigerfe/fe-…

多文件,单进度

借助XMLHttpRequest2的能力,实现多个文件或者一个文件的上传进度条的显示。

DEMO

上传攻略(转)_第7张图片
image.png

说明

  • 页面内增加一个用于显示进度的标签 div.progress
  • js 内处理增加进度处理的监听函数xhr.upload.onprogress
  • event.lengthComputable这是一个状态,表示发送的长度有了变化,可计算
  • event.loaded表示发送了多少字节
  • event.total表示文件总大小
  • 根据event.loadedevent.total计算进度,渲染div.progress

PS 特别提醒

xhr.upload.onprogress要写在xhr.send方法前面,否则event.lengthComputable状态不会改变,只有在最后一次才能获得,也就是100%的时候.

HTML

 
选择文件(可多选):

复制代码

JS


复制代码

CODE

github.com/Bigerfe/fe-…

多文件上传+预览+取消

上一个栗子的多文件上传只有一个进度条,有些需求可能会不大一样,需要观察到每个文件的上传进度,并且可以终止上传。

DEMO

上传攻略(转)_第8张图片
image.png

说明

  • 为了预览的需要,我们这里选择上传图片文件,其他类型的也一样,只是预览不方便
  • 页面内增加一个多图预览的容器div.img-box
  • 根据选择的文件信息动态创建所属的预览区域和进度条以及取消按钮
  • 为取消按钮绑定事件,调用xhr.abort();终止上传
  • 使用window.URL.createObjectURL预览图片,在图片加载成功后需要清除使用的内存window.URL.revokeObjectURL(this.src);

HTML

 
选择文件(可多选):
添加文件
复制代码

JS


复制代码

问题1

这里没有做上传的并发控制,可以通过控制同时可上传文件的个数(这里控制为最多6个)或者上传的时候做好并发处理,也就是同时只能上传 X 个文件。

问题2

在测试过程中,取消请求的方法xhr.abort()调用后,xhr.readyState会立即变为4,而不是0,所以这里需要做容错处理。

MDN 上说是0.

image.png

如果大家有不同的结果,欢迎留言。

CODE

github.com/Bigerfe/fe-…

拖拽上传

html5的出现,让拖拽上传交互成为可能,现在这样的体验也屡见不鲜。

DEMO

上传攻略(转)_第9张图片
image.png

说明

  • 定义一个允许拖放文件的区域div.drop-box
  • 取消drop 事件的默认行为e.preventDefault();,不然浏览器会直接打开文件
  • 为拖拽区域绑定事件,鼠标在拖拽区域上 dragover, 鼠标离开拖拽区域dragleave, 在拖拽区域上释放文件drop
  • drop事件内获得文件信息e.dataTransfer.files

HTML

 
拖动文件到这里,开始上传
复制代码

JS


复制代码

CODE

github.com/Bigerfe/fe-…

剪贴板上传

掘金的写文编辑器是支持粘贴上传图片的,比如我从磁盘粘贴或者从网页上右键复制图片。

DEMO

上传攻略(转)_第10张图片
image.png

说明

  • 页面内增加一个可编辑的编辑区域div.editor-box,开启contenteditable
  • div.editor-box绑定paste事件
  • 处理paste 事件,从event.clipboardData || window.clipboardData获得数据
  • 将数据转换为文件items[i].getAsFile()
  • 实现在编辑区域的光标处插入内容 insertNodeToEditor 方法

问题1

测试中发现复制多个文件无效,只有最后一个文件上传,在掘金的编辑器里也同样存在,在坐有知道原因的可以留言说下。

问题2

mac系统可以支持从磁盘复制文件后上传,windows 系统测试未通过,剪贴板的数据未拿到。

HTML

 
可以直接粘贴图片到这里直接上传
复制代码

JS

 //光标处插入 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);
        }
    }

    var box = document.getElementById('editor-box');
    //绑定paste事件
    box.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++) {
                console.log(items[i].getAsFile());
                fileList.push(items[i].getAsFile());
            }
        }

        window.willUploadFileList = fileList;
        event.preventDefault();//阻止默认行为

        submitUpload();
    }); 

    function submitUpload() {

        var fileList = window.willUploadFileList||[];
        var fd = new FormData();   //构造FormData对象
        for(var i =0;i

CODE

github.com/Bigerfe/fe-…

大文件上传-分片

ie 时代由于无法使用xhr上传二进制数据,上传大文件需要借助浏览器插件来完成。 现在来看实现大文件上传简直soeasy。

如果太大的文件,比如一个视频1g 2g那么大,直接采用上面的栗子中的方法上传可能会出链接现超时的情况,而且也会超过服务端允许上传文件的大小限制,所以解决这个问题我们可以将文件进行分片上传,每次只上传很小的一部分 比如2M。

DEMO

上传攻略(转)_第11张图片
image.png
上传攻略(转)_第12张图片
image.png

说明

相信大家都对Blob 对象有所了解,它表示原始数据,也就是二进制数据,同时提供了对数据截取的方法slice,而 File 继承了Blob的功能,所以可以直接使用此方法对数据进行分段截图。

  • 把大文件进行分段 比如2M,发送到服务器携带一个标志,暂时用当前的时间戳,用于标识一个完整的文件
  • 服务端保存各段文件
  • 浏览器端所有分片上传完成,发送给服务端一个合并文件的请求
  • 服务端根据文件标识、类型、各分片顺序进行文件合并
  • 删除分片文件

HTML

代码略,只需要一个 input file 标签。

JS

   //分片逻辑  像操作字符串一样

    var start=0,end=0;
    while (true) {
        end+=chunkSize;
        var blob = file.slice(start,end);
        start+=chunkSize;

        if(!blob.size){//截取的数据为空 则结束
            //拆分结束
            break;
        }

        chunks.push(blob);//保存分段数据
    }



复制代码

NODE

服务端需要做一些改动,保存分片文件、合并分段文件、删除分段文件。

PS

合并文件这里使用 stream pipe 实现,这样更节省内存,边读边写入,占用内存更小,效率更高,代码见fnMergeFile方法。

//二次处理文件,修改名称
app.use((ctx) => {
    var body = ctx.request.body;
    var files = ctx.request.files ? ctx.request.files.f1:[];//得到上传文件的数组
    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;
        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));
        }
    });

    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);
            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';
    }

});
复制代码

CODE

github.com/Bigerfe/fe-…

大文件上传-断点续传

在上面我们实现了大文件的分片上传,解决了大文件上传超时和服务器的限制。

但是仍然不够完美,大文件上传并不是短时间内就上传完成,如果期间断网,页面刷新了仍然需要重头上传。

方法1

基于上面一个栗子进行改进,服务端已保存了部分片段,重新上传的时候,服务端对当前的分段进行对比,只接收本地没有的分段,前提是分段大小一致。

在上面为了方便,使用了时间戳作为这个文件的标志,其实可以使用spark-md5来生成文件的 hash 值,这样服务器就可以进行文件的对比了。

但是不好的地方是每个分段都要重新发送请求。

方法2 - 断点续传

方法1中,重新上传时请求和数据还会发到服务器,其实已上传的分段就不应该再发送到服务器了,所以我们可以使用断点续传来进行改进。

  • 为每个分段生成 hash 值,使用 spark-md5
  • 将上传成功的分段信息保存到本地
  • 重新上传时,进行和本地分段 hash 值的对比,如果相同的话则跳过,继续下一个分段的上传

PS

生成 hash 过程肯定也会耗费资源,但是和重新上传相比可以忽略不计了。

DEMO

上传攻略(转)_第13张图片
image.png
上传攻略(转)_第14张图片
image.png

HTML

代码略 
复制代码

JS

模拟分段保存,本地保存到localStorage


    //获得本地缓存的数据
    function getUploadedFromStorage(){
        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++){
            console.log('index',i, uploadedInfo[i]?'已上传过':'未上传');

            if(uploadedInfo[i]){//对比分段
                sendChunkCount=i+1;//记录已上传的索引
                continue;//如果已上传则跳过
            }
            var fd = new FormData();   //构造FormData对象
            fd.append('token', token);
            fd.append('f1', chunks[i]);
            fd.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);
    }

复制代码

node 端上传图片

不只会从客户端上传文件到服务器,服务器也会上传文件到其他服务器。

  • 读取文件buffer fs
  • 构建 form-data form-data
  • 上传文件 node-fetch

NODE

 /**
     * filepath = 相对根目录的路径即可
     */
   async function getFileBufer(filePath) => {

        return new Promise((resolve) => {
            fs.readFile(filePath, function (err, data) {
                var bufer = null;
                if (!err) {
                    resolve({
                        err: err,
                        data: data
                    });
                }

            });

        });
    }

    /**
     * 上传文件
     */
    let fetch = require('node-fetch');
    let formData = require('form-data');

    module.exports = async (options) => {
        let {
            imgPath
        } = options;
        let data = await getFileBufer(imgPath);
        if (data.err) {
            return null;
        }
        let form = new formData();
        form.append('xxx', xxx);
        form.append('pic', data.data);
        return fetch('http://xx.com/upload', {
            body: form,
            method: 'POST',
            headers: form.getHeaders()//要活的 form-data的头,否则无法上传
        }).then(res => {
            return res.json();
        }).then(data => {
            return data;
        })
    }
复制代码

其他

在浏览器端对文件的类型、大小、尺寸进行判断

  • file.type判断类型
  • file.size判断大小
  • 通过动态创建 img 标签,图片加载后获得尺寸,naturalWidth naturalHeightor width height

JS

    var file = document.getElementById('f1').files[0];

    //判断类型
    if(f.type!=='image/jpeg' &&  f.type !== 'image/jpg'  ){
        alert('只能上传 jpg 图片');
        flag=false;
        break;
    }

    //判断大小
    if(file.size>100*1024){
        alert('不能大于100kb');
    }

    //判断图片尺寸
    var img =new Image();
    img.onload=function(){
         console.log('图片原始大小 width*height', this.width, this.height);
        if(this.naturalWidth){
        console.log('图片原始大小 naturalWidth*naturalHeight', this.naturalWidth, this.naturalHeight);
        }else{
          console.log('oImg.width*height', this.width, this.height);
        }
    }
复制代码

input file 外观更改

由于input file 的外观比较传统,很多地方都需要进行美化。

  1. 定义好一个外观,然后将 file input 定位到该元素上,让他的透明度为0。
  2. 使用 label 标签
  
    
复制代码
  1. 隐藏 input file 标签,然后调用 input 元素的 click 方法

PS

file 标签隐藏后在 ie 下无法获得文件内容,建议还是方法1 兼容性强。

源码在这里

以上代码均已上传 github

github.com/Bigerfe/fe-…

参考资料

developer.mozilla.org/zh-CN/docs/…

developer.mozilla.org/zh-CN/docs/…

cloud.tencent.com/developer/n…

你可能感兴趣的:(上传攻略(转))