JavaScript, les bons éléments, façon 2020 : L’asynchonicité

On est là sur le coeur du JavaScript moderne.

Franchement, que ferait-on en 2020 sans l’asynchronicité en JavaScript ? Au centre du langage depuis sa plus tendre enfance naissance, l’asynchronicité est un concept permettant de décrire une action, mais de ne pas l’effectuer tout de suite. Elle se fera… quand elle aura le temps. Ou quand le moment opportun sera venu.

En JavaScript cependant, attention : Il ne se passera JAMAIS plusieurs actions à la fois : le langage est single-thread, ce qui lui assure une certaine intégrité des données. Globalement, un langage typé dynamiquement et interprété ne sera jamais multi-thread : C’est dangereux pour le programmeur mais surtout pour l’interpréteur.

Des solutions existent pour toucher au multi-threading en JavaScript, comme child_process ou worker_threads sur Node, ou les web workers sur le navigateur, mais nous n’en parlerons pas ici (c’est plateform-specific, et on s’intéresse qu’au "vrai" JS universel !).

Non, lorsqu’on parle d’asynchronicité en JS, c’est uniquement pour désigner du code dont l’exécution est en dehors du control-flow, c’est à dire qu’il ne suit pas l’ordre des lignes dans le code, et surtout que le code "n’attend" pas que cette fonction se termine.

Un exemple très simple est le principe des écouteurs d’évènements. Il s’agit de code s’exécutant uniquement lorsque quelque chose de précis se passe : un clic, la fin d’une requête HTTP, le mouvement d’une souris… Quand on écrit un écouteur d’évènement, on lui assigne une fonction à exécuter lorsque le moment se présentera. Cette fonction est techniquement asynchrone.

Un second exemple implémenté partout est la fonction globale setTimeout. On peut lui passer une fonction de rappel (callback) en premier paramètre, et en second le temps (en millisecondes) avant l’exécution de celle-ci. Le code est aussi asynchrone : La fonction ne s’exécutera pas en même temps que l’appel à setTimeout, d’ailleurs, notre code continue à se lancer sans attendre que l’appel soit fait !

function sayHello() {
  setTimeout(() => {
    console.log('Hello from timeout!');
  }, 200);

  console.log('Hello after timeout call!');
}

sayHello();
// Affiche :
// Hello after timeout call!
// Hello from timeout!

La fonction sayHello a terminé son exécution avant l’appel du callback passé à setTimeout !

L’enfer des callbacks

Maintenant que l’on a vu comment l’asynchronicité existait en JavaScript, il est temps de voir sous quelle forme on la réalisait.

Ici, deux écoles s’affrontent :

  • L’école des fonctions de rappel
  • L’école des évènements

Les deux manières utilisent de toute manière des fonctions pour traiter le code asynchrone, c’est juste que celles-ci ne sont pas introduites de la même façon. Les deux alternatives ont des défauts (et se ressemblent) et les deux amènent à ce que l’on appelle "l’enfer des callbacks".

La gestion via les fonctions de rappel

Cette méthode là a été énormément popularisée par Node.js, elle consiste, dès l’appel d’une action asynchrone, à fournir une "fonction de rappel".

Par exemple, la fonction fs.readFile de Node lit un fichier de manière asynchrone (cela veut dire que le code "n’attend pas" que le fichier soit lu pour continuer).

const fs = require('fs');

console.log('before file read');
fs.readFile('file.txt', (err, data) => {
  if (err) {
    console.error('Error during file read', err);
    return;
  }

  console.log('File content:', data.toString());
});
console.log('after file read');

Ce code pourra afficher :

> before file read
> after file read
> File content: Hello!

On voit déjà deux choses dans le code utilisant les fonctions de rappel : La gestion des erreurs est faite (généralement) par la même fonction qui est appelée si tout se passe bien. Le premier paramètre de celle-ci est donc généralement l’erreur lancée (et null si tout s’est bien passé). Cela impose de vérifier à chaque fois si err vaut quelque chose et de gérer en conséquence.

