本文介紹一個jquery的小技巧,能讓任意組件對象都能支持類似DOM的事件管理,也就是說除了派發事件,添加或刪除事件監聽器,還能支持事件冒泡,阻止事件默認行為等等。在jquery的幫助下,使用這個方法來管理普通對象的事件就跟管理DOM對象的事件一模一樣,雖然在最後當你看到這個小技巧的具體內容時,你可能會覺得原來如此或者不過如此,但是我覺得如果能把普通的發布-訂閱模式的實現改成DOM類似的事件機制,那開發出來的組件一定會有更大的靈活性和擴展性,而且我也是第一次使用這種方法(見識太淺的原因),覺得它的使用價值還蠻大的,所以就把它分享出來了。
在正式介紹這個技巧之前,得先說一下我之前考慮的一種方法,也就是發布-訂閱模式,看看它能解決什麼問題以及它存在的問題。
1. 發布-訂閱模式
很多博客包括書本上都說javascript要實現組件的自定義事件的話,可以采用發布-訂閱模式,起初我也是堅定不移地這麼認為的,於是用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]'; } /** * 這個基類可以讓普通的類具備事件驅動的能力 * 提供類似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設置成一個實例屬性,以便子類可以覆蓋 this.CALLBACKS_FLAG = 'unique'; }, on: function (type, callback) { type = $.trim(type); //如果type或者callback參數無效則不處理 if (!(type && isFunc(callback))) return; var event = this.events[type]; if (!event) { //定義一個新的jq隊列,且該隊列不能添加重復的回調 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對應的隊列中移除 event.remove(callback); } else { //否則就移除整個type對應的隊列 delete this.events[type]; } }, trigger: function () { var args = [].slice.apply(arguments), type = args[0];//第一個參數轉為type type = $.trim(type); if (!type) return; var event = this.events[type]; if (!event) return; //用剩下的參數來觸發type對應的回調 //同時把回調的上下文設置成當前實例 event.fireWith(this, args.slice(1)); } } }); return EventBase; });
(基於seajs以及《詳解Javascript的繼承實現》介紹的繼承庫class.js)
只要任何組件繼承這個EventBase,就能繼承它提供的on off trigger方法來完成消息的訂閱,發布和取消訂閱功能,比如我下面想要實現的這個FileUploadBaseView:
define(function(require, exports, module) { var $ = require('jquery'); var Class = require('./class'); var EventBase = require('./eventBase'); var DEFAULTS = { data: [], //要展示的數據列表,列表元素必須是object類型的,如[{url: 'xxx.png'},{url: 'yyyy.png'}] sizeLimit: 0, //用來限制BaseView中的展示的元素個數,為0表示不限制 readonly: false, //用來控制BaseView中的元素是否允許增加和刪除 onBeforeRender: $.noop, //對應beforeRender事件,在render方法調用前觸發 onRender: $.noop, //對應render事件,在render方法調用後觸發 onBeforeAppend: $.noop, //對應beforeAppend事件,在append方法調用前觸發 onAppend: $.noop, //對應append事件,在append方法調用後觸發 onBeforeRemove: $.noop, //對應beforeRemove事件,在remove方法調用前觸發 onRemove: $.noop //對應remove事件,在remove方法調用後觸發 }; /** * 數據解析,給每個元素的添加一個唯一標識_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; });
實際調用測試如下:
測試中,實例化了一個FileUploadBaseView對象f,並設置了它的name屬性,通過on方法添加一個跟hello相關的監聽器,最後通過trigger方法觸發了hello的監聽器,並傳遞了額外的兩個參數,在監聽器內部除了可以通過監聽器的函數參數訪問到trigger傳遞過來的數據,還能通過this訪問f對象。
從目前的結果來說,這個方式看起來還不錯,但是在我想要繼續實現FileUploadBaseView的時候碰到了問題。你看我在設計這個組件的時候那幾個訂閱相關的option:
我原本的設計是:這些訂閱都是成對定義,一對訂閱跟某個實例方法對應,比如帶before的那個訂閱會在相應的實例方法(render)調用前觸發,不帶before的那個訂閱會在相應的實例方法(render)調用後觸發,而且還要求帶before的那個訂閱如果返回false,就不執行相應的實例方法以及後面的訂閱。最後這個設計要求是考慮到在調用組件的實例方法之前,有可能因為一些特殊的原因,必須得取消當前實例方法的調用,比如調用remove方法時有的數據不能remove,那麼就可以在before訂閱裡面做一些校驗,能刪除的返回true,不能刪除的返回false,然後在實例方法中觸發before的訂閱後加一個判斷就可以了,類似下面的這種做法:
但是這個做法只能在單純的回調函數模式裡實現,在發布-訂閱模式下是行不通的,因為回調函數只會跟一個函數引用相關,而發布-訂閱模式裡,同一個消息可能有多個訂閱,如果把這種做法應用到發布-訂閱裡面,當調用this.trigger('beforeRender')的時候,會把跟beforeRender關聯的所有訂閱全部調用一次,那麼以哪個訂閱的返回值為准呢?也許你會說可以用隊列中的最後一個訂閱的返回值為准,在大多數情況下也許這麼干沒問題,但是當我們把“以隊列最後的一個訂閱返回值作為判斷標准”這個邏輯加入到EventBase中的時候,會出現一個很大的風險,就是外部在使用的時候,一定得清楚地管理好訂閱的順序,一定要把那個跟校驗等一些特殊邏輯相關的訂閱放在最後面才行,而這種跟語法、編譯沒有關系,對編碼順序有要求的開發方式會給軟件帶來比較大的安全隱患,誰能保證任何時候任何場景都能控制好訂閱的順序呢,更何況公司裡面可能還有些後來的新人,壓根不知道你寫的東西還有這樣的限制。
解決這個問題的完美方式,就是像DOM對象的事件那樣,在消息發布的時候,不是簡簡單單的發布一個消息字符串,而是把這個消息封裝成一個對象,這個對象會傳遞給它所有的訂閱,哪個訂閱裡覺得應該阻止這個消息發布之後的邏輯,只要調用這個消息的preventDefault()方法,然後在外部發布完消息後,調用消息的isDefaultPrevented()方法判斷一下即可:
而這個做法跟使用jquery管理DOM對象的事件是一樣的思路,比如bootstrap的大部分組件以及我在前面一些博客中寫的組件都是用的這個方法來增加額外的判斷邏輯,比如bootstrap的alert組件在close方法執行的時候有一段這樣的判斷:
按照這個思路去改造EventBase是一個解決問題的方法,但是jquery的一個小技巧,能夠讓我們把整個普通對象的事件管理變得更加簡單,下面就讓我們來瞧一瞧它的廬山真面目。
2. jquery小技巧模式
1)技巧一
如果在定義組件的時候,這個組件是跟DOM對象有關聯的,比如下面這種形式:
那麼我們可以完全給這個組件添加on off trigger one這幾個常用事件管理的方法,然後將這些方法代理到$element的相應方法上:
通過代理,當調用組件的on方法時,其實調用的是$element的on方法,這樣的話這種類型的組件就能支持完美的事件管理了。
2)技巧二
第一個技巧只能適用於跟DOM有關聯的組件,對於那些跟DOM完全沒有關聯的組件該怎麼添加像前面這樣完美的事件管理機制呢?其實方法也很簡單,只是我自己以前真的是沒這麼用過,所以這一次用起來才會覺得特別新鮮:
看截圖中框起來的部分,只要給jquery的構造函數傳遞一個空對象,它就會返回一個完美支持事件管理的jquery對象。而且除了事件管理的功能外,由於它是一個jquery對象。所以jquery原型上的所有方法它都能調用,將來要是需要借用jquery其它的跟DOM無關的方法,說不定也能參考這個小技巧來實現。
3. 完美的事件管理實現
考慮到第2部分介紹的2種方式裡面有重復的邏輯代碼,如果把它們結合起來的話,就可以適用所有的開發組件的場景,也就能達到本文標題和開篇提到的讓任意對象支持事件管理功能的目標了,所以最後結合前面兩個技巧,把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; });
實際調用測試如下
1)模擬跟DOM關聯的組件
測試代碼一:
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); //添加監聽 this.on('beforeRender', $.proxy(options.onBeforeRender, this)); this.on('render', $.proxy(options.onRender, this)); }, render: function () { //觸發beforeRender事件 var e = $.Event('beforeRender'); this.trigger(e); if(e.isDefaultPrevented())return; //主要邏輯代碼 console.log('render complete!'); //觸發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關聯的Demo組件並繼承了EventBase這個事件管理的類,給beforeRender事件和render事件都添加了一個監聽,render方法中也有打印信息來模擬真實的邏輯,實例化Demo的時候用到了#demo這個DOM元素,最後的測試結果是:
完全與預期一致。
測試代碼二:
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); //添加監聽 this.on('beforeRender', $.proxy(options.onBeforeRender, this)); this.on('render', $.proxy(options.onRender, this)); }, render: function () { //觸發beforeRender事件 var e = $.Event('beforeRender'); this.trigger(e); if(e.isDefaultPrevented())return; //主要邏輯代碼 console.log('render complete!'); //觸發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相關的Demo組件並繼承了EventBase這個事件管理的類,給beforeRender事件添加了3個監聽,其中一個有加prevetDefault()的調用,而且該回調還不是最後一個,最後的測試結果是:
從結果可以看到,render方法的主要邏輯代碼跟後面的render事件都沒有執行,所有beforeRender的監聽器都執行了,說明e.preventDefault()生效了,而且它沒有對beforeRender的事件隊列產生影響。
2)模擬跟DOM無關聯的普通對象
測試代碼一:
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(); //添加監聽 this.on('beforeRender', $.proxy(options.onBeforeRender, this)); this.on('render', $.proxy(options.onRender, this)); }, render: function () { //觸發beforeRender事件 var e = $.Event('beforeRender'); this.trigger(e); if(e.isDefaultPrevented())return; //主要邏輯代碼 console.log('render complete!'); //觸發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無關的Demo組件並繼承了EventBase這個事件管理的類,給beforeRender事件和render事件都添加了一個監聽,render方法中也有打印信息來模擬真實的邏輯,最後的測試結果是:
完全與預期的一致。
測試代碼二:
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(); //添加監聽 this.on('beforeRender', $.proxy(options.onBeforeRender, this)); this.on('render', $.proxy(options.onRender, this)); }, render: function () { //觸發beforeRender事件 var e = $.Event('beforeRender'); this.trigger(e); if(e.isDefaultPrevented())return; //主要邏輯代碼 console.log('render complete!'); //觸發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無關的Demo組件並繼承了EventBase這個事件管理的類,給beforeRender事件添加了3個監聽,其中一個有加prevetDefault()的調用,而且該回調還不是最後一個,最後的測試結果是:
從結果可以看到,render方法的主要邏輯代碼跟後面的render事件都沒有執行,所有beforeRender的監聽器都執行了,說明e.preventDefault()生效了,而且它沒有對beforeRender的事件隊列產生影響。
所以從2個測試來看,通過改造後的EventBase,我們得到了一個可以讓任意對象支持jquery事件管理機制的方法,將來在考慮用事件機制來解耦的時候,就不用再去考慮前面第一個介紹的發布-訂閱模式了,而且相對而言這個方法功能更強更穩定,也更符合你平常使用jquery操作DOM的習慣。
4. 本文小結
有2點需要再說明一下的是:
1)即使不用jquery按照第1部分最後提出的思路,把第一部分常規的發布-訂閱模式改造一下也可以的,只不過用jquery更加簡潔些;
2)最終用jquery 的事件機制來實現任意對象的事件管理,一方面是用到了代理模式,更重要的還是要用發布-訂閱模式,只不過最後的這個實現是由jquery幫我們把第一部分的發布-訂閱實現改造好了而已。
以上內容是針對jQuery技巧之讓任何組件都支持類似DOM的事件管理的相關知識,希望對大家有所幫助!