三.DOM Scripting
DOM scripting is expensive, and it’s a common performance bottleneck in rich web applications. This chapter discusses the areas of DOM scripting that can have a negative effect on an application’s responsiveness and gives recommendations on how to improve response time. The three categories of problems discussed in the chapter include:
1. Accessing and modifying DOM elements
2. Modifying the styles of DOM elements and causing repaints and reflows
3.Handling user interaction through DOM events
function innerHTMLLoop() { for (var count = 0; count < 15000; count++) { document.getElementById('here').innerHTML += 'a'; } }This is a function that updates the contents of a page element in a loop. The problem with this code is that for every loop iteration, the element is accessed twice: once to read the value of the innerHTML property and once to write it.
function innerHTMLLoop2() { var content = ''; for (var count = 0; count < 15000; count++) { content += 'a'; } document.getElementById('here').innerHTML += content; }This new version of the function will run much faster across all browsers. Figure 3-1 shows the results of measuring the time improvement in different browsers. The y-axis in the figure (as with all the figures in this chapter) shows execution time improvement, i.e., how much faster it is to use one approach versus another. In this case, for example,using innerHTMLLoop2() is 155 times faster than innerHTMLLoop() in IE6.
a.innerHTML Versus DOM methods
Over the years, there have been many discussions in the web development community over this question: is it better to use the nonstandard but well-supported innerHTML property to update a section of a page, or is it best to use only the pure DOM methods, such as document.createElement()? Leaving the web standards discussion aside, does it matter for performance? The answer is: it matters increasingly less, but still,innerHTML is faster in all browsers except the latest WebKit-based ones (Chrome and Safari). The benefits of innerHTML are more obvious in older browser versions (innerHTML is 3.6 times faster in IE6), but the benefits are less pronounced in newer versions. And in newer WebKit-based browsers it’s the opposite: using DOM methods is slightly faster. So the decision about which approach to take will depend on the browsers your users are commonly using, as well as your coding preferences.
As a side note, keep in mind that this example used string concatenation, which is not optimal in older IE versions. Using an array to concatenate large strings will make innerHTML even faster in those browsers.
Using innerHTML will give you faster execution in most browsers in performance-critical operations that require updating a large part of the HTML page. But for most everyday cases there isn’t a big difference, and so you should consider readability, maintenance, team preferences, and coding conventions when deciding on your approach.
b.Cloning Nodes
Another way of updating page contents using DOM methods is to clone existing DOM elements instead of creating new ones—in other words, using element.cloneNode() (where element is an existing node) instead of document.createElement().
Cloning nodes is more efficient in most browsers, but not by a big margin. Regenerating the table from the previous example by creating the repeating elements only once and then copying them results in slightly faster execution times:
• 2% in IE8, but no change in IE6 and IE7
• Up to 5.5% in Firefox 3.5 and Safari 4
• 6% in Opera (but no savings in Opera 10)
• 10% in Chrome 2 and 3% in Chrome 3
c.HTML Collections
HTML collections are array-like objects containing DOM node references. Examples of collections are the values returned by the following methods:
• document.getElementsByName()
• document.getElementsByClassName()
• document.getElementsByTagName()
The following properties also return HTML collections:
document.images
All img elements on the page
document.links
All a elements
document.forms
All forms
document.forms[0].elements
All fields in the first form on the page
These methods and properties return HTMLCollection objects, which are array-like lists.They are not arrays (because they don’t have methods such as push() or slice()), but provide a length property just like arrays and allow indexed access to the elements in the list. For example, document.images[1] returns the second element in the collection. As defined in the DOM standard, HTML collections are “assumed to be live, meaning that they are automatically updated when the underlying document is updated” (see http://www.w3.org/TR/DOM-Level-2-HTML/html.html#ID-75708506).
The HTML collections are in fact queries against the document, and these queries are being reexecuted every time you need up-to-date information, such as the number of elements in the collection (i.e., the collection’s length). This could be a source of inefficiencies.
To demonstrate that the collections are live, consider the following snippet:
// an accidentally infinite loop var alldivs = document.getElementsByTagName('div'); for (var i = 0; i < alldivs.length; i++) { document.body.appendChild(document.createElement('div')) }
This code looks like it simply doubles the number of div elements on the page. It loops through the existing divs and creates a new div every time, appending it to the body. But this is in fact an infinite loop because the loop’s exit condition, alldivs.length, increases by one with every iteration, reflecting the current state of the underlying document.
Looping through HTML collections like this may lead to logic mistakes, but it’s also slower, due to the fact that the query needs to run on every iteration. When the length of the collection is accessed on every iteration, it causes the collection to be updated and has a significant performance penalty across all browsers. The way to optimize this is to simply cache the length of the collection into a variable and use this variable to compare in the loop’s exit condition:
function loopCacheLengthCollection() { var coll = document.getElementsByTagName('div'), len = coll.length; for (var count = 0; count < len; count++) { // ... } }
The previous example used just an empty loop, but what happens when the elements of the collection are accessed within the loop?
In general, for any type of DOM access it’s best to use a local variable when the same DOM property or method is accessed more than once. When looping over a collection, the first optimization is to store the collection in a local variable and cache the length outside the loop, and then use a local variable inside the loop for elements that are accessed more than once.
In the next example, three properties of each element are accessed within the loop. The lowest version accesses the global document every time, an optimized version caches a reference to the collection, and the fastest version also stores the current element of the collection into a variable. All three versions cache the length of the collection.
// slow function collectionGlobal() { var coll = document.getElementsByTagName('div'), len = coll.length, name = ''; for (var count = 0; count < len; count++) { name = document.getElementsByTagName('div')[count].nodeName; name = document.getElementsByTagName('div')[count].nodeType; name = document.getElementsByTagName('div')[count].tagName; } return name; }; // faster function collectionLocal() { var coll = document.getElementsByTagName('div'), len = coll.length, name = ''; for (var count = 0; count < len; count++) { name = coll[count].nodeName; name = coll[count].nodeType; name = coll[count].tagName; } return name; }; // fastest function collectionNodesLocal() { var coll = document.getElementsByTagName('div'), len = coll.length, name = '', el = null; for (var count = 0; count < len; count++) { el = coll[count]; name = el.nodeName; name = el.nodeType; name = el.tagName; } return name; };
function testNextSibling() { var el = document.getElementById('mydiv'), ch = el.firstChild, name = ''; do { name = ch.nodeName; } while (ch = ch.nextSibling); return name; }; function testChildNodes() { var el = document.getElementById('mydiv'), ch = el.childNodes, len = ch.length, name = ''; for (var count = 0; count < len; count++) { name = ch[count].nodeName; } return name; };Bear in mind that childNodes is a collection and should be approached carefully, caching the length in loops so it’s not updated on every iteration.
Property | Use as a replacement for |
children | childNodes |
childElementCount | childNodes.length |
firstElementChild | firstChild |
lastElementChild | lastChild |
nextElementSibling | nextSibling |
previousElementSibling | previousSibling |
var elements = document.querySelectorAll('#menu a');The value of elements will contain a list of references to all a elements found inside an element with id="menu". The method querySelectorAll() takes a CSS selector string as an argument and returns a NodeList—an array-like object containing matching nodes. The method doesn’t return an HTML collection, so the returned nodes do not represent the live structure of the document. This avoids the performance (and potentially logic) issues with HTML collection discussed previously in this chapter.
var elements = document.getElementById('menu').getElementsByTagName('a');In this case elements will be an HTML collection, so you’ll also need to copy it into an array if you want the exact same type of static list as returned by querySelectorAll(). Using querySelectorAll() is even more convenient when you need to work with a union of several queries. For example, if the page has some div elements with a class name of “warning” and some with a class of “notice”, to get a list of all of them you can use querySelectorAll():
var errs = document.querySelectorAll('div.warning, div.notice');Getting the same list without querySelectorAll() is considerably more work. One way is to select all div elements and iterate through them to filter out the ones you don’t need.
var errs = [], divs = document.getElementsByTagName('div'), classname = ''; for (var i = 0, len = divs.length; i < len; i++) { classname = divs[i].className; if (classname === 'notice' || classname === 'warning') { errs.push(divs[i]); } }The Selectors API is supported natively in browsers as of these versions: Internet Explorer 8, Firefox 3.5, Safari 3.1, Chrome 1, and Opera 10.
A DOM tree A representation of the page structure A render tree A representation of how the DOM nodes will be displayedThe render tree has at least one node for every node of the DOM tree that needs to be displayed (hidden DOM elements don’t have a corresponding node in the render tree). Nodes in the render tree are called frames or boxes in accordance with the CSS model that treats page elements as boxes with padding, margins, borders, and position. Once the DOM and the render trees are constructed, the browser can display (“paint”) the elements on the page.
// setting and retrieving styles in succession var computed, tmp = '', bodystyle = document.body.style; if (document.body.currentStyle) { // IE, Opera computed = document.body.currentStyle; } else { // W3C computed = document.defaultView.getComputedStyle(document.body, ''); } // inefficient way of modifying the same property // and retrieving style information right after bodystyle.color = 'red'; tmp = computed.backgroundColor; bodystyle.color = 'white'; tmp = computed.backgroundImage; bodystyle.color = 'green'; tmp = computed.backgroundAttachment;In this example, the foreground color of the body element is being changed three times,and after every change, a computed style property is retrieved. The retrieved properties—backgroundColor, backgroundImage, and backgroundAttachment—are unrelated to the color being changed. Yet the browser needs to flush the render queue and reflow due to the fact that a computed style property was requested.
bodystyle.color = 'red'; bodystyle.color = 'white'; bodystyle.color = 'green'; tmp = computed.backgroundColor; tmp = computed.backgroundImage; tmp = computed.backgroundAttachment;
var el = document.getElementById('mydiv'); el.style.borderLeft = '1px'; el.style.borderRight = '2px'; el.style.padding = '5px';Here there are three style properties being changed, each of them affecting the geometry of the element. In the worst case, this will cause the browser to reflow three times. Most modern browsers optimize for such cases and reflow only once, but it can still be inefficient in older browsers or if there’s a separate asynchronous process happening at the same time (i.e., using a timer). If other code is requesting layout information while this code is running, it could cause up to three reflows. Also, the code is touching the DOM four times and can be optimized.
var el = document.getElementById('mydiv'); el.style.cssText = 'border-left: 1px; border-right: 2px; padding: 5px;';Modifying the cssText property as shown in the example overwrites existing style nformation,
el.style.cssText += '; border-left: 1px;';Another way to apply style changes only once is to change the CSS class name instead of changing the inline styles. This approach is applicable in cases when the styles do not depend on runtime logic and calculations. Changing the CSS class name is cleaner and more maintainable; it helps keep your scripts free of presentation code, although it might come with a slight performance hit because the cascade needs to be checked when changing classes.
var el = document.getElementById('mydiv'); el.className = 'active';
<ul id="mylist"> <li><a href="http://phpied.com">Stoyan</a></li> <li><a href="http://julienlecomte.com">Julien</a></li> </ul>Suppose additional data, already contained in an object, needs to be inserted into this list. The data is defined as:
var data = [ { "name": "Nicholas", "url": "http://nczonline.net" }, { "name": "Ross", "url": "http://techfoolery.com" } ];The following is a generic function to update a given node with new data:
function appendDataToElement(appendToElement, data) { var a, li; for (var i = 0, max = data.length; i < max; i++) { a = document.createElement('a'); a.href = data[i].url; a.appendChild(document.createTextNode(data[i].name)); li = document.createElement('li'); li.appendChild(a); appendToElement.appendChild(li); } };The most obvious way to update the list with the data without worrying about reflows would be the following:
var ul = document.getElementById('mylist'); appendDataToElement(ul, data);Using this approach, however, every new entry from the data array will be appended to the live DOM tree and cause a reflow. As discussed previously, one way to reduce reflows is to temporarily remove the <ul> element from the document flow by changing the display property and then revert it:
var ul = document.getElementById('mylist'); ul.style.display = 'none'; appendDataToElement(ul, data); ul.style.display = 'block';Another way to minimize the number of reflows is to create and update a document fragment, completely off the document, and then append it to the original list. A document fragment is a lightweight version of the document object, and it’s designed to help with exactly this type of task—updating and moving nodes around. One syntactically convenient feature of the document fragments is that when you append a fragment to a node, the fragment’s children actually get appended, not the fragment itself. The following solution takes one less line of code, causes only one reflow, and touches the live DOM only once:
var fragment = document.createDocumentFragment(); appendDataToElement(fragment, data); document.getElementById('mylist').appendChild(fragment);A third solution would be to create a copy of the node you want to update, work on the copy, and then, once you’re done, replace the old node with the newly updated copy:
var old = document.getElementById('mylist'); var clone = old.cloneNode(true); appendDataToElement(clone, data); old.parentNode.replaceChild(clone, old);The recommendation is to use document fragments (the second solution) whenever possible because they involve the least amount of DOM manipulations and reflows. The only potential drawback is that the practice of using document fragments is currently underused and some team members may not be familiar with the technique.
// inefficient myElement.style.left = 1 + myElement.offsetLeft + 'px'; myElement.style.top = 1 + myElement.offsetTop + 'px'; if (myElement.offsetLeft >= 500) { stopAnimation(); }This is not efficient, though, because every time the element moves, the code requests the offset values, causing the browser to flush the rendering queue and not benefit from its optimizations. A better way to do the same thing is to take the start value position once and assign it to a variable such as var current = myElement.offsetLeft;. Then, inside of the animation loop, work with the current variable and don’t request offsets:
current++ myElement.style.left = current + 'px'; myElement.style.top = current + 'px'; if (current >= 500) { stopAnimation(); }