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

背景

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

鲁迅曾经曰过:

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

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

一些热身准备

FileUpload对象

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

1
<input type="file">

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

XMLHttpRequest请求

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

告辞。runningrunning

注册插件

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ;冒号防止上下文中有其他方法未写;从而引起不必要的麻烦
;(function ($) {
// 创建构造函数Upload
function Upload (config) {
// ...
}
// Upload的原型方法
Upload.prototype = {
// ...
};
// 实例化一个Upload,挂载到jQuery
$.xupload = function (config) {
return new Upload(config)
};
})(jQuery);

代码解析

Upload构造函数

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

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

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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

原方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
init: function () {
var _this = this,
config = this.config; // 缓存合并后的config
var el = config.el; // 拿到具体的挂载元素
// 如果用户传入的是jQuery对象,则需要转化一下
if (el && el instanceof jQuery) {
el = el.selector;
}
_this.$root = $(el); // 将元素赋值,方便后续的调用
// 假如el是class或者直接是标签
$(el).each(function () {
// 这里需要将事件委托至body
$('body').on('change', el, function (e) {
var files = e.target.files; // 这就是咱们要拿到的files对象啦
_this.fileCached = $.extend({}, files); // 通过extend将files深拷贝
_this.handler(e, files); // 调用处理器函数
})
})
}

更新于20180423

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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,去负责接下来具体的逻辑,比如规则的验证、图片预览等等

原方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
handler: function (e, files) {
var _this = this,
config = this.config,
fileCached = this.fileCached,
rules = this.validate(files); // 验证函数返回的结果
// 如果验证结果为true
if (rules.result) {
// 如果配置中自动上传为true
if (config.autoUpload) {
// 触发接下来的上传操作
_this.triggerUpload();
}
// 调用上传前预览函数
_this._previewBefore(files);
} else {
// 验证结果为false则触发_checkError函数
_this._checkError(rules.msgQuene);
}
}

更新于20180423

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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,能够自动或者手动的执行接下来的上传操作
1
2
3
4
5
6
7
8
9
10
11
12
triggerUpload: function () {
var _this = this,
files = this.fileCached,
len = files.length;
// 如果是多文件上传,则传入files伪数组,执行upload函数
if (len > 1) {
_this.upload(files);
} else {
// 否则直接传入files中的第一个对象
_this.upload(files[0]);
}
}

更新于20180423

  • 然后需要一个触发器函数triggerUpload,能够自动或者手动的执行接下来的上传操作,然后再多思考一步,用户会不会只想上传其中某一个文件呢?这是完全有可能的,所以我们得提供多一种思路,这里我们可以使用“函数重载”,当用户不传值时,则默认全部上传,如果传入了指定的index值,则单独上传该文件,之所以带引号,是因为确实只是通过简单的参数去实现的,更高级的函数重载,可以参考jQuery之父John Resig利用闭包巧妙实现的重载 译文
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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请求,同时暴露出一些状态函数,比如onloadstartonerror等等
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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); // 发送请求
}

新增于于20180423

  • 接着让我们自己封装一个预览方法previewBefore吧。首先应该明确的是需要一个预览容器,不然图片不知道改放哪;接着图片的样式我们也应该让用户去控制(暂时没有做模版),所以有两个传入的新属性previewWrappreviewImgClass,顾名思义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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);
}
}

新增于于20180423

  • 有了预览,是不是还差个删除呢,让我们回想triggerUpload方法,此时应该也沿用那种思想,传入指定的index值去删除指定的文件,不传值则默认删除所有。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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的事件回调啦
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 开始上传
_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);
},
// 上传前预览
_previewBefore: function (files) {
this.config.previewBefore.call(this, files);
}
  • 当然验证方法validate是必不可少的,但是这里我只是通过rules简单的定义了一些规则,而且感觉这块其实应该给用户去自定义,然后我在代码里面去转义成我的代码能看懂的方法,这里还需要改进,也欢迎大家提宝贵意见
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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大妹子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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
};
}
  • 同时可能需要一些工具方法,比如在还未上传的时候去getset files的值呀,暂时想到的是这些
1
2
3
4
5
6
get: function () {
return this.fileCached; // 这时候缓存值就有用啦
},
set: function (files) {
this.fileCached = files; // 简单的处理下...
}

插件使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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…好像也没啥说的了,大家都是面向工资编程,那就祝大家早日一夜暴富吧。

代码是什么,能吃吗?stuck_out_tongue

Todo

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