Writing A Better JavaScript Library For The DOM

Go to Source


  

At present, jQuery is the de facto library for working with the document object model (DOM). It can be used with popular client-side MV* frameworks (such as Backbone), and it has a ton of plugins and a very large community. As developers’ interest in JavaScript increases by the minute, a lot of people are becoming curious about how native APIs really work and about when we can just use them instead of including an extra library.

Lately, I have started to see more and more problems with jQuery, at least my use of it. Most of the problems are with jQuery’s core and can’t be fixed without breaking backwards compatibility — which is very important. I, like many others, continued using the library for a while, navigating all of the pesky quirks every day.

Then, Daniel Buchner created SelectorListener, and the idea of “live extensions” manifested. I started to think about creating a set of functions that would enable us to build unobtrusive DOM components using a better approach than what we have used so far. The objective was to review existing APIs and solutions and to build a clearer, testable and lightweight library.

Adding Useful Features To The Library

The idea of live extensions encouraged me to develop the better-dom project, although other interesting features make the library unique. Let’s review them quickly:

  • live extensions
  • native animations
  • embedded microtemplating
  • internationalization support

Live Extensions

jQuery has a concept called “live events.” Drawing on the idea of event delegation, it enables developers to handle existing and future elements. But more flexibility is required in a lot of cases. For example, delegated events fall short when the DOM needs to be mutated in order to initialize a widget. Hence, live extensions.

The goal is to define an extension once and have any future elements run through the initialization function, regardless of the widget’s complexity. This is important because it enables us to write Web pages declaratively; so, it works great with AJAX applications.

Live extensions make is easier to handle any future elements.
Live extensions enable you to handle any future elements without the need to invoke the initialization function. (Image credits)

Let’s look at a simple example. Let’s say our task is to implement a fully customizable tooltip. The :hover pseudo-selector won’t help us here because the position of the tooltip changes with the mouse cursor. Event delegation doesn’t fit well either; listening to mouseover and mouseleave for all elements in the document tree is very expensive. Live extensions to the rescue!


DOM.extend("[title]", {
  constructor: function() {
    var tooltip = DOM.create("span.custom-title");

    // set the title's textContent and hide it initially
    tooltip.set("textContent", this.get("title")).hide();

    this
      // remove legacy title
      .set("title", null)
      // store reference for quicker access
      .data("tooltip", tooltip)
      // register event handlers
      .on("mouseenter", this.onMouseEnter, ["clientX", "clientY"])
      .on("mouseleave", this.onMouseLeave)
      // insert the title element into DOM
      .append(tooltip);
  },
  onMouseEnter: function(x, y) {
    this.data("tooltip").style({left: x, top: y}).show();
  },
  onMouseLeave: function() {
    this.data("tooltip").hide();
  }
});

We can style the .custom-title element in CSS:


.custom-title {
  position: fixed; /* required */
  border: 1px solid #faebcc;
  background: #faf8f0;
}

The most interesting part happens when you insert a new element with a title attribute in the page. The custom tooltip will work without any initialization call.

Live extensions are self-contained; thus, they don’t require you to invoke an initialization function in order to work with future content. So, they can be combined with any DOM library and will simplify your application logic by separating the UI code into many small independent pieces.

Last but not least, a few words on Web components. A section of the specification, “Decorators,” aims to solve a similar problem. Currently, it uses a markup-based implementation with a special syntax for attaching event listeners to child elements. But it’s still an early draft:

“Decorators, unlike other parts of Web Components, do not have a specification yet.”

Native Animations

Thanks to Apple, CSS has good animation support now. In the past, animations were usually implemented in JavaScript via setInterval and setTimeout. It was a cool feature — but now it’s more like a bad practice. Native animations will always be smoother: They are usually faster, take less energy and degrade well if not supported by the browser.

In better-dom, there is no animate method: just show, hide and toggle. To capture a hidden element state in CSS, the library uses the standards-based aria-hidden attribute.

To illustrate how it works, let’s add a simple animation effect to the custom tooltip that we introduced earlier:


.custom-title {
  position: fixed; /* required */
  border: 1px solid #faebcc;
  background: #faf8f0;
  /* animation code */
  opacity: 1;
  -webkit-transition: opacity 0.5s;
  transition: opacity 0.5s;
}

.custom-title[aria-hidden=true] {
  display: block; /* have to override default "none" */
  opacity: 0;
}

Internally, show() and hide() just set the aria-hidden attribute value to be false and true. This is enough to enable the CSS to handle the animations and transitions.

You can see a demo with more animation examples that use better-dom.

Embedded Microtemplating

HTML strings are annoyingly verbose. Looking for a replacement, I found the excellent Emmet. Today, Emmet is quite a popular plugin for text editors, and it has a nice and compact syntax. Take this HTML:


body.append("<ul><li class='list-item'></li><li class='list-item'></li><li class='list-item'></li></ul>");

And compare it to the equivalent microtemplate:


body.append("ul>li.list-item*3");

