jquery技巧之讓任何組件都支持相似DOM的事件管理

本文介紹一個jquery的小技巧,能讓任意組件對象都能支持相似DOM的事件管理,也就是說除了派發事件,添加或刪除事件監聽器,還能支持事件冒泡,阻止事件默認行爲等等。在jquery的幫助下,使用這個方法來管理普通對象的事件就跟管理DOM對象的事件如出一轍,雖然在最後當你看到這個小技巧的具體內容時,你可能會以爲原來如此或者不過如此,可是我以爲若是能把普通的發佈-訂閱模式的實現改爲DOM相似的事件機制,那開發出來的組件必定會有更大的靈活性和擴展性,並且我也是第一次使用這種方法(見識太淺的緣由),以爲它的使用價值還蠻大的,因此就把它分享出來了。javascript

在正式介紹這個技巧以前,得先說一下我以前考慮的一種方法,也就是發佈-訂閱模式,看看它能解決什麼問題以及它存在的問題。html

1. 發佈-訂閱模式

不少博客包括書本上都說javascript要實現組件的自定義事件的話,能夠採用發佈-訂閱模式,起初我也是堅決不移地這麼認爲的,因而用jquery的$.Callbacks寫了一個:java

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)jquery

只要任何組件繼承這個EventBase,就能繼承它提供的on off trigger方法來完成消息的訂閱,發佈和取消訂閱功能,好比我下面想要實現的這個FileUploadBaseView:bootstrap

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;
});

實際調用測試以下:
無標題
image
測試中,實例化了一個FileUploadBaseView對象f,並設置了它的name屬性,經過on方法添加一個跟hello相關的監聽器,最後經過trigger方法觸發了hello的監聽器,並傳遞了額外的兩個參數,在監聽器內部除了能夠經過監聽器的函數參數訪問到trigger傳遞過來的數據,還能經過this訪問f對象。api

從目前的結果來講,這個方式看起來還不錯,可是在我想要繼續實現FileUploadBaseView的時候碰到了問題。你看我在設計這個組件的時候那幾個訂閱相關的option:
image 
我本來的設計是:這些訂閱都是成對定義,一對訂閱跟某個實例方法對應,好比帶before的那個訂閱會在相應的實例方法(render)調用前觸發,不帶before的那個訂閱會在相應的實例方法(render)調用後觸發,並且還要求帶before的那個訂閱若是返回false,就不執行相應的實例方法以及後面的訂閱。最後這個設計要求是考慮到在調用組件的實例方法以前,有可能由於一些特殊的緣由,必須得取消當前實例方法的調用,好比調用remove方法時有的數據不能remove,那麼就能夠在before訂閱裏面作一些校驗,能刪除的返回true,不能刪除的返回false,而後在實例方法中觸發before的訂閱後加一個判斷就能夠了,相似下面的這種作法:安全

image

可是這個作法只能在單純的回調函數模式裏實現,在發佈-訂閱模式下是行不通的,由於回調函數只會跟一個函數引用相關,而發佈-訂閱模式裏,同一個消息可能有多個訂閱,若是把這種作法應用到發佈-訂閱裏面,當調用this.trigger('beforeRender')的時候,會把跟beforeRender關聯的全部訂閱所有調用一次,那麼以哪一個訂閱的返回值爲準呢?也許你會說能夠用隊列中的最後一個訂閱的返回值爲準,在大多數狀況下也許這麼幹沒問題,可是當咱們把「以隊列最後的一個訂閱返回值做爲判斷標準」這個邏輯加入到EventBase中的時候,會出現一個很大的風險,就是外部在使用的時候,必定得清楚地管理好訂閱的順序,必定要把那個跟校驗等一些特殊邏輯相關的訂閱放在最後面才行,而這種跟語法、編譯沒有關係,對編碼順序有要求的開發方式會給軟件帶來比較大的安全隱患,誰能保證任什麼時候候任何場景都能控制好訂閱的順序呢,更況且公司裏面可能還有些後來的新人,壓根不知道你寫的東西還有這樣的限制。app

解決這個問題的完美方式,就是像DOM對象的事件那樣,在消息發佈的時候,不是簡簡單單的發佈一個消息字符串,而是把這個消息封裝成一個對象,這個對象會傳遞給它全部的訂閱,哪一個訂閱裏以爲應該阻止這個消息發佈以後的邏輯,只要調用這個消息的preventDefault()方法,而後在外部發布完消息後,調用消息的isDefaultPrevented()方法判斷一下便可:
image
而這個作法跟使用jquery管理DOM對象的事件是同樣的思路,好比bootstrap的大部分組件以及我在前面一些博客中寫的組件都是用的這個方法來增長額外的判斷邏輯,好比bootstrap的alert組件在close方法執行的時候有一段這樣的判斷:
image
按照這個思路去改造EventBase是一個解決問題的方法,可是jquery的一個小技巧,可以讓咱們把整個普通對象的事件管理變得更加簡單,下面就讓咱們來瞧一瞧它的廬山真面目。dom

2. jquery小技巧模式

1)技巧一函數

若是在定義組件的時候,這個組件是跟DOM對象有關聯的,好比下面這種形式:
image
那麼咱們能夠徹底給這個組件添加on off trigger one這幾個經常使用事件管理的方法,而後將這些方法代理到$element的相應方法上: 
無標題2
經過代理,當調用組件的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元素,最後的測試結果是:
image
徹底與預期一致。

測試代碼二:

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()的調用,並且該回調還不是最後一個,最後的測試結果是:
image
從結果能夠看到,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方法中也有打印信息來模擬真實的邏輯,最後的測試結果是:image

徹底與預期的一致。

測試代碼二:

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()的調用,並且該回調還不是最後一個,最後的測試結果是:
image
從結果能夠看到,render方法的主要邏輯代碼跟後面的render事件都沒有執行,全部beforeRender的監聽器都執行了,說明e.preventDefault()生效了,並且它沒有對beforeRender的事件隊列產生影響。

因此從2個測試來看,經過改造後的EventBase,咱們獲得了一個可讓任意對象支持jquery事件管理機制的方法,未來在考慮用事件機制來解耦的時候,就不用再去考慮前面第一個介紹的發佈-訂閱模式了,並且相對而言這個方法功能更強更穩定,也更符合你日常使用jquery操做DOM的習慣。

4. 本文小結

有2點須要再說明一下的是:

1)即便不用jquery按照第1部分最後提出的思路,把第一部分常規的發佈-訂閱模式改造一下也能夠的,只不過用jquery更加簡潔些;
2)最終用jquery 的事件機制來實現任意對象的事件管理,一方面是用到了代理模式,更重要的仍是要用發佈-訂閱模式,只不過最後的這個實現是由jquery幫咱們把第一部分的發佈-訂閱實現改造好了而已。

最後真切地但願這篇分享可以給你的工做帶來一些幫助,謝謝閱讀:)


補充於2016-04-08:

自定義事件的名稱,有的時候會跟jquery內部的一些事件名稱衝突,我遇到一種狀況:定義一個跟DOM關聯的組件時,我用到了一個自定義事件remove,當我調用this.trigger('remove')的時候,居然把這個組件關聯DOM元素從DOM中刪除了,估計這個事件名稱已經在jquery內部使用,當在DOM元素上觸發這個事件後,會致使元素remove。因此當你碰到相似的意外狀況時,回頭想一想自定義事件的名稱是否是跟jquery某些API有關聯,而後試試換一個名稱是否是就能解決問題。

相關文章
相關標籤/搜索