One of the most common tasks in web development is event management. Our JavaScript code is usually listening to events dispatched by the DOM elements.
This is how we get information from the user: That is, he or she clicks, types, interacts with our page and we need to know once this happen. Adding event listeners looks trivial but could be a tough process.
In this article, we will see a real case problem and its 1.6K solution.
The Problem
A friend of mine works as a junior developer. As such, he doesn't have a lot of experience with vanilla JavaScript; however, he has had to start using frameworks such as AngularJS and Ember without having the fundamental understanding of DOM-to-JavaScript relationship. During his time as a junior developer, he was put in charge of a small project: A single page campaign websites with almost no JavaScript involved. He faced a small but very interesting problem which ultimately lead me to write Bubble.js.
Imagine that we have a popup. A nicely styled <div>
element:
<div class="popup"> ... </div>
Here is the code that we use to show a message:
var popup = document.querySelector('.popup'); var showMessage = function(msg) { popup.style.display = 'block'; popup.innerHTML = msg; } ... showMessage('Loading. Please wait.');
We have another function hideMessage
that changes the display
property to none
and hides the popup. The approach may work in the most generic case but still has some problems.
For example, say we need to implement additional logic if the issues come in one by one. Let’s say that we have to add two buttons to the content of the popup - Yes and No.
var content = 'Are you sure?<br />'; content += '<a href="#" class="popup--yes">Yes</a>'; content += '<a href="#" class="popup--no">No</a>'; showMessage(content);
So, how we will know that the user clicks on them? We have to add event listeners to every one of the links.
For example:
var addListeners = function(yesCB, noCB) { popup.querySelector('.popup--yes').addEventListener('click', yesCB); popup.querySelector('.popup--no').addEventListener('click', noCB); } showMessage(content); addListeners(function() { console.log('Yes button clicked'); }, function() { console.log('No button clicked'); });
The code above works during the first run. What if we need a new button, or even worse, what if we need a different type of button? That is, what if we were to continue using <a>
elements but with different class names? We can't use the same addListeners
function, and it's annoying to create a new method for every variation of popup.
Here are where problems become visible:
- We have to add the listeners again and again. In fact, we have to do this every time when the HTML in the popup’s
<div>
is changed. - We could attach event listeners only if the content of the popup is updated. Only after the
showMessage
calling. We have to think about that all the time and sync the two processes. - The code that adds the listeners has one hard dependency - the
popup
variable. We need to call itsquerySelector
function instead ofdocument.querySelector
. Otherwise, we may select a wrong element. - Once we change the logic in the message we have to change the selectors and probably the
addEventListener
calls. It is not DRY at all.
There must be a better way to do this.
Yes, there is a better approach. And no, the solution is not to use a framework.
Before to reveal the answer let’s talk a bit about the events in the DOM tree.
Understanding Event Handling
Events are an essential part of web development. They add interactivity to our applications and act as a bridge between the business logic and the user. Every DOM element can dispatch events. All we have to do is to subscribe for these events and process the received Event object.
There is a term event propagation that stands behind event bubbling and event capturing both of which are two ways of event handling in DOM. Let’s use the following markup and see the difference between them.
<div class="wrapper"> <a href="#">click me</a> </div>
We will attach click
event handlers to the both elements. However, because there are nested into each other, they both will receive the click
event.
document.querySelector('.wrapper').addEventListener('click', function(e) { console.log('.wrapper clicked'); }); document.querySelector('a').addEventListener('click', function(e) { console.log('a clicked'); });
Once we press the link we see the following output in the console:
a clicked .wrapper clicked
So, indeed the both elements receive the click
event. First, the link and then the <div>
. This is the bubbling effect. From the deepest possible element to its parents. There is a way to stop the bubbling. Every handler receives an event object that has stopPropagation
method:
document.querySelector('a').addEventListener('click', function(e) { e.stopPropagation(); console.log('a clicked'); });
By using stopPropagation
function, we indicate that the event should not be sent to the parents.
Sometimes we may need to reverse the order and have the event caught by the outer element. To achieve this, we have to use a third parameter in addEventListener
. If we pass true
as a value we will do event capturing. For example:
document.querySelector('.wrapper').addEventListener('click', function(e) { console.log('.wrapper clicked'); }, true); document.querySelector('a').addEventListener('click', function(e) { console.log('a clicked'); }, true);
That is how our browser process the events when we interact with the page.
The Solution
Okay, so why did we spend a section of the article talking about bubbling and capturing We mentioned them because bubbling is the answer of our problems with the popup. We should set the event listeners not to the links but to the <div>
directly.
var content = 'Are you sure?<br />'; content += '<a href="#" class="popup--yes">Yes</a>'; content += '<a href="#" class="popup--no">No</a>'; var addListeners = function() { popup.addEventListener('click', function(e) { var link = e.target; }); } showMessage(content); addListeners();
By following this approach, we eliminate the issues listed in the beginning.
- There is only one event listener and we are adding it once. No matter what we put inside the popup, the catching of the events will happen in their parent.
- We are not bound to the additional content. In other words, we do not care when the
showMessage
is called. As long as thepopup
variable is alive we will catch the events. - Because we call
addListeners
once, we use thepopup
variable also once. We do not have to keep it or pass it between the methods. - Our code became flexible because we opted not care about the HTML passed to
showMessage
. We have access to the clicked anchor in thate.target
points to the pressed element.
The above code is better than the one that we started with. However, still doesn’t function the same way. As we said, e.target
points to the clicked <a>
tag. So, we will use that to distinguish the Yes and No buttons.
var addListeners = function(callbacks) { popup.addEventListener('click', function(e) { var link = e.target; var buttonType = link.getAttribute('class'); if(callbacks[buttonType]) { callbacks[buttonType](e); } }); } ... addListeners({ 'popup--yes': function() { console.log('Yes'); }, 'popup--no': function() { console.log('No'); } });
We fetched the value of the class
attribute and use it as a key. The different classes point to different callbacks.
However, it is not a good idea to use the class
attribute. It is reserved for applying visual styles to the element, and its value may change at any time. As JavaScript developers, we should use data
attributes.
var content = 'Are you sure?<br />'; content += '<a href="#" data-action="yes" class="popup--yes">Yes</a>'; content += '<a href="#" data-action="no" class="popup--no">No</a>';
Our code becomes a little bit better too. We can remove the quotes used in addListeners
function:
addListeners({ yes: function() { console.log('Yes'); }, no: function() { console.log('No'); } });
The result could be seen in this JSBin.
Bubble.js
I applied the solution above in several projects so it made sense to wrap it a library. It’s called Bubble.js and it is available in GitHub. It is 1.6K file that does exactly what we did above.
Let’s transform our popup example to use Bubble.js
. The first thing that we have to change is the used markup:
var content = 'Are you sure?<br />'; content += '<a href="#" data-bubble-action="yes" class="popup--yes">Yes</a>'; content += '<a href="#" data-bubble-action="no" class="popup--no">No</a>';
Instead of data-action
we should use data-bubble-action
.
Once we include bubble.min.js
in our page, we have a global bubble
function available. It accepts a DOM element selector and returns the library’s API. The on
method is the one that adds the listeners:
bubble('.popup') .on('yes', function() { console.log('Yes'); }) .on('no', function() { console.log('No'); });
There is also an alternative syntax:
bubble('.popup').on({ yes: function() { console.log('Yes'); }, no: function() { console.log('No'); } });
By default, Bubble.js
listens for click
events, but there is an option to change that. Let’s add an input field and listens for its keyup
event:
<input type="text" data-bubble-action="keyup:input"/>
The JavaScript handler still receives the Event object. So, in this case we are able to show the text of the field:
bubble('.popup').on({ ... input: function(e) { console.log('New value: ' + e.target.value); } });
Sometimes we need to catch not one but many events dispatched by the same element. data-bubble-action
accepts multiple values separated by comma:
<input type="text" data-bubble-action="keyup:input, blur:inputBlurred"/>
Find the final variant in a JSBin here.
Fallbacks
The solution provided in this article relies completely on the event bubbling. In some cases e.target
may not point to the element that we need.
For example:
<div class="wrapper"> <a href="#">Please, <span>choose</span> me!</a> </div>
If we place our mouse over "choose" and perform a click, the element that dispatches the event is not the <a>
tag but the span
element.
Summary
Admittedly, communication with the DOM is an essential part of our application development, but it is a common practice that we use frameworks just to bypass that communication.
We do not like adding listeners again and again. We do not like debugging weird double-event-firing bugs. The truth is that if we know how the browser works, we are able to eliminate these problems.
Bubble.js is but one result of few hours reading and one hour coding - it's our 1.6K solution to one of the most common problems.
Comments