Accueil > Web > Faites de l’asynchrone synchrone en Javascript !

Faites de l’asynchrone synchrone en Javascript !

Après plus de six ans sans améliorations au langage JavaScript, la nouvelle version EcmaScript 2015 a apporté un vent de fraîcheur pour le développeur. Mûrement réfléchie et résolument orientée vers la Developer Experience, elle offre une toute nouvelle gamme d’outils adaptés à des problématiques récurrentes de la vie du développeur.

Lors de Devoxx France 2016, j’ai présenté un live-coding se concentrant sur les nouveaux outils permettant une meilleure gestion de l’asynchrone – une composante essentielle du développement JavaScript. Cet article est une adaptation de cette présentation.

L’asynchrone via les callbacks

Traditionnellement, l’asynchrone a été géré au travers de fonctions de callback – des fonctions transmises à des fonctions asynchrones afin d’être appelées une fois la tâche asynchrone terminée. Ainsi, pour récupérer par exemple les informations d’un utilisateur sur le serveur et afficher son pseudonyme, on peut obtenir le code suivant :

getUserDataAsynchronously((errors, user) => {
  if (errors) {
    console.error('Something bad happened...');
  } else {
    console.log(`User's nickname ${user.nickname}.`);
  }
});

Cette méthode fonctionne parfaitement mais présente plusieurs désavantages. Le premier concerne les cas où on va devoir effectuer plusieurs appels à la suite, chacun dépendant du précédent. On se retrouve rapidement à devoir indenter exagérément son code. Complétons cette théorie avec un exemple qui sera plus parlant :

authenticateUser((err, userData) => {
  if (err) {
    console.error(err);
    return;
  }
  getUserInfos(userData.id, (err, userInfos) => {
    if (err) {
      console.error(err);
      return;
    }
    getUserFriends(userInfos.friendsIds, (err, friends) => {
      if (err) {
        console.error(err);
      } else {
        console.log('We have our friend\'s informations!');
      }
    });
  });
});

Ici, on peut repérer immédiatement la succession d’accolades fermantes en fin de code, qui est le symptôme le plus parlant de ce callback hell comme l’appellent les développeurs ! L’autre problème est également bien visible dans le code ci-dessus : la gestion des erreurs ne semble pas naturelle et nous force à interrompre manuellement notre workflow à coup de return ou de conditions.

Alors comment remédier à ça ? Un premier outil qui va nous aider et autour duquel s’articulent les autres outils est l’API Promise.

Les promises et leurs promesses

Une promise – promesse en français – est un objet qui peut avoir trois états différents :

  • En attente de résolution – pending
  • Résolu avec succès – resolved
  • Résolu en échec – rejected

Sur cet objet, on peut utiliser deux méthodes. La première, la méthode then permet d’exécuter une fonction en cas de succès tandis que la seconde, la méthode catch, permet d’exécuter une fonction en cas d’échec. On peut également fournir un second argument à then pour les erreurs. Si l’on considère que la fonction getUserDataAsynchronously retourne une promise, notre premier exemple devient ainsi :

getUserDataAsynchronously()
  .then(user => console.log(`User's nickname ${user.nickname}.`))
  .catch(errors => console.error('Something bad happened...'));
});

Le code est un peu plus clair et on a une gestion d’erreur bien séparée. Le principal avantage des promesses est cependant la possibilité de les chaîner. En effet, ces deux méthodes qu’on vient de voir retournent elles-même des promises, mais pas la même que la promise initiale !

Si la fonction fournie retourne une valeur, cette valeur est transmise au prochain then. Si elle retourne une promise, le prochain then sera appelé une fois cette promise résolue et avec la valeur de cette promise. Si elle retourne une erreur, l’erreur sera transmise au prochain catch ou au prochain then ayant un second paramètre. Voici ce que cela peut donner sur notre second exemple de tout à l’heure :

authenticateUser()
  .then(userData => getUserInfos(userData.id))
  .then(userInfos => getUserFriends(userInfos.friendsIds))
  .then(friends => {
    console.log('We now have our friend\'s informations!');
  })
  .catch(errors => console.error(errors));

Comme vous pouvez le voir, le code est à la fois bien plus concis et bien plus lisible ! De plus, la gestion d’erreur peut se faire à un seul et même endroit, ce qui a du sens puisqu’il s’agit d’un extrait de code destiné à un travail précis.