Plus encore, si l’on veut faire plusieurs actions asynchrones qui dépendent les unes des autres à la suite, on va se retrouver dans une suite de callbacks, car on ne peut pas attendre que le code asynchrone soit terminé autrement que dans la fonction de rappel.

fs.access('file.txt', fs.constants.F_OK, err => {
  // Le fichier n'existe pas ! On ne peut pas le lire
  if (err) return;

  // Ok, on peut lire le fichier
  fs.readFile('file.txt', 'utf-8', (err, data) => {
    // Impossible de lire le fichier (format invalide ?)
    if (err) return console.error('Unable to read file', err);

    // Ok, on aimerait rajouter une chaîne au début de ce fichier
    const new_data = 'Fr: Bonjour le monde !\n' + data;

    fs.writeFile('file.txt', new_data, err => {
      if (err) return console.log('Unable to write file', err);
      
      console.log('File written successfully!');
    });
  });
});

Vous voyez dès cet exemple les limites de ce système : À chaque nouvelle action, on doit rentrer dans un niveau supplémentaire. Pire : la fonction de rappel initiale contient tout le code des deux prochains appels asynchrones !

On peut rendre ça un peu plus joli, mais ça implique de créer des fonctions nommées.

function atFileAccess(err) {
  // Le fichier n'existe pas ! On ne peut pas le lire
  if (err) return;

  // Ok, on peut lire le fichier
  fs.readFile('file.txt', 'utf-8', atFileRead);
}

function atFileRead(err, data) {
  // Impossible de lire le fichier (format invalide ?)
  if (err) return console.error('Unable to read file', err);

  // Ok, on aimerait rajouter une chaîne au début de ce fichier
  const new_data = 'Fr: Bonjour le monde !\n' + data;

  fs.writeFile('file.txt', new_data, atFileWrite);
}

function atFileWrite(err) {
  if (err) return console.log('Unable to write file', err);
      
  console.log('File written successfully!');
}

// On lance l'appel
fs.access('file.txt', fs.constants.F_OK, atFileAccess);

On a gagné en lisibilité au niveau de l’indentation, mais le code est maintenant découpé en plein de fragments distincts, qui sont en plus spécifiques les uns les autres… On a créé des fonctions nommées à usage unique, dont les erreurs sont en plus difficiles à remonter : si une erreur arrive dans une fonction asynchrone, on aura juste Node qui va recevoir une "unhandled error". Pas génial, au final…

Pour les petits malins : oui, avec fs, il est possible d’éviter ce code en utilisant les méthodes synchrones d’accès aux fichiers, mais ce n’est pas le but ici ! Node n’est pas pensé pour faire du synchrone, vous ne devez jamais bloquer le thread principal, et c’est précisément ce que font les méthodes sync de fs.

Ce mode de gestion est vraiment très commun. En fait, la plupart des APIs de Node avant 2015 utilisent ce format. Niveau navigateur, on est plus resté sur les évènements, qui ne sont pas forcément meilleurs. Voyons ça.

La gestion par l’évènementiel

Dès l’origine de JavaScript, on utilisait des évènements pour traiter les actions de l’utilisateur : C’était même la principale utilisation du langage. Quand l’utilisateur clique, alors exécute ça, quand l’utilisateur soumet un formulaire fais ceci, etc…

Pour un clic, ça se passe comme ça :

const element = document.getElementById('el');

// La fonction s'exécutant au clic
element.onclick = function () {
  // this vaut l'élément cliqué
  console.log('clicked!', this);
};

// On peut également l'écrire de cette manière
element.addEventListener('click', function () {
  // this vaut toujours l'élément cliqué
  console.log('clicked!', this);
});

Pour les évènements du DOM, "l’empilement" des appels asynchrones est rare, il est peu commun d’ajouter un nouveau listener à l’intérieur d’un autre. De plus, il ne peut pas y avoir "d’erreur" associés aux listeners, en général, c’est plutôt lié à une requête, à la fenêtre… On utilise l’évènement window.onerror pour les capturer, par exemple.

Cependant, une autre API, presque aussi vieille que JavaScript, utilise aussi les évènements pour arriver à ses fins : XMLHttpRequest.

