本文要介紹的是網頁中常見的圖片上傳後直接在頁面生成小圖預覽的實現思路,考慮到該功能有一定的適用性,於是把相關的邏輯封裝成了一個ImageUploadView組件,實際使用效果可查看下一段的git效果圖。在實現這個組件的過程中,有用到前面幾篇博客介紹的相關內容,比如繼承庫class.js,任意組件的事... ...
本文要介紹的是網頁中常見的圖片上傳後直接在頁面生成小圖預覽的實現思路,考慮到該功能有一定的適用性,於是把相關的邏輯封裝成了一個ImageUploadView組件,實際使用效果可查看下一段的git效果圖。在實現這個組件的過程中,有用到前面幾篇博客介紹的相關內容,比如繼承庫class.js,任意組件的事件管理庫eventBase.js,同時包含進了自己對職責分離,表現與行為分離這兩方面的一些思考,歡迎閱讀與交流。
演示效果(代碼下載):
註:由於演示的代碼都是靜態的,所以文件上傳的組件是用setTimeout模擬的,不過它的調用方式跟我自己在實際工作中用上傳組件完全相同,所以演示效果的代碼實現完全符合真實的功能需求。
按照我以前博客的思路,先來介紹下這個上傳預覽功能的需求。
1. 需求分析
根據前面的演示效果圖,分析需求如下:
1)初始時上傳區域只顯示一個可點擊上傳的按鈕,當點擊該按鈕後,將上傳成功的圖片顯示在後面的預覽區域
2)上傳的圖片添加到預覽區域以後,可以通過刪除按鈕來移除
3)當已上傳的圖片總數達到一定限制之後,比如演示中已上傳的限製為4,就把上傳按鈕給移除掉;
4)當已上傳的圖片總數達到一定限制之時,如果通過刪除操作移除了某張圖片,還得再把上傳按鈕給顯示出來。
以上需求是看的見的,根據經驗,還可以分析得出的需求如下:
1)如果頁面是編輯狀態,也就是從資料庫中查詢出來的狀態,只要圖片列表不為空,初始時還得把圖片顯示出來;而且還要根據查出來的圖片列表的長度跟上傳限制去控制上傳按鈕是否顯示;
2)如果當前頁面是一種只能看不能改的狀態,那麼在初始時一定要把上傳按鈕和刪除按鈕移除掉。
需求分析完畢,接下來說明一下我的實現思路。
2. 實現思路
由於這是個表單頁面,所以圖片上傳完以後如果要提交到後臺,肯定得需要一個文本域,所以我在做靜態頁面的時候就把這個文本域考慮進去了,當上傳完新的圖片以及刪除了圖片之後都得去修改這個文本域的值。當時做靜態頁時這部分的結構如下:
<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>
從這個結構還可以看出,我把整個上傳區域都放在一個ul裡面,然後把ul的第一個li作為上傳按鈕來使用。為了完成這個功能,我們主要的任務是:上傳及上傳後的回調,新增或刪除圖片預覽以及文本域值的管理。從這一點,結合職責分離的思想,這個功能至少需要三個組件,一個負責文件上傳,一個負責圖片預覽的管理,一個負責文本域值的管理。千萬不能把這三個功能,兩兩或者全部都封裝在一起,那樣的話功能耦合太強,寫出來的組件可擴展性可重用性不高。如果這三個組件之間需要交互,我們只要藉助回調函數或者發佈-訂閱模式定義它們給外部調用的介面即可。
不過文本域值的管理本身就很簡單,寫不寫成組件都關係不大,但是至少函數級別的封裝是得有的;文件上傳組件雖然不是本文的重點,但是網上有很多現成的開源插件,比如webuploader,不管是直接用還是做二次封裝都可以應用進來;圖片預覽的功能是本文的核心內容,ImageUploadView這個組件就是對它的封裝,從需求來看,這個組件有語義的實例方法無非就是三個,分別是render, append, delItem,其中render用來在初始化完成之後顯示初始的預覽列表,append用來在上傳成功後添加新的圖片預覽,delItem用來刪除已有的圖片預覽,按照這個基本思路,我們只需要再結合需求和組件開發的經驗為它設計好options和事件即可。
從前面的需求我們發現,這個ImageUploadView組件的render會受到頁面狀態的影響,當頁面為查看模式時,這個組件不能做上傳和刪除的操作,所以可以考慮給它加一個readonly的option。同時它的上傳和刪除操作還會影響到上傳按鈕的UI邏輯,這個跟上傳限制有關係,為了靈活性,也得把上傳限製作為一個option。從前一段提到的三個實例方法來說,按照自己以前定義事件的經驗,一般一個實例方法會定義一對事件,就像bootstrap的插件的做法一樣,比如render方法,可以定義一個render.before,這個事件在render的主要邏輯執行前觸發,如果外部監聽器調用了這個事件的preventDefault()方法,那麼render的主要邏輯都不會執行;還有一個render.after事件,這個事件在render的主要邏輯執行後觸發。這種成對定義事件的好處是,既給外部提供擴展組件功能的方法,又能增加組件預設行為的管理。
最後從我之前的工作經驗來說,除了有上傳圖片進行預覽這樣的功能,我曾經還做過上傳視頻,上傳音頻,上傳普通文檔等類似的,所以這一次碰到這個功能的時候我就覺得應該把這些功能裡面相似的東西抽取出來,作為一個基類,圖片上傳,視頻上傳等分別繼承這個基類去實現各自的邏輯。這個基類還有一個好處,就是能夠讓那些通用的邏輯完全與HTML結構分離,在這個基類裡面只做一些通用的事情,比如options與組件行為(render, append, delItem)的定義,以及通用事件的監聽和觸發,它只要留有固定的介面留給子類來實現即可。在後面的實現中,我定義了一個FileUploadBaseView組件來完成這個基類的功能,這個基類不包含任何html或css處理的邏輯,它只是抽象了我們要完成的功能,不處理任何業務邏輯。根據業務邏輯實現的子類會受html結構的限制,所以子類的適用範圍小;而基類因為做到了與html結構完全分離,所以有更大的適用範圍。
3. 實現細節
從第2部分的實現思路,要實現的類有:FileUploadBaseView和ImageUploadView,前者是後者的基類。同時考慮到要給組件提供事件管理的功能,所以要用到上一篇博客的eventBase.js,FileUploadBaseView得繼承該庫的EventBase組件;考慮到要有類的定義和繼承,還要用到之前寫的繼承庫class.js來定義組件以及組件的繼承關係。相關組件的繼承關係為:ImageUploadView extend FileUploadBaseView extend EventBase。
(註:以下相關代碼中模塊化用的是seajs。)
FileUploadBaseView所做的事情有:
1)定義通用的option以及通用的事件管理
在該組件的DEFAULTS配置中可以看到所有的通用option和通用事件的定義:
var DEFAULTS = { data: [], //要展示的數據列表,列表元素必須是object類型的,如[{url: 'xxx.png'},{url: 'yyyy.png'}] sizeLimit: 0, //用來限制BaseView中的展示的元素個數,為0表示不限制 readonly: false, //用來控制BaseView中的元素是否允許增加和刪除 onBeforeRender: $.noop, //對應render.before事件,在render方法調用前觸發 onRender: $.noop, //對應render.after事件,在render方法調用後觸發 onBeforeAppend: $.noop, //對應append.before事件,在append方法調用前觸發 onAppend: $.noop, //對應append.after事件,在append方法調用後觸發 onBeforeDelItem: $.noop, //對應delItem.before事件,在delItem方法調用前觸發 onDelItem: $.noop //對應delItem.after事件,在delItem方法調用後觸發 };
在該組件的init方法中可以看到對通用option和事件管理的初始化邏輯:
init: function (element, options) { //通過this.base調用父類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)定義組件的行為,預留可供子類實現的介面:
render: function () { /** * render是一個模板,子類不需要重寫render方法,只需要重寫_render方法 * 當調用子類的render方法時調用的是父類的render方法 * 但是執行到_render方法時,調用的是子類的_render方法 * 這樣就能把before跟after事件的觸發操作統一起來 */ var e; this.trigger(e = $.Event('render.before')); if (e.isDefaultPrevented()) return; this._render(); this.trigger($.Event('render.after')); }, //子類需實現_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); }, //子類需實現_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); }, //子類需實現_delItem方法 _delItem: function (data) { }為了統一處理行為前後的事件派發邏輯,將render, append ,delItem的主要邏輯抽出來成為需被子類實現的方法_render, _append和_delItem。當調用子類的render方法時,調用的實際上父類的方法,但是當父類執行到_render方法時,執行的就是子類的方法,另外兩個方法也是類似的處理。需要註意的是子類不能去覆蓋render, append ,delItem三個方法,否則就得自己去處理相關事件的觸發邏輯。
FileUploadBaseView整體實現如下:
define(function (require, exports, module) { var $ = require('jquery'); var Class = require('mod/class'); var EventBase = require('mod/eventBase'); var DEFAULTS = { data: [], //要展示的數據列表,列表元素必須是object類型的,如[{url: 'xxx.png'},{url: 'yyyy.png'}] sizeLimit: 0, //用來限制BaseView中的展示的元素個數,為0表示不限制 readonly: false, //用來控制BaseView中的元素是否允許增加和刪除 onBeforeRender: $.noop, //對應render.before事件,在render方法調用前觸發 onRender: $.noop, //對應render.after事件,在render方法調用後觸發 onBeforeAppend: $.noop, //對應append.before事件,在append方法調用前觸發 onAppend: $.noop, //對應append.after事件,在append方法調用後觸發 onBeforeDelItem: $.noop, //對應delItem.before事件,在delItem方法調用前觸發 onDelItem: $.noop //對應delItem.after事件,在delItem方法調用後觸發 }; /** * 數據處理,給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調用父類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) { //根據uuid獲取dateItem return 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方法 * 當調用子類的render方法時調用的是父類的render方法 * 但是執行到_render方法時,調用的是子類的_render方法 * 這樣就能把before跟after事件的觸發操作統一起來 */ var e; this.trigger(e = $.Event('render.before')); if (e.isDefaultPrevented()) return; this._render(); this.trigger($.Event('render.after')); }, //子類需實現_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); }, //子類需實現_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); }, //子類需實現_delItem方法 _delItem: function (data) { } }, extend: EventBase, staticMembers: { DEFAULTS: DEFAULTS } }); return FileUploadBaseView; });
ImageUploadView 的實現就比較簡單了,跟填空差不多,有幾個點需要說明一下:
1)這個類的DEFAULTS需要擴展父類的DEFAULTS,以便添加這個子類的預設options,同時還保留父類預設options的定義;根據靜態頁面結構,新增了一個onAppendClick事件,外部可在這個事件中調用文件上傳組件的相關方法:
//繼承並擴展父類的預設DEFAULTS var DEFAULTS = $.extend({}, FileUploadBaseView.DEFAULTS, { onAppendClick: $.noop //點擊上傳按鈕時候的回調 });
2)在init方法中,需要調用父類的init方法,才能完成那些通用的邏輯處理;同時在init的最後還得手動調用一下render方法,以便在組件實例化之後就能看到效果:
其它實現純粹是業務邏輯實現,跟第2部分的需求密切相關。
ImageUploadView的整體實現如下:
define(function (require, exports, module) { var $ = require('jquery'); var Class = require('mod/class'); var FileUploadBaseView = require('mod/fileUploadBaseView'); //繼承並擴展父類的預設DEFAULTS var DEFAULTS = $.extend({}, FileUploadBaseView.DEFAULTS, { onAppendClick: $.noop //點擊上傳按鈕時候的回調 }); var ImageUploadView = Class({ instanceMembers: { init: function (element, options) { var $element = this.$element = $(element); var opts = this.getOptions(options); //調用父類的init方法完成options獲取,data解析以及通用事件的監聽處理 this.base(this.$element, options); //添加上傳和刪除的監聽器及觸發處理 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; //如果不是只讀的狀態,並且還沒有達到上傳限制的話,就添加上傳按鈕 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'); //如果已經達到上傳限制的話,就移除上傳按鈕 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. 演示說明
演示的項目結構為:
框起來的就是演示的核心代碼。其中fileUploadBaserView.js和imageUploadView.js是前面實現的兩個核心組件。fileUploader.js是用來模擬上傳組件的,它的實例有一個onSuccess的回調,表示上傳成功;還有一個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的值產生影響 var $legalPersonIDPic = $('#legalPersonIDPic-input'), data = JSON.parse($legalPersonIDPic.val() || '[]');//data是初始值,比如當前頁面有可能是從資料庫載入的,需要用ImageUploadView組件呈現出來 //在文件上傳成功之後,將剛上傳的文件保存到$legalPersonIDPic的value中 //$legalPersonIDPic以json字元串的形式存儲 var appendImageInputValue = function ($input, item) { var value = JSON.parse($input.val() || '[]'); value.push(item); $input.val(JSON.stringify(value)); }; //當調用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. 本文總結
ImageUploadView這個組件最終實現起來並不難,但是我也花了不少時間去琢磨它及其它父類的實現方法,大部分時間都花在對職責分離和行為分離的抽象過程中。我在本文表達的關於這兩方面編程思想的觀點也只是自己個人的實際體會,因為抽象層面的東西,每個人的思考方式不同最終理解的成果也就不會相同,所以我也不能直接說我的對還是不對,寫出來的目的就是為了分享和交流,看看有沒有其他有經驗的朋友也願意把自己在這方面的想法拿出來跟大家說一說,相信每個人看多了別人的思路之後,也會對自己的編程思想方面的鍛煉帶來幫助。
最後,希望本文的理論和實踐,都能給大家帶來實際的工作價值,謝謝閱讀:)