In better-dom, any method that accepts HTML may use Emmet expressions as well. The abbreviation parser is fast, so no need to worry about a performance penalty. A template precompilation function also exists to be used on demand.

Internationalization Support

Developing a UI widget often requires localization — not an easy task. Over the years, many have tackled this in different ways. With better-dom, I believe that changing the state of a CSS selector is like switching languages.

Conceptually speaking, switching a language is like changing the “representation” of content. In CSS2, several pseudo-selectors help to describe such a model: :lang and :before. Take the code below:


[data-i18n="hello"]:before {
  content: "Hello Maksim!";
}

[data-i18n="hello"]:lang(ru):before {
  content: "Привет Максим!";
}

The trick is simple: The value of the content property changes according to the current language, which is determined by the lang attribute of the html element. By using data attributes such as data-i18n, we can maintain the textual content in HTML:


[data-i18n]:before {
  content: attr(data-i18n);
}

[data-i18n="Hello Maksim!"]:lang(ru):before {
  content: "Привет Максим!";
}

Of course, such CSS isn’t exactly attractive, so better-dom has two helpers: i18n and DOM.importStrings. The first is used to update the data-i18n attribute with the appropriate value, and the second localizes strings for a particular language.


label.i18n("Hello Maksim!");
// the label displays "Hello Maksim!"
DOM.importStrings("ru",  "Hello Maksim!", "Привет Максим!");
// now if the page is set to ru language,
// the label will display "Привет Максим!"
label.set("lang", "ru");
// now the label will display "Привет Максим!"
// despite the web page's language

Parameterized strings can be used as well. Just add ${param} variables to a key string:


label.i18n("Hello ${user}!", {user: "Maksim"});
// the label will display "Hello Maksim!"

Making Native APIs More Elegant

Generally, we want to stick to standards. But sometimes the standards aren’t exactly user-friendly. The DOM is a total mess, and to make it bearable, we have to wrap it in a convenient API. Despite all of the improvements made by open-source libraries, some parts could still be done better:

  • getter and setter,
  • event handling,
  • functional methods support.

Getter and Setter

The native DOM has the concept of attributes and properties of elements that could behave differently. Assume we have the markup below on a Web page:


<a href="/chemerisuk/better-dom" id="foo" data-test="test">better-dom</a>

To explain why “the DOM is a total mess,” let’s look at this:


var link = document.getElementById("foo");

link.href; // => "https://github.com/chemerisuk/better-dom"
link.getAttribute("href"); // => "/chemerisuk/better-dom"
link["data-test"]; // => undefined
link.getAttribute("data-test"); // => "test"

link.href = "abc";
link.href; // => "https://github.com/abc"
link.getAttribute("href"); // => "abc"

An attribute value is equal to the appropriate string in HTML, while the element property with the same name could have some special behavior, such as generating the fully qualified URL in the listing above. These differences can be confusing.

In practice, it’s hard to imagine a practical situation in which such a distinction would be useful. Moreover, the developer should always keep in mind which value (attribute or property) is being used that introduces unnecessary complexity.

In better-dom, things are clearer. Every element has only smart getters and setters.


var link = DOM.find("#foo");

link.get("href"); // => "https://github.com/chemerisuk/better-dom"
link.set("href", "abc");
link.get("href"); // => "https://github.com/abc"
link.get("data-attr"); // => "test"

In the first step, it does a property lookup, and if it’s defined, then it’s used for manipulation. Otherwise, getter and setter work with the appropriate attribute of the element. For booleans (checked, selected, etc.), you could just use true or false to update the value: Changing such a property on an element would trigger the appropriate attribute (native behavior) to be updated.

Improved Event Handling

Event handling is a big part of the DOM, however, I’ve discovered one fundamental problem: Having an event object in element listeners forces a developer who cares about testability to mock the first argument, or to create an extra function that passes only event properties used in the handler.


var button = document.getElementById("foo");

button.addEventListener("click", function(e) {
  handleButtonClick(e.button);
}, false);

This is really annoying. What if we extracted the changing part as an argument? This would allow us to get rid of the extra function:


var button = DOM.find("#foo");

button.on("click", handleButtonClick, ["button"]);

By default, the event handler passes the ["target", "defaultPrevented"] array, so no need to add the last argument to get access to these properties:


button.on("click", function(target, canceled) {
  // handle button click here
});

Late binding is supported as well (I’d recommend reading Peter Michaux’s review of the topic). It’s a more flexible alternative to the regular event handlers that exist in the W3C’s standard. It could be useful when you need frequent on and off method calls.


button._handleButtonClick = function() { alert("click!"); };

button.on("click", "_handleButtonClick");
button.fire("click"); // shows "clicked" message
button._handleButtonClick = null;
button.fire("click"); // shows nothing

Last but not least, better-dom has none of the shortcuts that exist in legacy APIs and that behave inconsistently across browsers, like click(), focus() and submit(). The only way to call them is to use the fire method, which executes the default action when no listener has returned false:


link.fire("click"); // clicks on the link
link.on("click", function() { return false; });
link.fire("click"); // triggers the handler above but doesn't do a click

