Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Lazyload Images with Knockout & jQuery

I have an image intensive site built with knockout & includes jQuery.

These are in foreach loops:

<!-- ko foreach: {data: categories, as: 'categories'} -->
<!-- Category code -->
<!-- ko foreach: {data: visibleStations, as: 'stations'} -->
<!-- specific code -->
<img class="thumb lazy" data-bind="attr: { src: imageTmp,
                                           'data-src': imageThumb,
                                           alt: name,
                                           'data-original-title': name },
                                   css: {'now-playing-art': isPlaying}">
<!-- /ko -->
<!-- /ko -->

So basically when I create these elements, imageTmp is a computed observable that returns a temporary url, and imageThumb gets set to a real url from the CDN.

And I also have this chunk of code, call this the Lazy Sweeper:

var lazyInterval = setInterval(function () {
    $('.lazy:in-viewport').each(function () {
        $(this).attr('src', $(this).data('src')).bind('load', function(){
            $(this).removeClass('lazy')
        });
    });
}, 1000);

That code goes and looks for these images that are in the viewport (using a custom selector to only find images on the screen) and then sets the src to the data-src.

The behavior we want is to avoid the overhead of loading a jillion (er, actually, a few hundred) that the user won't see.

The behavior we are seeing is that on first load, it looks like after ko.applyBindings() is called somehow the Lazy Sweeper gets clobbered and we see the images revert to the default image. Then the sweeper re-runs and we see them display again.

It's not clear to us how best to implement this in a more knockout-ish way.

Thoughts? Insights? Ideas?


I got an answer on twitter mentioning a different lazyloading library. That did not solve the problem - the problem is not understanding how the DOM and the ko representations need to interact to set up lazyloading. I believe what I need is a better way to think about the problem of creating a knockout model that sets imageTmp, and responds to lazyloading based on whether it's in the viewport, and then updates the model once imageThumb (the real image) is loaded.

like image 552
artlung Avatar asked Dec 10 '13 00:12

artlung


2 Answers

Update: now with a working example.

My approach would be:

  • let your model (stations) decide what the image URL is - either the temporary or the real one, just as you do already
  • have a binding whose job is to deal with the DOM - setting that image source and handling the load event
  • limit the lazy sweeper to simply providing the signal "you're visible now"

The viewmodel

  1. add a showPlaceholder flag which contains our state:

    this.showPlaceholder = ko.observable(true);
    
  2. add a computed observable that always returns the currently correct image url, depending on that state:

    this.imageUrl = ko.computed(function() {
      return this.showPlaceholder() ? this.imageTemp() : this.imageThumb();
    }, this);
    

Now all we have to do is set showPlaceholder to false whenever an image should load. More on that in a minute.

The binding

Our bindings job is to set the <img src> whenever the computed imageUrl changes. If the src is the real image, it should remove the lazy class after loading.

  ko.bindingHandlers.lazyImage = {
    update: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
      var $element     = $(element),
          // we unwrap our imageUrl to get a subscription to it,
          // so we're called when it changes.
          // Use ko.utils.unwrapObservable for older versions of Knockout
          imageSource  = ko.unwrap(valueAccessor());

      $element.attr('src', imageSource);

      // we don't want to remove the lazy class after the temp image
      // has loaded. Set temp-image-name.png to something that identifies
      // your real placeholder image
      if (imageSource.indexOf('temp-image-name.png') === -1) {
        $element.one('load', function() {
          $(this).removeClass('lazy');
        });
      }
    }
  };

The Lazy Sweeper

All this needs to do is give a hint to our viewmodel that it should switch from placeholder to real image now.

The ko.dataFor(element) and ko.contextFor(element) helper functions give us access to whatever is bound against a DOM element from the outside:

var lazyInterval = setInterval(function () {
    $('.lazy:in-viewport').each(function () {
      if (ko.dataFor(this)) {
        ko.dataFor(this).showPlaceholder(false);
      }
    });
}, 1000);
like image 66
janfoeh Avatar answered Oct 30 '22 11:10

janfoeh


I am not familiar with Knockout.js so i can't point you in the direction of a more 'knockoutish way', however: So don't consider this a full answer, just a tip to make it less expensive to check every image.

First: You could optimize your code for a bit

var lazyInterval = setInterval(function () {
    $('.lazy:in-viewport').each(function () {
        $(this)
            .attr('src', $(this).data('src'))
            .removeClass('lazy'); 
        // you want to remove it from the loadable images once you start loading it
        // so it wont be checked again.
        // if it won't be loaded the first time, 
        // it never will since the SRC won't change anymore.
    });
}, 1000);

also: if you check for images in your viewport but your viewport does not change, you are just rechecking them over ander over again for no good reason... You could add a 'dirty flag' to check if the viewport actually changed.

var reLazyLoad = true;
var lazyInterval = setInterval(function () {
    if (! reLazyLoad) return;
    ...current code...
    reLazyLoad = false;
}, 1000);
$(document).bind('scroll',function(){ reLazyLoad = true; });

And of course, you want it to be rechecked every time you modify the DOM in this case.

This doesn't solve your databinding problem, but it helps on the performance part :-)

(you could also just make the lazySweeper a throttled function and call it everytime something changes (either viewport or dom). Creates prettier code...)

And last : Can't you add the lazy-class using the data-binding? That way, it will only get picked up by the lazySweeper once the binding is complete... (came up with this while typing. I really don't know how knockout js works with databinding so it's a longshot)

like image 23
DoXicK Avatar answered Oct 30 '22 11:10

DoXicK