What’s wrong with extending the DOM
I was recently surprised to find out how little the topic of DOM extensions is covered on the web. What’s disturbing is that downsides of this seemingly useful practice don’t seem to be well known, except in certain secluded circles. The lack of information could well explain why there are scripts and libraries built today that still fall into this trap. I’d like to explain why extending DOM is generally a bad idea, by showing some of the problems associated with it. We’ll also look at possible alternatives to this harmful exercise.
But first of all, what exactly is DOM extension? And how does it all work?
How DOM extension works§
DOM extension is simply the process of adding custom methods/properties to
DOM objects. Custom properties are those that don’t exist in a particular
implementation. And what are the DOM objects? These are host objects
implementing Element
, Event
, Document
, or
any of dozens of other DOM interfaces. During extension, methods/properties can
be added to objects directly, or to their prototypes (but only in environments
that have proper support for it).
The most commonly extended objects are probably DOM elements (those that
implement Element
interface), popularized by Javascript libraries like Prototype and Mootools.
Event objects (those that implement Event
interface), and documents (Document
interface) are often extended as well.
In environment that exposes prototype of Element objects, an example of DOM extension would look something like this:
Element.prototype.hide = function() {
this.style.display = 'none';
};
...
var element = document.createElement('p');
element.style.display; // ''
element.hide();
element.style.display; // 'none'
As you can see, “hide” function is first assigned to a hide
property of Element.prototype
. It is then invoked directly on an
element, and element’s “display” style is set to “none”.
The reason this “works” is because object referred to by
Element.prototype
is actually one of the objects in
prototype chain of P element. When hide
property
is resolved on it, it’s searched throughout the prototype chain until found on
this Element.prototype
object.
In fact, if we were to examine prototype chain of P element in some of the modern browsers, it would usually look like this:
// "^" denotes connection between objects in prototype chain
document.createElement('p');
^
HTMLParagraphElement.prototype
^
HTMLElement.prototype
^
Element.prototype
^
Node.prototype
^
Object.prototype
^
null
Note how the nearest ancestor in the prototype chain of P
element is object referred to by HTMLParagraphElement.prototype
.
This is an object specific to type of an element. For P
element,
it’s HTMLParagraphElement.prototype
; for DIV
element,
it’s HTMLDivElement.prototype
; for A
element, it’s
HTMLAnchorElement.prototype
, and so on.
But why such strange names, you might ask?
These names actually correspond to interfaces defined in DOM Level 2 HTML Specification. That same specification also defines inheritance between those interfaces. It says, for example, that “… HTMLParagraphElement interface have all properties and functions of the HTMLElement interface …” (source) and that “… HTMLElement interface have all properties and functions of the Element interface …” (source), and so on.
Quite obviously, if we were to create a property on “prototype object” of paragraph element, that property would not be available on, say, anchor element:
HTMLParagraphElement.prototype.hide = function() {
this.style.display = 'none';
};
...
typeof document.createElement('a').hide; // "undefined"
typeof document.createElement('p').hide; // "function"
This is because anchor element’s prototype chain never includes object
refered to by HTMLParagraphElement.prototype
, but instead includes
that referred to by HTMLAnchorElement.prototype
. To “fix” this, we
can assign to property of object positioned further in the prototype chain, such
as that referred to by HTMLElement.prototype
,
Element.prototype
or Node.prototype
.
Similarly, creating a property on Element.prototype
would not
make it available on all nodes, but only on nodes of element type. If we wanted
to have property on all nodes (e.g. text nodes, comment nodes, etc.), we would
need to assign to property of Node.prototype
instead. And speaking
of text and comment nodes, this is how interface inheritance usually looks for
them:
document.createTextNode('foo'); // < Text.prototype < CharacterData.prototype < Node.prototype
document.createComment('bar'); // < Comment.prototype < CharacterData.prototype < Node.prototype
Now, it's important to understand that exposure of these DOM object
prototypes is not guaranteed. DOM Level 2 specification merely defines
interfaces, and inheritance between those interfaces. It does not state that
there should exist global Element
property, referencing object
that's a prototype of all objects implementing Element
interface.
Neither does it state that there should exist global Node
property,
referencing object that's a prototype of all objects implementing
Node
interface.
Internet Explorer 7 (and below) is an example of such environment; it does
not expose global Node
, Element
,
HTMLElement
, HTMLParagraphElement
, or other
properties. Another such browser is Safari 2.x (and most likely Safari 1.x).
So what can we do in environments that don't expose these global "prototype" objects? A workaround is to extend DOM objects directly:
var element = document.createElement('p');
...
element.hide = function() {
this.style.display = 'none';
};
...
element.style.display; // ''
element.hide();
element.style.display; // 'none'
What went wrong?§
Being able to extend DOM elements through prototype objects sounds amazing. We are taking advantage of Javascript prototypal nature, and scripting DOM becomes very object-oriented. In fact, DOM extension seemed so temptingly useful that few years ago, Prototype Javascript library made it an essential part of its architecture. But what hides behind seemingly innocuous practice is a huge load of trouble. As we'll see in a moment, when it comes to cross-browser scripting, the downsides of this approach far outweigh any benefits. DOM extension is one of the biggest mistakes Prototype.js has ever done.
So what are these problems?
Lack of specification§
As I have already mentioned, exposure of "prototype objects" is not part of
any specification. DOM Level 2 merely defines interfaces and their inheritance
relations. In order for implementation to conform to DOM Level 2 fully, there's
no need to expose those global Node
, Element
,
HTMLElement
, etc. objects. Neither is there a requirement to expose
them in any other way. Given that there's always a possibility to extend DOM
objects manually, this doesn't seem like a big issue. But the truth is that
manual extension is a rather slow and inconvenient process (as we will see
shortly). And the fact that fast, "prototype object" -based extension is merely
somewhat of a de-facto standard among few browsers, makes this practice
unreliable when it comes to future adoption or portability across non-convential
platforms (e.g. mobile devices).
Host objects have no rules§
Next problem with DOM extension is that DOM objects are host objects, and host objects are the worst bunch. By specification (ECMA-262 3rd. ed), host objects are allowed to do things, no other objects can even dream of. To quote relevant section [8.6.2]:
Host objects may implement these internal methods with any implementation-dependent behaviour, or it may be that a host object implements only some internal methods and not others.
The internal methods specification talks about are [[Get]], [[Put]], [[Delete]], etc. Note how it says that internal methods behavior is implementation-dependent. What this means is that it's absolutely normal for host object to throw error on invocation of, say, [[Get]] method. And unfortunatey, this isn't just a theory. In Internet Explorer, we can easily observe exactly this?an example of host object [[Get]] throwing error:
document.createElement('p').offsetParent; // "Unspecified error."
new ActiveXObject("MSXML2.XMLHTTP").send; // "Object doesn't support this property or method"
Extending DOM objects is kind of like walking in a minefield. By definition,
you are working with something that's allowed to behave in unpredictable and
completely erratic way. And not only things can blow up; there's also a
possibility of silent failures, which is even worse scenario. An example of
erratic behavior is applet
, object
and
embed
elements, which in certain cases throw
errors on assignment of properties. Similar disaster happens with XML
nodes:
var xmlDoc = new ActiveXObject("Microsoft.XMLDOM");
xmlDoc.loadXML('bar ');
xmlDoc.firstChild.foo = 'bar'; // "Object doesn't support this property or method"
There are other cases
of failures in IE, such as document.styleSheets[99999]
throwing
"Invalid procedure call or argument" or
document.createElement('p').filters
throwing "Member not found."
exceptions. But not only MSHTML DOM is the problem. Trying to overwrite "target"
property of event
object in Mozilla throws TypeError, complaining
that property has only a getter (meaning that it's readonly and can not be set).
Doing same thing in WebKit, results in silent failure, where "target" continues
to refer to original object after assignment.
When creating API for working with event objects, there's now a need to consider all of these readonly properties, instead of focusing on concise and descriptive names.
A good rule of thumb is to avoid touching host objects as much as possible. Trying to base architecture on something that?by definition?can behave so sporadically is hardly a good idea.
Chance of collisions§
API based on DOM element extensions is hard to scale. It's hard to scale for developers of the library?when adding new or changing core API methods, and for library users?when adding domain-specific extensions. The root of the issue is a likely chance of collisions. DOM implementations in popular browsers usually all have properietary API's. What's worse is that these API's are not static, but constantly change as new browser versions come out. Some parts get deprecated; others are added or modified. As a result, set of properties and methods present on DOM objects is somewhat of a moving target.
Given huge amount of environments in use today, it becomes impossible to tell if certain property is not already part of some DOM. And if it is, can it be overwritten? Or will it throw error when attempting to do so? Remember that it's a host object! And if we can quietly overwrite it, how would it affect other parts of DOM? Would everything still work as expected? If everything is fine in one version of such browser, is there a guarantee that next version doesn't introduce same-named property? The list of questions goes on.
Some examples of proprietary extensions that broke Prototype are wrap property on textareas in
IE (colliding with Element#wrap method), and
select
method on
form control elements in Opera (colliding with Element#select method).
Even though both of these cases are documented, having to remember these little
exceptions is annoying.
Proprietary extensions are not the only problem. HTML5 brings new methods and
properties to the table. And most of the popular browsers have already started
implementing them. At some point, WebForms defined
replace
property on input elements, which Opera decided to add to
their browser. And once again, it broke Prototype, due to
conflict with Element#replace
method.
But wait, there's more!
Due to long-standing DOM Level 0 tradition, there's this "convenient" way to
access form controls off of form
elements, simply by their name. What this means is that instead of using
standard elements
collection, you can access form control like
this:
<form action="">
<input name="foo">
</form>
...
<script type="text/javascript">
document.forms[0].foo; // non-standard access
// compare to
document.forms[0].elements.foo; // standard access
</script>
So, say you extend form elements with login
method, which for
example checks validation and submits login form. If you also happen to have
form control with “login” name (which is pretty likely, if you ask me), what
happens next is not pretty:
<form action="">
<input name="login">
...
</form>
...
<script type="text/javascript">
HTMLFormElement.prototype.login = function(){
return 'logging in';
};
...
$(myForm).login(); // boom!
// $(myForm).login references input element, not `login` method
</script>
Every named form control shadows properties inherited through prototype chain. The chance of collisions and unexpected errors on form elements is even higher.
Situation is somewhat similar with named form
elements, where
they can be accessed directly off document
by
their names:
<form name="foo">
...
</form>
...
<script type="text/javascript">
document.foo; // [object HTMLFormElement]
</script>
When extending document objects, there’s now an additional risk of form names conflicting with extensions. And what if script is running in legacy applications with tons of rusty HTML, where changing/removing such names is not a trivial task?
Employing some kind of prefixing strategy can alleviate the problem. But will probably also bring extra noise.
Not
modifying objects you don’t own is an ultimate recipe for avoiding
collisions. Breaking this rule already got Prototype into trouble, when it overwrote
document.getElementsByClassName
with own, custom
implementation. Following it also means playing nice with other scripts, running
in the same environment?no matter if they modify DOM objects or not.
Performance overhead§
As we’ve seen before, browsers that don’t support element extensions?like IE
6, 7, Safari 2.x, etc.?require manual object extension. The problem is that
manual extension is slow, inconvenient and doesn’t scale. It’s slow because
object needs to be extended with what’s often a large number of
methods/properties. And ironically, these browsers are the slowest ones around.
It’s inconvenient because object needs to be first extended in
order to be operated on. So instead of
document.createElement('p').hide()
, you would need to do something
like $(document.createElement('p')).hide()
. This, by the way, is
one of the most common stumbing blocks for beginners of Prototype. Finally,
manual extension doesn’t scale well because adding API methods affects
performance pretty much linearly. If there’s 100 methods on
Element.prototype
, there has to be 100 assignments made to an
element in question; if there’s 200 methods, there has to be 200 assignments
made to an element, and so on.
Another performance hit is with event objects. Prototype follows similar approach with events and extends them with a certain set of methods. Unfortunately, some events in browsers?mousemove, mouseover, mouseout, resize, to name few?can fire literally dozens of times per second. Extending each one of them is an incredibly expensive process. And what for? Just to invoke what could be a single method on event obejct?
Finally, once you start extending elements, library API most likely needs to
return extended elements everywhere. As a result,
querying methods like $$
could end up extending every single
element in a query. It’s easy to imagine performance overead of such process,
when we’re talking about hundreds or thousands of elements.
IE DOM is a mess§
As shown in previous section, manual DOM extension is a mess. But manual DOM extension in IE is even worse, and here’s why.
We all know that in IE, circular references between host and native objects leak, and are best avoided. But adding methods to DOM elements is a first step towards creation of such circular references. And since older versions of IE don’t expose “object prototypes”, there’s not much to do but extend elements directly. Circular references and leaks are almost inevitable. And in fact, Prototype suffered from them for most of its lifetime.
Another problem is the way IE DOM maps properties and attributes to each
other. The fact that attributes are in the same namespace as properties,
increases chance of collisions and all kinds of unexpected inconsistencies. What
happens if element has custom “show” attribute and is then extended by
Prototype. You’ll be surprised, but show “attribute” would get overwritten by
Prototype’s Element#show
method.
extendedElement.getAttribute('show')
would return a reference to a
function, not the value of “show” attribute. Similarly,
extendedElement.hasAttribute('hide')
would say “true”, even if
there was never custom “hide” attribute on an element. Note that IE<8 lacks
hasAttribute
, but we could still see attribute/property conflict:
typeof extendedElement.attributes['show'] != "undefined"
.
Finally, one of the lesser-known downsides is the fact that adding properties to DOM elements causes reflow in IE, so mere extension of element becomes a quite expensive operation. This actually makes sense, given the deficient mapping of attributes and properties in its DOM.
Bonus: browser bugs§
If everything we’ve been over so far is not enough (in which case, you’re probably a masochist), here’s a couple more bugs to top it all of.
In some versions of Safari 3.x, there’s a bug where navigating to a previous
page via back button wipes
off all host object extensions. Unfortunately, the bug is undetectable, so
to work around the issue, Prototype has to do something horrible. It sniffs
browser for that version of WebKit, and explicitly disables bfcache by attaching
“unload” event listener to window
. Disabled bfcache means that
browser has to re-fetch page when navigating via back/forward
buttons, instead of restoring page from the cached state.
Another bug is with HTMLObjectElement.prototype
and
HTMLAppletElement.prototype
in IE8, and the way object and applet
elements don’t inherit from those prototype objects. You can
assign to a property of HTMLObjectElement.prototype
, but that
property is never “resolved” on object element. Ditto for applets. As a result,
those elements always have to be extended manually, which is another
overhead.
IE8 also exposes
only a subset of prototype objects, when compared to other popular
implementations. For example, there’s
HTMLParagraphElement.prototype
(as well as other type-specific
ones), and Element.prototype
, but no HTMLElement
(and
so HTMLElement.prototype
) or Node
(and so
Node.prototype
). Element.prototype
in IE8 also doesn’t
inherit from Object.prototype
. These are not bugs, per se, but is
something to keep in mind nevertheless: there’s nothing good about trying to
extend non-existent Node
, for example.
Wrappers to the rescue§
One of the most common alternatives to this whole mess of DOM extension is object wrappers. This is the approach jQuery has taken from the start, and few other libraries followed later on. The idea is simple. Instead of extending elements or events directly, create a wrapper around them, and delegate methods accordingly. No collisions, no need to deal with host objects madness, easier to manage leaks and operate in dysfunctional MSHTML DOM, better performance, saner maintenance and painless scaling.
And you still avoid procedural approach.
Prototype 2.0§
The good news is that Prototype mistake is something that’s going away in the next major version of the library. As far as I’m concerned, all core developers understand the problems mentioned above, and that wrapper approach is the saner way to move forward. I’m not sure what the plans are in other DOM-extending libraries like Mootools. From what I can see they are already using wrappers with events, but still extend elements. I’m certinaly hoping they move away from this madness in a near future.
Controlled environments§
So far we looked at DOM extension from the point of view of cross-browser scripting library. In that context, it’s clear how troublesome this idea really is. But what about controlled environments? When script is only run in one or two environments, such as those based on Gecko, WebKit or any other modern non-MSHTML DOM. Perhaps it’s an intranet application, that’s accessed through certain browsers. Or a desktop, WebKit-based app.
In that case, situtation is definitly better. Let’s look at the points listed above.
Lack of specification becomes somewhat irrelevant, as there’s no need to worry about compatibility with other platforms, or future editions. Most of the non-MSHTML DOM environments expose DOM object prototypes for quite a while, and are unlikely to drop it in a near future. There’s still a possibility for change, however.
Point about host objects unreliability also loses its weight, since host objects in Gecko or WebKit -based DOMs are much, much saner than those in MSHTML DOM. But they are still host objects, and so should be treated with care. Besides, there are readonly properties covered before, which could easily cripple the flexibility of API.
The point about collisions still holds weight. These environments support non-standard form controls access, have proprietary API, and are constantly implementing new HTML5 features. Modifying objects you don’t own is still a wicked idea and can lead to hard-to-find bugs and inconsistencies.
Performance overhead is practically non-existent, as these DOM support prototype-based DOM extension. Performance can actually be even better, comparing to, say, wrappers approach, as there’s no need to create any additional objects in order to invoke methods (or access properties) off DOM objects.
Extending DOM in controlled environment sure seems like a perfectly healthy thing to do. But even though the main problem is that with collisions, I would still advise to employ wrappers instead. It’s a safer way to move forward, and will save you from maintenance overhead in the future.
Afterword§
Hopefuly, you can now clearly see all the truth behind what looks like an elegant approach. Next time you design a Javascript framework, just say no to DOM extensions. Say no, and save yourself from all the trouble of maintaining a cumbersome API and suffering unnecessary performance overheads. If on the other hand, you’re considering to employ Javascript library that extends DOM, stop for a second, and ask yourself if you’re willing to take a risk. Is ellusive convenience of DOM extension really worth all the trouble?