原文地址:震驚!一個檔案上傳外掛竟然如此簡單!
請大家多多指教,鞠躬。
背景
在之前的工作當中,遇到有檔案上傳的需求基本都是通過引用外掛來實現的,效果是完成了,但是其實並沒有一個系統的認識,理解比較粗淺。
魯迅曾經曰過:
好讀書,也要求甚解。諸葛村夫不求甚解,所以多智也只能近妖。
最近又遇到了相關的需求,在閱讀了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建構函式
一個建構函式需要做些什麼呢?
- 通過掛載到this的方式,初始化一些後續需要使用到的變數,此過程可以視後續程式碼需要不斷增量更新
- 配置一個defaultConfig預設配置項,在使用者直接呼叫xupload方法時直接使用配置項,當然,當使用者傳遞屬於自己的配置項時,需要將使用者配置項跟預設配置項進行更新合併,此時可以用到jQuery的extend函式
- 呼叫初始化函式
程式碼如下:
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
- 檔案的拖拽上傳
- 檔案的取消上傳,重新上傳
- 一些其他細節和bug處理