原文地址:
请大家多多指教,鞠躬。
背景
在之前的工作当中,遇到有文件上传的需求基本都是通过引用插件来实现的,效果是完成了,但是其实并没有一个系统的认识,理解比较粗浅。
鲁迅曾经曰过:
好读书,也要求甚解。诸葛村夫不求甚解,所以多智也只能近妖。
最近又遇到了相关的需求,在阅读了Deng mu qin(大神都是这样的,只留下了一串拼音字符,不带走一片云彩~)前辈的upload.js源码后,觉得可能跟业务比较耦合,通用性相对不是那么好,所以决定自己撸一个文件上传的小插件,既当是学习,同时也吸(chao)取(xi)一下前辈的人生经验。第一次写技术文章,其实技术性谈不上多强,主要是提醒自己要不断学习、不断总结,希望以后能成为一方小牛。真心希望能多多讨论,一起进步!
一些热身准备
FileUpload对象
初来乍到,萌新们可能跟我一样对FileUpload对象一无所知,无妨,先看一个最简单的例子:
复制代码
当上面的标签出现在页面中时,一个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构造函数
一个构造函数需要做些什么呢?
- 通过挂载到this的方式,初始化一些后续需要使用到的变量,此过程可以视后续代码需要不断增量更新
- 配置一个defaultConfig默认配置项,在用户直接调用xupload方法时直接使用配置项,当然,当用户传递属于自己的配置项时,需要将用户配置项跟默认配置项进行更新合并,此时可以用到jQuery的函数
- 调用初始化函数
代码如下:
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值,则单独上传该文件,之所以带引号,是因为确实只是通过简单的参数去实现的,更高级的函数重载,可以参考。
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.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
- 文件的拖拽上传
- 文件的取消上传,重新上传
- 一些其他细节和bug处理