Functional Methods Support

ES5 standardized a couple of useful methods for arrays, including map, filter and some. They allow us to use common collection operations in a standards-based way. As a result, today we have projects like Underscore and Lo-Dash, which polyfill these methods for old browsers.

Each element (or collection) in better-dom has the methods below built in:

  • each (which differs from forEach by returning this instead of undefined)
  • some
  • every
  • map
  • filter
  • reduce[Right]

var urls, activeLi, linkText; 

urls = menu.findAll("a").map(function(el) {
  return el.get("href");
});
activeLi = menu.children().filter(function(el) {
  return el.hasClass("active");
});
linkText = menu.children().reduce(function(memo, el) {
  return memo || el.hasClass("active") && el.find("a").get()
}, false);

Avoiding jQuery Problems

Most of the following issues can’t be fixed in jQuery without breaking backwards compatibility. That’s why creating a new library seemed like the logical way out.

  • the “magical” $ function
  • the value of the [] operator
  • issues with return false
  • find and findAll

The “Magical” $ Function

Everyone has heard at some point that the $ (dollar) function is kind of like magic. A single-character name is not very descriptive, so it looks like a built-in language operator. That’s why inexperienced developers call it inline everywhere.

Behind the scenes, the dollar is quite a complex function. Executing it too often, especially in frequent events such as mousemove and scroll, could cause poor UI performance.

Despite so many articles recommending jQuery objects to be cached, developers continue to insert the dollar function inline, because the library’s syntax encourages them to use this coding style.

Another issue with the dollar function is that it allows us to do two completely different things. People have gotten used to such a syntax, but it’s a bad practice of a function design in general:


$("a"); // => searches all elements that match “a” selector
$("<a>"); // => creates a <a> element with jQuery wrapper

In better-dom, several methods cover the responsibilities of the dollar function in jQuery: find[All] and DOM.create. find[All] is used to search element(s) according to the CSS selector. DOM.create makes a new elements tree in memory. Their names make it very clear what they are responsible for.

Value of the [] Operator

Another reason for the problem of frequent dollar function calls is the brackets operator. When a new jQuery object is created, all associated nodes are stored in numeric properties. But note that the value of such a property contains a native element instance (not a jQuery wrapper):


var links = $("a");

links[0].on("click", function() { ... }); // throws an error
$(links[0]).on("click", function() { ... }); // works fine

Because of such a feature, every functional method in jQuery or another library (like Underscore) requires the current element to be wrapped with $() inside of a callback function. Therefore, developers must always keep in mind the type of object they are working with — a native element or a wrapper — despite the fact that they are using a library to work with the DOM.

In better-dom, the brackets operator returns a library’s object, so developers can forget about native elements. There is only one acceptable way to access them: by using a special legacy method.


var foo = DOM.find("#foo");

foo.legacy(function(node) {
  // use Hammer library to bind a swipe listener
  Hammer(node).on("swipe", function(e) {
    // handle swipe gesture here
  }); 
});

In reality, this method is required in very rare cases, such as to be compatible with a native function or with another DOM library (like Hammer in the example above).

Issues With return false

One thing that really blows my mind is the strange return false interception in jQuery’s event handlers. According to the W3C’s standards, it should in most cases cancel the default behavior. In jQuery, return false also stops event delegation.

Such interception creates problems:

  1. Invoking stopPropagation() by itself could lead to compatibility problems, because it prevents listeners that are related to some other task from doing their work.
  2. Most developers (even experienced ones) are not aware of such behavior.

It’s unclear why the jQuery community decided to go cross-standards. But better-dom is not going to repeat the same mistake. Thus, return false in an event handler only prevents the browser’s default action, without messing with event propagation, as everyone would expect.

find and findAll

Element search is one of the most expensive operations in the browser. Two native methods could be used to implement it: querySelector and querySelectorAll. The difference is that the first one stops searching on the first match.

This feature enables us to decrease the iterations count dramatically in certain cases. In my tests, the speed was up to 20 times faster! Also, you can expect that the improvement will grow according to the size of the document tree.

jQuery has a find method that uses querySelectorAll for general cases. Currently, no function uses querySelector to fetch only the first matched element.

The better-dom library has two separate methods: find and findAll. They allow us to use querySelector optimization. To estimate the potential improvement in performance, I searched for the usage of these methods in all of the source code of my last commercial project:

  • find
    103 matches across 11 files
  • findAll
    14 matches across 4 files

The find method is definitely much more popular. It means that querySelector optimization makes sense in most use cases and could give a major performance boost.

Conclusion

Live extensions really make solving front-end problems much easier. Splitting the UI in many small pieces leads to more independent and maintainable solutions. But as we’ve shown, a framework is not only about them (although it is the main goal).

One thing I’ve learned in the development process is that if you don’t like a standard or you have a different opinion of how things should work, then just implement it and prove that your approach works. It’s really fun, too!

More information about the better-dom project can be found on GitHub.

(al, il, ea)


© Maksim Chemerisuk for Smashing Magazine, 2014.