本文介紹一個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的事件管理的相關知識,希望對大家有所幫助!