const req = new XMLHttpRequest();

req.open('GET', '/file.txt', true);

req.addEventListener('progress', evt => {
  if (evt.lengthComputable) {
    const percent = evt.loaded / evt.total;
    console.log('Requête terminée à', percent, 'pourcent');
  }
});
req.addEventListener('load', () => {
  console.log('La requête a réussi. Contenu :', req.responseText);
});
req.addEventListener('error', err => {
  console.error('La requête a échoué.', err);
});
req.addEventListener('abort', () => {
  console.log("L'utilisateur a annulé la requête.");
});

req.send();

Ici, on voit bien que la philosophie est différente : On affecte une fonction à une action spécifique. On peut choisir d’ailleurs d’ignorer certaines actions : progress et abort par exemple, sont pas très intéressants, en général on veut juste savoir quand une requête est terminée (pour son résultat), ou au contraire quand elle a échouée (pour avertir l’utilisateur).

La gestion de l’erreur est bien séparée de la gestion du résultat, ce qui est plus joli et pratique. Cependant, on n’élimine pas le problème du "callback hell" : si on a d’autres actions asynchrones à réaliser qui dépendent d’autres (par exemple : une autre requête qui dépend de la précédente), alors on va devoir refaire des listeners, dans un niveau d’indentation supplémentaire…

Les évènements sont plus puissants, mais aussi plus longs à écrire. Ils sont généralement utilisés quand l’action n’est pas binaire (cela dépasse l’action de réussir ou non), quand on doit communiquer des informations pendant l’action (une progression de la requête), ou encore quand on souhaite diffuser cette action (plusieurs bouts de code peuvent s’abonner au clic d’un seul élément).

Le problème de l’agrégation

Ces deux méthodes présentent un autre énorme problème : il n’y a de moyen simple d’agréger des actions asynchrones. Il est difficilement possible de lancer plusieurs actions, puis d’attendre simplement qu’elles soient toutes terminées pour faire quelque chose.

Imaginons que nous souhaitons lire quatre images depuis le disque en Node.js. La manière la plus simple est d’enchaîner les callbacks :

fs.readFile('img1.jpg', (err, img1) => {
  if (err) throw err;

  fs.readFile('img2.jpg', (err, img2) => {
    if (err) throw err;

    fs.readFile('img3.jpg', (err, img3) => {
      if (err) throw err;

      fs.readFile('img4.jpg', (err, img4) => {
        if (err) throw err;

        // Les variables img1, img2, img3 et img4 sont prêtes ici
        // Faire quelque chose avec les images...
      });
    });
  });
});

Bon déjà : c’est très moche. Enchaîner quatre niveaux d’indentation juste pour lire quatre images, c’est pas top. Deuxième problème : pour lire l’image 2, on attend que l’image 1 soit lue, pour l’image 3, l’image 1 et 2, et ainsi de suite. Niveau performance, c’est pas optimal : les accès I/O peuvent très bien être concurrents.

Le plus simple pour résoudre ce problème, c’est d’utiliser un système d’écouteurs d’évènements qui attend que toute la lecture soit terminée.

const { EventEmitter } = require('events');

function readFiles(...files) {
  function onFileRead(err, data) {
    // this vaudra un entier ici
    const i = this;

    if (err) {
      emitter.emit('error', err, files[i]);
      return;
    }

    results[i] = data;
    emitter.emit('progress', data, files[i]);
    remaining--;

    if (!remaining) {
      // Si tous les items du tableau sont définis
      emitter.emit('load', results);
    }
  }

  const total = files.length;
  const emitter = new EventEmitter;
  let remaining = total;

  const results = Array(total);

  for (let i = 0; i < total; i++) {
    // On lit de manière concurrentielle tous les fichiers
    // La fonction onFileRead sera appelée avec this === i
    fs.readFile(files[i], onFileRead.bind(i));
  }

  return emitter;
}

const file_reader = readFiles(
  'img1.jpg', 
  'img2.jpg', 
  'img3.jpg', 
  'img4.jpg'
);

