Des promesses et beaucoup d’attente pour JavaScript

Récemment, je me suis lancé dans un projet quasi entièrement codé en JavaScript, plus précisément une application Electron, demandant de faire beaucoup de requêtes externes (à la base le projet était un site web, mais les navigateurs n’aime pas appeler des serveurs externes…).

Libre de travailler avec les technologies ECMAScript (le nom formel de JS) les plus récentes supportées par Electron/Chromium, j’ai approfondi ma connaissance des Promise que j’avais effleuré dans d’autres petits scripts.
Autrefois, ma seule promesse à tenir avec JS était de rompre le plus vite avec lui, mais les Promise ont quelque peu « amélioré » mon estime de ce langage que je porte peu dans mon coeur.

Alors, qu’est-ce que c’est une Promise ? Comment que ça marche enfin ?

Le concept du truc, c’est de permettre très facilement l’exécution de code asynchrone, de pouvoir concevoir le sien, et faire très rapidement des fonctions de callback une fois la tâche terminée / échouée. En prime, les normes les plus récentes de ECMAScript rajoutent une couche supplémentaire : les mots-clés async et await qui vont permettre d’explicitement attendre la résolution (ou l’échec) d’une Promise, les rapprochant du mécanisme des threads.

Application simple

Commençons avec un exemple tout simple, qui ne demande presque pas de connaissance en JavaScript. On va tout simplement créer une Promise qui attend un temps aléatoire, et qui renvoie une bête chaîne de caractères.
Quand elle est terminée, on affiche son contenu dans la console. On gère aussi le cas où la promesse échouerait.

var promise = new Promise(function (resolve, reject) {
    setTimeout(
        function() {
          resolve('Cette promesse a été résolue !');
        }, 
        Math.random() * 3000 + 500
        // Math.random() tire entre 0 et 1, on a donc un attente entre 500 et 3500 millisecondes
    );
});
// La promesse est crée, et en attente

promise.then(function (value) {
    // Cette fonction s'exécute si la promesse est résolue (utilisation de resolve()).
    // value contient la variable passée en paramètre de resolve.

    console.log(value); // Affiche "Cette promesse a été résolue !"
});

promise.catch(function (value) {
    // Cette fonction s'exécute si la promesse est rompue OU 
    // si la promesse rencontre une Exception lancée de façon synchrone 
    // (les exceptions lancées dans des fonctions de rappel ne sont pas attrapées).

    console.log('Cette fonction ne s\'exécutera jamais dans notre cas.');
});

Comme vu ici, une promesse est crée avec une fonction de rappel, prenant deux arguments (resolve, une fonction à appeler pour valider la promesse, et reject, pour la rejeter). Un appel à resolve ou reject se fait avec un argument de n’importe quel type (scalaire, chaîne, objet, tableau) qui sera passé en tant que « value » dans le .then (si resolve) ou le .catch (si reject).
En plus, il est possible d’utiliser .finally qui s’exécute quoiqu’il arrive, réussie ou non, après le .then et le .catch.

On enchaîne

L’énorme avantage des Promise, c’est qu’elles sont chaînables. Une fonction asynchrone (on verra ça juste après) ou une fonction de rappel de Promise (.then, .catch, .finally) renvoient aussi des Promise ! C’est à dire qu’il est possible d’écrire un .then qui suit un .then, au cas où l’instruction du .then soit elle-même asynchrone.
C’est donc autorisé d’écrire :

promise.then(function (value) {
    // Cette fonction s'exécute si la promesse est résolue (utilisation de resolve()).
    // value contient la variable passée en paramètre de resolve.

    console.log(value); // Affiche "Cette promesse a été résolue !"
}).catch(function (error) {
    // error contient une possible Exception attrapée du .then()
}).finally(function () {
    // Code à exécuter quand tout se termine, quoiqu'il arrive
});

Et tu deviendras-synchrone