Imaginons maintenant qu’on ait des appels asynchrones qui doivent être exécutés en parallèle puis aggrégés. Partons par exemple du principe que nous n’avons pas un seul appel à faire pour récupérer les informations des amis de l’utilisateur, mais deux fonctions différentes, une pour récupérer les amis favoris et un autre pour récupérer les autres amis :

authenticateUser()
  .then(userData => getUserInfos(userData.id))
  .then(userInfos => {
    getUserFavoriteFriends(userInfos.favoriteFriendsIds));
    getUserOtherFriends(userInfos.otherFriendsIds));
  })
  .then(friends => {
    console.log('We now have our friend\'s informations!');
  })
  .catch(errors => console.error(errors));

Ce code ne peut pas marcher puisqu’on ne retourne rien dans le second then. Par conséquent dans le troisième then, friends vaut undefined. Ce qu’il nous faut, c’est donc un moyen de retourner une promise qui sera résolue une fois que nos deux promises seront résolues. C’est ce que fait Promise.all. Voici comment l’utiliser :

authenticateUser()
  .then(userData => getUserInfos(userData.id))
  .then(userInfos => {
    const favoritesPromise = getUserFavoriteFriends(userInfos.favoriteFriendsIds));
    const othersPromise = getUserOtherFriends(userInfos.otherFriendsIds));
    return Promise.all([favoritesPromise, othersPromise]);
  })
  .then(([favorites, others]) => {
    console.log('We now have our friend\'s informations!');
  })
  .catch(errors => console.error(errors));

On résout ainsi facilement un problème qui serait autrement plus compliqué à résoudre avec des callbacks (essayez, vous verrez qu’il est impossible d’avoir un exemple aussi concis !)

L’API Promise possède quelques autres méthodes très utiles :

  • Promise.race : Prend un tableau de Promise en entrée et retourne une promise résolue dès qu’une des promises est résolue avec la valeur résolue de cette promise ;
  • Promise.resolve : Retourne une promise résolue avec succès avec la valeur fournie en argument ;
  • Promise.reject : Retourne une promise résolue en erreur avec la valeur fournie en argument.

Allons plus loin avec les generators

Ce code est déjà plus joli, mais on peut aller plus loin ! Et si on pouvait faire de l’asynchrone… synchrone ? C’est possible grâce aux generators, un nouveau concept apporté par ES 2015.

Mais c’est quoi un generator ?

Une generator est une fonction capable de se mettre en pause au cours de son exécution jusqu’à ce qu’on l’appelle de nouveau. Encore une fois, un exemple sera plus parlant. Dans cet extrait de code, on crée un itérateur sur notre generator puis on l’exécute. A chaque fois que celui-ci rencontre le mot-clé yield, il retourne la valeur qui le suit à celui qui consomme le generator :

function *myGenerator() {
  yield 1;
  yield 'Bonjour';
}
const iterator = myGenerator();
it.next().value; // 1
it.next().value; // 'Bonjour'
it.next().value; // undefined

Mais cette communication consumer / generator marche dans les deux sens. En appelant it.next(), on peut fournir une valeur qui sera récupérable dans le generator :

function *myGenerator() {
  const value = yield 1;
  yield 'Bonjour';
  yield value;
}
const it = myGenerator();
it.next().value; // 1
it.next('TestValue').value; // 'Bonjour'
it.next().value; // 'TestValue'

On peut également envoyer une erreur au generator :

function *myGenerator() {
  try {
    yield 1;
  } catch(err) {
    yield `Error: ${err}`;
  }
  yield 'Bonjour';
}
const it = myGenerator();
it.next().value; // 1
it.throw('SomeError').value; // 'Error: SomeError'
it.next().value; // 'Bonjour'

Et… ça m’avance à quoi ?

En effet, cela ne semble pas servir à grand chose dans notre cas. Pourtant, songez à ce qu’il peut se passer dès qu’on commence à combiner les generators et les promises. Imaginons qu’au lieu de renvoyer des valeurs quelconques, le generator nous retourne des promises. On pourrait alors attendre que la promise soit résolue pour appeler de nouveau le générateur et lui renvoyer la valeur attendue. Voilà à quoi ça ressemble en reprenant notre tout premier exemple :

