关于文件上传那些可能不怎么对的姿势

1,164 阅读6分钟

原文地址:震惊!一个文件上传插件竟然如此简单!

请大家多多指教,鞠躬。

背景

在之前的工作当中,遇到有文件上传的需求基本都是通过引用插件来实现的,效果是完成了,但是其实并没有一个系统的认识,理解比较粗浅。

鲁迅曾经曰过:

好读书,也要求甚解。诸葛村夫不求甚解,所以多智也只能近妖。

最近又遇到了相关的需求,在阅读了Deng mu qin(大神都是这样的,只留下了一串拼音字符,不带走一片云彩~)前辈的upload.js源码后,觉得可能跟业务比较耦合,通用性相对不是那么好,所以决定自己撸一个文件上传的小插件,既当是学习,同时也吸(chao)取(xi)一下前辈的人生经验。第一次写技术文章,其实技术性谈不上多强,主要是提醒自己要不断学习、不断总结,希望以后能成为一方小牛。真心希望能多多讨论,一起进步!

一些热身准备

FileUpload对象

初来乍到,萌新们可能跟我一样对FileUpload对象一无所知,无妨,先看一个最简单的例子:

<input type="file">

当上面的标签出现在页面中时,一个FileUpload对象就会被创建,然后就会出现一个大家熟悉的银灰色小方块,点击选择文件,出现对应的文件名称和格式。

XMLHttpRequest请求

现代浏览器中(IE10 & IE10+),XMLHttpRequest请求可以传输FormData对象,可以通过XMLHttpRequest对象的upload属性的onprogress事件回调方法获取传输进度,这也是在下的xupload.js的安生立命之本。至于IE9IE8IE7IE6,emmmm...

告辞。:running::running:

注册插件

通过一个经典的自执行匿名函数,再将方法注册到jQuery上,就可以基本实现一个jq插件的初步建立:

// ;冒号防止上下文中有其他方法未写;从而引起不必要的麻烦
;(function ($) {
    // 创建构造函数Upload
    function Upload (config) {
        // ...
    }
    // Upload的原型方法
    Upload.prototype = {
        // ...
    };
    // 实例化一个Upload,挂载到jQuery
    $.xupload = function (config) {
        return new Upload(config)
    };
})(jQuery);

代码解析

Upload构造函数

一个构造函数需要做些什么呢?

  1. 通过挂载到this的方式,初始化一些后续需要使用到的变量,此过程可以视后续代码需要不断增量更新
  2. 配置一个defaultConfig默认配置项,在用户直接调用xupload方法时直接使用配置项,当然,当用户传递属于自己的配置项时,需要将用户配置项跟默认配置项进行更新合并,此时可以用到jQuery的extend函数
  3. 调用初始化函数

代码如下:

function Upload(config) {
    var _this = this; // 缓存this
    _this.uploading = false; // 设置传输状态初始值

    _this.defaultConfig = {
        el: null, // {string || jQuery object} 绑定的元素,必填
        uploadUrl: null, // {string} 上传路径,必填
        uploadParams: {}, // {object} 上传携带参数对象,选填
        maxSize: null, // {number} 上传的最大尺寸,选填
        autoUpload: false, // {boolean} 是否自动上传,默认否
        noGif: false, // {boolean} 是否支持gif上传,默认支持
        previewWrap: null, // 图片预览的容器,选填
        previewImgClass: 'x-preview-img', // 预览图片的class,previewWrap生效时方可用
        start: function () {}, // 开始上传回调
        done: function () {}, // 上传完成回调
        fail: function () {}, // 上传失败回调
        progress: function () {}, // 上传进度回调
        checkError: function () {}, // 检测失败回调
    };

    _this.fileCached = []; // 上传文件缓存数组
    _this.$root = null; // 挂载元素

    // 防止previewImgClass为null或undefine
    if (config.previewImgClass === null || config.previewImgClass === '') {
        config.previewImgClass = _this.defaultConfig.previewImgClass; // 置为默认值
    }
    
    // 用户传入了配置项且配置项是一个纯粹的对象
    if (config && $.isPlainObject(config)) {
        // 通过jquery的extend方法进行合并
        _this.config = $.extend({}, _this.defaultConfig, config);
    } else {
        _this.config = _this.defaultConfig; // 继承默认配置项
        _this.isDefault = true;
    }
    _this.init(); // 调用初始化函数
}

构造函数原型的结构

prototype在我看来有点类似于class之于css,你能想象如果css中没有class会发生什么吗?可用性和复用性都成了灾难,这是绝对不行的。

关于prototype的进一步解读,大家可以参考一下方应杭老师的精彩解读

