Event Delegation, take 2

Tuesday, December 30th, 2008 @ 4:53 pm | filed under: Code Releases

Last week I released (prematurely) a MooTools plugin called Event.Delegate that added a new method to elements that allowed you to delegate a method to a parent. From that post:

Event delegation is a common practice where by you attach an event listener to a parent object to monitor its children rather than attach events to all the children. It’s far more efficient when you have numerous items on a page that you want to interact with. For instance, let’s say you want to monitor all the links on a page. Using MooTool’s addEvent method you could loop through all the link as and attach an event to each:

$$('a').each(function(link) {
    link.addEvent('click', function(){
        alert('you clicked a link!');
    });
});

This would take a while with a page full of links.

Delegation

With delegation we attach the event to the parent of a group of elements and then limit the behavior when the target of the event matches our criteria. In this case, we’d attach a click event to the document.body, so our code would run very quickly on startup (instead of looping over all the links on the page). Then, when the user clicks on that parent element, we check if the target of the event (i.e. the thing they clicked) matches our conditions to fire the element.

After I posted this plugin originally I was informed that there was already a plugin for MooTools that did this written by Daniel Steigerwald. His was more robust and illustrated how premature my release was. Mainly, I wasn’t checking element’s children for the match. I posted at length about this and why I had removed my delegation plugin.

My code, however, didn’t really account for mouseover/out bubbling, custom events (a MooTools convention), or the fact that you need to apply the event when it’s a child of the element you are monitoring. I.e. if you delegate to document.body all the click events on links like so:

$(document.body).delegate('a', 'click', someFunction);

You need to determine if the clicked element (the event target) is not just a match for the selector (here, a link) but also if it matches a child of the selector (a dom element inside a link).

The rest of that post posed some questions about how to best make the interface to this functionality work and sought feedback.

I spent a few days chewing on it and came up with what I think is a more elegant method for managing delegation that utilizes the power of MooTools, should perform better (in some circumstances, but certainly never worse), and feels more Moo-ish.

Delegation, take 1

The previous manner in which both Daniel and I attached events for delegation worked like this. The user specified an event (‘click’) and a selector (‘a’) and a function that should be fired whenever the event fired where the target matched the selector or was a child of an element that did. When you called the method to delegate (my method name was Element.delegate, Daniel’s was Element.relayEvent) and passed in the event, the selector, and the function, our plugins created a wrapper function that contained the function you passed in inside a test that would run the selector when the event fired. So if a user clicked on the parent element, our wrapper function would run the selector and check the target against the selector’s elements (and their children) and then, if they matched, execute the function you passed in. In code, this is kind of what happened – though this is grossly simplified:

parent.relayEvent('click', 'a', myFunc);
//create a wrapper:
//below 'ISIN' is just a shorthand for a whole bunch of logic
//that runs the selector and checks the children
var wrapper = function(e){ if (e.target ISIN $$('a')) myFunc(); };
parent.addEvent('click', wrapper);

Delegation, take 2

The idea I had works like this: instead of wrapping each function you attach using delegation to the element inside a wrapper, use the custom events methodology in MooTools directly.

In MooTools you can do this:

myElement.addEvent('foo', myFunc);
//later
myElement.fireEvent('foo');

MooTools doesn’t care if you give arbitrary event names to monitor. This is at the heart of its custom event system (‘domready’ for instance). So my new delegation method works like so:

myElement.addEvent('click(div.foo)', myFunc);

When you pass in an event with a selector in it (inside the parenthesis) I attach the method as usual. Now your element has a method (myFunc) attached to it waiting for something to fire the custom event ‘click(div.foo)’. Then, separately, I check to see if there’s already a monitor for that event/selector and, if not, attach one. All that monitor does is check the selector against the target as with the previous methods mentioned above and, when it matches, it calls fireEvent(‘click(div.foo)’). Now any method attached to the element for that selector will fire.

So, a grossly simplified example of what we’re doing now looks like this:

