Introducing Live Extensions For Better-DOM: What They Are And How They Work
After recently writing an article on “Writing A Better JavaScript Library For The DOM”, I realized that the topic is indeed a very complex one and that it’s important to understand what exactly live extensions are and how they work. In today’s article, I will answer most questions that were asked regarding “live extensions” and help you get going with this new concept.
The Responsibilities Of Live Extensions
Event handling is one of the key principles of working with the DOM. Events are the primary means of receiving feedback from user interaction.
Simple Event Binding
In this first example, documentation and tutorials that cover DOM events is what I call “simple event binding”. You attach a listener for the desired event on the DOM element in which you expect it to happen on.
link.addEventListener("click", function(e) {
// do something when the link is clicked
}, false);
The first argument indicates the type of an event, the second argument is a listener, and the third argument defines an event phase (so-called “bubbling” or “capturing”). The reason why the last argument exists is because most DOM events traverse the DOM tree from document node to target node (capture phase) and back to the document node (bubble phase). This process is called “event flow” and brings several powerful features.
Live and Delegated Events
Instead of attaching a handler for each element in a group, we can attach one listener onto an ancestor shared by all of the elements in that specific group. Then, we can determine where an event took place using the target
property of the event object, passed into the listener. This is known as “event delegation”:
list.addEventListener("click", function(e) {
if (e.target.tagName === "LI") {
// do something when a child <li> element is clicked
}
}, false);
By having all event handlers on a particular parent, we can update the innerHTML
property of this element without losing the ability to listen to events for new elements. The feature was called “Live Events” in jQuery, and it quickly became popular because of its ability to filter events by a CSS selector. Later, delegated events replaced them due to their flexibility by allowing to bind a listener to any element within the document tree.
But even event delegation does not overcome the following problems:
- When DOM mutation is required after a new element (that matches a specific selector) comes into the document tree,
- When an element should be initialized on a excessive event such as
scroll
ormousemove
, - Or on non-bubbling events, e.g.
load
,error
, etc.
This is what live Extensions aim to solve.
Live Extensions Use Cases
Take a look at the following diagram that explains the responsibilities:
1. DOM Mutations For Existing And Future Elements
Imagine you want to develop a reusable datepicker widget. In HTML5, there is a standards-based <input type="date">
element that could be used to create a polyfill. But the problem is that this element looks and behaves very different from browser to browser:
Date input element in different browsers.
The only way to make the element behave consistently is to set the type attribute value to “text”
. This will cancel a legacy implementation and enable JavaScript to make your own. Try defining a live extension with the example below:
DOM.extend("input[type=date]", {
constructor: function() {
// cancel browser-specific implementation
this.set("type", "text");
// make your own styleable datepicker,
// attach additional event handlers etc.
}
});
2. Media Query Callbacks
I highly recommend reading Paul Hayes’ article on how to “Use CSS transitions to link Media Queries and JavaScript”.
"A common problem in responsive design is the linking of CSS3’s media queries and JavaScript. For instance on a larger screen we can restyle, but it might be useful to use JavaScript and pull in different content at the same time, e.g. higher quality images."
Paul was probably the first who started to use “hidden force” of CSS3 animation events to solve mutation-related problems. Live extensions are powered by the same trick, therefore you can use them to make DOM modifications depending on the current viewport:
DOM.extend(".rwd-menu", {
constructor: function() {
var viewportWidth = DOM.find("html").get("clientWidth");
if (viewportWidth < 768) {
// hide <ul> and construct Emmet abbreviation for a
// <select> element that should be used on small screens
this.hide().after("select[onchange='location=this.value']>" +
this.children("li").reduce(function(memo, item) {
var text = item.get("textContent"),
href = item.find("a").get("href");
memo.push("option[value=" + href + "]>{" + text + "}");
return memo;
}, []).join("^"));
}
}
});
3. Element Media Queries
Back in 2011, Andy Hume implemented a script for applying styles depending on the dimensions of a particular element (not viewport, like for media queries). Later, this technique was named “element media queries”:
"Media queries work really well when you want to adjust the core layouts of the site, but they're less suited to changing styles at a smaller more granular level."
With the help of live extensions, it’s easy to implement element media queries support using the offset
method:
DOM.extend(".signup-form", {
constructor: function() {
var currentWidth = this.offset().width;
// add extra class depending on current width
if (currentWidth < 150) {
this.addClass("small-signup-form");
} else if (currentWidth > 300) {
this.addClass("wide-signup-form");
}
}
});
4. Efficiently Attach A Global Listener To Frequent Events
DOM.extend(".detectable", {
constructor: function() {
// mousemove bubbles but it’s usually a very bad
// idea to listen to such event on a document level
// but live extensions help to solve the issue
this.on("mousemove", this.onMouseMove, ["pageX", "pageY"]);
},
onMouseMove: function(x, y) {
// just output current coordinates into console
console.log("mouse position: x=" + x + ", y=" + y);
}
});
5. Listing Non-Bubbling Events On A Document Level
DOM.extend("img.safe-img", {
constructor: function() {
// error event doesn’t bubble so it’s not
// possible to do the same using live events
this.on("error", this.onError);
},
onError: function() {
// show a predefined png if an image download fails
this.src = "/img/download-failed.png"
}
});
Brief Look Into History
The problems which live extensions aim to solve is not entirely new, of course. There are different approaches that address the above-mentioned issues. Let’s have a quick look at some of them.
HTML Components
Internet Explorer started supporting DHTML behaviors with IE 5.5:
"DHTML behaviors are components that encapsulate specific functionality or behavior on a page. When applied to a standard HTML element on a page, a behavior enhances that element's default behavior."
To attach behavior to future elements, Internet Explorer used an *.htc
file with a special syntax. Here’s an example illustrating how we used to make :hover
work on elements instead of <a>
:
<PUBLIC:COMPONENT URN="urn:msdn-microsoft-com:workshop" >
<PUBLIC:ATTACH EVENT="onmouseover" ONEVENT="Hilite()" />
<PUBLIC:ATTACH EVENT="onmouseout" ONEVENT="Restore()" />
<SCRIPT LANGUAGE="JScript">
var normalColor, normalSpacing;
function Hilite() {
normalColor = currentStyle.color;
normalSpacing= currentStyle.letterSpacing;
runtimeStyle.color = "red";
runtimeStyle.letterSpacing = 2;
}
function Restore() {
runtimeStyle.color = normalColor;
runtimeStyle.letterSpacing = normalSpacing;
}
</SCRIPT>
</PUBLIC:COMPONENT>
If you provided the above-mentioned code into the hilite.htc
file, you could access it within CSS through the behavior
property:
li {
behavior: url(hilite.htc);
}
I was really surprised to discover that HTML components supported creating custom tags (starting from version 5.5), have single domain limitations and tons of other stuff that you probably have never used before. Despite Microsoft submitting a proposal to W3C, other browser vendors decided not to support this feature. As a result, HTML components were removed from Internet Explorer 10.
Decorators
In my previous article, I mentioned the Decorators which are a part of Web components. Here’s how you can implement the open/closed state indicator of the <details>
element using decorators:
<decorator id="details-closed">
<script>
function clicked(event) {
event.target.setAttribute('open', 'open');
}
[{selector: '#summary', type: 'click', handler: clicked}];
</script>
<template>
<a id="summary">
▸ <content select="summary"></content>
</a>
</template>
</decorator>
<decorator id="details-open">
<script>
function clicked(event) {
event.target.removeAttribute('open');
}
[{selector: '#summary', type: 'click', handler: clicked}];
</script>
<template>
<a id="summary">
▾ <content select="summary"></content>
</a>
<content></content>
</template>
</decorator>
Decorators are also applied using the special decorator
property in CSS:
details {
decorator: url(#details-closed);
}
details[open] {
decorator: url(#details-open);
}
You’ll quickly notice that this is very close to what Microsoft proposed in HTML Components. The difference is that instead of separate HTC files, decorators are HTML elements that can be defined within the same document. The example above is only provided to show that the Web platform is working on these topics, since decorators aren’t properly specified just yet.
Live Extensions API
While designing APIs for live extensions, I decided to follow the following rules:
- Live extensions should be declared in JavaScript. I strongly believe that everything that somehow changes the behavior of an element should be presented in a JavaScript file. (Note that better-dom inserts a new CSS rule behind the scenes, but this includes only implementation details).
- APIs should be simple to use. No tricky file formats or new HTML elements: only a small amount of knowledge related to the constructor and event handlers is required to start developing a live extension (thus, the barrier to entry should be low).
As a result, there are only two methods to deal with: DOM.extend
and DOM.mock
.
DOM.extend
DOM.extend
declares a live extension. It accepts a CSS selector as the first argument which defines what elements you want to capture. General advice: try to make the selector simple.
Ideally, you should only use a tag name, class or attribute with or without a value or their combinations with each other. These selectors can be tested quicker without calling an expensive matchesSelector
method.
The second argument is a live extension definition. All properties of the object will be mixed with an element wrapper interface except constructor and event handlers.
Let’s look at a simple example. Let’s assume we have such an element on a Web page:
<div class="signin-form modal-dlg">...</div>
The task is to show it as a modal dialog. This is how the live extension could look like:
DOM.extend(".modal-dlg", {
constructor: function() {
var backdrop = DOM.create("div.modal-dlg-backdrop");
// using bind to store reference to backdrop internally
this.showModal = this.showModal.bind(this, backdrop);
// we will define event handlers later
},
showModal: function(backdrop) {
this.show();
backdrop.show();
}
});
Now you can access the public method showModal
in any (present or future) element that has the modal-dlg
class (in our case this is the signin-form
div):
var signinForm = DOM.find(".signin-form");
DOM.find(".signin-btn").on("click", function() {
// the signin button doesn’t have the modal-dlg class
// so it’s interface doesn’t contain the showModal method
console.log(this.showModal); // => undefined
signinForm.showModal(); // => shows the signin dialog
});
Note: The better-dom-legacy.js
file which is included conditionally for Internet Explorer versions 8 and 9, contains the es5-shim library so you can safely use standards-based EcmaScript 5 functions (such as Function.prototype.bind
) in your code. I’ve been using the bind
method heavily in my code to build testable methods easily.
The Constructor Property
The constructor function is called when an element becomes visible. This is because of the animationstart
event that is used to implement DOM.extend
. Browsers are clever so they don’t fire animation events for hidden elements. This lazy initialization saves resources sometimes, but be careful with accessing initially hidden elements.
In older Internet Explorers versions such as 8 and 9, contentready
event from better-dom-legacy.htc
is used to implement live extensions. Therefore, the constructor function executes immediately in these browsers — even for hidden elements.
Note: Keep in mind not to rely on time whenever an extension has been initialized. The actual initialization of a live extension varies across browsers!
Constructor is usually the place where you attach event handlers and perform DOM mutations where necessary. Once the function has been completed, all methods that begin with “on” (in better-dom 1.7 also “do”) followed by an uppercase letter, event handlers, will be removed from the element wrapper’s interface.
Let’s update our .signin-form
live extension with the help of a close button and the ESC
key:
DOM.extend(".modal-dlg", {
constructor: function() {
var backdrop = DOM.create("div.modal-dlg-backdrop"),
closeBtn = this.find(".close-btn");
this.showModal = this.showModal.bind(this, backdrop);
// handle click on the close button and ESC key
closeBtn.on("click", this.onClose.bind(this, backdrop));
DOM.on("keydown", this.onKeyDown.bind(this, closeBtn), ["which"])
},
showModal: function(backdrop) {
this.show();
backdrop.show();
},
onClose: function(backdrop) {
this.hide();
frame.hide();
},
onKeyDown: function(closeBtn, which) {
if (which === 27) {
// close dialog by triggering click event
closeBtn.fire("click");
}
}
});
Despite the fact that the live extension contains both onClose
and onKeyDown
methods, they won’t be mixed into the element wrapper interface:
var signinForm = DOM.find(".signin-form");
console.log(signinForm.onClose); // => undefined
console.log(signinForm.onKeyDown); // => undefined
This kind of behavior exists simply because you can have multiple live extensions for a single element that may overload public methods of each other and produce unexpected results. For event handlers, this is not possible; they exist only inside of the constructor function.
Extending * Elements
Sometimes it is useful to extend all of the element wrappers with a particular method (or methods). But then again, you can also use the universal selector to solve the problem:
DOM.extend("*", {
gesture: function(type, handler) {
// implement gestures support
}
});
…
DOM.find("body").gesture("swipe", function() {
// handle a swipe gesture on body
});
The *
selector has a special behavior: all extension declaration properties will be injected directly into the element wrapper prototype except for the constructor which is totally ignored. Therefore, there is no performance penalty that is usually associated with the universal selector.
Note: Never pass more specific selectors such as .some-class *
into DOM.extend
because they are slow and do not have the same behavior as mentioned above.
Multiple Live Extensions on the Same Element
More often that not, it makes sense to split a large live extension into several pieces to reduce complexity. For instance, you may have such an element on your page:
<div class="infinite-scroll chat"></div>
There are two different extensions attached to it. The .infinite-scroll
extension implements a well-known infinite scroll pattern, e.g. it’s responsible for loading new content. At the same time, the .chat
extension shows tooltips whenever a user hovers over a userpic, adds smileys into messages, and so on. However, be accurate with multiple extensions: even though all event handlers may have been removed from the interface, you still may have public methods that intersect with each other.
Inheritance
Live extensions respect declaration order; you can use this to your advantage and develop your own component hierarchy. Late binding helps to declare overridable event handlers and method overloading allows to redefine a method implementation in a child extension:
DOM.extend(".my-widget", {
constructor: function() {
this.on("click", "_handleClick");
},
showMessage: function() { }
});
DOM.extend(".my-button", {
_handleClick: function() {
console.log("I am a button!");
},
showMessage: function() {
alert("I am a button message!");
}
});
If you take a closer look at the code above, you’ll notice that the .my-button
extension does not attach a click listener. The registration is done with the help of late binding instead of a simple event handler in .my-widget
. Late binding is a perfect choice here: even if a child does not implement _handleClick
there won’t be any errors since the handler will be silently ignored.
While spreading functionality across multiple modules is possible, this is not recommended in everyday use. Double check if you really need to go in this direction, because it’s the most complex one.
Writing Tests with DOM.mock
One requirement for a high-quality widget is test coverage. New elements are captured by a live extension asynchronously, so it’s not that easy to simply make them in memory. To solve this problem, better-dom has the DOM.mock
function:
var myButton = DOM.mock("button.my-button");
DOM.mock
creates elements, just like DOM.create
. Additionally, it synchronously applies the registered live extensions to the newly created elements. For even more convenience, all wrapper objects created by DOM.mock
preserve event handlers (e.g. onClick
), so you can test them.
From time to time, you may need to create a “fake” instance of an element. Use DOM.mock
without arguments to make such an object:
console.log(DOM.mock().length); // => 0
A test for the modal dialog live extension introduced earlier could look like this (I use Jasmine):
describe(".modal-dlg", function() {
var dlg, backdrop;
beforeEach(function() {
dlg = DOM.mock("div.modal-dlg");
backdrop = DOM.mock();
});
it("should hide itself and backdrop on close", function() {
var dlgSpy = spyOn(dlg, "hide"),
backdropSpy = spyOn(backdrop, "hide");
dlg.onClose(backdrop);
expect(dlgSpy).toHaveBeenCalled();
expect(backdropSpy).toHaveBeenCalled();
});
it("should show itself and backdrop on show", function() {
var dlgSpy = spyOn(dlg, "show"),
backdropSpy = spyOn(backdrop, "show");
dlg.showModal(backdrop);
expect(dlgSpy).toHaveBeenCalled();
expect(backdropSpy).toHaveBeenCalled();
});
});
Feature Detection (in better-dom 1.7)
There are some cases when filtering with a CSS selector is not flexible enough. For instance, let’s say you want to declare a live extension but only for browsers that support (or do not support) a particular feature. You may need to run tests in a headless browser like PhantomJS that support the feature natively. Starting with better-dom 1.7, DOM.extend
supports the optional argument condition
.
Assume we need to create a polyfill for the placeholder
attribute. It doesn’t make sense to implement it for browsers that have built-in support. Below is an example of how the feature detection could look like:
var supportsPlaceholder = typeof DOM.create("input")
.get("placeholder") === "string";
By using just a simple “If” statement as shown in the example below, we won’t have an ability to test the widget because PhantomJS supports the placeholder
attribute and the live extension will never be declared.
if (!supportsPlaceholder) {
DOM.extend("[placeholder]", {
// implement placeholder support
};
}
In order to solve this problem, you can use an extra condition
argument in DOM.extend
that might be Boolean or a function:
DOM.extend("[placeholder]", !supportsPlaceholder, {
constructor: function() { … },
onFocus: function() { … },
onBlur: function() { … }
});
DOM.mock
ignores the condition
argument, so you can access all methods of the [placeholder]
extension even if current browser passes the check:
var input = DOM.mock("input[placeholder=test]");
typeof input.onFocus; // => "function"
Conclusion
Live extensions — and better-dom as an implementation of the concept — are a good base to build upon whenever your target is uncertain, e.g. when creating a polyfill that may or may not be used on a particular site. Or regular widgets that may or may not be needed, depending upon some AJAX call.
Live extensions aim to separate declaration and the use of widgets. They bring loose coupling (or decoupling, rather) of any DOM-based component, and allow your code to become smaller, cleaner and easier to maintain. You can even combine such independent pieces with any existing framework within the market (or with the vanilla DOM, of course).
You may now be thinking, “But wait, there are projects like Polymer or x-tags, right?” Well, live extensions cover a different area; they are not about custom tags but rather about extending existing ones instead. I prefer a standards-based way (if possible) of creating UI widgets, so making polyfills is my choice.
Better-dom also has another advantage: a carefully crafted live extension does not force you to rewrite a website’s markup using different tags. All you need is to simply include a script file on your page. Standards-based elements can potentially work without JavaScript, so they degrade well when it’s disabled. And the library’s browser support allows you to start using live extensions straight away.
Feel free to share your thoughts in the comments section below or on the better-dom project home page.
Further Reading
- Browser Input Events: Can We Do Better Than The Click?
- Analyzing Network Characteristics Using JavaScript And The DOM
- Building A Simple Cross-Browser Offline To-Do List
- JavaScript Events And Responding To The User