想象一下,我们把一些常用的工具方法挂载到prototype上,这样调用一个实例,这个实例就自动继承了所有在prototype上的方法,修改一下prototype,所有实例也都自动响应过来,是不是跟css中的class很像呢?

那么让我们来设计一下Upload的原型函数需要哪些基础的方法吧:

  • 首先需要一个init初始化函数,在这里调用必须用到的方法。 仔细想想,一个上传插件,第一步最需要的是不是响应用户选择文件的操作呢?再进一步,页面中是否只有一个上传input?
init: function () {
    var _this = this,
        config = this.config, // 缓存合并后的config
        el = config.el,
        isEl = _this._isSelector('el'), // 调用_isSector判断传入的格式是否符合要求
        isPreviewWrap = _this._isSelector('previewWrap'); // 同上
    
    // 抛出异常
    if (!isEl) {
        throw '请输入正确格式的el值'
    }
    
    if (!isPreviewWrap) {
        throw '请输入正确格式的previewWrap值'
    }
    
    _this.$root = $(el); // 将元素赋值,方便后续的调用
    
    _this.$root.each(function () {
        $('body').on('change', el, function (e) {
            var files = e.target.files;
            Array.prototype.push.apply(_this.fileCached, files); // 同之前的深拷贝不同,为了后续的数组操作,我们应该将伪数组转化为真正的数组
            _this.handler(e, files); // 调用处理器函数
        });
    });
},
_isSelector: function (el) {
    var which = this.config[el]; // 拿到config里的属性
    return Object.prototype.toString.call(which) === '[object String]' && which !== '' && !/^[0-9]+.?[0-9]*$/.test(which); // 必须是字符串且不能为空字符串且是非负整数
}
  • 其次需要一个处理函数handler,去负责接下来具体的逻辑,比如规则的验证、图片预览等等
handler: function (e, files) {
    var _this = this,
        config = this.config,
        fileCached = this.fileCached,
        rules = this.validate(files);
        
    if (rules.result) {
        config.autoUpload && _this.triggerUpload();
        // 暂时只支持图片预览
        if (_this.$root.attr('accept').substr(0, 5) === 'image') { // 预览模式暂时只支持图片,通过判断accept来判断(需改进)
            _this.previewBefore(); // 调用上传前函数
        }
    } else {
        _this._checkError(rules.msgQuene); // 验证结果为false则触发_checkError函数
    }
}
  • 然后需要一个触发器函数triggerUpload,能够自动或者手动的执行接下来的上传操作,然后再多思考一步,用户会不会只想上传其中某一个文件呢?这是完全有可能的,所以我们得提供多一种思路,这里我们可以使用“函数重载”,当用户不传值时,则默认全部上传,如果传入了指定的index值,则单独上传该文件,之所以带引号,是因为确实只是通过简单的参数去实现的,更高级的函数重载,可以参考jQuery之父John Resig利用闭包巧妙实现的重载 译文
triggerUpload: function (index) {
    var _this = this,
        files = this.fileCached,
        len = files.length;

    var isIndex = (index >= 0); // 判断是否传入参数(排除index为0时的特殊情况)
    var isValid = /^\d+$/.test(index) && index < len; // 判断传入的index是否为整数,切数目不能大于文件个数

    if (isIndex && isValid) { // 如果传入了index参数且验证通过
        if (len > 1) {
            _this.upload(files[index]); // 多个文件直接传入指定index文件
        } else if (len === 1) {
            _this.upload(files[0]); // 否则传入第一个
        }
    } else if (!isIndex && !isValid) { // 如果传入了没有传入index参数且并没有验证通过
        if (len > 1) {
            _this.upload(files);
        } else if (len === 1) {
            _this.upload(files[0]);
        }
    } else if (isIndex && !isValid) { // 如果传入了index参数且并没有验证通过
        throw 'triggerUpload方法传入的索引值为从0开始的整数且不得大于您上传的文件数' // 抛出异常
    }
}
  • 接下来就是重头戏upload了,需要这样一个函数去处理上传的POST请求,同时暴露出一些状态函数,比如onloadstart、onerror等等
