Tutoriel JavaScript Async Await - Comment attendre la fin d'une fonction dans JS

Quand une fonction asynchrone se termine-t-elle? Et pourquoi est-ce une question si difficile à répondre?

Eh bien, il s'avère que la compréhension des fonctions asynchrones nécessite beaucoup de connaissances sur le fonctionnement fondamental de JavaScript.

Allons explorer ce concept et apprenons beaucoup de choses sur JavaScript dans le processus.

Es-tu prêt? Allons-y.

Qu'est-ce que le code asynchrone?

De par sa conception, JavaScript est un langage de programmation synchrone. Cela signifie que lorsque le code est exécuté, JavaScript commence en haut du fichier et parcourt le code ligne par ligne, jusqu'à ce qu'il soit terminé.

Le résultat de cette décision de conception est qu'une seule chose peut se produire à la fois.

Vous pouvez penser à cela comme si vous jongliez avec six petites balles. Pendant que vous jonglez, vos mains sont occupées et ne peuvent rien gérer d'autre.

C'est la même chose avec JavaScript: une fois que le code est en cours d'exécution, il a les mains pleines avec ce code. Nous appelons cela ce type de blocage de code synchrone . Parce que cela bloque efficacement l'exécution d'autres codes.

Revenons à l'exemple de la jonglerie. Que se passerait-il si vous vouliez ajouter une autre balle? Au lieu de six balles, vous vouliez jongler avec sept balles. Cela pourrait être un problème.

Vous ne voulez pas arrêter de jongler, car c'est tellement amusant. Mais vous ne pouvez pas non plus aller chercher une autre balle, car cela signifierait que vous deviez vous arrêter.

La solution? Déléguez le travail à un ami ou à un membre de la famille. Ils ne jonglent pas, alors ils peuvent aller chercher le ballon pour vous, puis le lancer dans votre jonglerie à un moment où votre main est libre et que vous êtes prêt à ajouter une autre balle au milieu de la jonglerie.

C'est ce qu'est le code asynchrone. JavaScript délègue le travail à autre chose, puis s'occupe de sa propre entreprise. Ensuite, quand il sera prêt, il recevra les résultats du travail.

Qui fait l'autre travail?

Très bien, nous savons donc que JavaScript est synchrone et paresseux. Il ne veut pas faire tout le travail lui-même, alors il le ferme à autre chose.

Mais qui est cette entité mystérieuse qui fonctionne pour JavaScript? Et comment est-il embauché pour travailler pour JavaScript?

Eh bien, jetons un coup d'œil à un exemple de code asynchrone.

const logName = () => { console.log("Han") } setTimeout(logName, 0) console.log("Hi there")

L'exécution de ce code entraîne la sortie suivante dans la console:

// in console Hi there Han

Bien. Que se passe-t-il?

Il s'avère que la façon dont nous travaillons dans JavaScript consiste à utiliser des fonctions et des API spécifiques à l'environnement. Et c'est une source de grande confusion en JavaScript.

JavaScript s'exécute toujours dans un environnement.

Souvent, cet environnement est le navigateur. Mais cela peut aussi être sur le serveur avec NodeJS. Mais quelle est la différence?

La différence - et c'est important - est que le navigateur et le serveur (NodeJS), du point de vue des fonctionnalités, ne sont pas équivalents. Ils sont souvent similaires, mais ils ne sont pas identiques.

Illustrons cela avec un exemple. Disons que JavaScript est le protagoniste d'un livre fantastique épique. Juste un enfant de ferme ordinaire.

Disons maintenant que ce gamin de la ferme a trouvé deux armures spéciales qui leur ont donné des pouvoirs au-delà des leurs.

Lorsqu'ils utilisaient l'armure du navigateur, ils avaient accès à un certain ensemble de capacités.

Lorsqu'ils ont utilisé l'armure du serveur, ils ont eu accès à un autre ensemble de capacités.

Ces combinaisons se chevauchent, car les créateurs de ces combinaisons avaient les mêmes besoins à certains endroits, mais pas à d'autres.

Voilà ce qu'est un environnement. Un endroit où le code est exécuté, où il existe des outils basés sur le langage JavaScript existant. Ils ne font pas partie du langage, mais la ligne est souvent floue car nous utilisons ces outils tous les jours lorsque nous écrivons du code.

setTimeout, fetch et DOM sont tous des exemples d'API Web. (Vous pouvez voir la liste complète des API Web ici.) Ce sont des outils intégrés au navigateur et mis à notre disposition lorsque notre code est exécuté.

