3

I was preloading images with the following code:

    function preLoad() {
        var deferred = $q.defer();
        var imageArray = [];
        for (var i = 0; i < $scope.abbreviations.length; i++) {
            imageArray[i] = new Image();
            imageArray[i].src = $scope.abbreviations[i].imgPath;
        }
        imageArray.forEach.onload = function () {
            deferred.resolve();
            console.log('Resolved');
        }
        imageArray.forEach.onerror = function () {
            deferred.reject();
            console.log('Rejected')
        }
        return deferred.promise;
    }
    preLoad();

I thought images were all loading correctly because I could see the 'Resolved' log.

Later somebody pointed out that the code above doesn't guarantee that all images are loaded before resolving the promise. In fact, only the first promise is resolved.

I was advised to use $q.all applied to an array of promises instead. This is the resulting code:

    function preLoad() {
        var imageArray = [];
        var promises;
        for (var i = 0; i < $scope.abbreviations.length; i++) {
            imageArray[i] = new Image();
            imageArray[i].src = $scope.abbreviations[i].imgPath;
        };

        function resolvePromises(n) {
            return $q.when(n);
        }
        promises = imageArray.map(resolvePromises);
        $q.all(promises).then(function (results) {
            console.log('array promises resolved with', results);
        });
    }
    preLoad();

This works, but I want to understand:

  1. what's happening in each function;
  2. why I need $q.all to make sure all images are loaded before resolving the promises.

The relevant docs are somewhat cryptic.

U r s u s
  • 6,680
  • 12
  • 50
  • 88
  • Where did you get this `imageArray.forEach.onload` from? – Henrique Barcelos Aug 25 '15 at 22:14
  • @HenriqueBarcelos do you mean it should be `imageArray.onload` instead? – U r s u s Aug 25 '15 at 22:24
  • I wonder why you're talking of the "first promise"… there is only one promise in your first snippet. – Bergi Aug 25 '15 at 22:28
  • 1
    I don't think your second code works. Yes, it does log the finial message, but it does log it too early. Nowhere in this code you are awaiting `load` or `error` events, and I'm pretty confident that `$q.when` does not automagically do this when being passed an `Image`. – Bergi Aug 25 '15 at 22:30
  • 1
    @Ursus neither this would work. – Henrique Barcelos Aug 25 '15 at 22:31
  • This may only be working now because you have cached images in your browser. You need promises and a load handler for each image to make it work properly – charlietfl Aug 25 '15 at 22:35
  • @charlietfl fair point. I did try a hard reload and empty cache in Chrome and as far as I can tell (by looking at the network tab) the images are loaded – U r s u s Aug 25 '15 at 22:39
  • can also do it the old fashioned way without promises and using a counter and update count in onload – charlietfl Aug 25 '15 at 22:42
  • @Bergi and charlietfl any suggestion in an answer would be helpful, thanks – U r s u s Aug 25 '15 at 22:43

2 Answers2

5

Check out this plunkr.

Your function:

function preLoad() {

    var promises = [];

    function loadImage(src) {
        return $q(function(resolve,reject) {
            var image = new Image();
            image.src = src;
            image.onload = function() {
              console.log("loaded image: "+src);
              resolve(image);
            };
            image.onerror = function(e) {
              reject(e);
            };
        })
    }

    $scope.images.forEach(function(src) {
      promises.push(loadImage(src));
    })

    return $q.all(promises).then(function(results) {
        console.log('promises array all resolved');
        $scope.results = results;
        return results;
    });
}

The idea is very similar to Henrique's answer, but the onload handler is used to resolve each promise, and onerror is used to reject each promise.

To answer your questions:

1) Promise factory

$q(function(resolve,reject) { ... })  

constructs a Promise. Whatever is passed to the resolve function will be used in the then function. For example:

$q(function(resolve,reject) {
     if (Math.floor(Math.random() * 10) > 4) {
         resolve("success")
     }
     else {
         reject("failure")
     }
}.then(function wasResolved(result) {
    console.log(result) // "success"
}, function wasRejected(error) {
    console.log(error) // "failure"
})

2) $q.all is passed an array of promises, then takes a function which is passed an array with the resolutions of all the original promises.

