secret
Last active

Getting some urls for content, then fetching that content & adding it to the page sequentially. Promises vs node style callbacks vs events.

  • Download Gist
1-promises.js
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
// With Promises
getJson('story.json').then(function(story) {
addHtmlToPage(story.heading);
 
// Map our array of chapter urls
// to an array of chapter json promises
return story.chapterUrls.map(getJson).reduce(function(chain, chapterPromise) {
// Use reduce to chain the promises together,
// but adding content to the page for each chapter
return chain.then(function() {
return chapterPromise;
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
});
2-node.js
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
// With Node.js-style callbacks
// A couple of functions to save repetiton
function handleError(err) {
addTextToPage("Argh, broken: " + err.message);
hideSpinner();
}
 
function hideSpinner() {
document.querySelector('.spinner').style.display = 'none';
}
 
getJsonCallback('story.json', function(err, story) {
// In Node.js callbacks, the first param is an error
if (err) {
handleError(err);
return;
}
 
addHtmlToPage(story.heading);
 
// We need to track if anything failed
var errored = false;
// Track the next chapter that can appear on the page
var nextChapterToAdd = 0;
var chapterHtmls = [];
 
story.chapterUrls.forEach(function(chapterUrl, i) {
getJsonCallback(chapterUrl, function(err, chapter) {
// If we've already errored, don't want to do anything
if (errored) {
return;
}
// If this is an error, handle it
if (err) {
errored = true;
handleError(err);
return;
}
 
// Store this chapter, we might not be able to deal with it yet
chapterHtmls[i] = chapter.html;
 
// Try and add new chapters to the page, in order.
// Stop when we reach a chapter we've yet to download.
for (; nextChapterToAdd in chapterHtmls; nextChapterToAdd++) {
addHtmlToPage(chapterHtmls[nextChapterToAdd]);
 
// Did we just add the final chapter?
if (nextChapterToAdd == story.chapterUrls.length - 1) {
addTextToPage("All done");
hideSpinner();
}
}
});
});
});
3-events.js
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
// With events (using simpler on* properties)
// A couple of functions to save repetiton
function handleError(err) {
addTextToPage("Argh, broken: " + err.message);
hideSpinner();
}
 
function hideSpinner() {
document.querySelector('.spinner').style.display = 'none';
}
 
// In this example, we get an object we add load & error listeners to
var request = getJsonEvent('story.json');
 
request.onload = function() {
addHtmlToPage(request.response.heading);
// We need to track if anything failed
var errored = false;
// Track the next chapter that can appear on the page
var nextChapterToAdd = 0;
var chapterHtmls = [];
 
 
// We store chapter requests so we can remove listeners later
var chapterRequests = request.response.chapterUrls.map(function(chapterUrl, i) {
var request = getJsonEvent(chapterUrl);
 
request.onload = function() {
// Store this chapter, we might not be able to deal with it yet
chapterHtmls[i] = request.response.html;
 
// Try and add new chapters to the page, in order.
// Stop when we reach a chapter we've yet to download.
for (; nextChapterToAdd in chapterHtmls; nextChapterToAdd++) {
addHtmlToPage(chapterHtmls[nextChapterToAdd]);
 
// Did we just add the final chapter?
if (nextChapterToAdd == story.chapterUrls.length - 1) {
addTextToPage("All done");
hideSpinner();
}
}
};
 
request.onerror = function(event) {
// prevent further events
chapterRequests.forEach(function(request) {
request.onload = null;
request.onerror = null;
});
handleError(event.error);
};
 
return request;
});
};
 
request.onerror = function(event) {
handleError(event.error);
};
4-promise-generators.js
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
// With Promises + Generators (and the spawn helper)
spawn(function *() {
try {
// 'yield' effectively does an async wait, returning the result of the promise
let story = yield getJson('story.json');
addHtmlToPage(story.heading);
 
// Map our array of chapter urls
// to an array of chapter json promises.
// This makes sure they all download parallel.
let chapterPromises = story.chapterUrls.map(getJson);
 
// Can't use chapterPromises.forEach, because yielding within doesn't work
for (let chapterPromise of chapterPromises) {
// Wait for each chapter to be ready, then add it to the page
let chapter = yield chapterPromise;
addHtmlToPage(chapter.html);
}
 
addTextToPage("All done");
}
catch (err) {
// try/catch just works, rejected promises are thrown here
addTextToPage("Argh, broken: " + err.message);
}
document.querySelector('.spinner').style.display = 'none';
});

I don't know Jake … you almost convinced me then I've read the comparison by number of lines and spot many differences, declared functions, empty lines, etc etc … so here the eddy.js (a 2.2KB minzipped utility) version of your first Promises based example, still using events and bits of some smart trick you put in there.

getJsonEvent('story.json').on('load', function() {
  var ok = true, j = 0, theEnd = function(message) {
    addTextToPage(message);
    document.querySelector('.spinner').style.display = 'none';
  };
  addHtmlToPage(this.response.heading);
  this.response.chapterUrls.forEach(function(chapterUrl, i, chapterUrls) {
    delete chapterUrls[i];
    getJsonEvent(chapterUrl).on('load', function() {
        chapterUrls[i] = this.response.html;
        while (ok && j in chapterUrls) addHtmlToPage(chapterUrls[j++]);
        if (ok && j === chapterUrls.length) theEnd("All done");
    }).on('error', function(event) {
        ok && ((ok = false), theEnd("Argh, broken: " + event.message));
    });
  });
}).on('error', function(event) {
  theEnd("Argh, broken: " + event.message);
});

@WebReflection @jakearchibald pretty simple in RxJS as well, for example:

/* Assume getJSON is an Observable, else a Promise wrapped as Observable using 
Rx.Observable.fromPromise */

var observable = getJSON('story.json')
    .do(function (story) { addHtmlToPage(story.heading); })
    .flatMap(function (story) {
        return Rx.Observable.for(story.chapters, getJSON);
    });

var subscription = observable.subscribe(
    function (chapter) {
        addHtmlToPage(chapter.html);
    },
    function (err) {
        addTextToPage("Argh, broken: " + err.message);
        document.querySelector('.spinner').style.display = 'none';
    },
    function () {
        addTextToPage("All done");
        document.querySelector('.spinner').style.display = 'none';
    });

just in case somebody else would like to "play" … the game here is the ability to render chapter 1, 2, and 3 at once then chapter 4 and 5 in a second "flush" considering a disordered reply chapters order such: 2, 5, 3, 1, 4

where 5 won't be rendered but 1,2,3 will after the 1 is complete, and 4,5 will be rendered after the 4 is complete.

Anything that just render all of them at once is not valid example
Anything that renders ASAP each chapter is not valid example

Accordingly, check your code before posting and show your magic ;-)

Why does the generator example have try/catch?

@rwaldron to catch errors :D

Rejected promises are thrown as errors. There's a section on this at http://www.html5rocks.com/en/tutorials/es6/promises/

@WebReflection love the technique of reusing the original array! However, you should include the handleError definition, and you need to stop the spinner on error/success.

Also, unless I've read it wrong, your error handler may get called multiple times if multiple chapters fail? You'll need something to prevent that.

@mattpodwysocki that's really nice. The plain promises example loses out because there's a lack of "map" or similar, which is a shame because I don't think the reduce stuff is awfully friendly.

Is your error message going to be displayed once at most, even if multiple chapters fail? Also, you need to hide the spinner on error/success.

If we're going for brevity, here's the generators example:

// getJSON returns an ES6 Promise, spawn implementation is at
// http://www.html5rocks.com/en/tutorials/es6/promises/
spawn(function *() {
  try {
    let story = yield getJson('story.json');
    addHtmlToPage(story.heading);
    story.chapterUrls.map(getJson).forEach(p => addHtmlToPage((yield p).html));
    addTextToPage("All done");
  } catch (err) { addTextToPage("Argh, broken: " + err.message); }
  document.querySelector('.spinner').style.display = 'none';
});

Just to expand on the rules laid down by @webreflection:

  • Use getJson('story.json') to fetch story.json in json format - if this returns a promise/observable whatever, state what it returns & what libraries you need to return it
  • The response has a heading property, add it to the page with addHtmlToPage
  • The response has a chapterUrls property, an array of urls. Fetch each chapter as json then addHtmlToPage(responseJson.html)
  • Chapters must download in parallel
  • Chapters must be shown when the previous chapter is shown (unless it's the first, of course), or when the chapter downloads, whichever happens last
  • Chapters must display on the page in the correct order
  • If anything fails (downloads, processing), display an error message
  • Once an error is shown, no further chapters should be shown, and no further errors should be shown
  • When done, either due to success or error, hide the spinner with document.querySelector('.spinner').style.display = 'none';

@jakearchibald the error and finalization are only called at a maximum once. The nice thing is that we can add a retry(3) to each one to ensure that we retry if there are failures per chapter, else provide a default answer if it still continues to fail,.

updated accordingly with rules … well, it might be more beautiful despite the amount of lines … very readable, much wow :D

Nice, fun exercise. I enjoyed the challenge. :)

I'm including here my two answers, using asynquence, which is a 1.1k minzipped abstraction on top of promises. BTW, I didn't go for least-number-of-LoC, I wanted most-readable form, so I factored out many of the inline anonymous functions to named functions.

The boiler-plate:

function getJson(url) {
    return ASQ(function(done){
        // using jquery $.get(..)
        $.get(url).success(done).error(done.fail);
    });
}

function allDone() {
    addTextToPage("All done");
    document.querySelector('.spinner').style.display = 'none';
}

function loadingError(err) {
    // catch any error that happened along the way
    addTextToPage("Argh, broken: " + err.message);
    document.querySelector('.spinner').style.display = 'none';
}

The first solution uses asynquence almost identically to the first promises example above. While some don't like map/reduce, I actually think they're quite helpful for readability here.

getJson("story.json")
.seq(function(story){

    function waitForChapter(sq,loadingChapter) {
        return sq
            .seq(loadingChapter)
            .val(renderChapter);
    }

    function renderChapter(chapterJson) {
        addHtmlToPage(chapterJson.html);
    }

    addHtmlToPage(story.heading);

    return story.chapterUrls
        .map(getJson)
        .reduce(waitForChapter,ASQ()); // ASQ() creates an empty sequence
})
.val(allDone)
.or(loadingError);

The second solution sets up both an iterable rendering sequence and a separate sequence for the chapter loading. This second loading sequence controls the iteration (via next()) of the rendering sequence, executing each step at the appropriate time. Depending on how you think about the task, I think this code is cleaner (though a little longer) because it separates the concerns of loading from rendering.

getJson("story.json")
.seq(function(story){

    function waitForChapter(sq,loadingChapter) {
        return sq
            .seq(loadingChapter)
            .val(rendering.next);
    }

    function renderChapter(chapterJson) {
        addHtmlToPage(chapterJson.html);
    }

    addHtmlToPage(story.heading);

    // iterable `rendering` sequence, one step for each chapter rendering
    // ASQ.iterable(..) creates an empty iterable sequence
    var rendering = ASQ.iterable.apply(null,
            story.chapterUrls
            .map(function(){ return renderChapter; })
        );

    // sequence for loading chapters and interleaving iteration control of `rendering` sequence
    story.chapterUrls
    .map(getJson)
    .reduce(waitForChapter,ASQ()) // ASQ() creates an empty sequence
    .or(rendering.throw);

    return rendering;
})
.val(allDone)
.or(loadingError);

Note: This iterable syntax for asynquence is an upcoming feature addition to the library which should be available shortly. See Issue #4 for more information about the adding of iterable() support.

Update: Iterable Sequences are now in asynquence, and work as shown above!

Update:

Realized another way to do the promises-style example, without needing reduce, but instead another map(..) call, plus apply(..). This feels a bit more readable IMO:

getJson("story.json")
.seq(function(story){

    function waitForChapter(loadingChapter) {
        return function() {
            return loadingChapter
                .val(renderChapter);
        };
    }

    function renderChapter(chapterJson) {
        addHtmlToPage(chapterJson.html);
    }

    addHtmlToPage(story.heading);

    return ASQ()
        .seq.apply(null,
            story.chapterUrls
            .map(getJson)
            .map(waitForChapter)
        );
})
.val(allDone)
.or(loadingError);

Here's how it works:

  1. getJson("story.json") fires off a request to get the JSON for the story.
  2. Once the JSON request finishes, seq(function(story){... runs, where story is the JSON object.
  3. addHtmlToPage(story.heading) renders the story heading to the page.
  4. story.chapterUrls is an array of strings (URLs).
  5. story.chapterUrls.map(getJson) calls getJson() for each URL string in the original array, which produces a new array of loading sequences, one for each chapter URL.
  6. story.chapterUrls.map(getJson).map(waitForChapter) calls waitForChapter() for each loading-chapter sequence, which returns a function that will generate a new sequence which listens for each chapter loading sequence to finish and then calls renderChapter() to render that newly loaded chapter. The result of this second map(..) call is yet another new array, a series of functions which, when called, each produce a load-and-render sequence for each chapter.
  7. ASQ().seq.apply(null, ... ) takes this array of functions and applys it to ASQ.seq(), which chains each chapter load-and-render sequence as sequential steps in this main sequence. So each chapter sequence will, when the previous chapter finishes rendering, check to see if the next chapter is loaded, and if so, render the it, otherwise will keep waiting.

The end-result of this map/map/apply trick is basically the same as:

// fire off the loading of each chapter, all in parallel
var chapter_loading = [
   getJson(story.chapterUrls[0]),  // start loading chapter 1 right now
   getJson(story.chapterUrls[1]),  // start loading chapter 2 right now
   getJson(story.chapterUrls[2]),  // start loading chapter 3 right now
   getJson(story.chapterUrls[3]),  // start loading chapter 4 right now
   getJson(story.chapterUrls[4])   // start loading chapter 5 right now
];

ASQ()
// make sure chapter 1 has loaded, wait if not yet...
.seq(chapter_loading[0])
   // now render chapter 1
   .val(renderChapter)
   // once chapter 1 finishes rendering, then proceed

// make sure chapter 2 has loaded, wait if not yet...
.seq(chapter_loading[1])
   // now render chapter 2
   .val(renderChapter)
   // once chapter 2 finishes rendering, then proceed

// make sure chapter 3 has loaded, wait if not yet...
.seq(chapter_loading[2])
   // now render chapter 3
   .val(renderChapter)
   // once chapter 3 finishes rendering, then proceed

// make sure chapter 4 has loaded, wait if not yet...
.seq(chapter_loading[3])
   // now render chapter 4
   .val(renderChapter)
   // once chapter 4 finishes rendering, then proceed

// make sure chapter 5 has loaded, wait if not yet...
.seq(chapter_loading[4])
   // now render chapter 5
   .val(renderChapter)
   // once chapter 5 finishes rendering, then proceed

// all chapters rendered!
.val(allDone)
// oops, something went wrong along the way :(
.or(loadingError);

Please sign in to comment on this gist.

Something went wrong with that request. Please try again.