Et comme nous exécutons toujours JavaScript dans un environnement, il semble que ceux-ci font partie du langage. Mais ils ne le sont pas.

Donc, si vous vous êtes déjà demandé pourquoi vous pouvez utiliser fetch en JavaScript lorsque vous l'exécutez dans le navigateur (mais que vous devez installer un package lorsque vous l'exécutez dans NodeJS), voici pourquoi. Quelqu'un a pensé que Fetch était une bonne idée et l'a construit comme un outil pour l'environnement NodeJS.

Déroutant? Oui!

Mais maintenant, nous pouvons enfin comprendre ce qui prend le travail de JavaScript et comment il est embauché.

Il s'avère que c'est l'environnement qui prend en charge le travail, et la façon de faire en sorte que l'environnement fasse ce travail, est d'utiliser des fonctionnalités qui appartiennent à l'environnement. Par exemple, fetch ou setTimeout dans l'environnement du navigateur.

Qu'arrive-t-il au travail?

Génial. Donc, l'environnement prend le travail. Alors quoi?

À un moment donné, vous devez récupérer les résultats. Mais réfléchissons à la façon dont cela fonctionnerait.

Revenons à l'exemple de la jonglerie du début. Imaginez que vous ayez demandé un nouveau ballon et qu'un ami ait juste commencé à vous lancer le ballon alors que vous n'étiez pas prêt.

Ce serait un désastre. Peut-être que vous pourriez avoir de la chance et l'attraper et l'intégrer efficacement dans votre routine. Mais il y a de grandes chances que cela vous fasse perdre toutes vos balles et que votre routine échoue. Ne serait-il pas préférable de donner des instructions strictes sur le moment de recevoir le ballon?

En fait, il existe des règles strictes concernant le moment où JavaScript peut recevoir du travail délégué.

Ces règles sont régies par la boucle d'événements et impliquent la file d'attente microtâche et macrotask. Oui je sais. C'est beaucoup. Mais supportez-moi.

Alright. So when we delegate asynchronous code to the browser, the browser takes and runs the code and takes on that workload. But there may be multiple tasks that are given to the browser, so we need to make sure that we can prioritise these tasks.

This is where the microtask queue and the macrotask queue come in play. The browser will take the work, do it, then place the result in one of the two queues based on the type of work it receives.

Promises, for example, are placed in the microtask queue and have a higher priority.

Events and setTimeout are examples of work that is put in the macrotask queue, and have a lower priority.

Now once the work is done, and is placed in one of the two queues, the event loop will run back and forth and check whether or not JavaScript is ready to receive the results.

Only when JavaScript is done running all its synchronous code, and is good and ready, will the event loop start picking from the queues and handing the functions back to JavaScript to run.

So let's take a look at an example:

setTimeout(() => console.log("hello"), 0) fetch("//someapi/data").then(response => response.json()) .then(data => console.log(data)) console.log("What soup?")

What will the order be here?

  1. Firstly, setTimeout is delegated to the browser, which does the work and puts the resulting function in the macrotask queue.
  2. Secondly fetch is delegated to the browser, which takes the work. It retrieves the data from the endpoint and puts the resulting functions in the microtask queue.
  3. Javascript logs out "What soup"?
  4. The event loop checks whether or not JavaScript is ready to receive the results from the queued work.
  5. When the console.log is done, JavaScript is ready. The event loop picks queued functions from the microtask queue, which has a higher priority, and gives them back to JavaScript to execute.
  6. After the microtask queue is empty, the setTimeout callback is taken out of the macrotask queue and given back to JavaScript to execute.
In console: // What soup? // the data from the api // hello

Promises

Now you should have a good deal of knowledge about how asynchronous code is handled by JavaScript and the browser environment. So let's talk about promises.

A promise is a JavaScript construct that represents a future unknown value. Conceptually, a promise is just JavaScript promising to return a value. It could be the result from an API call, or it could be an error object from a failed network request. You're guaranteed to get something.