ShaharZ
  • 389
  • 5
  • 11
  • 1
    I don't see the difference. What do you mean by "*but the onload handler is used to resolve each promise, and onerror is used to reject each promise.*"? Btw, you should post your code, especially the `loadImage` function, in your answer, not on some plunker. – Bergi Aug 25 '15 at 23:53
  • 1
    Nice, you're right & it's edited. To answer your question, instead of determining to resolve/reject in the onload handler, I trust the onerror handler to decide whether to reject. The onload handler can only resolve. Depends on the use case, but the other solution could be better if you need more granular rejection criteria. Also, i wanted to actually explain promises a little more in depth to OP – ShaharZ Aug 26 '15 at 00:01
  • Ah, right. Notice that Henrique had to fix this as well, see the comments on his answer for details :-) – Bergi Aug 26 '15 at 00:02
  • @ShaharZ thanks for the effort. At first blush, your snippet looks clearer than Henrique's. I'll also give it a go. +1. – U r s u s Aug 26 '15 at 09:02
  • @ShaharZ in your link function `console.log(newVal,oldVal);` returns `undefined, undefined`. – U r s u s Aug 26 '15 at 09:09
  • @ShaharZ also why are you injecting `function($scope,$q)` inside the controller in the plnkr? You're already injecting `$scope` and `$q`... – U r s u s Aug 26 '15 at 09:34
  • @ShaharZ although my final solution is slightly different, your answer helped me a lot, thanks. – U r s u s Aug 26 '15 at 15:45
  • $scope.$watch('results') will fire when the $scope.results first initializes, so at that point it is 'undefined'. If you use dependency injection, you must put the controller function in the injection array! The order of arguments matters. – ShaharZ Aug 26 '15 at 16:19
  • @U r s u s Also important, this directive in the plunkr works because it is on the same scope as controller and relies on a scope var named "results". If we wanted to use an arbitrary scope variable, we would pass it in directive HTML attribute, and use an isolate scope in the directive definition. – ShaharZ Aug 26 '15 at 16:27
2

I'm not used to angular promise library, but the idea is as follows:

function getImagePromise(imgData) {
    var imgEl = new Image();
    imgEl.src = imgData.imgPath;

    return $q(function(resolve, reject){
        imgEl.addEventListener('load', function(){
            if ((
                   'naturalHeight' in this 
                    && this.naturalHeight + this.naturalWidth === 0
                ) 
                || (this.width + this.height == 0)) {
                reject(new Error('Image not loaded:' + this.src));
            } else {
                resolve(this);
            }
        });

        imgEl.addEventListener('error', function(){
            reject(new Error('Image not loaded:' + this.src));
        });
    })
}

function preLoad() {
    return $q.all($scope.abbreviations.map(getImagePromise));
}

// using
preLoad().then(function(data){
    console.log("Loaded successfully");
    data.map(console.log, console);
}, function(reason){
    console.error("Error loading: " + reason);
});
Henrique Barcelos
  • 7,670
  • 1
  • 41
  • 66
  • Did you leave out adding an `onerror` handler on purpose? – Bergi Aug 25 '15 at 23:26
  • Did you mean `onerror` handler for the `Image` element? – Henrique Barcelos Aug 25 '15 at 23:27
  • Yes, exactly. OP had used one so I wondered whether your test for the dimensions in the `onload` handler is known to be sufficient? – Bergi Aug 25 '15 at 23:33
  • 1
    `onerror` will be triggered only when the response status is different from `200`. The approach I used on the post is bullet-proof for cases where a `200` status can be returned, but the response content (mime) is not an image. This [question](http://stackoverflow.com/questions/9809015/image-onerror-event-never-fires-but-image-isnt-valid-data-need-a-work-around) is related. – Henrique Barcelos Aug 25 '15 at 23:36
  • But nothing prevents OP from using it instead if he thinks he will never face this sort of problem. – Henrique Barcelos Aug 25 '15 at 23:37
  • 1
    Yes, but afaik (and how I understood [the spec](http://www.w3.org/TR/html5/embedded-content-0.html#update-the-image-data)) no `load` event is fired when *no* 200 status is returned - there are cases where only `error` is fired. – Bergi Aug 25 '15 at 23:50
  • 1
    You are right, so a robust solution have to include both approaches. I'll edit the answer. – Henrique Barcelos Aug 25 '15 at 23:51
  • @HenriqueBarcelos I'll test this and get back to you. +1 for now :) Quick question: what's the 'natural height' thing about? – U r s u s Aug 26 '15 at 08:57