I write this article as much for internal use as for others. Its target audience is competent jQuery users who need their code to run faster.
jQuery is fantastic, but its documentation does little to distinguish best practices from worst. In particular, jQuery supports very-slow and very-fast ways to do the same thing. What follows is a collection of my notes during two days dedicated to optimizing a large, JavaScript-intensive website.
Before You Begin
Before you begin to optimize, do not optimize. Only optimize:
- When somebody complains;
- When you think somebody is only not-complaining out of courtesy;
- When you suspect somebody is about to complain; or
- When you have implemented all functionality and you want additional bragging rights
Why? Because optimizing early is a waste of time: you are not able to guess where the bottlenecks are. Trust me.
Also, bottlenecks may be slightly different on different web browsers.
Okay, disclaimer out of the way, here is where your bottlenecks may be.
Step 1: Define Metrics and Targets
Do not optimize until you have metrics and targets. A fair metric is,
for instance, “how long it takes $(document).ready()
to run, in
milliseconds.” A fair target depends on your worldview; 50ms might sound
good for some pages. Different projects or optimization sessions may
call for different metrics. Every metric needs a unit (usually
“milliseconds”).
In our particular project, we invoke $(document).ready()
in two
places: either from an “initializer JavaScript” file or in <script>
tags. Any code we run is within $(document).ready()
. Since each file
implements a different feature, we decided to measure each file’s delay
separately so we could determine, feature-by-feature, whether to
optimize or scrap our functionality.
Step 2: Measure
Design some way to quickly measure all your metrics.
Firebug has a very useful “Profile” mode which is simple enough to use. We wrapped our code like this:
if (window.console) window.console.profile("path/to/file.js");
[ ... code to profile ... ]
if (window.console) window.console.profileEnd();
For non-Firefox browsers, we added some pseudo-profiling code which will
alert()
a list of metrics after the page has loaded. This lets us at
least verify our work, though it does not help with the actual work like
Firebug’s profiling data does.
if (!window.console || !window.console.profile) {
window.PROFILES = [];
window.console = {
profile: function(name) {
window.PROFILE_NAME = name;
window.PROFILE_TIME = new Date();
},
profileEnd: function() {
window.PROFILES.push(window.PROFILE_NAME + ': ' + (new Date() - window.PROFILE_TIME) + 'ms');
}
};
$(window).load(function() {
// Ensure we finish $(document).ready() first....
window.setTimeout(function() {
if (window.PROFILES.length) {
alert("Profiled:\
\
" + window.PROFILES.join("\
"));
}
}, 100);
});
}
Step 3: Profile
For our particular website, because we use Firebug for our metrics anyway, profiling in Firefox amounts to loading our web page. We can see which functions are slow, which functions are called too many times, and which functions are not worth investigating.
The slowdowns generally fall into some basic categories, and the solutions are often transferrable site-wide.
Step 4: Optimize
Speed Up jQuery Selectors
Selectors come in varying flavours:
- ID-based selectors, such as
$("#foo")
. These are blindingly fast. - Element-based selectors, such as
$("input")
. These are fast, but slower depending on what element you use.$("label")
will probably be faster than $("div")
if you use morediv
s thanlabel
s. - Class-based selectors, such as
$(".foo")
. These are less fast but more versatile. - Attribute-based selectors, such as
$("[name=bar]")
, and virtual selectors, such as$(':input')
. These are painfully slow.
If jQuery.find()
is dragging you down, your first order of business is
to move your selectors up the above list. If you are using $(":input")
on your entire site during page load, the browser needs to walk the
entire DOM tree; change that to an element-based selector such as
$("button, input, select, textarea")
and the search will be an order
of magnitude faster. If you are dealing with a unique element on the
page, consider giving it an ID and selecting with that.
It is worth explicitly mentioning: avoid writing selectors without
either element names or IDs. In other words, never use $(".foo")
when
you can write $("div.foo")
instead.
These rules apply for compound selectors, too. For instance, rewrite
$(".foo .bar")
as $("div.foo div.bar")
.
Remove Selectors
If your selectors are still too slow, you may be able to remove them altogether. For instance, if we start with code like this:
$('div.geo').each(function() {
var $geo = $(this);
var lat = parseFloat($geo.children('div.latitude').text());
var lng = parseFloat($geo.children('div.longitude').text());
var image_path = $geo.children('div.image_path').text();
// ...
});
Because there were so many calls to $geo.children()
in our website
(with maybe 75 $geo
elements on a page), these selectors were
excessively slow. The rewrite was not too painful:
$('div.geo').each(function() {
var $geo = $(this);
var lat, lng, image_path;
$geo.children().each(function() {
var c = this.className;
if (/\blatitude\b/.test(c)) {
lat = parseFloat($(this).text());
} else if (/\blongitude\
/.test(c)) {
lng = parseFloat($(this).text());
} else if (/\bimage_path\b/.test(c)) {
image_path = $(this).text();
}
});
// ...
});
By eliminating all the excess selectors, we sped up our code by 40%.
Notice, by the way, an interesting tidbit from the above code:
$element.hasClass('foo'); // slow
/\bfoo\b/.test($element[0].className); // fast
This optimization works when hasClass() is being called very frequently on a jQuery object which we are sure holds exactly one DOM element.
Cache Selectors
If you have code like this:
$('div.foo div.bar div.baz1').something();
$('div.foo div.bar div.baz2').somethingElse();
$('div.foo div.bar div.baz3').somethingElseEntirely();
Consider rewriting to this:
var $bar = $('div.foo div.bar');
$bar.find('div.baz1').something();
$bar.find('div.baz2').somethingElse();
$bar.find('div.baz3').somethingElseEntirely();
Add Redundant Selector Elements
Imagine your page is divided into #header
, #page
, and #footer
.
Also, imagine #header
contains a significant portion of the
website—maybe 1/5 of it. Then, changing $("div.foo")
to
$("#page div.foo")
may, in some cases, give you a 20% speed boost.
Check this with your profiler. (Hopefully you’re scraping the bottom of
the barrel at this point, as we are talking of 1ms-2ms differences
here….)
Upgrade to jQuery 1.3
While jQuery 1.3 has some regressions compared with 1.2.6, it boasts a rewritten selector engine with a noticeable performance improvement. The above rules almost certainly apply anyway.
Avoid DOM Manipulation
DOM manipulation is slow. For instance, imagine a calendar written like this:
var $calendar = $('<table></table>');
for (var i = 0; i < 6; i++) {
var $tr = $('<tr></tr>');
for (var j = 0; j < 7; j++) {
$tr.append('<td></td>');
}
$calendar.append($tr);
}
Yes, it’s pretty, but it is impractical. You will have to rewrite it to something more pragmatic, such as:
var $calendar = $('<table><tr><td><td>...</tr><tr>...</tr>...</table>');
(Except When Escaping User Input)
In general, a good portion of DOM manipulation can be circumvented. The speedups are enormous, but make sure you escape user-supplied HTML portions through jQuery’s interfaces.
That is, do not do this:
var $calendar = $('<table class="' + $('input.calendar_name').text() + '"><tr>...</table>');
…because a quotation mark in the $('input.calendar_name').text()
would
lead to unpredictable results. Use jQuery’s attr()
, text()
, and
html()
when dealing with strings which may be partially or fully
constructed by your end user’s input.
Push Static Stuff to the Server Side
We use semantic HTML
on our particular website, so it seems horrendous to us to write
multi-column lists on the server side. Idealists as we were, we served
our navigation menus in pristene HTML ul
elements, relying upon a
jQuery plugin to split the menus into smaller sub-lists as they were
received by the client. Our profiler reported this splitting code as
taking 130ms (DOM manipulations are expensive). We caved and split the
menus on the server side, removing all that JavaScript altogether.
Manipulate the DOM Directly
jQuery’s DOM manipulation methods are brilliant, but they present some overhead. For instance, imagine an unoptimized method to create an “excerpt” of text which fits within a certain bounds, working something like this (untested) jQuery plugin:
jQuery.fn.excerpt = function(max_height) {
return $(this).each(function() {
var $e = $(this);
var words = $e.text().split('\s*');
var longest_fitting_string = '';
var cur_string = '';
for (var i = 0; i < words.length; i++) {
cur_string = cur_string + ' ' + words[i];
$e.text(cur_string);
if ($e.height() > max_height) {
break;
} else {
longest_fitting_string = cur_string;
}
}
$e.text(longest_fitting_string);
});
};
This method is terribly slow: in particular, $e.text()
and
$e.height()
eat up too much time, as they are called dozens or
hundreds of times in the loop.
Because we know there is exactly one element in our inner loop and we
assume the element only contains text, we can replace
$e.text(cur_string)
with the following:
$e[0].firstChild.nodeValue = cur_string;
Likewise, we can use similar assumptions to transform
$e.height() > max_height
into the following:
$e[0].offsetHeight > max_height
These will make the excerpt()
function many times faster than before
(but, frankly, still not enough to use in a production website).
Many aspects of jQuery are surprisingly slow. css()
, show()
,
hide()
, and hasClass()
spring to mind immediately, as they are so
easy to replicate with similar, yet much faster, behaviour. If you are
calling jQuery methods in a loop your profiler highlights, consider
using direct DOM manipulation, as long as you are certain you are not
relying upon some of the wonderful guarantees jQuery gives you.
Reimplement Algorithms
Computer science geeks excel here. Using the above excerpt()
code as a
typical example, we can generally say that many algorithms are slower
than they need to be: not because of their tiny details (such as using
$e.text()
instead of $e[0].firstChild.nodeValue
), but because they
go about things the wrong way.
I left this optimization for the end for two reasons: it is often time-consuming; and my readership is likely divided between the camp who have done this already and the camp who do not know how. Basically, this is the “computer science” aspect of JavaScript coding.
Improving the running time
of algorithms can be fun, and the profiler will drop hints when it is
necessary. For instance, the profiler will show us that excerpt()
is
still too slow. A savvy computer scientist may then rewrite it to use a
more clever heuristic, possibly based on a binary search, to eliminate
90% of the calls to $e.text()
in typical calls to excerpt()
.
Optimizing in this manner is often (usually?) more effective than circumventing jQuery, and it leaves legible code. It works particularly well if you coded using Test-driven development since rewriting algorithms will often break them.
Step 5: Test
Ensure you did not break anything. There are many, many things you could have broken. Hopefully you unit-test your code….
Step 6: Repeat
Engineering is all about steps. Depending on your allocated time, shifting priorities, and mood, after optimizing you should return to one of the following steps:
- Return to Step 5 (Test)
- Return to Step 4 (Optimize)
- Return to Step 3 (Profile)
- Return to Step 2 (Measure)
- Return to Step 1 (Define Metrics and Targets)
- Return to Step 0 (Do Not Optimize)