L’asynchrone en JavaScript

Début décembre s’est tenue la conférence dotJS à Aubervilliers et l’un des speakers, Wes Bos, connu notamment pour ses cours JavaScript en ligne, a présenté deux nouveaux mots-clés : async et await.
Ceux d’entre vous qui suivent l’évolution de JavaScript en ont certainement déjà entendu parler, il s’agit de la dernière nouveauté concernant la programmation asynchrone en JavaScript. Apparus avec ES7 ou ECMAScript version 2016, ils se basent sur un concept : les Promesses.
Mais pour bien saisir leur fonctionnement, il me paraît intéressant de revenir un peu en arrière et de voir l’évolution de la programmation asynchrone en JavaScript.

Commençons par le commencement : la programmation asynchrone c’est quoi ?

Imaginons un homme, que nous appellerons Georges, qui souhaite se préparer un café.
Georges, dont le nom a bien entendu été choisi au hasard, va prendre une tasse, la mettre sous la machine à Nesp… à café et lancer la machine. Seulement voilà, Georges est synchrone, il effectue les actions de manière séquentielle. Il va donc attendre devant la machine que le café soit prêt car il est incapable de faire plusieurs choses en même temps (il parait que c’est normal pour les hommes…)
Pour résoudre ce problème, la plupart des langages ont opté pour une approche concurrentielle (multithreading) et vont donc clooney cloner Georges. Chacun des Georges ne peut toujours faire qu’une seule chose à la fois, mais il y a plein de Georges donc ils peuvent travailler en parallèle.
Maintenant si Georges était asynchrone, il pourrait gérer plusieurs choses en même temps. Au lieu d’attendre devant sa machine à café, il pourrait par exemple lancer son café, préparer son petit déjeuner et revenir à son café une fois qu’il est prêt. C’est cette approche qui est utilisée par JavaScript.

Grâce au code asynchrone, on lance un traitement, on continu d’exécuter notre programme puis une fois que le traitement est terminé on revient dessus et on peut exploiter les résultats obtenus.
Pour mettre en pratique ce principe, on utilise différentes techniques qui ont évolué au fil du temps et des nouvelles spécifications du langage. Petit retour en arrière dans l’histoire de JavaScript…

 

Les callbacks

La 1ère manière, et certainement la plus connue, de faire de l’asynchrone en JavaScript utilise ce qu’on appelle un callback.
Vous en avez même peut être utilisé sans le savoir, exemple dans l’API Google map :

<script async defer src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&callback=initMap"></script>

initMap est une fonction de callback qui ne sera exécutée qu’une fois que le script aura été chargé.

Un callback est tout simplement une fonction qu’on va passer en paramètre à une autre fonction.
Pour prendre un exemple simple :

function addition(nombre1, nombre2, callback) {
    const somme = nombre1 + nombre2;
    callback(result);
}

addition(1, 2, function(result) {
    console.log(result);
});

Ici j’utilise comme callback une fonction anonyme qui va simplement tracer dans la console ce qu’on lui passe en paramètre.
La fonction addition va appeler le callback une fois qu’elle a effectué son traitement. Dans cet exemple très simple, il s’agit seulement d’une addition mais le principe est le même si on veut effectuer un traitement complexe ou une requête HTTP au lieu d’une simple somme.
On placera donc dans le callback toutes les instructions qui dépendent des données obtenues.

Mais il y a un « mais »…

Cette approche fonctionne bien quand on a un code relativement simple ou plutôt un code qui n’a pas besoin de récupérer de multiples données venant de multiples requêtes. Mais que se passe-t-il quand on a une fonction qui a besoin des données renvoyées par une autre fonction qui elle-même a besoin des données d’une autre fonction qui… bref vous voyez le principe.

function readPost(req, res) {
    post.findById(req.params.id, function (err, post) {
        if (err) {
            return handleError(err);
        }

        post.findRecentComments(function (err, comments) {
            if (err) {
                return handleError(err);
            }

            res.render('posts/show', {
                post: post,
                comments: comments,
                title: post.title
            });
        });
    });
}

Ici on veut récupérer un post sur un forum par exemple. On commence par retrouver le post grâce à son id, on récupère tous les commentaires associés et on affiche le tout. Seulement voilà… c’est moche. Le code n’est pas lisible et son déroulement pas intuitif.
On entre ici dans le « Callback Hell » ou « Pyramide of Doom » (non non ce n’est pas le titre du prochain Indiana Jones), comprendre du callback dans du callback dans du callback… et c’est un cauchemar à debugger.