function *myGenerator() {
  try {
    const userData = yield getUserDataAsynchronously();
    console.log(`User's nickname ${user.nickname}.`);
  } catch(error) {
    console.error('Something bad happened...');
  }
}
const it = myGenerator();
const promise = it.next().value;
promise
  .then(value => it.next(value))
  .catch(error => it.throw(error));

Ce qui est vraiment intéressant ici, c’est que le code a l’intérieur du generator ressemble à du code synchrone alors qu’il est en fait totalement asynchrone ! On peut même gérer les erreurs avec un try...catch, ce qui ne fonctionne pas avec les callbacks ou les promises !

Imaginons maintenant une fonction qui effectue ce mécanisme exact, à savoir itérer sur notre generator, et à chaque fois qu’on reçoit une promise, attendre que celle-ci soit résolue (en échec ou en erreur) pour exécuter notre generator. Eh bien on pourrait simplement placer notre code dans des generators et développer comme si on exécutait du code synchrone ! Ce procédé s’appelle des coroutines, et on peut utiliser notamment le module co pour cela. Reprenons notre second exemple en utilisant cette bibliothèque :

co(function*() {
  const userData = yield authenticateUser();
  const userInfos = yield getUserInfos(userData.id);
  const favoritesPromise = getUserFavoriteFriends(userInfos.favoriteFriendsIds));
  const othersPromise = getUserOtherFriends(userInfos.otherFriendsIds));
  yield Promise.all([favoritesPromise, othersPromise]);
});

Et voilà, notre code a maintenant l’air totalement synchrone ! Notons tout de même que notre gestion d’erreur a disparu. Nous pourrions – comme dans l’exemple ci-dessus – utiliser un try...catch, mais on peut aussi profiter du fait que co retourne une promise :

co(function*() {
  const userData = yield authenticateUser();
  const userInfos = yield getUserInfos(userData.id);
  const favoritesPromise = getUserFavoriteFriends(userInfos.favoriteFriendsIds));
  const othersPromise = getUserOtherFriends(userInfos.otherFriendsIds));
  yield Promise.all([favoritesPromise, othersPromise]);
}).catch(err => console.log(err));

Place aux async functions

Cette syntaxe est tellement pratique qu’elle sera probablement standardisée dans une prochaine version de la spécification. La proposition est actuellement au stade 3 et sera sûrement présente dans ECMAScript 2017. Avec cette nouveauté, notre dernier exemple deviendrait :

async function myAsyncFunction() {
  const userData = await authenticateUser();
  const userInfos = await getUserInfos(userData.id);
  const favoritesPromise = getUserFavoriteFriends(userInfos.favoriteFriendsIds));
  const othersPromise = getUserOtherFriends(userInfos.otherFriendsIds));
  await Promise.all([favoritesPromise, othersPromise]);
}

myAsyncFunction().catch(err => console.log(err));

Soyez plus réactifs

Nous avons vu ainsi comment organiser bien mieux son code asynchrone lorsqu’on attend une valeur en retour. Mais que faire lorsque notre évènement asynchrone est en réalité… une suite d’évènements asynchrones ?

On peut citer ainsi l’écoute des évènements utilisateurs. Imaginons qu’on souhaite effectuer une action à chaque clic sur un bouton. Voici comment on fait cela avec les callbacks :

const element = document.getElementById('myButton'); 
element.addEventListener('click', function(event) {
  console.log('Button clicked!', event);
}), false);

Depuis quelques temps émerge en JavaScript un nouveau paradigme : la programmation réactive, reposant notamment sur les Observable. A ce jour, la spécification pour la standardisation des Observable n’en est qu’au stade 1, mais il existe des bibliothèques comme RxJS utilisables dès aujourd’hui. Voici ce que donnerait ainsi l’exemple précédent :

const element = document.getElementById('myButton'); 
const source = Observable.fromEvent(element, 'click');
source.subscribe(event => {
  console.log('Button clicked!', event);
});

L’idée est de ne plus traiter un évènement seul, mais plutôt un flux – stream – d’évènements auquel on peut s’abonner. Cette description est très réductrice et rend peu honneur à la programmation réactive, mais cette notion nécessiterait un article entier pour être développée. Je vous conseille donc de lire cette excellente introduction de André Medeiros.

Quelques liens utiles

 


sept − 6 =