upload: function (files) {
    var _this = this,
        uploadParams = this.config.uploadParams, // 有些时候请求需要携带额外的参数
        xhr = new XMLHttpRequest(), // 创建一个XMLHttpRequest请求
        data = new FormData(), // 创建一个FormData表单对象
        fileRequestName = ''; // 文件请求名
    
    // 如果uploadParams有fileRequestName则直接使用,否则为file[]
    uploadParams.fileRequestName ? 
    fileRequestName = uploadParams.fileRequestName : 
    fileRequestName = 'file[]';

    // 多文件上传处理
    for (var i = 0, len = files.length; i < len; i++) {
        var file = files[i];
        // 将fileappend到FormData对象
        data.append(fileRequestName, file);
    }
    // 参数处理
    if (uploadParams) {
        for (var key in uploadParams) {
            // 忽略fileRequestName
            if (key !== 'fileRequestName') {
                // 将各个参数append到FormData
                data.append(key, uploadParams[key]);
            }
        }
    }

    // 上传开始
    xhr.onloadstart = function (e) {
        _this._loadStart(e, xhr); // 调用_loadStart函数
    };

    // 上传结束
    xhr.onload = function (e) {
        _this._loaded(e, xhr); // 同上
    }

    // 上传错误
    xhr.onerror = function (e) {
        _this._loadFailed(e, xhr); // 同上
    };

    // 上传进度
    xhr.upload.onprogress = function (e) {
        _this._loadProgress(e, xhr); // 同上
    }
  
    xhr.open('post', _this.config.uploadUrl); // post到uploadUrl
    xhr.send(data); // 发送请求
}
  • 接着让我们自己封装一个预览方法previewBefore吧。首先应该明确的是需要一个预览容器,不然图片不知道改放哪;接着图片的样式我们也应该让用户去控制(暂时没有做模版),所以有两个传入的新属性previewWrap、previewImgClass,顾名思义。
previewBefore: function () {
    var _this = this,
        files = _this.fileCached,
        filesNeed = [], // 我们真正需要的file数组,防止往页面里多次append之前存在的dom
        filesHad = [], // 已经存在的file数组,方便后续计算
        previewWrap = _this.config.previewWrap,
        previewImgClass = _this.config.previewImgClass;

    var $previewWrap = $(previewWrap);

    // 如果已经存在预览位置,即页面中已经存在了预览元素
    if ($previewWrap.find('.' + previewImgClass).length > 0) {

        $previewWrap.find('.' + previewImgClass).each(function (index, value) {
            var $this = $(this);
            filesHad.push($this.data('name')); // 把已经存在的file name推入filesHad
        });
        
        for (var i = 0; i < files.length; i++) {
            if (filesHad.indexOf(files[i].name) < 0) { // 数组的去重
                filesNeed.push(files[i]); 
            }
        }
    } else {
        filesNeed = files; // 首次预览不需要处理
    }

    for (var i = 0; i < filesNeed.length; i++) {
        (function (i) { // 创建一个闭包获取正确的i值
            var	reader = new FileReader(); // 新建一个FileReader对象
            reader.readAsDataURL(filesNeed[i]); // 获取该file的base64
            reader.onload = function () {
                var dataUrl = reader.result; // 获取url
                var img = $('<img src="' + dataUrl + '" class="' + previewImgClass + '" data-name="' + filesNeed[i].name + '"/>');
                img.appendTo($previewWrap);
            };
        })(i);
    }  
}
  • 有了预览,是不是还差个删除呢,让我们回想triggerUpload方法,此时应该也沿用那种思想,传入指定的index值去删除指定的文件,不传值则默认删除所有。
delBefore: function (index) {
    var _this = this,
        files = this.fileCached,
        len = files.length,
        previewWrap = _this.config.previewWrap;
        previewImgClass = _this.config.previewImgClass;
    
    var isIndex = (index >= 0); // 判断是否传入参数(排除index为0时的特殊情况)
    var isValid = /^\d+$/.test(index) && index < len; // 判断传入的index是否为整数,且数目不能大于文件个数

    if (isIndex && isValid) {
        files.splice(index, 1); // 删除数组中指定file
        $(previewWrap).find('.' + previewImgClass).eq(index).remove();
    } else if (!isIndex && !isValid) {
        $(previewWrap).find('.' + previewImgClass).each(function () { // 删除所有
            $(this).remove();
        })
    } else if (isIndex && !isValid) {
        throw 'delBefore方法传入的索引值为从0开始的整数且不得大于您上传的文件数' // 抛出异常
    }
}
  • 同时需要一些私有状态函数来接收xhr的事件回调方法,然后"call"一下暴露在外的config里面的对应的函数,疯狂打call后,就可以在外边接收到xhr的事件回调啦
