本文要介紹的是網(wǎng)頁中常見的圖片上傳后直接在頁面生成小圖預覽的實現(xiàn)思路,考慮到該功能有一定的適用性,于是把相關的邏輯封裝成了一個ImageUploadView組件,實際使用效果可查看下一段的git效果圖。在實現(xiàn)這個組件的過程中,有用到前面幾篇博客介紹的相關內(nèi)容,比如繼承庫class.js,任意組件的事件管理庫eventBase.js,同時包含進了自己對職責分離,表現(xiàn)與行為分離這兩方面的一些思考,歡迎閱讀與交流。
演示效果:
注:由于演示的代碼都是靜態(tài)的,所以文件上傳的組件是用setTimeout模擬的,不過它的調(diào)用方式跟我自己在實際工作中用上傳組件完全相同,所以演示效果的代碼實現(xiàn)完全符合真實的功能需求。
按照我以前博客的思路,先來介紹下這個上傳預覽功能的需求。
1. 需求分析
根據(jù)前面的演示效果圖,分析需求如下:
1)初始時上傳區(qū)域只顯示一個可點擊上傳的按鈕,當點擊該按鈕后,將上傳成功的圖片顯示在后面的預覽區(qū)域
2)上傳的圖片添加到預覽區(qū)域以后,可以通過刪除按鈕來移除
3)當已上傳的圖片總數(shù)達到一定限制之后,比如演示中已上傳的限制為4,就把上傳按鈕給移除掉;
4)當已上傳的圖片總數(shù)達到一定限制之時,如果通過刪除操作移除了某張圖片,還得再把上傳按鈕給顯示出來。
以上需求是看的見的,根據(jù)經(jīng)驗,還可以分析得出的需求如下:
1)如果頁面是編輯狀態(tài),也就是從數(shù)據(jù)庫中查詢出來的狀態(tài),只要圖片列表不為空,初始時還得把圖片顯示出來;而且還要根據(jù)查出來的圖片列表的長度跟上傳限制去控制上傳按鈕是否顯示;
2)如果當前頁面是一種只能看不能改的狀態(tài),那么在初始時一定要把上傳按鈕和刪除按鈕移除掉。
需求分析完畢,接下來說明一下我的實現(xiàn)思路。
2. 實現(xiàn)思路
由于這是個表單頁面,所以圖片上傳完以后如果要提交到后臺,肯定得需要一個文本域,所以我在做靜態(tài)頁面的時候就把這個文本域考慮進去了,當上傳完新的圖片以及刪除了圖片之后都得去修改這個文本域的值。當時做靜態(tài)頁時這部分的結(jié)構(gòu)如下:
<div class="holy-layout-am appForm-group appForm-group-img-upload clearfix"><label class="holy-layout-al">法人身份證電子版</label><div class="holy-layout-m"><input id="legalPersonIDPic-input" name="legalPersonIDPic" class="form-control form-field"type="hidden"><ul id="legalPersonIDPic-view" class="image-upload-view clearfix"><li class="view-item-add"><a class="view-act-add" href="javascript:;" title="點擊上傳">+</a></li></ul><p class="img-upload-msg">請確保圖片清晰,文字可辨<a href="#" title="查看示例"><i class="fa fa-question-circle"></i> 查看示例</a></p></div></div>
從這個結(jié)構(gòu)還可以看出,我把整個上傳區(qū)域都放在一個ul里面,然后把ul的第一個li作為上傳按鈕來使用。為了完成這個功能,我們主要的任務是:上傳及上傳后的回調(diào),新增或刪除圖片預覽以及文本域值的管理。從這一點,結(jié)合職責分離的思想,這個功能至少需要三個組件,一個負責文件上傳,一個負責圖片預覽的管理,一個負責文本域值的管理。千萬不能把這三個功能,兩兩或者全部都封裝在一起,那樣的話功能耦合太強,寫出來的組件可擴展性可重用性不高。如果這三個組件之間需要交互,我們只要借助回調(diào)函數(shù)或者發(fā)布-訂閱模式定義它們給外部調(diào)用的接口即可。
不過文本域值的管理本身就很簡單,寫不寫成組件都關系不大,但是至少函數(shù)級別的封裝是得有的;文件上傳組件雖然不是本文的重點,但是網(wǎng)上有很多現(xiàn)成的開源插件,比如webuploader,不管是直接用還是做二次封裝都可以應用進來;圖片預覽的功能是本文的核心內(nèi)容,ImageUploadView這個組件就是對它的封裝,從需求來看,這個組件有語義的實例方法無非就是三個,分別是render, append, delItem,其中render用來在初始化完成之后顯示初始的預覽列表,append用來在上傳成功后添加新的圖片預覽,delItem用來刪除已有的圖片預覽,按照這個基本思路,我們只需要再結(jié)合需求和組件開發(fā)的經(jīng)驗為它設計好options和事件即可。
從前面的需求我們發(fā)現(xiàn),這個ImageUploadView組件的render會受到頁面狀態(tài)的影響,當頁面為查看模式時,這個組件不能做上傳和刪除的操作,所以可以考慮給它加一個readonly的option。同時它的上傳和刪除操作還會影響到上傳按鈕的UI邏輯,這個跟上傳限制有關系,為了靈活性,也得把上傳限制作為一個option。從前一段提到的三個實例方法來說,按照自己以前定義事件的經(jīng)驗,一般一個實例方法會定義一對事件,就像bootstrap的插件的做法一樣,比如render方法,可以定義一個render.before,這個事件在render的主要邏輯執(zhí)行前觸發(fā),如果外部監(jiān)聽器調(diào)用了這個事件的preventDefault()方法,那么render的主要邏輯都不會執(zhí)行;還有一個render.after事件,這個事件在render的主要邏輯執(zhí)行后觸發(fā)。這種成對定義事件的好處是,既給外部提供擴展組件功能的方法,又能增加組件默認行為的管理。
最后從我之前的工作經(jīng)驗來說,除了有上傳圖片進行預覽這樣的功能,我曾經(jīng)還做過上傳視頻,上傳音頻,上傳普通文檔等類似的,所以這一次碰到這個功能的時候我就覺得應該把這些功能里面相似的東西抽取出來,作為一個基類,圖片上傳,視頻上傳等分別繼承這個基類去實現(xiàn)各自的邏輯。這個基類還有一個好處,就是能夠讓那些通用的邏輯完全與HTML結(jié)構(gòu)分離,在這個基類里面只做一些通用的事情,比如options與組件行為(render, append, delItem)的定義,以及通用事件的監(jiān)聽和觸發(fā),它只要留有固定的接口留給子類來實現(xiàn)即可。在后面的實現(xiàn)中,我定義了一個FileUploadBaseView組件來完成這個基類的功能,這個基類不包含任何html或css處理的邏輯,它只是抽象了我們要完成的功能,不處理任何業(yè)務邏輯。根據(jù)業(yè)務邏輯實現(xiàn)的子類會受html結(jié)構(gòu)的限制,所以子類的適用范圍小;而基類因為做到了與html結(jié)構(gòu)完全分離,所以有更大的適用范圍。
3. 實現(xiàn)細節(jié)
從第2部分的實現(xiàn)思路,要實現(xiàn)的類有:FileUploadBaseView和ImageUploadView,前者是后者的基類。同時考慮到要給組件提供事件管理的功能,所以要用到上一篇博客的eventBase.js,F(xiàn)ileUploadBaseView得繼承該庫的EventBase組件;考慮到要有類的定義和繼承,還要用到之前寫的繼承庫class.js來定義組件以及組件的繼承關系。相關組件的繼承關系為:ImageUploadView extend FileUploadBaseView extend EventBase。
(注:以下相關代碼中模塊化用的是seajs。)
FileUploadBaseView所做的事情有:
1)定義通用的option以及通用的事件管理
在該組件的DEFAULTS配置中可以看到所有的通用option和通用事件的定義:
var DEFAULTS = {data: [], //要展示的數(shù)據(jù)列表,列表元素必須是object類型的,如[{url: 'xxx.png'},{url: 'yyyy.png'}]sizeLimit: 0, //用來限制BaseView中的展示的元素個數(shù),為0表示不限制readonly: false, //用來控制BaseView中的元素是否允許增加和刪除onBeforeRender: $.noop, //對應render.before事件,在render方法調(diào)用前觸發(fā)onRender: $.noop, //對應render.after事件,在render方法調(diào)用后觸發(fā)onBeforeAppend: $.noop, //對應append.before事件,在append方法調(diào)用前觸發(fā)onAppend: $.noop, //對應append.after事件,在append方法調(diào)用后觸發(fā)onBeforeDelItem: $.noop, //對應delItem.before事件,在delItem方法調(diào)用前觸發(fā)onDelItem: $.noop //對應delItem.after事件,在delItem方法調(diào)用后觸發(fā)};
在該組件的init方法中可以看到對通用option和事件管理的初始化邏輯:
init: function (element, options) {//通過this.base調(diào)用父類EventBase的init方法this.base(element);//實例屬性var opts = this.options = this.getOptions(options);this.data = resolveData(opts.data);delete opts.data;this.sizeLimit = opts.sizeLimit;this.readOnly = opts.readOnly;//綁定事件this.on('render.before', $.proxy(opts.onBeforeRender, this));this.on('render.after', $.proxy(opts.onRender, this));this.on('append.before', $.proxy(opts.onBeforeAppend, this));this.on('append.after', $.proxy(opts.onAppend, this));this.on('delItem.before', $.proxy(opts.onBeforeDelItem, this));this.on('delItem.after', $.proxy(opts.onDelItem, this));},
2)定義組件的行為,預留可供子類實現(xiàn)的接口:
render: function () {/*** render是一個模板,子類不需要重寫render方法,只需要重寫_render方法* 當調(diào)用子類的render方法時調(diào)用的是父類的render方法* 但是執(zhí)行到_render方法時,調(diào)用的是子類的_render方法* 這樣就能把before跟after事件的觸發(fā)操作統(tǒng)一起來*/var e;this.trigger(e = $.Event('render.before'));if (e.isDefaultPrevented()) return;this._render();this.trigger($.Event('render.after'));},//子類需實現(xiàn)_Render方法_render: function () {},append: function (item) {var e;if (!item) return;item = resolveDataItem(item);this.trigger(e = $.Event('append.before'), item);if (e.isDefaultPrevented()) return;this.data.push(item);this._append(item);this.trigger($.Event('append.after'), item);},//子類需實現(xiàn)_append方法_append: function (data) {},delItem: function (uuid) {var e, item = this.getDataItem(uuid);if (!item) return;this.trigger(e = $.Event('delItem.before'), item);if (e.isDefaultPrevented()) return;this.data.splice(this.getDataItemIndex(uuid), 1);this._delItem(item);this.trigger($.Event('delItem.after'), item);},//子類需實現(xiàn)_delItem方法_delItem: function (data) {}
為了統(tǒng)一處理行為前后的事件派發(fā)邏輯,將render, append ,delItem的主要邏輯抽出來成為需被子類實現(xiàn)的方法_render, _append和_delItem。當調(diào)用子類的render方法時,調(diào)用的實際上父類的方法,但是當父類執(zhí)行到_render方法時,執(zhí)行的就是子類的方法,另外兩個方法也是類似的處理。需要注意的是子類不能去覆蓋render, append ,delItem三個方法,否則就得自己去處理相關事件的觸發(fā)邏輯。
FileUploadBaseView整體實現(xiàn)如下:
define(function (require, exports, module) {var $ = require('jquery');var Class = require('mod/class');var EventBase = require('mod/eventBase');var DEFAULTS = {data: [], //要展示的數(shù)據(jù)列表,列表元素必須是object類型的,如[{url: 'xxx.png'},{url: 'yyyy.png'}]sizeLimit: 0, //用來限制BaseView中的展示的元素個數(shù),為0表示不限制readonly: false, //用來控制BaseView中的元素是否允許增加和刪除onBeforeRender: $.noop, //對應render.before事件,在render方法調(diào)用前觸發(fā)onRender: $.noop, //對應render.after事件,在render方法調(diào)用后觸發(fā)onBeforeAppend: $.noop, //對應append.before事件,在append方法調(diào)用前觸發(fā)onAppend: $.noop, //對應append.after事件,在append方法調(diào)用后觸發(fā)onBeforeDelItem: $.noop, //對應delItem.before事件,在delItem方法調(diào)用前觸發(fā)onDelItem: $.noop //對應delItem.after事件,在delItem方法調(diào)用后觸發(fā)};/*** 數(shù)據(jù)處理,給data的每條記錄都添加一個_uuid的屬性,方便查找*/function resolveData(data) {var time = new Date().getTime();return $.map(data, function (d) {return resolveDataItem(d, time);});}function resolveDataItem(data, time) {time = time || new Date().getTime();data._uuid = '_uuid' + time + Math.floor(Math.random() * 100000);return data;}var FileUploadBaseView = Class({instanceMembers: {init: function (element, options) {//通過this.base調(diào)用父類EventBase的init方法this.base(element);//實例屬性var opts = this.options = this.getOptions(options);this.data = resolveData(opts.data);delete opts.data;this.sizeLimit = opts.sizeLimit;this.readOnly = opts.readOnly;//綁定事件this.on('render.before', $.proxy(opts.onBeforeRender, this));this.on('render.after', $.proxy(opts.onRender, this));this.on('append.before', $.proxy(opts.onBeforeAppend, this));this.on('append.after', $.proxy(opts.onAppend, this));this.on('delItem.before', $.proxy(opts.onBeforeDelItem, this));this.on('delItem.after', $.proxy(opts.onDelItem, this));},getOptions: function (options) {return $.extend({}, this.getDefaults(), options);},getDefaults: function () {return DEFAULTS;},getDataItem: function (uuid) {//根據(jù)uuid獲取dateItemreturn this.data.filter(function (item) {return item._uuid === uuid;})[0];},getDataItemIndex: function (uuid) {var ret;this.data.forEach(function (item, i) {item._uuid === uuid && (ret = i);});return ret;},render: function () {/*** render是一個模板,子類不需要重寫render方法,只需要重寫_render方法* 當調(diào)用子類的render方法時調(diào)用的是父類的render方法* 但是執(zhí)行到_render方法時,調(diào)用的是子類的_render方法* 這樣就能把before跟after事件的觸發(fā)操作統(tǒng)一起來*/var e;this.trigger(e = $.Event('render.before'));if (e.isDefaultPrevented()) return;this._render();this.trigger($.Event('render.after'));},//子類需實現(xiàn)_Render方法_render: function () {},append: function (item) {var e;if (!item) return;item = resolveDataItem(item);this.trigger(e = $.Event('append.before'), item);if (e.isDefaultPrevented()) return;this.data.push(item);this._append(item);this.trigger($.Event('append.after'), item);},//子類需實現(xiàn)_append方法_append: function (data) {},delItem: function (uuid) {var e, item = this.getDataItem(uuid);if (!item) return;this.trigger(e = $.Event('delItem.before'), item);if (e.isDefaultPrevented()) return;this.data.splice(this.getDataItemIndex(uuid), 1);this._delItem(item);this.trigger($.Event('delItem.after'), item);},//子類需實現(xiàn)_delItem方法_delItem: function (data) {}},extend: EventBase,staticMembers: {DEFAULTS: DEFAULTS}});return FileUploadBaseView;});
ImageUploadView 的實現(xiàn)就比較簡單了,跟填空差不多,有幾個點需要說明一下:
1)這個類的DEFAULTS需要擴展父類的DEFAULTS,以便添加這個子類的默認options,同時還保留父類默認options的定義;根據(jù)靜態(tài)頁面結(jié)構(gòu),新增了一個onAppendClick事件,外部可在這個事件中調(diào)用文件上傳組件的相關方法:
//繼承并擴展父類的默認DEFAULTSvar DEFAULTS = $.extend({}, FileUploadBaseView.DEFAULTS, {onAppendClick: $.noop //點擊上傳按鈕時候的回調(diào)});
2)在init方法中,需要調(diào)用父類的init方法,才能完成那些通用的邏輯處理;同時在init的最后還得手動調(diào)用一下render方法,以便在組件實例化之后就能看到效果:
其它實現(xiàn)純粹是業(yè)務邏輯實現(xiàn),跟第2部分的需求密切相關。
ImageUploadView的整體實現(xiàn)如下:
define(function (require, exports, module) {var $ = require('jquery');var Class = require('mod/class');var FileUploadBaseView = require('mod/fileUploadBaseView');//繼承并擴展父類的默認DEFAULTSvar DEFAULTS = $.extend({}, FileUploadBaseView.DEFAULTS, {onAppendClick: $.noop //點擊上傳按鈕時候的回調(diào)});var ImageUploadView = Class({instanceMembers: {init: function (element, options) {var $element = this.$element = $(element);var opts = this.getOptions(options);//調(diào)用父類的init方法完成options獲取,data解析以及通用事件的監(jiān)聽處理this.base(this.$element, options);//添加上傳和刪除的監(jiān)聽器及觸發(fā)處理if (!this.readOnly) {var that = this;that.on('appendClick', $.proxy(opts.onAppendClick, this));$element.on('click.append', '.view-act-add', function (e) {e.preventDefault();that.trigger('appendClick');});$element.on('click.remove', '.view-act-del', function (e) {var $this = $(e.currentTarget);that.delItem($this.data('uuid'));e.preventDefault();});}this.render();},getDefaults: function () {return DEFAULTS;},_setItemAddHtml: function () {this.$element.prepend($('<li class="view-item-add"><a class="view-act-add" href="javascript:;" title="點擊上傳">+</a></li>'));},_clearItemAddHtml: function ($itemAddLi) {$itemAddLi.remove();},_render: function () {var html = [], that = this;//如果不是只讀的狀態(tài),并且還沒有達到上傳限制的話,就添加上傳按鈕if (!(this.readOnly || (this.sizeLimit && this.sizeLimit <= this.data.length))) {this._setItemAddHtml();}this.data.forEach(function (item) {html.push(that._getItemRenderHtml(item))});this.$element.append($(html.join('')));},_getItemRenderHtml: function (item) {return ['<li id="',item._uuid,'"><a class="view-act-preview" href="javascript:;"><img alt="" src="',item.url,'">',this.readOnly ? '' : '<span class="view-act-del" data-uuid="',item._uuid,'">刪除</span>','</a></li>'].join('');},_dealWithSizeLimit: function () {if (this.sizeLimit) {var $itemAddLi = this.$element.find('li.view-item-add');//如果已經(jīng)達到上傳限制的話,就移除上傳按鈕if (this.sizeLimit && this.sizeLimit <= this.data.length && $itemAddLi.length) {this._clearItemAddHtml($itemAddLi);} else if (!$itemAddLi.length) {this._setItemAddHtml();}}},_append: function (data) {this.$element.append($(this._getItemRenderHtml(data)));this._dealWithSizeLimit();},_delItem: function (data) {$('#' + data._uuid).remove();this._dealWithSizeLimit();}},extend: FileUploadBaseView});return ImageUploadView;});
4. 演示說明
演示的項目結(jié)構(gòu)為:
框起來的就是演示的核心代碼。其中fileUploadBaserView.js和imageUploadView.js是前面實現(xiàn)的兩個核心組件。fileUploader.js是用來模擬上傳組件的,它的實例有一個onSuccess的回調(diào),表示上傳成功;還有一個openChooseFileWin用來模擬真實的打開選擇文件窗口并上傳的這個過程:
define(function(require, exports, module) {return function() {var imgList = ['../img/1.jpg','../img/2.jpg','../img/3.jpg','../img/4.jpg'], i = 0;var that = this;that.onSuccess = function(uploadValue){}this.openChooseFileWin = function(){setTimeout(function(){that.onSuccess(imgList[i++]);if(i == imgList.length) {i = 0;}},1000);}}});
app/regist.js是演示頁面的邏輯代碼,關鍵的部分已用注釋進行說明:
define(function (require, exports, module) {var $ = require('jquery');var ImageUploadView = require('mod/imageUploadView');var FileUploader = require('mod/fileUploader');//這是用異步任務模擬的文件上傳組件//$legalPersonIDPic,用來存儲已上傳的文件信息,上傳組件上傳成功之后以及ImageUploadView組件刪除某個item之后會對$legalPersonIDPic的值產(chǎn)生影響var $legalPersonIDPic = $('#legalPersonIDPic-input'),data = JSON.parse($legalPersonIDPic.val() || '[]');//data是初始值,比如當前頁面有可能是從數(shù)據(jù)庫加載的,需要用ImageUploadView組件呈現(xiàn)出來//在文件上傳成功之后,將剛上傳的文件保存到$legalPersonIDPic的value中//$legalPersonIDPic以json字符串的形式存儲var appendImageInputValue = function ($input, item) {var value = JSON.parse($input.val() || '[]');value.push(item);$input.val(JSON.stringify(value));};//當調(diào)用ImageUploadView組件刪除某個item之后,要同步把$legalPersonIDPic中已存儲的信息清掉var removeImageInputValue = function ($input, uuid) {var value = JSON.parse($input.val() || '[]'), index;value.forEach(function (item, i) {if (item._uuid === uuid) {index = i;}});value.splice(index, 1);$input.val(JSON.stringify(value));};var fileUploader = new FileUploader();fileUploader.onSuccess = function (uploadValue) {var item = {url: uploadValue};legalPersonIDPicView.append(item);appendImageInputValue($legalPersonIDPic, item);};var legalPersonIDPicView = new ImageUploadView('#legalPersonIDPic-view', {data: data,sizeLimit: 4,onAppendClick: function () {//打開選擇文件的窗口fileUploader.openChooseFileWin();},onDelItem: function (data) {removeImageInputValue($legalPersonIDPic, data._uuid);}});});
5. 本文總結(jié)
ImageUploadView這個組件最終實現(xiàn)起來并不難,但是我也花了不少時間去琢磨它及其它父類的實現(xiàn)方法,大部分時間都花在對職責分離和行為分離的抽象過程中。我在本文表達的關于這兩方面編程思想的觀點也只是自己個人的實際體會,因為抽象層面的東西,每個人的思考方式不同最終理解的成果也就不會相同,所以我也不能直接說我的對還是不對,寫出來的目的就是為了分享和交流,看看有沒有其他有經(jīng)驗的朋友也愿意把自己在這方面的想法拿出來跟大家說一說,相信每個人看多了別人的思路之后,也會對自己的編程思想方面的鍛煉帶來幫助。
新聞熱點
疑難解答