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有关联,而后试试换一个名称是否是就能解决问题。

相关文章
相关标签/搜索