關於檔案上傳那些可能不怎麼對的姿勢

Louis Go發表於2019-03-01

原文地址:震驚!一個檔案上傳外掛竟然如此簡單!

請大家多多指教,鞠躬。

關於檔案上傳那些可能不怎麼對的姿勢

背景

在之前的工作當中,遇到有檔案上傳的需求基本都是通過引用外掛來實現的,效果是完成了,但是其實並沒有一個系統的認識,理解比較粗淺。

魯迅曾經曰過:

好讀書,也要求甚解。諸葛村夫不求甚解,所以多智也只能近妖。

最近又遇到了相關的需求,在閱讀了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處理

相關文章