myElement.addEvent('click(a)', myFunc):
    //inside addEvent:
    if (!monitor['click(a)']) {
        myElement.addEvent('click', function(e){
            //below 'ISIN' is just a shorthand for a whole bunch of logic
            //that runs the selector and checks the children
            if (e.target ISIN $$('a')) this.fireEvent('click(a)');
        });
    }

Again, this is grossly simplified, but hopefully you get the idea.

Benefits

There are a lot of nice things that come out of this convention. For one, you can continue to add and remove events as you always have:

myElement.addEvent('click(a)', myFunc);
myElement.addEvents({
    'click(a)': myFunc1,
    'mouseover(div.foo)': myFunc2, etc
});
myElement.removeEvent('mouseover(div.foo)', myFunc2);
myElement.removeEvents('click(a)');

Additionally, we aren’t going to create a new monitor for each event we add, so if we reference the same selector on the same parent twice we’re just going to attach those events twice, but only monitor it once. So when someone clicks a link, our monitor figures out there’s a match and calls fireEvent(‘click(a)’), which fires our events after checking only once.

You can also use custom events as you like. Here’s a custom event by SubtleGradient:

Element.Events.extend({
	'key:esc':{
		base:'keydown',
		condition: function(event){
			return event.key == 'esc';
		}
	}
});

You could attach this as a delegate like so:

myParent.addEvent('key:esc(input, textarea)', myFunc);

Finally, I like that it fits within the existing event management system. The code itself is about 60 lines, though when/if it’s integrated into MooTools directly it would probably only be about 40 or less.

Limitations

Right now you can’t monitor the events blur or focus using this plugin. As outlined in this excellent quirksmode article, blur and focus events don’t bubble. You can only monitor them via capture (in everything but IE, where you have to use the events onfocusin/out). MooTools 1.2 doesn’t expose an option to use capture for events and writing an extension to do this isn’t practical because MooTools has a lot of logic for managing memory and event listeners and these methods are private. The only way to add this support would be to duplicate a lot of this logic, which is overkill. Expect to see this support in the next iteration of MooTools though.

The other limitation with this plugin is that the API isn’t 100% yet. It’s possible that when this is supported by the MooTools framework that the interface will be different. If/when that happens, I’ll be sure to provide a compatibility layer.

As such I’m providing my plugin as “beta” code for the time being. It’s also possible that I’ll change the API myself as I use it and receive feedback, which is where you come in.

Open Questions

I have a few questions on my mind and I’d love to hear from everyone on what they think of things:

  • What do you think of the syntax (addEvent(‘event(selector)’, fn)). I originally had it as addEvent(‘event:selector’, fn); but it was pointed out to me by SubtleGradient that semicolons are already used in custom events and I couldn’t be sure when testing the argument that the argument was intended to delegate. I considered several other syntaxes including using a pipe (|) and none of them really worked or felt right. Many are used in css and I wanted to avoid that confusion.
  • As I have it now, the event, when it fires, binds the method by default to the child element that matched the selector, not the parent. This is different than how addEvent normally works. When you add an event listener to an element, the method you pass it is bound to that element by default. Here we’re monitoring a parent and telling it to find children that match a selector when the event occurs. It seems to me that the event should be bound to the element that matched the selector.
  • Are there any use cases you can conceive that this syntax and interface wouldn’t be able to handle (except the blur and focus issues I mentioned previously)?

Demo

Here’s a very simple demo of the plugin in action. Look at the source of the html file and you’ll see:

$('someListing').addEvents({
	'mouseover:.item': function(e) {
		this.morph({ backgroundColor: '#222' });
	},
	'mouseout:.item': function(e) {
		this.morph({ backgroundColor: '#2D5E4C' });
	}
//...etc.

I’ve commented up the plugin file so you can see how it works line by line.

Some credit

Usually when I need a plugin I just write it and (usually) release it. This time it took a lot more effort, thought, and discussion. Obviously Daniel’s work plays a big part in this plugin. His version checks all the children correctly and exposed the shortcomings in my first effort. I’ve spent a lot of time discussing this topic with Tom Occhino and Valerio, as well as with SubtleGradient and David Walsh. I also spent a lot of time in the IRC channel with the devs right after I first released this, so a big thanks to everyone who chimed in with their opinions thus far. I’m looking forward to any feedback I can get from here on, so please, speak up.

No TweetBacks yet. (Be the first to Tweet this post)

27 Responses to “Event Delegation, take 2”

  1. Garrick Says:

    I’m glad you got a couple of the issues worked out. I really like this delegation idea.

    Just curious, why did you do away with the delegate(event, selector, func) syntax? I know it doesn’t work with addEvent or addEvents, but that syntax looks cleaner than addEvent(‘click(a)’, func).

    I haven’t looked at your code closely yet, but are you parsing the first argument to grab the the event? How much speed/memory is that costing?

    As for your questions:
    1. I’m not feeling the pipes or colons. Not feeling ‘event(selector)’ much either, but I can’t think of anything better… yet.
    2. Because you’re adding the ‘monitor’ to the parent, I think the event should be bound to the parent, and not the matched selectors. If it WAS bound to the matched selectors, would it be difficult to remove the events?
    3. NA

  2. Aaron N. Says:

    There were two big reasons for using a single string for the event:selector combo:

    1. I wanted to be able to manage these events with addEvents, removeEvents, set, and the Element constructor, which meant that it had to be a string.
    2. More importantly, I wanted to use these things as custom events, which meant actually adding the event as writ. If you call el.addEvent(‘click(a)’, fn), I wanted to actually add an event for that value just as MooTools always does. The only thing that delegation does is monitor your element for that event:selector and then fire that custom event. This seemed like a very elegant use of MooTools’ own built in system for custom events.

    Regarding your #2 thought above, the binding happens when the monitor calls fireEvent, not when the function is added (this has always been the case). All I’m doing is telling fireEvent to bind to the child that matches the selector instead of the parent. When you add or remove a function to an element, it isn’t bound at that time, so none of that logic changes at all.

    You’re the only person who’s commented to me that they’d like to see the function bound to the parent. Can you elaborate as to why you would want that and what use cases you can think of where that behavior would be more useful? The key to me is that when you add the event to the parent you have a reference to it, but not to the child element. So if the method isn’t bound to the child element, then we must instead pass it to the fired event somehow (as an argument). For example:

    parent.addEvent('click(a)', function(e) {
    //I can reference parent through closure easily
    //but if 'this' isn't the anchor that was clicked
    //how do I reference it?
    });

  3. Kevin Says:

    Great stuff Aaron. I really enjoy reading your blog.

    How exactly does bubbling work with event delegation? If you check the event’s target’s parents for the selector, wouldn’t the function fire multiple times due to bubbling?

  4. Aaron N. Says:

    Delegation works by attaching an event listener (say, click) to a parent. When the user clicks the parent, the event fires for the target (say, a link inside the parent) and then it fires for the target’s parent on up through the dom until it hits the document body. Because we attached our listener to some parent (perhaps the document body, perhaps not) our listener only fires when it reaches our parent. So, if the user clicks a link inside the div we’ve attached our listener to, that event will fire on our parent, but we only attach the listener to one of those things (in this case the parent) so we don’t have to worry about it calling our function more than once.

  5. Kevin Says:

    Thanks for clarifying!

  6. lamaster Says:

    what about this syntax?(like pseudo selectors)
    $("menu").addEvent("li a:click",function(){});

  7. Aaron N. Says:

    As I noted above, a semi-colon won’t work because pseudo selectors themselves contain them. That’s why I eventually settled on parenthesis (I originally implemented it as a semi-colon as you suggest).

  8. Garrick Says:

    Sorry for the late response.

    I gave this a little more thought. At the time I was thinking in terms of a parent/child relationship where I felt the parent element should be responsible for the event instead of the child.

    It makes more sense to bind the event to the child element. If necessary, I could always pass the parent into the function.

    I’ll try not to comment at 4am next time.

  9. Jens Anders Bakke Says:

    Very nice implementation, Aaron!
    I was just thinking about the same thing the other day, and the next day I’m catching up on the blogs after christmas, and there it is, in all its glory :)

    I agree with Garrick on the syntax.
    Mainly because there are pseudo selectors that uses (). (see bug #1 below)
    I actually would have prefered a third argument, but that might be because that’s how I pictured the implementation in my own mind before I red your posts.

    I got 2 bugs for you:
    1. The syntax breaks the :contains(text) selector (and other selectors and custom selectors with the same syntax, like :not(selector) and my own :containsNoCase(text)).
    The argument isn’t sent back to the Pseudo selector function.
    Might be a quick regex fix for that, though.

    2. mouseenter and mouseleave isn’t working. Haven’t tested mousewheel yet.

  10. Aaron N. Says:

    The syntax thing is going to be a problem. The issue is that custom selectors can themselves use any characters they like. You could define a custom selector called “:not::foo(bar)!!~whatsiwhosit” which makes the delegation code nearly impossible to manage.

    Maybe we use squiggly brackets? Ungh. A separate argument doesn’t work. Maybe a double semi-colon? “:contains(test)::div.foo”

    I’m open to suggestions here.

  11. Aaron N. Says:

    What about >>?
    a:not(.foo) >> div.bar

  12. Jens Anders Bakke Says:

    Vel, I would say that as long as the syntax of the real pseudo selectors are left alone.. it should be all good.
    if you make a custom selector named :oh!my:>!%god(foo) and it interferes, too bad, blame yourself.

    >> or something similiar isn’t such a bad idea..

    With >>, would it become something like this:
    $(‘foo’).addEvent(“click >> selector”,fn); ?

    I would argue for such a syntax, cause if you separate the event and the selector with a set of characters that you can do a split on, and remove the regex (or simplify it), you wouldn’t have to worry about the syntax of the selector..

    (actually, with that in mind, your original : might work too.. )

  13. Jens Anders Bakke Says:

    Also, a small fix for the regex of the () syntax would also do the trick..

  14. Jens Anders Bakke Says:

    var splitType = function(type) {
    if (type.test(/.*?\(.*?\)$/)) {
    return {
    event: type.match(/.*?(?=\()/),
    //damned js engine doesn’t support lookbehinds
    selector: type.replace(/.*?\(/,”").replace(/\)$/, “”)
    }
    }
    return {event: type};
    };

    Makes :contains(foo) work :)

  15. Aaron N. Says:

    I don’t see how that makes something like “:not(selector)” work. Wouldn’t that regex interpret the (selector) as a delegation instruction?

  16. Jens Anders Bakke Says:

    Actually, it works.
    And it will work on all selectors.

    parsing ‘click(:not(div))’ will result in
    event: click
    selector: :not(div)

    The new .test makes sure of that the type is in the format event(selector), with ) beeing the last character in the string.
    And the double replace makes sure the script will remove the first ( and anything before it, and the ) at the end of the string.
    thus leaving you with the selector.

    Unlike the original splitStyle that uses split(“)”) which splits on all occurances of (, and only pass on the second array value, resulting in broken selectors.

    Example:
    http://webfreak.no/lab/moo/event.delegation/take1.htm
    Sorry for blatantly ripping of your demo, but I thought it would be ok to use the same html to show a working :not(selector) example.
    I’ve also fixed the regex some more, adding ^ at the beginning of the test and the first replace. For thoroughness sake (works without it, but it’s good practice imo)

  17. Aaron N. Says:

    NIIICCEEE. I just released the change. Excellent work.

    BTW, don’t feel bad about ripping of the example – I stole it from Daniel.

  18. Jens Anders Bakke Says:

    Excellent :) Glad I could help make the class work as intended.
    Now the only thing left is to get mouseenter and mouseleave to work, and I’ll be truly satisfied :)

    btw, I improved the double replace code into one regex replace as well..

    selector: type.replace(/^.*?\((.*)\)$/,”$1″)

    Example can be found here:
    http://webfreak.no/lab/moo/event.delegation/take2.htm

  19. Aaron N. Says:

    Published.

    Regarding custom events (like mouseenter) I have an idea how to do this so that not only will mouseenter/mouseleave work but so that people can write their own custom rules for delegation. In the case of mouseenter/leave the wrapper function has to check that the target == the selector, and not look for children. Other custom events might have different rules. I’ll try and tackle shortly.

  20. emailtoid.net/i/ac6706df/ Says:

    Am I missing something? I get this error in the code, I think I need some Module cause this is with MT1.2 complete.

    events[type].contains is not a function
    [Break on this error] if (!events[type] || (fn && !events[type].contains(fn))) return this;

  21. Aaron N. Says:

    Hmmm. In what context? Is this on the demo page? Can you post a url that I can look at?

  22. jQuery implineste 3 ani si lanseaza versiunea 1.3 | Interfete Web Says:

    [...] Personal, nu am avut timp sa incerc practic ce aduce nou aceasta versiune, insa am aruncat o privire pe pagina in care este anuntata oficial noua versiune, si pot spune ca mi s-au parut interesante castigurile de performanta datorate motorului de selectori Sizzle (daca aruncati o privire pe graficele din articol o sa vedeti ca sunt cu adevarat semnificative in cazul Internet Explorer 6/7, pentru restul browserelor diferentele de performanta fiind destul de mici), si noile evenimente “live” (utile in special in cazul in care se creaza elemente DOM dupa ce s-a incarcat pagina, elemente la care e nevoie sa atasam ascultatori – chiar exista astfel de pagini, luati ca exemplu activitatea lui Robert Scoble si a prietenilor sai contactelor sale de pe Friendfeed, vazuta in timp real) realizate cu ajutorul delegarii de evenimente (ca o paranteza, si viitoarea versiune de MooTools va avea parte de asa ceva). [...]

  23. JTR Says:

    Great plugin!

    Im writing a multi-resource calendar system (that utilizes Mootools 1.2) for a client. I use the ContextMenu class to give the user a right-click menu. Using the standard ContextMenu code it associates a function to the ‘contextmenu’ event. The event is added to each element that you specify (it uses $$(element) to achieve this).

    In my code I am using a table to display my calendar and the table can contain upto 2000 cells which I need to assign the event to. After getting everything to work.. I then stumbled across this plugin.

    I have modified the ContextMenu class to use delegation. My parent element becomes the table and the children are the cells (td). My event assignment time has gone from 1 second to instant – as I now add 1 event to the table and not to ~2000 cells. I know it doesnt sound like a great improvement but the 1 second delay was noticable.

    BTW: The element where the event occurs is obtained by grabbing the events target, for example:

    parent.addEvent(‘click(td)’, function(e) {
    targetEl = e.target;
    targetEl.setStyle(‘background-color’, ‘#CCCCCC’);
    });

    Thanks again! I hope to see delegation in Mootools 1.3

  24. JTR Says:

    Aaron,
    In regard to post 20 (above), I have found that the error occurs when you use element.removeEvents();

    removeEvents works when you specify the event, such as element.removeEvents(‘click’); but if you want to remove ALL events the error appears.

  25. Aaron N. Says:

    Nice work JTR. You’ve definitely got the idea. Note that MooTools 2.0 is coming along and aiming for a summer release and the delegation system in it does indeed have a different format. I strongly suggest leaving some sort of note in a comment in your code whenever you use this so you can find it and switch it out later (as it’s not something you can easily search for). The basic mechanism will remain the same, but the syntax of the selector has changed.

  26. JTR Says:

    Aaron, have you progressed any further with the custom event handling? i.e. mouseenter/mouseleave.

    I happen to use those events and noticed that they don’t work using thi plugin – as you’ve highlighted.

    It wont stop me doing what I want to do as I will just leave the old code in there – but it would be a nice to have.

  27. Aaron N. Says:

    I have stopped working on this plugin as MooTools 2.0 will contain this functionality. Anyone using this version will need to convert their selectors (the syntax is different in M2.0) when they upgrade. That’s why it’s still “beta” here.