file_reader.on('load', files => {
  // Tous les fichiers ont été chargés
  console.log(files);
});

file_reader.on('error', (err, filename) => {
  console.log('Le chargement du fichier', filename, 'a échoué :', err);
});

Vous voyez à quel point c’est l’enfer ? Et encore, on a accès à EventEmitter ici, imaginez sur navigateur sans cette API…

La promesse de la fin de l’enfer

Au début des années 2010, la communauté autour de JavaScript s’est mise d’accord sur un format pour résoudre cet enfer des callbacks, cet empilement d’appels asynchrones dépendant les uns des autres.

Cette "mise en commun" s’est traduite par une standardisation dans JavaScript ES6 : La Promise. Cet objet est véritablement au centre du JavaScript moderne, vous le verrez absolument partout.

Une "Promise" est une entité dont la résolution est asynchrone (on ne peut pas prévoir quand elle se termine) et incertaine (il se peut qu’elle échoue). Dit comme ça, ça se rapproche énormément du système de "callbacks" de Node, que l’on a vu plus haut. Et c’est vrai !

Une Promise est un objet disposant de trois méthodes :

  • .then(callback) qui attache un callback automatiquement exécuté quand la promesse réussit (on dit qu’elle est résolue)
  • .catch(callback) qui attache un callback exécuté si la promesse échoue (on dit qu’elle est rejetée)
  • .finally(callback) qui attache un callback s’exécutant quoi qu’il arrive lors que la promesse est résolue ou rejetée

Elle contiendra toujours une seule valeur (de n’importe quel type), qu’elle soit résolue ou rejetée. Cette valeur est accessible en tant que premier paramètre des callbacks via .then (si la promesse est résolue) ou .catch (si la promesse est rejetée).

Le grand avantage est que ces méthodes, .then, .catch et .finally renvoient toutes elles-même des Promise : il est donc possible de les chaîner. D’ailleurs, si le callback exécuté par ses méthodes renvoie une Promise, alors elle sera utilisée pour la chaîne. Cela implique que le reste de la chaîne attendra la nouvelle promesse renvoyée avant de s’exécuter. On verra ça juste après pour clarifier.

Voyons les bases ensemble :

// getFile est une fonction qui renvoie une promesse
const data_promise = getFile('file.txt');

data_promise
  .then(data => {
    // {data} est le résultat de la promesse {data_promise} (sa valeur)
    // si elle est réussie
    console.log(data);
  })
  .catch(err => {
    // Cette fonction est exécutée si {data_promise} échoue
    // ou si la fonction du .then() renvoie une promesse rejetée
    // {err} est le contenu du rejet (une exception, par exemple)
  });

On observe bien que erreur et réussite sont séparées ! Les promesses ont au moins le mérite de permettre ça.

Maintenant, ça reste finalement encore un callback (il est juste pas passé au même endroit), alors comment les promesses nous sauvent-elles de l’enfer des callbacks ? Je l’ai déjà évoqué précédemment : Si un des éléments de la chaîne renvoie une Promise, alors elle est attendue avant que la chaîne continue.

const data_promise = getFile('file.txt');

data_promise
  .then(data => {
    // {data} est le résultat de la promesse {data_promise}
    // si elle est réussie

    // On obtient une autre promesse
    const other_promise = getFile(data);
    
    // On la retourne : elle sera attendue avant que la chaîne continue
    return other_promise;
  })
  .then(other_file_data => {
    // {other_file_data} est le contenu de la promesse obtenue dans le .then() plus haut

    // Si on renvoie une valeur "classique", alors elle sera traitée
    // comme "enveloppée" dans une promesse résolue
    return 3;

    // comme écrire
    // Promise.resolve crée une promesse déjà résolue
    return Promise.resolve(3);
  })
  .then(count => {
    console.log(count); // 3
  })
  .catch(err => {
    // Cette fonction est exécutée si {data_promise} échoue
    // ou si la fonction d'un des .then() renvoie une promesse rejetée
  });

