jQuery技巧之讓任何組件都支持類似DOM的事件管理
本文介紹一個jquery的小技巧,能讓任意組件對象都能支持類似DOM的事件管理,也就是說除了派發(fā)事件,添加或刪除事件監(jiān)聽器,還能支持事件冒泡,阻止事件默認行為等等。在jquery的幫助下,使用這個方法來管理普通對象的事件就跟管理DOM對象的事件一模一樣,雖然在最后當(dāng)你看到這個小技巧的具體內(nèi)容時,你可能會覺得原來如此或者不過如此,但是我覺得如果能把普通的發(fā)布-訂閱模式的實現(xiàn)改成DOM類似的事件機制,那開發(fā)出來的組件一定會有更大的靈活性和擴展性,而且我也是第一次使用這種方法(見識太淺的原因),覺得它的使用價值還蠻大的,所以就把它分享出來了。
在正式介紹這個技巧之前,得先說一下我之前考慮的一種方法,也就是發(fā)布-訂閱模式,看看它能解決什么問題以及它存在的問題。
1. 發(fā)布-訂閱模式
很多博客包括書本上都說javascript要實現(xiàn)組件的自定義事件的話,可以采用發(fā)布-訂閱模式,起初我也是堅定不移地這么認為的,于是用jquery的$.Callbacks寫了一個:
define(function(require, exports, module) { var $ = require('jquery'); var Class = require('./class'); function isFunc(f) { return Object.prototype.toString.apply(f) === '[object Function]'; } /** * 這個基類可以讓普通的類具備事件驅(qū)動的能力 * 提供類似jq的on off trigger方法,不考慮one方法,也不考慮命名空間 * 舉例: * var e = new EventBase(); * e.on('load', function(){ * console.log('loaded'); * }); * e.trigger('load');//loaded * e.off('load'); */ var EventBase = Class({ instanceMembers: { init: function () { this.events = {}; //把$.Callbacks的flag設(shè)置成一個實例屬性,以便子類可以覆蓋 this.CALLBACKS_FLAG = 'unique'; }, on: function (type, callback) { type = $.trim(type); //如果type或者callback參數(shù)無效則不處理 if (!(type && isFunc(callback))) return; var event = this.events[type]; if (!event) { //定義一個新的jq隊列,且該隊列不能添加重復(fù)的回調(diào) event = this.events[type] = $.Callbacks(this.CALLBACKS_FLAG); } //把callback添加到這個隊列中,這個隊列可以通過type來訪問 event.add(callback); }, off: function (type, callback) { type = $.trim(type); if (!type) return; var event = this.events[type]; if (!event) return; if (isFunc(callback)) { //如果同時傳遞type跟callback,則將callback從type對應(yīng)的隊列中移除 event.remove(callback); } else { //否則就移除整個type對應(yīng)的隊列 delete this.events[type]; } }, trigger: function () { var args = [].slice.apply(arguments), type = args[0];//第一個參數(shù)轉(zhuǎn)為type type = $.trim(type); if (!type) return; var event = this.events[type]; if (!event) return; //用剩下的參數(shù)來觸發(fā)type對應(yīng)的回調(diào) //同時把回調(diào)的上下文設(shè)置成當(dāng)前實例 event.fireWith(this, args.slice(1)); } } }); return EventBase; });
(基于seajs以及《詳解Javascript的繼承實現(xiàn)》介紹的繼承庫class.js)
只要任何組件繼承這個EventBase,就能繼承它提供的on off trigger方法來完成消息的訂閱,發(fā)布和取消訂閱功能,比如我下面想要實現(xiàn)的這個FileUploadBaseView:
define(function(require, exports, module) { var $ = require('jquery'); var Class = require('./class'); var EventBase = require('./eventBase'); var DEFAULTS = { data: [], //要展示的數(shù)據(jù)列表,列表元素必須是object類型的,如[{url: 'xxx.png'},{url: 'yyyy.png'}] sizeLimit: 0, //用來限制BaseView中的展示的元素個數(shù),為0表示不限制 readonly: false, //用來控制BaseView中的元素是否允許增加和刪除 onBeforeRender: $.noop, //對應(yīng)beforeRender事件,在render方法調(diào)用前觸發(fā) onRender: $.noop, //對應(yīng)render事件,在render方法調(diào)用后觸發(fā) onBeforeAppend: $.noop, //對應(yīng)beforeAppend事件,在append方法調(diào)用前觸發(fā) onAppend: $.noop, //對應(yīng)append事件,在append方法調(diào)用后觸發(fā) onBeforeRemove: $.noop, //對應(yīng)beforeRemove事件,在remove方法調(diào)用前觸發(fā) onRemove: $.noop //對應(yīng)remove事件,在remove方法調(diào)用后觸發(fā) }; /** * 數(shù)據(jù)解析,給每個元素的添加一個唯一標(biāo)識_uuid,方便查找 */ function resolveData(ctx, data){ var time = new Date().getTime(); return $.map(data, function(d){ d._uuid = '_uuid' + time + Math.floor(Math.random() * 100000); }); } var FileUploadBaseView = Class({ instanceMembers: { init: function (options) { this.base(); this.options = this.getOptions(options); }, getOptions: function(options) { return $.extend({}, DEFAULTS, options); }, render: function(){ }, append: function(data){ }, remove: function(prop){ } }, extend: EventBase }); return FileUploadBaseView; });
實際調(diào)用測試如下:
測試中,實例化了一個FileUploadBaseView對象f,并設(shè)置了它的name屬性,通過on方法添加一個跟hello相關(guān)的監(jiān)聽器,最后通過trigger方法觸發(fā)了hello的監(jiān)聽器,并傳遞了額外的兩個參數(shù),在監(jiān)聽器內(nèi)部除了可以通過監(jiān)聽器的函數(shù)參數(shù)訪問到trigger傳遞過來的數(shù)據(jù),還能通過this訪問f對象。
從目前的結(jié)果來說,這個方式看起來還不錯,但是在我想要繼續(xù)實現(xiàn)FileUploadBaseView的時候碰到了問題。你看我在設(shè)計這個組件的時候那幾個訂閱相關(guān)的option:
我原本的設(shè)計是:這些訂閱都是成對定義,一對訂閱跟某個實例方法對應(yīng),比如帶before的那個訂閱會在相應(yīng)的實例方法(render)調(diào)用前觸發(fā),不帶before的那個訂閱會在相應(yīng)的實例方法(render)調(diào)用后觸發(fā),而且還要求帶before的那個訂閱如果返回false,就不執(zhí)行相應(yīng)的實例方法以及后面的訂閱。最后這個設(shè)計要求是考慮到在調(diào)用組件的實例方法之前,有可能因為一些特殊的原因,必須得取消當(dāng)前實例方法的調(diào)用,比如調(diào)用remove方法時有的數(shù)據(jù)不能remove,那么就可以在before訂閱里面做一些校驗,能刪除的返回true,不能刪除的返回false,然后在實例方法中觸發(fā)before的訂閱后加一個判斷就可以了,類似下面的這種做法:
但是這個做法只能在單純的回調(diào)函數(shù)模式里實現(xiàn),在發(fā)布-訂閱模式下是行不通的,因為回調(diào)函數(shù)只會跟一個函數(shù)引用相關(guān),而發(fā)布-訂閱模式里,同一個消息可能有多個訂閱,如果把這種做法應(yīng)用到發(fā)布-訂閱里面,當(dāng)調(diào)用this.trigger('beforeRender')的時候,會把跟beforeRender關(guān)聯(lián)的所有訂閱全部調(diào)用一次,那么以哪個訂閱的返回值為準呢?也許你會說可以用隊列中的最后一個訂閱的返回值為準,在大多數(shù)情況下也許這么干沒問題,但是當(dāng)我們把“以隊列最后的一個訂閱返回值作為判斷標(biāo)準”這個邏輯加入到EventBase中的時候,會出現(xiàn)一個很大的風(fēng)險,就是外部在使用的時候,一定得清楚地管理好訂閱的順序,一定要把那個跟校驗等一些特殊邏輯相關(guān)的訂閱放在最后面才行,而這種跟語法、編譯沒有關(guān)系,對編碼順序有要求的開發(fā)方式會給軟件帶來比較大的安全隱患,誰能保證任何時候任何場景都能控制好訂閱的順序呢,更何況公司里面可能還有些后來的新人,壓根不知道你寫的東西還有這樣的限制。
解決這個問題的完美方式,就是像DOM對象的事件那樣,在消息發(fā)布的時候,不是簡簡單單的發(fā)布一個消息字符串,而是把這個消息封裝成一個對象,這個對象會傳遞給它所有的訂閱,哪個訂閱里覺得應(yīng)該阻止這個消息發(fā)布之后的邏輯,只要調(diào)用這個消息的preventDefault()方法,然后在外部發(fā)布完消息后,調(diào)用消息的isDefaultPrevented()方法判斷一下即可:
而這個做法跟使用jquery管理DOM對象的事件是一樣的思路,比如bootstrap的大部分組件以及我在前面一些博客中寫的組件都是用的這個方法來增加額外的判斷邏輯,比如bootstrap的alert組件在close方法執(zhí)行的時候有一段這樣的判斷:
按照這個思路去改造EventBase是一個解決問題的方法,但是jquery的一個小技巧,能夠讓我們把整個普通對象的事件管理變得更加簡單,下面就讓我們來瞧一瞧它的廬山真面目。
2. jquery小技巧模式
1)技巧一
如果在定義組件的時候,這個組件是跟DOM對象有關(guān)聯(lián)的,比如下面這種形式:
那么我們可以完全給這個組件添加on off trigger one這幾個常用事件管理的方法,然后將這些方法代理到$element的相應(yīng)方法上:
通過代理,當(dāng)調(diào)用組件的on方法時,其實調(diào)用的是$element的on方法,這樣的話這種類型的組件就能支持完美的事件管理了。
2)技巧二
第一個技巧只能適用于跟DOM有關(guān)聯(lián)的組件,對于那些跟DOM完全沒有關(guān)聯(lián)的組件該怎么添加像前面這樣完美的事件管理機制呢?其實方法也很簡單,只是我自己以前真的是沒這么用過,所以這一次用起來才會覺得特別新鮮:
看截圖中框起來的部分,只要給jquery的構(gòu)造函數(shù)傳遞一個空對象,它就會返回一個完美支持事件管理的jquery對象。而且除了事件管理的功能外,由于它是一個jquery對象。所以jquery原型上的所有方法它都能調(diào)用,將來要是需要借用jquery其它的跟DOM無關(guān)的方法,說不定也能參考這個小技巧來實現(xiàn)。
3. 完美的事件管理實現(xiàn)
考慮到第2部分介紹的2種方式里面有重復(fù)的邏輯代碼,如果把它們結(jié)合起來的話,就可以適用所有的開發(fā)組件的場景,也就能達到本文標(biāo)題和開篇提到的讓任意對象支持事件管理功能的目標(biāo)了,所以最后結(jié)合前面兩個技巧,把EventBase改造如下(是不是夠簡單):
define(function(require, exports, module) { var $ = require('jquery'); var Class = require('./class'); /** * 這個基類可以讓普通的類具備jquery對象的事件管理能力 */ var EventBase = Class({ instanceMembers: { init: function (_jqObject) { this._jqObject = _jqObject && _jqObject instanceof $ && _jqObject || $({}); }, on: function(){ return $.fn.on.apply(this._jqObject, arguments); }, one: function(){ return $.fn.one.apply(this._jqObject, arguments); }, off: function(){ return $.fn.off.apply(this._jqObject, arguments); }, trigger: function(){ return $.fn.trigger.apply(this._jqObject, arguments); } } }); return EventBase; });
實際調(diào)用測試如下
1)模擬跟DOM關(guān)聯(lián)的組件
測試代碼一:
define(function(require, exports, module) { var $ = require('jquery'); var Class = require('mod/class'); var EventBase = require('mod/eventBase'); var Demo = window.demo = Class({ instanceMembers: { init: function (element,options) { this.$element = $(element); this.base(this.$element); //添加監(jiān)聽 this.on('beforeRender', $.proxy(options.onBeforeRender, this)); this.on('render', $.proxy(options.onRender, this)); }, render: function () { //觸發(fā)beforeRender事件 var e = $.Event('beforeRender'); this.trigger(e); if(e.isDefaultPrevented())return; //主要邏輯代碼 console.log('render complete!'); //觸發(fā)render事件 this.trigger('render'); } }, extend: EventBase }); var demo = new Demo('#demo', { onBeforeRender: function(e) { console.log('beforeRender event triggered!'); }, onRender: function(e) { console.log('render event triggered!'); } }); demo.render(); });
在這個測試里, 我定義了一個跟DOM關(guān)聯(lián)的Demo組件并繼承了EventBase這個事件管理的類,給beforeRender事件和render事件都添加了一個監(jiān)聽,render方法中也有打印信息來模擬真實的邏輯,實例化Demo的時候用到了#demo這個DOM元素,最后的測試結(jié)果是:
完全與預(yù)期一致。
測試代碼二:
define(function(require, exports, module) { var $ = require('jquery'); var Class = require('mod/class'); var EventBase = require('mod/eventBase'); var Demo = window.demo = Class({ instanceMembers: { init: function (element,options) { this.$element = $(element); this.base(this.$element); //添加監(jiān)聽 this.on('beforeRender', $.proxy(options.onBeforeRender, this)); this.on('render', $.proxy(options.onRender, this)); }, render: function () { //觸發(fā)beforeRender事件 var e = $.Event('beforeRender'); this.trigger(e); if(e.isDefaultPrevented())return; //主要邏輯代碼 console.log('render complete!'); //觸發(fā)render事件 this.trigger('render'); } }, extend: EventBase }); var demo = new Demo('#demo', { onBeforeRender: function(e) { console.log('beforeRender event triggered!'); }, onRender: function(e) { console.log('render event triggered!'); } }); demo.on('beforeRender', function(e) { e.preventDefault(); console.log('beforeRender event triggered 2!'); }); demo.on('beforeRender', function(e) { console.log('beforeRender event triggered 3!'); }); demo.render(); });
在這個測試了, 我定義了一個跟DOM相關(guān)的Demo組件并繼承了EventBase這個事件管理的類,給beforeRender事件添加了3個監(jiān)聽,其中一個有加prevetDefault()的調(diào)用,而且該回調(diào)還不是最后一個,最后的測試結(jié)果是:
從結(jié)果可以看到,render方法的主要邏輯代碼跟后面的render事件都沒有執(zhí)行,所有beforeRender的監(jiān)聽器都執(zhí)行了,說明e.preventDefault()生效了,而且它沒有對beforeRender的事件隊列產(chǎn)生影響。
2)模擬跟DOM無關(guān)聯(lián)的普通對象
測試代碼一:
define(function(require, exports, module) { var $ = require('jquery'); var Class = require('mod/class'); var EventBase = require('mod/eventBase'); var Demo = window.demo = Class({ instanceMembers: { init: function (options) { this.base(); //添加監(jiān)聽 this.on('beforeRender', $.proxy(options.onBeforeRender, this)); this.on('render', $.proxy(options.onRender, this)); }, render: function () { //觸發(fā)beforeRender事件 var e = $.Event('beforeRender'); this.trigger(e); if(e.isDefaultPrevented())return; //主要邏輯代碼 console.log('render complete!'); //觸發(fā)render事件 this.trigger('render'); } }, extend: EventBase }); var demo = new Demo({ onBeforeRender: function(e) { console.log('beforeRender event triggered!'); }, onRender: function(e) { console.log('render event triggered!'); } }); demo.render(); });
在這個測試里, 我定義了一個跟DOM無關(guān)的Demo組件并繼承了EventBase這個事件管理的類,給beforeRender事件和render事件都添加了一個監(jiān)聽,render方法中也有打印信息來模擬真實的邏輯,最后的測試結(jié)果是:
完全與預(yù)期的一致。
測試代碼二:
define(function(require, exports, module) { var $ = require('jquery'); var Class = require('mod/class'); var EventBase = require('mod/eventBase'); var Demo = window.demo = Class({ instanceMembers: { init: function (options) { this.base(); //添加監(jiān)聽 this.on('beforeRender', $.proxy(options.onBeforeRender, this)); this.on('render', $.proxy(options.onRender, this)); }, render: function () { //觸發(fā)beforeRender事件 var e = $.Event('beforeRender'); this.trigger(e); if(e.isDefaultPrevented())return; //主要邏輯代碼 console.log('render complete!'); //觸發(fā)render事件 this.trigger('render'); } }, extend: EventBase }); var demo = new Demo({ onBeforeRender: function(e) { console.log('beforeRender event triggered!'); }, onRender: function(e) { console.log('render event triggered!'); } }); demo.on('beforeRender', function(e) { e.preventDefault(); console.log('beforeRender event triggered 2!'); }); demo.on('beforeRender', function(e) { console.log('beforeRender event triggered 3!'); }); demo.render(); });
在這個測試了, 我定義了一個跟DOM無關(guān)的Demo組件并繼承了EventBase這個事件管理的類,給beforeRender事件添加了3個監(jiān)聽,其中一個有加prevetDefault()的調(diào)用,而且該回調(diào)還不是最后一個,最后的測試結(jié)果是:
從結(jié)果可以看到,render方法的主要邏輯代碼跟后面的render事件都沒有執(zhí)行,所有beforeRender的監(jiān)聽器都執(zhí)行了,說明e.preventDefault()生效了,而且它沒有對beforeRender的事件隊列產(chǎn)生影響。
所以從2個測試來看,通過改造后的EventBase,我們得到了一個可以讓任意對象支持jquery事件管理機制的方法,將來在考慮用事件機制來解耦的時候,就不用再去考慮前面第一個介紹的發(fā)布-訂閱模式了,而且相對而言這個方法功能更強更穩(wěn)定,也更符合你平常使用jquery操作DOM的習(xí)慣。
4. 本文小結(jié)
有2點需要再說明一下的是:
1)即使不用jquery按照第1部分最后提出的思路,把第一部分常規(guī)的發(fā)布-訂閱模式改造一下也可以的,只不過用jquery更加簡潔些;
2)最終用jquery 的事件機制來實現(xiàn)任意對象的事件管理,一方面是用到了代理模式,更重要的還是要用發(fā)布-訂閱模式,只不過最后的這個實現(xiàn)是由jquery幫我們把第一部分的發(fā)布-訂閱實現(xiàn)改造好了而已。
以上內(nèi)容是針對jQuery技巧之讓任何組件都支持類似DOM的事件管理的相關(guān)知識,希望對大家有所幫助!
相關(guān)文章
如何用jQuery實現(xiàn)ASP.NET GridView折疊伸展效果
我們今天就一個具體的需求進行分析,引出如何用jQuery實現(xiàn)ASP.NET GridView折疊伸展效果,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2015-09-09jQuery模擬下拉框選擇對應(yīng)菜單的內(nèi)容
這篇文章主要介紹了jQuery模擬下拉框選擇對應(yīng)菜單的內(nèi)容,非常不錯,具有參考借鑒價值,需要的朋友可以參考下2017-03-03Jquery+AJAX實現(xiàn)無刷新上傳并重命名文件操作示例【PHP后臺接收】
這篇文章主要介紹了Jquery+AJAX實現(xiàn)無刷新上傳并重命名文件操作,結(jié)合實例形式分析了jQuery+ajax前臺上傳文件與PHP后臺接收處理相關(guān)操作技巧,需要的朋友可以參考下2020-05-05