artlung
artlung

Reputation: 34013

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.

Upvotes: 9

Views: 3803

Answers (3)

janfoeh
janfoeh

Reputation: 10328

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);

Upvotes: 11

PW Kad
PW Kad

Reputation: 14995

Use a custom binding handler

I haven't created a fiddle to test this, but if you feel it is the right direction but my psuedo-code has a problem in it just let me know and I can verify in fiddle.

This is what I would do, personally -

Use a custom binding handler to check if there is a class 'lazy' on the element. This should fire whenever a binding on the same element has a 'viewPortChanged' name (allBindingsAccessor.get() should find that binding and set a local variable equal to it) -

ko.bindingHandlers.showPicture = {
    init: function (element, valueAccessor, allBindingsAccessor) {
        // The actual link to your image or w/e
        var actualSource = valueAccessor();
        var viewPortChanged = allBindingsAccessor.get('viewPortChanged');
        viewPortChanged.subscribe(function () {
            if ($(element).hasClass('lazy')) {
                $(element).attr("src", actualSource());
            }
        });
    }
};

And in your viewmodel, create a flag to trigger the custom binding handler -

function viewModel() {
    var self = this;
    self.viewPortChanged = ko.observable(false);

    // Register this to fire on resize of window
    $(window).resize(function() {
        // Do your view change class logic
        $('.lazy:in-viewport').each(function () {
            $(this).attr('src', $(this).data('src')).bind('load', function(){
                $(this).removeClass('lazy')
            });
        });
        // Have the observable flag change to recalc
        // the custom binding handler
        self.viewPortChanged(!self.viewPortChanged());
    });
}
ko.applyBindings(new viewModel());

And finally, register the custom binding handler on your element -

<img class="thumb lazy" data-bind="showPicture: thisImageSource, viewPortChanged: viewPortChanged">

Basically this should fire the lazy check on each picture whenever the viewPortChanged observable fires. One issue is that viewPortChanged is obviously just setting itself inversely so you may want to make that a computed that does something else, but becareful of double-flagging all of your observables.

Upvotes: 2

DoXicK
DoXicK

Reputation: 4812

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)

Upvotes: 3

Related Questions