Tout ceci est bien sympa, mais imaginez que vous avez une variable que vous initialisez, remplissez et que la Promise est censée modifier. Plus tard dans votre script, vous devez utiliser ces données modifiées.
Deux possibilités : À l’ancienne en JavaScript, vous écrivez l’entièreté du code utilisant cette variable dans la fonction de rappel .then(), et vous vous privez d’écrire dans la fonction maître.
Le problème, parfois, c’est que ça fait quand même beaucoup de fonctions dans des fonctions. JavaScript est assez ignoble avec ça (c’est une des raisons qui me font détester ce langage) et ça rend le code faiblement lisible.

La seconde possibilité, toute neuve : Vous lancez en parallèle, vous attendez, vous traitez. L’avantage, c’est qu’on peut continuer d’écrire le code dans la fonction qui lance les Promise, et qu’on décide aussi à quel moment on est censé attendre obligatoirement le résultat.

Pour pouvoir faire ça, on a besoin d’introduire les deux mots clés async et await. async s’utilise pour rendre la fonction que l’on est en train d’écrire asynchrone, c’est à dire qu’elle renverra elle-même… une Promise.
Oui je sais, ça fait beaucoup de promesses en même temps.

Voyons d’abord ça en premier, c’est quoi une fonction asynchrone pour JavaScript ?
En fait, lorsque vous appellerez cette fonction dans votre code, c’est comme si vous lanciez une Promise qui sera résolue automatiquement quand votre fonction arrivera sur un return. La valeur de la promesse sera la valeur retournée dans la fonction.

Alors COMMENT que ça S’ÉCRIT ??

async function sayHello() {
    // Renvoie immédiatement "Hello !"
    // En réalité, renvoie l'équivalent de Promise.resolve("Hello !")
    return 'Hello !';
}

let coucou = sayHello();
// coucou contient une Promise

coucou.then(function (value) {
    console.log(value); // Affiche "Hello !" dans la console
});

Rien d’incroyable mais un truc intéressant : On peut (doit) utiliser un .then() sur le retour de la fonction pour récupérer sa valeur. Si la fonction renvoie void (pour les non-adeptes du typage: rien), alors la Promise sera vide également.

Attends moi, j’arrive

Passons maintenant au plus important : await. Hé oui, c’est lui dont on veut parler ! Si je vous ai d’abord parlé de async, c’est parce qu’une fonction doit être asynchrone pour comporter le mot clé await en son sein.
L’utilisation est simple : Il s’utilise devant une expression contenant une Promise. Son existence précise que l’exécution de la fonction sera stoppée tant que la Promise ne sera pas rejetée ou tenue.
Voyons un premier exemple.

async function main() {
    var promise = new Promise(function (resolve, reject) {
        setTimeout(
            function() {
              resolve('Cette promesse a été résolue !');
            }, 
            Math.random() * 3000 + 500
            // Math.random() tire entre 0 et 1, on a donc un attente entre 500 et 3500 millisecondes
        );
    });

    promise.then(function (value) {
        // Cette fonction s'exécute si la promesse est résolue (utilisation de resolve()).
        // value contient la variable passée en paramètre de resolve.
    
        console.log(value); // Affiche "Cette promesse a été résolue !"
    });

    // Tout plein d'opérations compliquées...

    // Maintenant, on a besoin des données de promise.
    // On utilise le mot clé await, qui attendra que la Promise soit résolue,
    // Et renvoie la valeur de celle-ci ce qui permet son stockage dans
    // value_of_promise.
    var value_of_promise = await promise;

    console.log(value_of_promise);
    // Affiche "Cette promesse a été résolue !"
}

On reprend notre base de code avec la promesse en début de l’article, qui se résout après quelques secondes automatiquement.
Au moment d’arriver sur l’instruction await, l’interprèteur attendra forcément la résolution (ou échec) de la Promise. De plus, await renvoie la valeur de la promesse, on peut donc stocker son résultat dans une variable.

Thread, à dérouler