Les callbacks posent donc un certain nombre de problèmes :

  • Code difficile à comprendre
  • La gymnastique mentale pour transcrire son algo en code est souvent compliqué
  • La gestion des erreurs est une vraie plaie. Erreur au 257e callback imbriqué qu’il faut propager…
  • Ils ne sont pas composables (je reviendrais sur ce point dans la partie sur les promesses)
  • Ils ne sont pas fiables. Il peut arriver que le callback soit appelé deux fois ou encore qu’à la fois le callback de succès et d’échec soient invoqués de manière inexplicable. Bien entendu c’est très rare mais assez pénible.

Heureusement, les promesses arrivent à la rescousse.

 

ES6 et les promesses

Après de longues années d’attente, ES6 est enfin arrivé en 2015 et il a apporté un grand nombre d’améliorations au langage. L’une d’elles est donc les promesses ou plutôt la prise en charge native des promesses car en réalité, le concept de promesse est beaucoup plus ancien. Il apparait pour la 1ère fois en 2007 dans la bibliothèque Dojo. JQuery également utilise un objet qXHR se rapprochant du concept de promesse. AngularJS a aussi une implémentation.

Mais c’est quoi une promesse ?

Une promesse est un objet qui représente une valeur qui n’est pas forcément disponible maintenant, mais qui le sera dans le futur (si tout va bien) ou pas (si l’exécution échoue).
Un exemple vaut mieux qu’un long discours donc voici à quoi ressemble une promesse :

var promise = new Promise(function (resolve, reject) {
    // Ici je fais mon traitement, mes appels http…

    if (/* tout a fonctionné */) {
        resolve("Tout est OK!");
    }
    else {
        reject(Error("Hmm c'est embêtant…"));
    }
});

Le constructeur de la promesse prend en argument un callback avec deux paramètres : resolve et reject qui seront appelés respectivement si le traitement réussi ou échoue.
Une promesse peut avoir 3 états :

  • Resolved : tout s’est bien déroulé => on appelle resolve.
  • Rejected : une erreur est survenue => on appelle reject
  • Pending : si elle n’est ni resolved ni rejected (elle est en attente du résultat)

Et voici comment on utilise cette promesse :

promise.then(function (result) {
    console.log(result); // "Tout est OK!"
}, function (err) {
    console.log(err); // Error: "Hmm c'est embêtant…"
});

Vous vous souvenez du dernier point sur les callbacks qui ne sont pas composables ? Eh bien les promesses, elles, elles le sont, ce qui veut dire que l’on peut chainer plusieurs promesses avec le mot-clé « then ». De plus, au lieu de fournir un callback en cas d’erreur comme dans l’exemple ci-dessus, il est possible d’ajouter un catch en fin de chaine et de gérer toutes les erreurs dans ce catch. Ainsi notre exemple de récupération de post devient :

function readPost(req, res) {
    post.findById(req.params.id)
        .then(post => {
            post.findRecentComments();
        })
        .then(result => {
            res.render('posts/show', {
                post: result.post,
                comments: result.comments,
                title: result.post.title
            });
        })
        .catch(error => {
            handleError(error);
        });
}

(* il est bien sûr sous-entendu, pour pouvoir écrire ce code, que les fonctions findById et finRecentComment évoluent également : elles n’attendent plus de callback en argument et doivent retourner une promesse)

Fini l’effet pyramidal, on obtient un code « aplati » et donc plus lisible.
Aujourd’hui les promesses sont massivement utilisées par des APIs web modernes comme fetch, ServiceWorker,… Le package axios également, qui permet de faire des appels HTTP et qui est très souvent associé à React, est basé sur les promesses.

Les avantages des promesses :

  • La gestion des erreurs est beaucoup plus simple. Les erreurs sont automatiquement propagées et un catch en fin de chaîne permet de les gérer
  • Elles sont composables (à condition bien sûr que le callback appelé par une promesse retourne une promesse que l’on peut insérer dans la chaîne)
  • Le code est plus facilement compréhensible
  • Leur fonctionnement est fiable. Un seul callback appelé une seule fois… garanti ou remboursé
  • Elles garantissent que le callback sera appelé. On peut attacher un listener à une promesse déjà completed (resolved ou rejected) et le listener sera quand même déclenché. A l’inverse, si on attache un callback en listener d’un événement, si l’événement a déjà eu lieu le callback ne sera jamais appelé.

A noter que les promesses génèrent une légère baisse de performance par rapport au callback même si cet écart s’est réduit depuis que les promesses sont prises en charge nativement par JavaScript.
Toutefois ce n’est pas encore parfait, le code reste très différent du code synchrone et le flux d’exécution n’est pas très intuitif.

 

async/await

Ou comment écrire du code asynchrone comme on écrit du synchrone.