Si un de nos trois .then() échoue, c’est à dire si il renvoie une promesse rejetée ou même si un item lance une exception, alors le callback .catch le plus proche dans la chaîne (en bas, évidemment) est exécuté avec le contenu du rejet / de l’exeception (en réalité, une exception lancée dans un listener de Promise est wrappée dans une promesse rejetée automatiquement) !

Vous avez tout compris comment ça marche ? Vraiment ?

Récapitulons.

Lorsque vous disposez d’une promesse, vous avez accès à trois méthodes, .then, .catch et .finally. Ces trois méthodes, qui prennent une fonction de rappel, renvoient elles-même une Promise.

Leur comportement est le suivant :

  • .then s’exécute si la promesse sur laquelle elle est attachée se résoud.
    • Si la promesse sur laquelle elle est attachée est rejetée, alors .then renvoie une promesse elle-même rejetée avec le même contenu.
  • .catch s’exécute si la promesse sur laquelle elle est attachée est rejetée.
    • Si la promesse sur laquelle elle est attachée est résolue, alors .catch renvoie une promesse elle-même résolue avec le même contenu.
  • .finally s’exécute quoiqu’il arrive, que la promesse sur laquelle elle est attachée soit résolue ou rejetée.

Le comportement lié aux fonctions de rappel qu’elles prennent en paramètre est le même pour les trois fonctions :

  • Si la fonction de rappel renvoie une promesse, le listener renvoie cette promesse.
  • Si la fonction de rappel renvoie un autre type de valeur, le listener enveloppe cette valeur dans une promesse résolue, et la renvoie.
  • Si la fonction de rappel lance une exception, le listener emballe l’objet lancé dans une promesse rejetée, et la renvoie.

On a quand même oublié le plus important ! Les Promise sont asynchrones. Totalement asynchrones. Les listeners s’exécutent quand la promesse est réussie/échouée, il n’y a pas "d’attente" du point de vue de la lecture des lignes de code.

console.log('Avant la promesse');

ma_promesse.then(() => console.log('résolue !'));

console.log('Après la promesse');

affichera sans doute :

> Avant la promesse
> Après la promesse
> résolue !

Créer notre propre promesse

C’est bien, on a vu comment se servir d’une promesse que l’on obtient de l’extérieur (ce qui est le plus commun), mais comment on peut en créer une ?

L’objet Promise dispose également d’un constructeur permettant de wrapper notre propre code asynchrone en promesse. Plutôt que présenter un bête code avec setTimeout, reprenons la magistrale API fs de Node.

const fs = require('fs');

function getFile(name) {
  // Le constructeur prend en paramètre une fonction avec deux arguments
  // resolve (pour résoudre la promesse) et reject (pour la rejeter)
  return new Promise((resolve, reject) => {
    fs.access(name, fs.constants.F_OK, err => {
      if (err) {
        // Si il y a erreur, on rejette la promesse avec l'erreur
        reject(err);
        return;
      }

      fs.readFile(name, 'utf-8', (err, data) => {
        // Même chose: erreur => rejet de la promesse
        if (err) return reject(err);

        // On a notre fichier : on peut résoudre la promesse !
        resolve(data);
      });
    });
  });
}

const file = getFile('file.txt');

file.then(data => {
  // La valeur {data} correspond à la valeur passée 
  // à la ligne "resolve(data)" de getFile()
  console.log('File data:', data);
});

Vous voyez, rien de sorcier, non ? Appelez juste resolve et reject quand c’est nécessaire, même nesté dans plusieurs callbacks asynchrones 🙂

On pourrait même découper .access et .readFile dans leur promesse respective pour utiliser une API 100% Promise… et devinez quoi, ça existe déjà.

// L'attribut fs.promises contient toutes les fonctions
// async de fs wrappés dans des promesess
const { promises: FileSystem, constants: FsConstants } = require('fs');

const name = 'file.txt';

const file = FileSystem
  .access(name, FsConstants.F_OK)
  // .readFile renvoie une promesse, elle sera utilisée par le .then
  .then(() => FileSystem.readFile(name, 'utf-8'));

