Understanding the problem
Consider the following Todo List widget:
All tasks are given a "remove" button. When new tasks are added, they should get a remove button that removes that task. Here's the markup for this:
<fieldset id="todo-example"> <legend>Todo List</legend> <ol> <li><button class="delete-todo">remove</button>Read YUI documentation</li> <li><button class="delete-todo">remove</button>Build awesome web app</li> <li><button class="delete-todo">remove</button>Profit!</li> </ol> <input id="todo"> <button id="add-todo" type="button">add</button> </fieldset>
In the old days, you would have four click subscriptions:
- The remove button for #1
- The remove button for #2
- The remove button for #3
- The add button for creating new tasks
When a user types in a new task and clicks the add button, a new <li> and corresponding <button> are created, and a fifth click subscription is added, one for the new button. The callback for the remove buttons could be unique for each button, or a generic function that determined which item to remove based on some other info from the event or button.
When a user clicks on one of the remove buttons, the item is removed. The associated click event subscription is left in the system, taking up memory. So to solve this, maybe the event subscription is detached before the item is removed. Now there are four initial subscriptions and additional logic to properly detach subscriptions before items are removed.
Over time, the number of items on the todo list grows, and so the number of
subscriptions in the system, and thus memory consumed, grows with it.
Additionally, if at some point, the entire list needs to be cleared, that's a
lot of subscriptions to detach before it's ok to flush the list's
innerHTML
.
What is event delegation?
Event delegation is a way to reduce the number of subscriptions used to support this system. In the example case, only two click subscriptions are needed: one for the add button, and one for every remove button click. The second one is the delegated subscription. Here's how to think about it:
The key to event delegation is understanding that a click on a remove button is also a click on
- the list item that the button is in
- the list itself
- the <fieldset> that the list is in
- etc up to the <body> and finally the
document
[1]
Instead of subscribing to the button's "click" event, you can subscribe to the list's "click" event[2].
You clicked somewhere, but where?
When you click anywhere on the document, the browser dispatches a click
event that is assigned an e.target
property corresponding to the element
that triggered the event. For example, if you click on "Profit!", the
event originated from the <li> with "Profit!" in it, so e.target
will
be that <li> element[3].
With these two bits of information, we can create a single click subscription to respond to every button click in the Todo list.
function handleClick(e) { // look at e.target } Y.one('#todo-example ol').on('click', handleClick);
Now since there are no subscriptions tied directly to the individual
buttons, we can add new items to the list without needing to add more
subscriptions. Similarly, we can remove items or even clear the list's
innerHTML
without needing to detach subscriptions because there aren't any
subscriptions inside the list to clear.
More work in the event subscriber
Since any click inside the list is now triggering the event subscriber, it
will be executed for button clicks, but also for clicks on the task item's text
(e.g. "Profit!"). To make sure this click happened on a button, we need to
inspect e.target
to make sure it is a button.
function handleClick(e) { if (e.target.get('tagName').toLowerCase() === 'button') { // remove the item } }
This can start to get tricky when you're triggering on an element that can
contain children. For example, if there were no buttons, but instead you
wanted to remove items just by clicking on the <li>, you'd need to check
if e.target
was an <li>. But if it's not, you have to look at
e.target
's parentNode
and potentially that node's parentNode
and so on,
because e.target
will always refer to the most specific element that received
the click. This can amount to a lot of filtering code wrapping the item
removal logic, which hinders the readability of your app.
Let node.delegate(...)
do the work for you
This is where node.delegate(...)
comes in. node.delegate(...)
boils down the filtering logic to a css
selector, passed as the third argument. The subscribed callback will only
execute if the event originated from an element that matches (or is contained
in an element that matches) this css selector. This allows the code to power
our Todo widget to look like this:
YUI().use('node-event-delegate', 'event-key', function (Y) { var todoList = Y.one('#todo-example ol'), newTask = Y.one('#todo'); // clicks inside the todo list on a <button> element will cause the // button's containing <li> to be removed todoList.delegate('click', function () { this.ancestor('li').remove(); }, 'button'); // Adding a new task is only appending a list item function addTodo() { todoList.append( '<li><button class="delete-todo">remove</button>' + newTask.get('value') + '</li>'); newTask.set('value', ''); } Y.one('#add-todo').on('click', addTodo); newTask.on('key', addTodo, 'enter'); // enter also adds todo (see event-key) });
Footnotes
- If there are click subscriptions at multiple points in
the DOM heirarchy, they will be executed in order from most specific (the
button) to least specific (document) unless
e.stopPropagation()
is called along the line. This will prevent subscriptions from elements higher up the parent axis from executing. - We're using the "click" event here, but this all applies to other events as well.
- Actually the event originated from the text node inside
the <li>, but IE reports the origin (the
srcElement
in IE) as the <li>, which is probably what developers want, anyway. YUI fixese.target
to bet the element for browsers that report it as the text node.