On EatDifferent, we let users track their meals with both text and pictures, and now that we have iPhone and Android apps, more and more users are using the mobile apps to quickly snap photos of their meals during the day. That means that the stream of updates on the home page includes a lot of images — a thumbnail for each user updating or commenting, and a thumbnail of each meal (and one user even takes photos of their blood glucose meter before each meal!). Here's a highly scientific diagram of that:
I realized that users were downloading all of these images for the stream, even though many of them were offscreen, and well, I felt bad for unnecessarily using their bandwidth and for making their experience slower. So, I decided to only load the images that were visible (or, nearly visible). Here's how I did it:
The HTML
Normally, when you write an IMG
into your HTML, you assign the URL to the src
attribute. I didn't want to do that anymore, though, since the browser would then load that URL, so I instead assign the URL to a data-src
attribute — something the browser will ignore, but that my JavaScript can pick up. Here's what my template looks like:
<div class="stream-user-img"> <a href="{{=log.user.profileUrl}}"><img data-src="{{=log.user.photoUrl}}"></a> </div>
The JavaScript
Now that I have src
-less IMG
tags, I need a function to decide which IMG
s to src
-ify. Here's what the function needs to verify:
- The
IMG
must have an emptysrc
and a non-emptydata-src
attribute. - The
IMG
must be visible per CSS - notvisibility:hidden
ordisplay:none
. - The
IMG
must be user-visible, within the viewport. Or, as I realized, it should be nearly visible, so that users don't see theIMG
s loading most of the time. I decided on 500 pixels as a reasonable near threshold, just by playing and seeing what felt right.
Here's what that looks like using jQuery:
function loadVisibleImages() { $('img').each(function() { if (this.src === '' && $(this).attr('data-src') && $(this).is(':visible'))) { if (inView($(this), 300)) { this.src = $(this).attr('data-src'); } } }); } function inView(elem, nearThreshold) { var viewportHeight = getViewportHeight(); var scrollTop = (document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop); var elemTop = elem.offset().top; var elemHeight = elem.height(); nearThreshold = nearThreshold || 0; if ((scrollTop + viewportHeight + nearThreshold) > (elemTop + elemHeight)) { return true; } return false; }
Okay, now that we have that function, we need to call it at the appropriate times. It should be called when:
- New
IMG
tags are written into the page (after data loading and template rendering, for example). IMG
tags go from hidden to visible.IMG
tags come into view after a user scroll.
I didn't find an easy way of programmatically detecting those first two cases, so I simply call loadVisibleImages()
after my template rendering and visibility toggling (which only happens in two places), and then for the last case, I need to listen to the window scroll
event. Unfortunately, I can't simply attach my callback to that event, as some browsers will fire it a lot and executing code after each event will slow down the page scrolling. Instead, I use Ben Alman's throttle library to only call my function every 500 milliseconds during the scroll
event. Here's what that looks like:
$(window).on('scroll', $.throttle(500, loadVisibleImages));
The Result
After implementing this viewport-based image loading, my home stream went from loading 120 images to loading just 12 images — a much better experience for the browser and users. There are various improvements that could be made to this technique, like setting appropriate filler images for the different image types (a blank user pic for thumbnails, a meal icon for meals, etc) and maybe even using data URIs for those... but this works well enough for now. ☺
No comments:
Post a Comment