-
-
Save jakearchibald/0e652d95c07442f205ce to your computer and use it in GitHub Desktop.
// 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'; | |
}); |
// 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(); | |
} | |
} | |
}); | |
}); | |
}); |
// 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); | |
}; |
// 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'; | |
}); |
@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 withaddHtmlToPage
- The response has a
chapterUrls
property, an array of urls. Fetch each chapter as json thenaddHtmlToPage(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:
getJson("story.json")
fires off a request to get the JSON for the story.- Once the JSON request finishes,
seq(function(story){...
runs, wherestory
is the JSON object. addHtmlToPage(story.heading)
renders the story heading to the page.story.chapterUrls
is an array of strings (URLs).story.chapterUrls.map(getJson)
callsgetJson()
for each URL string in the original array, which produces a new array of loading sequences, one for each chapter URL.story.chapterUrls.map(getJson).map(waitForChapter)
callswaitForChapter()
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 callsrenderChapter()
to render that newly loaded chapter. The result of this secondmap(..)
call is yet another new array, a series of functions which, when called, each produce a load-and-render sequence for each chapter.ASQ().seq.apply(null, ... )
takes this array of functions andapply
s it toASQ.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);
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.