// 开始上传
_loadStart: function (e, xhr) {
    this.uploading = true;
    this.config.start.call(this, xhr);
},
// 上传完成
_loaded: function (e, xhr) {
    // 简单的判断一下请求成功与否
    if (xhr.status === 200 || xhr.status === 304) {
        this.uploading = false;
        var res = JSON.parse(xhr.responseText);
        this.config.done.call(this, res);
    } else {
        this._loadFailed(e, xhr);
    }
},
// 上传失败
_loadFailed: function (e, xhr) {
    this.uploading = false;            
    this.config.fail.call(this, xhr);
},
// 上传进度
_loadProgress: function (e, xhr) {
    // e.loaded为当前加载值,e.total为文件大小值
    if (e.lengthComputable) {
        this.config.progress.call(this, e.loaded, e.total);
    }
},
// 验证失败
_checkError: function (msgQuene) {
    // msgQuene为错误消息队列
    this.config.checkError.call(this, msgQuene);
},

  • 当然验证方法validate是必不可少的,但是这里我只是通过rules简单的定义了一些规则,而且感觉这块其实应该给用户去自定义,然后我在代码里面去转义成我的代码能看懂的方法,这里还需要改进,也欢迎大家提宝贵意见
validate: function (files) {
    var _this = this,
        len = files.length,
        msgQuene = [], // 创建一个错误消息队列,因为多文件上传可能有多个错误状态
        matchCount = 0; // 创建一个初始值匹配值方便后续计算
    
    if (len > 1) {
        for (var i = 0; i < len; i++) {
            // 创建一个闭包
            (function (index) {
                // 参看下面的rules方法
                var result = _this.rules(files[index], index);
                // 根据rules计算返回的flag进行计数,正确则+1s,否则把错误消息推送到消息队列
                result.flag ? matchCount++ : msgQuene.push(result.msg);
            })(i);
        }
    } else {
        // 原理同上
        var result = _this.rules(files[0]);
        result.flag ? matchCount++ : msgQuene.push(result.msg);
    }
    // 当所有文件都通过validate
    if (matchCount === len) {
        return {
            result: true // 告诉别人通过啦!
        };
    } else {
        return {
            result: false, // 告诉别人我觉得不行
            msgQuene: msgQuene // 告诉别人哪里不行
        };
    }
}
  • 具体的规则呢就需要交给具体的人去处理,男女搭配干活不累,说的就是你,rules大妹子
rules: function (item, index) {
    var config = this.config,
        flag = true,
        msg = '';
    // 一些暂时想到的验证规则方案,只做参考
    // 是否能传gif
    if (config.noGif) {
        if (item.type === 'image/gif') {
            flag = false;
            msg = '不支持上传gif格式的图片'
        }
    }
    // 是否设置了大小限制
    if (config.maxSize) {
        if (item.size > config.maxSize) {
            flag = false;
            // index = 0 隐式转换为false,这里需要注意
            index >= 0 ? 
            msg = '第' + (index + 1) + '个文件过大,请重新上传': 
            msg = '文件过大,请重新上传';
        }
    }
    // 返回一个参考对象
    return {
        flag: flag,
        msg: msg
    };
}
  • 同时可能需要一些工具方法,比如在还未上传的时候去get和set files的值呀,暂时想到的是这些
get: function () {
    return this.fileCached; // 这时候缓存值就有用啦
},
set: function (files) {
    this.fileCached = files; // 简单的处理下...
}

插件使用

var up = $.xupload({
    el: '#file', // || $('#file')
    uploadUrl: '/test',
    uploadParams: {
        fileRequestName: 'uploadfile', // || undefined
        param1: 1,
        param2, 2
    },
    autoUpload: false, // || true,
    maxSize: 2000,
    noGif: true, // || false
    start: function (files) {
        console.dir(files);
    },
    done: function (res) {
        console.dir(res); // 上传成功responce
    },
    fail: function (error) {
        console.error(error);
    },
    progress: function (loaded, total) {
        console.log(Math.round(loaded / total * 100) + '%');
    },
    checkError: function (errors) {
        console.error(errors); // 得到验证失败数组
    }
});

$('#someSubmitBtn').click(function () {
     var files = up.get(); // 获取待上传的文件
     console.dir(files);
     up.triggerUpload(); // 触发异步upload, autoUpload为false时可用
});

总结

第一次写类似的插件,运用的技巧比较简单粗浅,也有很多不足,已经在计划改进了,大牛轻喷,以后会更加努力的(ง •̀_•́)ง。

虽然看到这篇文章的人可能不多,但是刘备也曾经曰过:

勿以善小而不为

我这叫做“善”好像也有点牵强...总之就是那么个意思!

emmm...好像也没啥说的了,大家都是面向工资编程,那就祝大家早日一夜暴富吧。

代码是什么,能吃吗?

Todo

  1. 文件的拖拽上传
  2. 文件的取消上传,重新上传
  3. 一些其他细节和bug处理