jQuery is an awesome tool, but is there ever a time when you shouldn’t use it? In this tutorial, we’re going to look at how to build some interesting scrolling behavior with jQuery, and then review how our project could potentially be improved by removing as much jQuery, or abstraction, as possible.
Intro: The Plan
Sometimes, your code can be even more “magical” by subtracting some of that jQuery goodness.
You might expect this to be your normal do-something-awesome-with-jQuery tutorial. Actually, it’s not. While you might end up building a rather cool—but, frankly, perhaps equally useless—effect, that’s not the main point I want you to take away from this tutorial.
As you’ll hopefully see, I want you to learn to look at the jQuery you’re writing as just regular JavaScript, and realize that there’s nothing magical about it. Sometimes, your code can be even more “magical” by subtracting some of that jQuery goodness. Hopefully, by the end of this, you’ll be a little bit better at developing with JavaScript than when you started.
If that sounds too abstract, consider this a lesson in performance and code refactoring … and also stepping outside your comfort zone as a developer.
Step 1: The Project
Here’s what we’re going to build. I got this inspiration from the relatively new Twitter for Mac app. If you have the app (it’s free), go view someone’s account page. As you scroll down, you’ll see that they don’t have an avatar to the left of each tweet; the avatar for the first tweet “follows” you as you scroll down. If you meet a retweet, you’ll see that the retweeted person’s avatar is appropriately placed beside his or her tweet. Then, when the retweeter’s tweets begin again, their avatar takes over.
This is the functionality that I wanted to build. With jQuery, this wouldn’t be too hard to put together, I thought. And so I began.
Step 2: The HTML & CSS
Of course, before we can get to the star of the show, we need some markup to work with. I won’t spend much time here, because it’s not the main point of this tutorial:
<!DOCTYPE HTML> <html lang="en"> <head> <meta charset="UTF-8"> <title>Twitter Avatar Scrolling</title> <link rel="stylesheet" href="style.css" /> </head> <body> <section> <article> <img class="avatar" src="images/one.jpg" /> <p> This is something that the twitter person had to say.</p> </article> <article> <img class="avatar" src="images/two.jpg" /> <p> This is something that the twitter person had to say.</p> </article> <article> <img class="avatar" src="images/one.jpg" /> <p> This is something that the twitter person had to say. </p> </article> <article> <article> <img class="avatar" src="images/one.jpg" /> <p> This is something that the twitter person had to say.</p> </article> <article> <img class="avatar" src="images/two.jpg" /> <p> This is something that the twitter person had to say.</p> </article> <article> <img class="avatar" src="images/two.jpg" /> <p> This is something that the twitter person had to say.</p> </article> <article> <img class="avatar" src="images/one.jpg" /> <p> This is something that the twitter person had to say.</p> </article> <!-- more assorted tweets --> </section> <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.5.0/jquery.min.js"></script> <script src="twitter-scrolling.js"></script> <script> $("section").twitter_scroll(".avatar"); </script> </body> </html>
Yes, it’s large.
How about some CSS? Try this:
body { font:13px/1.5 "helvetica neue", helvetica, arial, san-serif; } article { display:block; background: #ececec; width:380px; padding:10px; margin:10px; overflow:hidden; } article img { float:left; } article p { margin:0; padding-left:60px; }
Pretty minimal, but it will give us what we need.
Now, on to the JavaScript!
Step 3: The JavaScript, Round 1
I think it’s fair to say that this isn’t your average JavaScript widget-work; it’s a bit more complicated. Here are just a couple of the things you need to account for:
- You need to hide every image that’s the same as the image in the previous “tweet.”
- When the page is scrolled, you have to determine which “tweet” is closest to the top of the page.
- If the “tweet” is the first in a series of “tweets” by the same person, we have to fix the avatar in place, so it won’t scroll with the rest of the page.
- When the top “tweet” is the last in a run of tweets by one user, we have to stop the avatar at the appropriate spot.
- This all has to work for scrolling both down and up the page.
- Since all this is being executed each time a scroll event fires, it has to be incredibly fast.
When beginning writing something, worry about just getting it working; optimizing can come later. Version one ignored several important jQuery best practices. What we start with here is version two: the optimized jQuery code.
I decided to write this as a jQuery plugin, so the first step is to decide how it will be called; I went with this:
$(wrapper_element).twitter_scroll(entries_element, unscrollable_element);
The jQuery object we call the plugin on, wraps the “tweets” (or whatever you’re scrolling through). The first parameter the plugin takes is a selector for the elements that will be scrolling: the “tweets.” The second selector is for the elements that stay in place when necessary (the plugin expects these to be images, but it shouldn’t take much adjusting to make it work for other elements). So, for the HTML we had above, we call the plugin like so:
$("section").twitter_scroll("article", ".avatar"); // OR $("section").twitter_scroll(".avatar");
As you’ll see when we get to the code, the first parameter will be optional; if there’s only one parameter, we’ll assume that it’s the unscrollable selector, and the entries are the direct parents of the unscrollables (I know, unscrollables is a bad name, but it’s the most generic thing I could come up with).
So, here’s our plugin shell:
(function ($) { jQuery.fn.twitter_scroll = function (entries, unscrollable) { }; }(jQuery));
From now on, all the JavaScript we’ll look at goes in here.
Plugin Set-Up
Let’s start with the set-up code; there’s some work to do before we setup the scroll handler.
if (!unscrollable) { unscrollable = $(entries); entries = unscrollable.parent(); } else { unscrollable = $(unscrollable); entries = $(entries); }
First, the parameters: If unscrollable
is a false-y value, we’ll set it to the “jQuerified” entries
selector, and set entries
to the parents of unscrollable
. Otherwise, we’ll “jQuerify” both parameters. It’s important to notice that now (if the user has done their markup correctly, which we will have to assume they have), we have two jQuery objects in which the matching entries have the same index: so unscrollable[i]
is the child of entries[i]
. This will be useful later. (Note: if we didn’t want to assume that the user marked up their document correctly, or that they used selectors that would capture elements outside of what we want, we could use this
as the context parameter, or use the find
method of this
.)
Next, let’s set some variables; normally, I’d do this right at the top, but several depend on unscrollable
and entries
, so we dealt with that first:
var parent_from_top = this.offset().top, entries_from_top = entries.offset().top, img_offset = unscrollable.offset(), prev_item = null, starter = false, margin = 2, anti_repeater = null, win = $(window), scroll_top = win.scrollTop();
Let’s run through these. As you might imagine, the offset of the elements we’re working with here are important; jQuery’s offset
method returns an object with top
and left
offset properties. For the parent element (this
inside the plugin) and the entries
, we’ll only need the top offset, so we’ll get that. For the unscrollables, we’ll need both top and left, so we won’t get anything specific here.
The variables prev_item
, starter
, and anti_repeater
will be used later, either outside the scroll handler or inside the scroll handler, where we’ll need values that persist across callings of the handler. Finally, win
will be used in a few places, and scroll_top
is distance of the scrollbar from the top of the window; we’ll use this later to determine which direction we’re scrolling in.
Next, we’re going to determine which elements are first and last in streaks of “tweets.” There are probably a couple of ways to do this; we’re going to do this by applying an HTML5 data attribute to the appropriate elements.
entries.each(function (i) { var img = unscrollable[i]; if ($.contains(this, img)) { if (img.src === prev_item) { img.style.visibility = "hidden"; if (starter) { entries[i-1].setAttribute("data-8088-starter", "true"); starter = false; } if (!entries[i+1]) { entries[i].setAttribute("data-8088-ender", "true"); } } else { prev_item = img.src; starter = true; if (entries[i-1] && unscrollable[i-1].style.visibility === "hidden") { entries[i-1].setAttribute("data-8088-ender", "true"); } } } }); prev_item = null;
We’re using jQuery’s each
method on the entries
object. Remember that inside the function we pass in, this
refers to the current element from entries
. We also have the index parameter, which we’ll use. We’ll start by getting the corresponding element in the unscrollable
object and storing it in img
. Then, if our entry contains that element (it should, but we’re just checking), we’ll check to see if the source of the image is the same as prev_item
; if it is, we know that this image is that same as the one in the previous entry. Therefore, we’ll hide the image; we can’t use the display property, because that would remove it from the flow of the document; we don’t want other elements moving on us.
Then, if starter
is true, we’ll give the entry before this one the attribute data-8088-starter
; remember, all HTML5 data attributes must start with “data-“; and it’s a good idea to add your own prefix so that you won’t conflict with other developers’ code (My prefix is 8088; you’ll have to find your own :) ). HTML5 data attributes have to be strings, but in our case, it’s not the data we’re concerned about; we just need to mark that element. Then we set starter
to false. Finally, if this is the last entry, we’ll mark it as an end.
If the source of the image is not the same as the source of the previous image, we’ll reset prev_item
with the source of your current image; then, we set starter to true
. That way, if we find that the next image has the same source as this one, we can mark this one as the starter. Lastly, if there is an entry before this one, and its associated image is hidden, we know that entry is the end of a streak (because this one has a different image). Therefore, we’ll give it a data attribute marking it as an end.
Once we’ve finished that, we’ll set prev_item
to null
; we’ll be reusing it soon.
Now, if you look at the entries in Firebug, you should see something like this:
The Scroll Handler
Now we’re ready to work on that scroll handler. There are two parts to this problem. First, we find the entry that is closest to the top of the page. Second, we do whatever is appropriate for the image related to that entry.
$(document).bind("scroll", function (e) { var temp_scroll = win.scrollTop(), down = ((scroll_top - temp_scroll) < 0) ? true : false, top_item = null, child = null; scroll_top = temp_scroll; // more coming });
This is our scroll handler shell; we’ve got a couple of variables we’re creating to start with; right now, take note of down
. This will be true if we are scrolling down, false if we are scrolling up. Then, we’re reseting scroll_top
to be the distance down from the top that we’re scrolled to.
Now, let’s set top_item
to be the entry closest the top of the page:
top_item = entries.filter(function (i) { var distance = $(this).offset().top - scroll_top; return ( distance < (parent_from_top + margin) && distance > (parent_from_top - margin) ); });
This isn’t hard at all; we just use the filter
method to decide which entry we want to assign to top_item
. First, we get the distance
by subtracting the amount we’ve scrolled from the top offset of the entry. Then, we return true if distance
is between parent_from_top + margin
and parent_from_top - margin
; otherwise, false. If this confuses you, think about it this way: we want to return true when an element is right at the top of the window; in this case, distance
would equal 0. However, we need to account for the top offset of the container that’s wrapping our “tweets,” so we really want distance
to equal parent_from_top
.
But it’s possible that our scroll handler won’t fire when we’re exactly on that pixel, but instead when we’re close to it. I discovered this when I logged the distance and found it to be values that were a half-pixel off; also, if your scroll handling function isn’t too efficient (which this one won’t be; not yet), it’s possible that it won’t fire on every scroll event. To make sure you get one in the right area, we add or subtract a margin to give us a small range to work within.
Now that we have the top item, let’s do something with it.
if (top_item) { if (top_item.attr("data-8088-starter")) { if (!anti_repeater) { child = top_item.children(unscrollable.selector); anti_repeater = child.clone().appendTo(document.body); child.css("visibility", "hidden"); anti_repeater.css({ 'position' : 'fixed', 'top' : img_offset.top + 'px', 'left' : img_offset.left + "px" }); } } else if (top_item.attr("data-8088-ender")) { top_item.children(unscrollable.selector).css("visibility", "visible"); if (anti_repeater) { anti_repeater.remove(); } anti_repeater = null; } if (!down) { if (top_item.attr("data-8088-starter")) { top_item.children(unscrollable.selector).css("visibility", "visible"); if (anti_repeater) { anti_repeater.remove(); } anti_repeater = null; } else if (top_item.attr("data-8088-ender")) { child = top_item.children(unscrollable.selector); anti_repeater = child.clone().appendTo(document.body); child.css("visibility", "hidden"); anti_repeater.css({ 'position' : 'fixed', 'top' : img_offset.top + 'px', 'left' : img_offset.left + "px" }); } } }
You may notice that the above code is almost the same thing twice; remember that this is the unoptimized version. As I slowly worked through the problem, I started by figuring out how to get scrolling down working; once I had solved that, I worked on scrolling up. It wasn’t immediately obvious, until all the functionality was in place, that there would be this much similarity. Remember, we’ll optimize soon.
So, let’s dissect this. If the top item has a data-8088-starter
attribute, then, let’s check to see if the anti_repeater
has been set; this is the variable that will point to the image element that will be fixed while the page scrolls. If anti_repeater
hasn’t been set, then we’ll get the child our of top_item
entry that has the same selector as unscrollable
(no, this isn’t a smart way to do this; we’ll improve it later). Then, we clone that and append it to the body. We’ll hide that one, and then position the cloned one exactly where it should go.
If the element doesn’t have a data-8088-starter
attribute, we’ll check for a data-8088-ender
attribute. If that’s there, we’ll find the right child and make it visible, and then remove the anti_repeater
and set that variable to null
.
Happily, if we’re not going down (if we’re going up), it’s exactly the reverse for our two attributes. And, if the top_item
doesn’t have either attributes, we’re somewhere in the middle of a streak, and don’t have to change anything.
Performance Review
Well, this code does what we want; however, if you give it a try, you’ll notice that you have to scroll very slowly for it to work properly. Try adding the lines console.profile("scroll")
and console.profileEnd()
as the first and last lines of the scroll handling function. For me, the handler takes between 2.5ms - 4ms, with 166 - 170 function calls taking place.
That’s way too long for a scroll handler to run; and, as you might imagine, I’m running this on a reasonably well-endowed computer. Notice that some functions are being called 30-31 times; we have 30 entries that we’re working with, so this is probably part of the looping over them all to find the top one. This means that the more entries we have, the slower this will run; so inefficient! So, we have to see how we can improve this.
Step 4: The JavaScript, Round 2
If you’re suspecting that jQuery is the main culprit here, you’re right. While frameworks like jQuery are awesomely helpful and make working with the DOM a breeze, they come with a trade-off: performance. Yes, they are always getting better; and yes, so are the browsers. However, our situation calls for the fastest possible code, and in our case, we’re going to have to give up a bit of jQuery for some direct DOM work that isn’t too much more difficult.
The Scroll Handler
Let’s with the obvious part: what we do with the top_item
once we’ve found it. Currently, top_item
is a jQuery object; however, everything we’re doing with jQuery with top_item
is trivially harder without jQuery, so we’ll do it “raw.” When we review the getting of top_item
, we’ll make sure it’s a raw DOM element.
So, here’s what we can change to make it quicker:
- We can refactor our if-statements to avoid the huge amount of duplication (this is more of a code cleanliness point, not a performance point).
- We can use the native
getAttribute
method, instead of jQuery’sattr
. - We can get the element from
unscrollable
that corresponds to thetop_item
entry, instead of usingunscrollable.selector
. - We can use the native
clodeNode
andappendChild
methods, instead of the jQuery versions. - We can use the
style
property instead of jQuery’scss
method. - We can use the native
removeNode
instead of jQuery’sremove
.
By applying these ideas, we end up with this:
if (top_item) { if ( (down && top_item.getAttribute("data-8088-starter")) || ( !down && top_item.getAttribute("data-8088-ender") ) ) { if (!anti_repeater) { child = unscrollable[ entries.indexOf(top_item) ]; anti_repeater = child.cloneNode(false); document.body.appendChild(anti_repeater); child.style.visibility = "hidden"; style = anti_repeater.style; style.position = 'fixed'; style.top = img_offset.top + 'px'; style.left= img_offset.left + 'px'; } } if ( (down && top_item.getAttribute("data-8088-ender")) || (!down && top_item.getAttribute("data-8088-starter")) ) { unscrollable[ entries.indexOf(top_item) ].style.visibility = "visible"; if (anti_repeater) { anti_repeater.parentNode.removeChild(anti_repeater); } anti_repeater = null; } }
This is much better code: not only does it get rid of that duplication, but it also uses absolutely no jQuery. (As I’m writing this, I’m thinking it might even be a bit faster to apply a CSS class to do the styling; you can experiment with that!)
You might think that’s about as good as we can get; however, there’s some serious optimization that can take place in the getting of top_item
. Currently, we’re using jQuery’s filter
method. If you think about it, this is incredibly poor. We know we’re only going to get one item back from this filtering; but the filter
function doesn’t know that, so it continues to run elements through our function after we have found the one we want. We have 30 elements in entries
, so that’s a pretty huge waste of time. What we want to do is this:
for (i = 0; entries[i]; i++) { distance = $(entries[i]).offset().top - scroll_top; if ( distance < (parent_from_top + margin) && distance > (parent_from_top - margin) ) { top_item = entries[i]; break; } }
(Alternately, we could use a while loop, with the condition !top_item
; either way, it doesn’t matter much.)
This way, once we find the top_item
, we can stop searching. However, we can do better; because scrolling is a linear thing, we can predict which item with be closest to the top next. If we’re scrolling down, it will either be the same item as last time, or the one after it. If we’re scrolling up, it will be the same item as last time, or the item before it. This will be useful the further down the page we get, because the for loop always starts at the top of the page.
So how can we implement this? Well, we need to start by keeping track of what the top_item
was during our previous running of the scroll handler. We can do this by adding
prev_item = top_item;
to the very end of the scroll handling function. Now, up where we were previously finding the top_item
, put this:
if (prev_item) { prev_item = $(prev_item); height = height || prev_item.outerHeight(); if ( down && prev_item.offset().top - scroll_top + height < margin) { top_item = entries[ entries.indexOf(prev_item[0]) + 1]; } else if ( !down && (prev_item.offset().top - scroll_top - height) > (margin + parent_from_top)) { top_item = entries[ entries.indexOf(prev_item[0]) - 1]; } else { top_item = prev_item[0]; } } else { for (i = 0; entries[i]; i++) { distance = $(entries[i]).offset().top - scroll_top; if ( distance < (parent_from_top + margin) && distance > (parent_from_top - margin) ) { top_item = entries[i]; break; } } }
This is definitely an improvement; if there was a prev_item
, then we can use that to find the next top_item
. The math here gets a bit tricky, but this is what we’re doing:
- If we’re going down and the previous item is completely above the top of the page, get the next element (we can use the index of
prev_item
+ 1 to find the right element inentries
). - If we’re going up and the previous item is far enough down the page, get the previous entry.
- Otherwise, use the same entry as last time.
- If there’s no
top_item
, we use our for loop as a fallback.
There are a few other things to address here. Firstly, what’s this height
variable? Well, if all our entries are the same height, we can avoid calculating the height each time within the scroll handler by doing it in the setup. So, I’ve added this to the setup:
height = entries.outerHeight(); if (entries.length !== entries.filter(function () { return $(this).outerHeight() === height; }).length) { height = null; }
jQuery’s outerHeight
method gets the height of an element, including it’s padding and borders. If all the elements have the same height as the first one, then height
will be set; otherwise height
will be null. Then, when getting top_item
, we can use height
if it is set.
The other thing to note is this: you might think it’s inefficient to be doing prev_item.offset().top
twice; shouldn’t that go inside a variable. Actually, we’ll only be doing it once, because the second if statement will only be called if down
is false; because we’re using the logical AND operator, the second parts of the two if statements will never both be run on the same function call. Of course, you could put it in a variable if you think it keeps your code cleaner, but it doesn’t do anything for performance.
However, I’m still not satisfied. Sure, our scroll handler is faster now, but we can make it better. We’re only using jQuery for two things: to get the outerHeight
of prev_item
and to get the top offset of prev_item
. For something this small, it’s rather expensive to make a jQuery object. However, there isn’t an immediately-apparent, non-jQuery solution to these, as there was before. So, let’s dive into the jQuery source code to see exactly what jQuery is doing; this way, we can pull out the bits we need without using the expensive weight of jQuery itself.
Let’s start with the top offset problem. Here’s the jQuery code for the offset
method:
function (options) { var elem = this[0]; if (!elem || !elem.ownerDocument) { return null; } if (options) { return this.each(function (i) { jQuery.offset.setOffset(this, options, i); }); } if (elem === elem.ownerDocument.body) { return jQuery.offset.bodyOffset(elem); } var box = elem.getBoundingClientRect(), doc = elem.ownerDocument, body = doc.body, docElem = doc.documentElement, clientTop = docElem.clientTop || body.clientTop || 0, clientLeft = docElem.clientLeft || body.clientLeft || 0, top = box.top + (self.pageYOffset || jQuery.support.boxModel && docElem.scrollTop || body.scrollTop) - clientTop, left = box.left + (self.pageXOffset || jQuery.support.boxModel && docElem.scrollLeft || body.scrollLeft) - clientLeft; return { top: top, left: left }; }
This looks complicated, but it isn’t too bad; we only need the top offset, not the left offset, so we can pull this out and use it to create our own function:
function get_top_offset(elem) { var doc = elem.ownerDocument, body = doc.body, docElem = doc.documentElement; return elem.getBoundingClientRect().top + (self.pageYOffset || jQuery.support.boxModel && docElem.scrollTop || body.scrollTop) - docElem.clientTop || body.clientTop || 0; }
All we have to do it pass in the raw DOM element, and we’ll get the top offset back; great! so we can replace all our offset().top
calls with this function.
How about the outerHeight
? This one is a bit trickier, because it uses one of those multi-use internal jQuery functions.
function (margin) { return this[0] ? jQuery.css(this[0], type, false, margin ? "margin" : "border") : null; }
As we can see, this actually just makes a call to the css
function, with these parameters: the element, “height”, “border”, and false
. Here’s what that looks like:
function (elem, name, force, extra) { if (name === "width" || name === "height") { var val, props = cssShow, which = name === "width" ? cssWidth : cssHeight; function getWH() { val = name === "width" ? elem.offsetWidth : elem.offsetHeight; if (extra === "border") { return; } jQuery.each(which, function () { if (!extra) { val -= parseFloat(jQuery.curCSS(elem, "padding" + this, true)) || 0; } if (extra === "margin") { val += parseFloat(jQuery.curCSS(elem, "margin" + this, true)) || 0; } else { val -= parseFloat(jQuery.curCSS(elem, "border" + this + "Width", true)) || 0; } }); } if (elem.offsetWidth !== 0) { getWH(); } else { jQuery.swap(elem, props, getWH); } return Math.max(0, Math.round(val)); } return jQuery.curCSS(elem, name, force); }
We might seem hopelessly lost at this point, but it’s worth it to follow the logic though … because the solution is awesome! Turns out, we can just use an element’s offsetHeight
property; that gives us exactly what we want!
Therefore, our code now looks like this:
if (prev_item) { height = height || prev_item.offsetHeight; if ( down && get_top_offset(prev_item) - scroll_top + height < margin) { top_item = entries[ entries.indexOf(prev_item) + 1]; } else if ( !down && (get_top_offset(prev_item) - scroll_top - height) > (margin + parent_from_top)) { top_item = entries[ entries.indexOf(prev_item) - 1]; } else { top_item = prev_item; } } else { for (i = 0; entries[i]; i++) { distance = get_top_offset(entries[i]) - scroll_top; if ( distance < (parent_from_top + margin) && distance > (parent_from_top - margin) ) { top_item = entries[i]; break; } } }
Now, nothing needs to be a jQuery object! And if you run it down, you should be far under a millisecond and have only a couple of function calls.
Helpful Tools
Before we conclude, here’s a helpful tip for digging around inside jQuery like we did here: use James Padolsey’s jQuery Source Viewer. It’s an awesome tool that makes it incredibly simple to dig around inside the code and see what’s going on without being too overwhelmed. [Sid's note: Another awesome tool you should checkout -- jQuery Deconstructer.]
Conclusion: What We’ve Covered
I hope you’ve enjoyed this tutorial! You may have learned how to create some rather neat scrolling behaviour, but that wasn’t the main point. You should take these points away from this tutorial:
- The fact that your code does what you want doesn’t mean that it can’t be cleaner, or faster, or somehow better.
- jQuery—or whichever one is your favourite library—is just JavaScript; it can be slow too; and sometimes, it’s best not to use it.
- The only way you’re going to improve as a JavaScript developer is if you push yourself out of your comfort zone.
Have any questions? Or maybe you’ve got an idea on how to improve this even more! Let’s discuss it in the comments!
Comments