const promise = new Promise((resolve, reject) => { // Make a network request if (response.status === 200) { resolve(response.body) } else { const error = { ... } reject(error) } }) promise.then(res => { console.log(res) }).catch(err => { console.log(err) })

A promise can have the following states:

  • fulfilled - action successfully completed
  • rejected - action failed
  • pending - neither action has been completed
  • settled - has been fulfilled or rejected

A promise receives a resolve and a reject function that can be called to trigger one of these states.

One of the big selling points of promises is that we can chain functions that we want to happen on success (resolve) or failure (reject):

  • To register a function to run on success we use .then
  • To register a function to run on failure we use .catch
// Fetch returns a promise fetch("//swapi.dev/api/people/1") .then((res) => console.log("This function is run when the request succeeds", res) .catch(err => console.log("This function is run when the request fails", err) // Chaining multiple functions fetch("//swapi.dev/api/people/1") .then((res) => doSomethingWithResult(res)) .then((finalResult) => console.log(finalResult)) .catch((err => doSomethingWithErr(err))

Perfect. Now let's take a closer look at what this looks like under the hood, using fetch as an example:

const fetch = (url, options) => { // simplified return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() // ... make request xhr.onload = () => { const options = { status: xhr.status, statusText: xhr.statusText ... } resolve(new Response(xhr.response, options)) } xhr.onerror = () => { reject(new TypeError("Request failed")) } } fetch("//swapi.dev/api/people/1") // Register handleResponse to run when promise resolves .then(handleResponse) .catch(handleError) // conceptually, the promise looks like this now: // { status: "pending", onsuccess: [handleResponse], onfailure: [handleError] } const handleResponse = (response) => { // handleResponse will automatically receive the response, ¨ // because the promise resolves with a value and automatically injects into the function console.log(response) } const handleError = (response) => { // handleError will automatically receive the error, ¨ // because the promise resolves with a value and automatically injects into the function console.log(response) } // the promise will either resolve or reject causing it to run all of the registered functions in the respective arrays // injecting the value. Let's inspect the happy path: // 1. XHR event listener fires // 2. If the request was successfull, the onload event listener triggers // 3. The onload fires the resolve(VALUE) function with given value // 4. Resolve triggers and schedules the functions registered with .then 

So we can use promises to do asynchronous work, and to be sure that we can handle any result from those promises. That is the value proposition. If you want to know more about promises you can read more about them here and here.

When we use promises, we chain our functions onto the promise to handle the different scenarios.

This works, but we still need to handle our logic inside callbacks (nested functions) once we get our results back. What if we could use promises but write synchronous looking code? It turns out we can.

Async/Await

Async/Await is a way of writing promises that allows us to write asynchronous code in a synchronous way. Let's have a look.

const getData = async () => { const response = await fetch("//jsonplaceholder.typicode.com/todos/1") const data = await response.json() console.log(data) } getData()

Nothing has changed under the hood here. We are still using promises to fetch data, but now it looks synchronous, and we no longer have .then and .catch blocks.

Async / Await is actually just syntactic sugar providing a way to create code that is easier to reason about, without changing the underlying dynamic.

Let's take a look at how it works.

Async/Await lets us use generators to pause the execution of a function. When we are using async / await we are not blocking because the function is yielding the control back over to the main program.

Then when the promise resolves we are using the generator to yield control back to the asynchronous function with the value from the resolved promise.

You can read more here for a great overview of generators and asynchronous code.

In effect, we can now write asynchronous code that looks like synchronous code. Which means that it is easier to reason about, and we can use synchronous tools for error handling such as try / catch:

const getData = async () => { try { const response = await fetch("//jsonplaceholder.typicode.com/todos/1") const data = await response.json() console.log(data) } catch (err) { console.log(err) } } getData()

Alright. So how do we use it? In order to use async / await we need to prepend the function with async. This does not make it an asynchronous function, it merely allows us to use await inside of it.

Failing to provide the async keyword will result in a syntax error when trying to use await inside a regular function.

const getData = async () => { console.log("We can use await in this function") }

Because of this, we can not use async / await on top level code. But async and await are still just syntactic sugar over promises. So we can handle top level cases with promise chaining:

async function getData() { let response = await fetch('//apiurl.com'); } // getData is a promise getData().then(res => console.log(res)).catch(err => console.log(err); 

This exposes another interesting fact about async / await. When defining a function as async, it will always return a promise.

Using async / await can seem like magic at first. But like any magic, it's just sufficiently advanced technology that has evolved over the years. Hopefully now you have a solid grasp of the fundamentals, and can use async / await with confidence.

Conclusion

If you made it here, congrats. You just added a key piece of knowledge about JavaScript and how it works with its environments to your toolbox.

This is definitely a confusing subject, and the lines are not always clear. But now you hopefully have a grasp on how JavaScript works with asynchronous code in the browser, and a stronger grasp over both promises and async / await.

If you enjoyed this article, you might also enjoy my youtube channel. I currently have a web fundamentals series going where I go through HTTP, building web servers from scratch and more.

There's also a series going on building an entire app with React, if that is your jam. And I plan to add much more content here in the future going in depth on JavaScript topics.

And if you want to say hi or chat about web development, you could always reach out to me on twitter at @foseberg. Thanks for reading!