23 Jan 2020
JavaScript started out as just the language for browsers. Its event driven model made it very appealing to write rich UX driven front-end webapps. It turned out that being "event driven" is exactly what the backend of a webapp needed to be too. Hence Node.js was born.
Let's see how an event-driven Javascript example snippet looks like. I'm going to take the example of a button click on a web page as an example.
<body>
<button id='hello-btn'>Say Hello</button>
<script>
var btn = document.getElementById('hello-btn');
btn.addEventListener('click', function() {
console.log('Hello!')
})
</script>
</body>
When we put the above snippet into an html file and load it up on a browser, we'll see a button.
Sure enough when we click on it, we'll see Hello!
printed onto the console.
The interesting part in that snippet is
btn.addEventListener('click', function() {
console.log('Hello!')
})
The above snippet is an example of a callback function. addEventListener(...)
for an element,
invokes(calls) a function(){ ... }
, when a click
event happens on that element.
Let's consider another example. You can put this snippet directly in your browser's console or
put it in a .js
file and invoke it with node file.js
setTimeout(function () {
console.log('after 2 seconds')
}, 2000)
setTimeout(function () {
console.log('after 3 seconds')
}, 3000)
setTimeout(function () {
console.log('after 1 seconds')
}, 1000)
We see the execution coming out as
after 1 seconds
after 2 seconds
after 3 seconds
If you've programmed in JavaScript for long, this will not be a revelation. But for the sake of completeness,
let me tell you what's happening. We mentioned that JavaScript is an event-driven language.
Which simply put, when an event
happens, trigger a function() {...}
. The event wait 1 second
happens first, followed by the event wait 2 seconds and finally the event for wait 3 seconds
happens last. Since the JavaScript engine has no more events to listen for, it exits. To get an understanding
of how, this works under the hood, watch this sweet talk on the event-loop
by Philip Roberts.
The point here is, whenever there is an I/O bound operation, code written in JavaScript follows the above set
pattern. You might have also noted that the above 3 setTimeOut(...)
's finished executing in 3 seconds.
Now what if in the above example, we want the execution to follow that exact order. Let's modify the snippet for just that, using the callback pattern.
setTimeout(function () {
console.log('after 2 seconds')
setTimeout(function () {
console.log('after 3 seconds')
setTimeout(function () {
console.log('after 1 seconds')
}, 1000)
}, 3000)
}, 2000)
Sure enough, when we invoke it we see (after a whopping 6 seconds)
after 2 seconds
after 3 seconds
after 1 seconds
Well this is all fun and dandy, but I'm not gonna write something like that! But we all have!
Let's visit a JQuery snippet from the good ol' days to do an API call.
$.ajax({
url: 'https://api.example.com/items',
contentType: "application/json",
dataType: 'json',
success: function(result){
console.log(result);
},
error: function(err) {
console.log(err)
}
})
Let's imagine we want to do another API call with the result we got from the first API call
$.ajax({
url: 'https://api.example.com/items',
contentType: "application/json",
dataType: 'json',
success: function(result){
$.ajax({
url: 'https://api.example.com/items/' + results[0]['id'],
contentType: "application/json",
dataType: 'json',
success: function(another_result){
console.log(another_result)
},
error: function(another_err) {
console.log(another_err)
}
})
},
error: function(err) {
console.log(err)
}
})
Sure one can argue, that we could wrap the $.ajax({...})
function in a generic function
and invoke that. But still the pattern would be
function(event, callback1) {
callback1(event1, callback2) {
callback2(event2, callback3) {
...
}
}
}
This slowly, but surely results in what JavaScript programmer's fondly call, callback hell. This also has the problem of correctly handling errors. Which callback threw an error, when there are more than 3 or 4 callbacks in the chain? One's attention level has to be over 9000 when writing code for correct error handling.
In order to combat this, Promises came and then async await followed.
If you've reached this far, I'm assuming you're in it for the long run. Let's tame this beast!
From this point on, we'll have just one example snippet to work with. We'll be invoking it
with node app.js
. In the course of this article we'll convert it from a callback
to Promises
and
then finally to async
await
.
I initially thought of putting the content of app.js
as below,
setTimeout(function () {
console.log('after 2 seconds')
setTimeout(function () {
console.log('after 3 seconds')
setTimeout(function () {
console.log('after 1 seconds')
}, 1000)
}, 3000)
}, 2000)
for the callback example. As for Promises, app.js
would look like
const afterXSeconds = (x) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (x === 1) {
reject(x)
}
resolve(x)
}, x * 1000)
})
}
afterXSeconds(2)
.then((data) => {
console.log(`after ${data} seconds`);
return afterXSeconds(3);
})
.then((data) => {
console.log(`after ${data} seconds`);
return afterXSeconds(1)
})
.catch((e) => {
console.log(`execeptioned ${e}`)
})
and for finally async
& await
, our app.js
would contain,
const afterXSeconds = (x) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (x === 1) {
reject(x)
}
console.log(`after ${x} seconds`)
resolve()
}, x * 1000)
})
}
// note the async
const start = async () => {
try {
await afterXSeconds(2) // <- await here
await afterXSeconds(3)
await afterXSeconds(1)
} catch (e) {
console.log(e)
}
}
start()
But this has no practical use case. So let's take up a library function that we commonly see and make it work for us.
Put the following in app.js
.
const fs = require('fs');
const start = () => {
fs.readFile('./r.txt', 'utf-8', (err1, data) => { // callback 1
if (err1) {
console.error(err1);
process.exit(1)
}
fs.writeFile('./w.txt', data, (err2) => { // callback 2
if (err2) {
console.error(err2)
process.exit(1)
}
console.log(`Wrote: ${data}`)
});
});
}
start()
Make sure you have a file called r.txt
in the same directory as app.js
with some content.
Our example r.txt
has a Hello World!
followed by a new line in it.
$ cat r.txt
Hello World!
$
When we run the script we get a w.txt
file.
$ node app2.js
Wrote: Hello World!
$ cat w.txt
Hello World!
$
We read from a file called r.txt
and created a file called w.txt
with r.txt
's content.
Note how fs.writeFile(...)
was invoked from fs.readFile(...)
's callback.
If r.txt
is not found or node
doesn't have any permissions to access it, err1
will be present.
If w.txt
can't be created by node
, err2
will be populated. The script will exit in either case.
A function that returns a Promise will return either a positive or a negative handle for an event.
Any asynchronous operation(ex: I/O) can succeed(positive) or fail(negative). A positive is returned when the
resolve()
function in a Promise is invoked. A negative is returned when the reject()
function
is invoked from it.
We'll now wrap the readFile()
and writeFile()
functions of the fs
module in functions that
return Promises.
const fs = require('fs');
const readFilePromise = (filePath) => {
return new Promise((resolve, reject) => {
fs.readFile(filePath, 'utf-8', (err, data) => {
if (err) {
reject(err)
} else {
resolve(data)
}
})
})
}
const writeFilePromise = (filePath, data) => {
return new Promise((resolve, reject) => {
fs.writeFile(filePath, data, (err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
const start = () => {
readFilePromise('./r.txt')
.then(data => {
return writeFilePromise('./w.txt', data)
})
.catch(e => {
console.log(e)
})
}
start()
Look how small our start()
function is now. Remove w.txt
and run node app.js
again. We'll see
our script works as expected.
Now that we've seen how to convert callbacks into promises, we'll see a utility function that comes
with node.js(>8.0
) that simplifies this. Replace the content of app.js
with
const fs = require('fs');
const { promisify } = require('util');
const readFilePromise = promisify(fs.readFile);
const writeFilePromise = promisify(fs.writeFile);
const start = () => {
readFilePromise('./r.txt')
.then(data => {
return writeFilePromise('./w.txt', data)
})
.catch(e => {
console.log(e)
})
}
start()
The promisify(...)
function will take a callback as an argument and gives us a "Promisified" version of
it. Neat huh!
You know the dance. Replace the content of app.js
with
const fs = require('fs');
const { promisify } = require('util');
const readFilePromise = promisify(fs.readFile);
const writeFilePromise = promisify(fs.writeFile);
const start = async () => { // note the async
try {
const data = await readFilePromise('./r.txt');
if (!data) {
console.log('Could not read from file')
return;
}
await writeFilePromise('./w.txt', data)
} catch (e) {
console.log(e)
return;
}
}
start()
Remove w.txt
and run node app.js
. The gist about async
& await
is, if a function fx
returns
a Promise, function fx
can be called from within an async
function fy
. If fx
's resolve()
function
returns something, that value will can be stored in variable (ex: res
)
The syntax is
const fy = async () => {
try {
const res = await fx()
} catch (e) {
...
}
}
By using this approach we have all our operations running in the try{...}
block, and if something
goes wrong, the main catch(){...}
block will deal with it.
Phew! that was a long read. Thank you for reaching this far. Hopefully, we now have a good grasp on callbacks, promises and async/await.
If something was unclear or you my dear reader thinks that something needs improving, hit me an email. I'll gladly hit you back.
Happy Hacking & have a great day!