file
  .then(data => {
    // Le chaînage de promesse a réussi
    console.log('File data:', data);
  })
  .catch(err => {
    // .access ou .readFile ont émit une promesse rejetée
    console.log('Impossible de lire le fichier:', err);
  });

Le chaînage de promesse est la base de l’utilisation de celles-ci : Apprenez le au plus vite !

Il y a cependant une manière plus conventionnelle de créer ses propres promesses, lorsque vous utilisez du code 100% Promise-based : les fonctions asynchrones, que l’on verra juste après.

Agréger des promesses

Véritable force des promesses, cette fonctionnalité est faite pour corriger le problème inhérent aux callbacks et autres évènements vus plus haut.

Il est possible de manière très simple de dire : "Crée une promesse qui se résoud lorsqu’un ensemble de promesse est résolu. Si une promesse échoue, alors cette nouvelle échouera aussi."

Cette phrase, c’est Promise.all, une méthode statique sur le constructeur Promise. Étant donné un tableau de promesse passé en paramètre, il les agrège et crée une promesse qui se résoudra quand elles le seront toutes.

La valeur de la promesse résolue sera un tableau contenant les différentes valeurs des promesses données en entrées.

const promise1 = Promise.resolve(4);
const promise2 = Promise.resolve(18);
const promise3 = Promise.resolve(36);

Promise.all([promise1, promise2, promise3]).then(results => {
  console.log(results); // [4, 18, 36]
});


// Si une des promesses échoue, tout échoue.
const promise4 = Promise.reject('The reason of reject');

Promise.all([promise1, promise2, promise3, promise4])
  .then(results => {
    // Jamais exécuté : Une promesse a échoué
    console.log(results); // [4, 18, 36]
  })
  .catch(error => {
    // Sera exécuté
    console.log(error); // 'The reason of reject'
  });

Est-ce que vous vous rendez compte que notre problème d’agrégation est résolu ? Reprenons la fonction readFiles() vue plus haut, qui fonctionnait avec les EventEmitter. Faisons-là façon Promise.

const { promises: FileSystem } = require('fs');

function readFiles(...files) {
  const results = [];

  for (const file of files) {
    results.push(FileSystem.readFile(file));
  }

  return Promise.all(results);
}

const file_reader = readFiles(
  'img1.jpg', 
  'img2.jpg', 
  'img3.jpg', 
  'img4.jpg'
);

file_reader.then(files => {
  // Tous les fichiers ont été chargés
  console.log(files);
});

file_reader.catch(error => {
  console.log(error); // Ex: Error: NOENT...
});

Vous voyez ? La fonction readFiles prend désormais 5 lignes, et tous les accès sont gérés de manière concurrentiels !

Prenez du temps pour relire tout ce qu’on a abordé depuis le début de l’asynchronicité. C’est une notion complexe, mais essentielle pour comprendre JavaScript.

Vous en aurez besoin pour pleinement comprendre la partie suivante sans ambiguité.

Les fonctions asynchrones

On l’a vu, le chaînage de promesse est une manière d’éviter le "callback hell". On est cependant obligé de conserver une approche "fonctionnelle", avec l’utilisation des méthodes de Promise qui prennent des callbacks.

En 2017, une nouvelle fonctionnalité est apparue en JavaScript, déjà présente ailleurs comme en C# ou Python. Elle est cependant, selon moi, bien plus simple à utiliser en JS, car le langage comporte déjà tout le système d’asynchronicité (la fameuse event loop) en son sein.

Cette nouveauté, vous l’avez lu dans le titre, ce sont les fonctions asynchrones. Elles permettent, contrairement au code "classique", de s’interrompre pour attendre le résultat d’une action asynchrone, le tout sans bloquer l’exécution d’autres fonctions, qui peuvent librement se lancer pendant la "pause" (écouteurs d’évènements, résultats d’autres Promise, lectures du système de fichiers…).

En fait, elles permettent l’écriture d’un code linéaire tout en intégrant de l’asynchronicité.

Une fonction asynchrone renvoie forcément une Promise (de manière implicite, le langage fait tout pour nous !), de ce fait on peut chaîner un appel d’une fonction asynchrone à du code utilisant .then, ou même dans un autre fonction asynchrone.