C’est avec la version ES7 ou ES2016 d’ECMAScript que sont apparus deux nouveaux mots-clés : async et await.
Toute ressemblance avec un certain langage de Microsoft est bien entendu totalement fortuite…
async est un mot clé qui permet de définir une fonction comme étant asynchrone
await lui ne peut s’utiliser qu’à l’intérieur d’une fonction async. Il va avoir pour effet de « mettre le code en pause » en attendant la résolution d’une promesse

async function myAsyncFunction() {
    try {
        const result = await myPromise;
        // Ici "myPromise " est résolue
    } catch (err) {
        // Ici "myPromise " est rejetée
    }

    return 42; // cette ligne n'est atteinte qu'après résolution/rejet
}

L’exécution du code se déroule exactement comme du code classique. L’instruction return ne sera atteinte qu’une fois que le result aura été obtenu.
Tout l’intérêt de ces nouveaux mots-clés est là : ils permettent à du code asynchrone de ressembler et de se comporter de manière semblable à du code synchrone.
Il ne faut cependant pas oublier de gérer les erreurs éventuelles renvoyées par la promesse car result n’est ici plus un objet Promise mais bien une simple variable représentant le résultat obtenu.
Pour cela il y a plusieurs possibilités :

  • On peut utiliser un bloc try/catch comme dans l’exemple du dessus.
  • On peut définir une HOF (Higher Order Function) de la manière suivante :
function handleError(fn) {
    return function(...params) {
        return fn(...params).catch(function(error) {
            console.error('Oops', error);
        });
    }
}

En l’exécutant avec ma fonction en paramètre j’obtiens une nouvelle fonction qui prendra en charge la gestion des erreurs.

const safeAsyncFunction = handleError(myAsyncFunction);
safeAsyncFunction();

On a donc une gestion des erreurs plus efficace. Avec une promesse, si on a à la fois du code synchrone et du code asynchrone,  il faudrait implémenter la gestion des erreurs dans le .catch() de la promesse mais il faudrait également un bloc try/catch pour gérer les erreurs de la partie synchrone du code. Avec async/await, plus besoin de dupliquer cette gestion des erreurs, un seul bloc try/catch ou une HOF suffit.

Récupération du post

On peut utiliser ces nouveaux mots-clés pour refactoriser notre exemple.
J’ai choisi d’utiliser l’approche try/catch pour la gestion d’erreurs car c’est plus simple mais une HOF reste tout à fait valable.

async function readPost(req, res) {
    try {
        const post = await post.findById(req.params.id);
        const comments = await post.findRecentComments();
        res.render('posts/show', {
            post: post,
            comments: comments,
            title: post.title
        });
    } catch (err) {
        handleError(err);
    }
}

Avec cette nouvelle syntaxe, le code est concis, propre et facilement compréhensible.

Traitements parallèles

Dans notre exemple, la 2nde fonction a besoin du résultat de la première. L’exécution est donc séquentielle mais il est tout à fait possible de réaliser des traitements parallèles :

const getDetails = async function() {
    const chuckPromise = axios.get('https://sf5.com/players/chucknorris');
    const jcvdPromise = axios.get('https://sf5.com/players/vandamme');
    const [chuck, jcvd] = await Promise.all([chuckPromise, jcvdPromise]);
    return {
        player1: chuck.name,
        player2: jcvd.name
    }
}

On utilise ici Promise.all() qui va renvoyer une promesse « composée » de deux ou plusieurs promesses et qui ne sera resolved ou rejected qu’une fois que toutes les promesses qui la composent le sont également.

Comme toutes les nouvelles fonctionnalités, l’utilisation de async/await génère quelques pertes au niveau performance mais honnêtement, la simplification qu’ils apportent vaut complètement le coup, d’autant que les performances vont s’améliorer avec le temps.

Conclusion

L’apparition des promesses puis de async/await a permis de grandement simplifier l’asynchrone en JavaScript, que ce soit au niveau de la fiabilité, de la gestion des erreurs ou du fonctionnement plus intuitif du code. Si la refactorisation d’un code basé sur les promesses en async/await est très simple, elle est par contre beaucoup plus compliquée pour du code basé sur les callbacks et il n’est donc pas pensable de modifier toutes les bibliothèques existantes. Toutefois, des outils comme Promisify peuvent nous venir en aide en nous permettant de convertir d’anciennes APIs, transformant les fonctions callback en promesses.
Les callbacks quant à eux, ne vont certainement pas disparaître car leur utilisation est bien plus vaste que l’asynchrone mais il ne fait aucun doute que l’évolution de l’asynchrone en JavaScript, elle, tend vers cette simplification apportée par async/await et les promesses. Aujourd’hui tous les nouveaux packages se basent a minima sur les promesses et je ne peux que vous encourager à les utiliser vous aussi dans vos développements.