7.2 KiB
Callback hell
Consider this function loadScript(src)
that loads a script:
function loadScript(src) {
let script = document.createElement('script');
script.src = src;
document.head.append(script);
}
When the script element is added to the document, the browser loads it and executes. So, the function works.
We can use it like this:
// loads and executes the script
loadScript('/my/script.js');
The function is asynchronous: the script starts loading now, but finishes later.
"Synchonous" and "asynchronous" are general programming terms, not specific to JavaScript.
A synchronous action suspends the execution until it's completed. For instance, a call to alert
or prompt
is synchronous: the program may not continue until it's finished.
let age = prompt("How old are you", 20);
// the execution of the code below awaits for the prompt to finish
// the script hangs
An asynchronous action allows the program to continue while it's in progress. For instance, a call to loadScript
is asynchronous. It initiates the script loading, but does not suspend the execution. Other commands may execute while the script is loading:
loadScript('/my/script.js');
// the execution of the code below *does not* wait for the script loading to finish,
// it just continues
As of now, the loadScript
function loads the script, doesn't provide a way to track the load completion. The script loads and eventually runs, that's all. But we'd like to know when happens, to use additional functions and variables from that script.
Let's add a callback
function as a second argument to loadScript
that should execute when the script loads:
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
*!*
script.onload = () => callback(script);
*/!*
document.head.append(script);
}
Now we're able to load a script and then run our code that can use new functions from it, like here:
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(script);
document.head.append(script);
}
*!*
loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', function(script) {
alert(`Cool, the ${script.src} is loaded`);
alert( _ ); // function declared in the loaded script
});
*/!*
That's called a "callback-based" style of asynchronous programming. A function that does something asynchronously should provide a callback
argument where we put the function to run after it's complete.
Here it was used for loadScript
, but of course it's a general approach.
Callback in callback
What if we need to load two scripts sequentially: the first one, and then the second one after it?
We can put the second loadScript
inside the callback, like this:
loadScript('/my/script.js', function(script) {
alert(`Cool, the ${script.src} is loaded, let's load one more`);
*!*
loadScript('/my/script2.js', function(script) {
alert(`Cool, the second script is loaded`);
});
*/!*
});
Now after the outer loadScript
is complete, the callback initiates the inner one.
...What if we want one more script?
loadScript('/my/script.js', function(script) {
loadScript('/my/script2.js', function(script) {
*!*
loadScript('/my/script3.js', function(script) {
// ...continue after all scripts are loaded
});
*/!*
})
});
As we can see, a new asynchronous action means one more nesting level. So the code becomes deeper and deeper.
Handling errors
In examples above we didn't consider errors. What if a script loading failed with an error? Our callback should be able to react on that.
Here's an improved version of loadScript
that tracks loading errors:
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
*!*
script.onload = () => callback(null, script);
script.onerror = () => callback(new Error(`Script load error ` + src));
*/!*
document.head.append(script);
}
It calls callback(null, script)
for successful load and callback(error)
otherwise.
Usage:
loadScript('/my/script.js', function(error, script) {
if (error) {
// handle error
} else {
// script loaded successfully
}
});
The convention is:
- The first argument of
callback
is reserved for an error if it occurs. Thencallback(err)
is called. - The second argument and successive ones if needed are for the successful result. Then
callback(null, result1, result2…)
is called.
So the single callback
function is used both for reporting errors and passing back results. That's called "error-first callback" style. Or we could use different functions for successful and erroneous completion.
Pyramid of doom
From the first look it's a viable way of asynchronous coding. And indeed it is. For one or maybe two nested calls it looks fine.
But for multiple asynchronous actions that follow one after another we'll have a code like this:
loadScript('1.js', function(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('2.js', function(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('3.js', function(error, script) {
if (error) {
handleError(error);
} else {
*!*
// ...continue after all scripts are loaded (*)
*/!*
}
});
}
})
}
});
In the code above:
- We load
1.js
, then if there's no error. - We load
2.js
, then if there's no error. - We load
3.js
, then if there's no error -- do something else(*)
.
As calls become more nested, the whole thing becomes increasingly more difficult to manage, especially if we add real code instead of ...
, that may include more loops, conditional statements and other usage of loaded scripts.
That's sometimes called "callback hell" or "pyramid of doom".
The pyramid grows to the right with every asynchronous action. Soon it spirales out of control.
So this way of coding appears not so good.
In simple cases we can evade the problem by making every action a standalone function, like this:
loadScript('1.js', step1);
function step1(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('2.js', step2);
}
}
function step2(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('3.js', step3);
}
}
function step3(error, script) {
if (error) {
handleError(error);
} else {
// ...continue after all scripts are loaded (*)
}
};
See? It does the same, and there's no deep nesting now, because we moved every function to the top. But the code looks like a torn apart spreadsheet. We need to eye-jump between pieces while reading it. It's not very readable, especially if you are not familiar with it and don't know where to eye-jump.
Also the functions step*
have no use, they are only created to evade the "pyramid of doom".
So we'd like to have a better way of coding for complex asynchronous actions.
Luckily, there are other ways to evade such pyramids. For instance, we can use "promises", described in the next chapter.