La déclaration d’une fonction asynchrone commence par le mot clé async.

async function myAsync() {
  // Cette fonction est asynchrone !
}

const result = myAsync();
result instanceof Promise; // true : La fonction retourne une Promise !

// On peut en écrire de manière anonyme, et avec des fonctions fléchées :
const hello = async function () {
  // fonction "anonyme" asynchrone
};

const hello2 = async () => {
  // fonction "anonyme" fléchée
};

Lorsque vous renvoyez quelque chose depuis une fonction asynchrone, la valeur de ce retour devient la valeur de la Promise automatiquement construite lors de l’appel de la fonction.

Si une exception est lancée dans la fonction, alors la Promise construite est rejetée et contient l’exception.

async function getNumber() {
  return 3;
}

async function myThrowable() {
  throw 'failed :(';
}

getNumber()
  .then(number => {
    console.log(number); // Affiche 3 dans la console
  });

myThrowable()
  .catch(exception => {
    console.log(exception); // 'failed :('
  });

Bon en voyant ça, vous pouvez vous dire :

Mais enfin, à quoi ça sert là ? Ces fonctions pourraient très bien être synchrones !

Et vous avez raison ! L’intérêt des fonctions async est l’utilisation du mot clé await. Ce mot clé est valide uniquement dans les fonctions asynchrones, il ne fonctionne pas en dehors de celles-ci.

Il permet d’attendre la résolution d’une promesse et d’en extraire son résultat, si elle se résoud. Si la promesse est rejetée, await lance une exception avec comme valeur celle de la promesse rejetée.

Reprenons notre exemple avec l’obtention d’un fichier :

async function showFile(name) {
  // Lance getFile, attend sa résolution et stocke le résultat dans {data}
  const data = await getFile(name);

  console.log('Contenu du fichier :', data);
}

showFile('file.txt');

C’est aussi simple que cela ! Si la promesse de getFile est rejetée, alors await lance une exception qui, si elle n’est pas capturée au sein de la fonction, rejette la promesse de showFile.

// Si jamais showFile ou les promesses attendues à l'intérieur
// échouent, elles seront capturées ici !
showFile('file.txt')
  .catch(() => {
    console.log('La lecture du fichier a échoué !');
  });

Vous avez devant vous ce qui permet de faire de manière linéaire des enchaînements d’actions asynchrones !

Un autre exemple ? Imaginons notre script qui lisait 4 images, modifie un pixel à l’intérieur puis les ré-écrit sur le disque. Ensuite, il retourne le poids des images.

async function modifyImages() {
  const images = ['img1.jpg', 'img2.jpg', 'img3.jpg', 'img4.jpg'];

  const buffers = await Promise.all(images.map(img => FileSystem.readFile(img)));

  for (const buffer of buffers) {
    // Chaque image est un Buffer, un UInt8Array (on en parlera)
    // Change le contenu de l'octet 127 de l'image
    buffer[127] = 255;
  }

  // Sauvegarde les images sur le disque
  const write_promises = [];

  for (let i = 0; i < images.length; i++) {
    write_promises.push(FileSystem.writeFile(images[i], buffers[i]));
  }

  await Promise.all(write_promises);

  return buffers.map(buf => buf.length);
}

modifyImages()
  .then(lengths => {
    console.log('Taille des images :', lengths);
  })
  .catch(err => {
    console.log('La modification des images a échouée :', err);
  });

La fin

Et… ça fait déjà pas mal non ? Absorbez bien cette partie sur l’asynchronicité, relisez si nécessaire, parce que la prochaine fois, on parlera de fetch, le remplaçant de XMLHttpRequest, qui fonctionne totalement à l’aide de Promise !

Si il ne fait techniquement pas partie du langage, j’ai décidé de faire une exception, car il est présent sur navigateur et sur Deno. De plus, il est commun sur Node.js d’utiliser des bibliothèques qui émulent son fonctionnement. À la prochaine !

Article précédent : Ajouts syntaxiquesArticle suivant : fetch

Laisser un commentaire