Attendre une promesse, c’est bien, mais imaginez que vous souhaiteriez faire plusieurs requêtes en même temps, dans des promesses. Ou alors faire plusieurs opérations de calcul en parallèle (alors, attention quand même, JavaScript semble [à confirmer] être soumis aux mêmes limitations que Python, il est exécuté sur un thread réel, c’est à dire que les exécutions -asynchrones- seront juste exécutées les unes à la suite des autres, mais de façon automatique, donc le gain de performance est quasi nul) ? C’est un peu le principe des threads, dans les autres langages de programmation.

Actuellement, vous savez chaîner les Promise ok, mais mieux, vous avez Promise.all() !

Promise.all(array<Promise>) renvoie lui-même une Promise résolue lorsque le tableau de promesses passé en paramètre contient que des résolues. Si une seule des Promise échoue, Promise.all arrête d’attendre les autres et renvoie une promesse rompue. Pour que Promise.all attende systématiquement toutes les promesses, il est possible de créer un .catch lorsque écrivez les promesses à mettre dans le tableau (voir juste en bas).
Si vous chaînez un .then() à Promise.all(), alors la valeur « value » de la fonction de rappel passée dans le .then() sera un tableau contenant toutes les valeurs de résolution des Promise lui étant passées.

C’est pas hyper clair raconté comme ça, voici un exemple :

let p = [];

for (let i = 0; i < 10; i++) {
    p.push(new Promise(function (resolve, reject) {
            setTimeout(
                function() {
                  resolve('Cette promesse a été résolue !');
                }, 
                Math.random() * 3000 + 500
                // Math.random() tire entre 0 et 1, on a donc un attente entre 500 et 3500 millisecondes
            );
        }).catch(error => error));
        // Le .catch permet de renvoyer une promesse tenue contenant l'erreur,
        // plutôt que laisser une rompue casser le Promise.all()
}

Promise.all(p).then(function (value) {
    // Cette fonction s'exécute si toutes les promesses de p sont résolues (utilisation de resolve()).
    // value contient un tableau de toutes les valeurs passées à resolve().

    for (let v of value) { // Pour chaque valeur dans value
        console.log(v); // Affiche "Cette promesse a été résolue !"
    }
});

Très bien. Vous avez compris maintenant (j’espère !) ?
Tout le concept ici est de pouvoir attendre que toutes les promesses se termine. Et comment on attend ? Mettons un await !
Imaginons que nous faisons des requêtes dans les Promise. Avec un await Promise.all(), on est sûr que l’on exécute le code suivant cette instruction après la fin de toutes les requêtes !

async function main() {
    let p = [];

    for (let i = 0; i < 10; i++) {
        p.push(new Promise(function (resolve, reject) {
            setTimeout(
                function() {
                  resolve('Cette promesse a été résolue !');
                }, 
                Math.random() * 3000 + 500
                // Math.random() tire entre 0 et 1, on a donc un attente entre 500 et 3500 millisecondes
            );
        }).catch(error => error));
        // Le .catch permet de renvoyer une promesse tenue contenant l'erreur,
        // plutôt que laisser une rompue casser le Promise.all()
    }

    let super_valeurs = await Promise.all(p);

    // super_valeurs est désormais un tableau contenant toutes les valeurs
    // de retour de nos promesses, on peut commencer à bosser !

    // Code de traitement...
}

Voilà le code, plutôt simple !
Vous êtes libre de laisser un .then() à votre Promise.all(), sachant qu’il renvoie lui-même une promesse, le await attendra la fin de l’exécution du .then().

await article.then(function (v) { return ‘la fin’ });

Et, c’est le moment que vous attendiez tous (mdr), la fin de cet article ! Je pense qu’on a fait le tour du sujet. L’article n’a évidemment pas pour but d’être exaustif, mais normalement vous savez désormais les grandes lignes.
À bientôt dans le monde de l’asynchrone !

Laisser un commentaire