JavaScript, promesses et générateurs

Conceptually that’s a much cleaner and easier to understand process than what happens when promises are chained multiple times. If you’ve ever tried to understand just how a promise chain is wired up, or explain it to someone else, you’ll know it’s not so simple. I won’t belabor those details here, but I’ve previously explained it in more detail if you care to dig into it.

The sync-async pattern above wipes away any of that unnecessary complexity. It also conveniently hides the then(..) calling altogether, because truthfully, that part is a wart. The then(..) on a promise is an unfortunate but necessary component of the Promise API design that was settled on for JS.

Promises: All The Wrong Ways (cache)

À la lecture de cet article et utilisant fetch avec son polyfill sur les projets Etalab, j’ai décidé d’expérimenter ce qui pouvait être fait à base de générateurs pour éviter d’enchaîner les promesses. Ce billet technique sur JavaScript (encore !) n’est pas une introduction à l’objet Promise mais un usage possible, si vous n’avez jamais manipulé ça risque d’être un peu difficile à comprendre. Si vous êtes familier avec les générateurs Python ça peut aider :-).

Prenons un exemple classique où vous voulez récupérer des données depuis une API HTTP :

const url = 'http://httpbin.org/get'
fetch(url)

Jusqu’ici tout va bien mais dès la prochaine ligne ça se complique car il va falloir gérer les codes d’erreurs à la main :

function checkStatus (response) {
  if (response.status >= 200 && response.status < 300) {
    return response
  } else {
    const error = new Error(response.statusText)
    error.response = response
    throw error
  }
}

Ici on ne retourne la réponse que si elle est en 2XX et sinon on lève notre propre erreur. On peut maintenant enchaîner l’exécution de cette fonction au retour de notre fetch :

.then(checkStatus)

Puis récupérer la réponse sous forme de JSON :

.then((response) => response.json())

Et enfin faire quelque chose d’utile avec :

.then((jsonResponse) => {
  // Do something with the jsonResponse.
})

Si vous en restez là en cas d’erreur vous n’avez pas de moyen de savoir ce qu’il se passe, il ne faut jamais oublier d’ajouter un catch à vos chaînes de promesses :

.catch(console.error.bind(console))

Ici on fait le minimum en affichant un message dans la console. Investiguer des bugs liés à de l’asynchrone peut rapidement s’avérer être prise de tête si vous ne prenez pas ces précautions. Au même titre, ne mélangez jamais différentes implémentations des promises sous peine de devoir re-standardiser ensuite toutes vos promesses via Promise.resolve(promesse)

Donc au final notre promesse chaînée complète ressemble à :

fetch(url)
  .then(checkStatus)
  .then((response) => response.json())
  .then((jsonResponse) => {
    // Do something with the jsonResponse.
  })
  .catch(console.error.bind(console))

Ça reste lisible mais c’est loin d’être idéal, je vous laisse aller lire l’article en intro (cache) pour les différentes raisons.

En utilisant un générateur, je suis arrivé à ce résultat :

run(fetchJSON, url)
  .then((jsonResponse) => {
    // Do something with the jsonResponse.
  }, console.error.bind(console))

Qui est finalement la seule logique métier qui m’intéresse. Un seul .then() permettant de gérer le fulfilled/rejected directement en paramètre sans passer par le .catch(). Ensuite notre fetchJSON est un générateur * :

function * fetchJSON (url) {
  const rawResponse = yield fetch(url)
  const validResponse = yield checkStatus(rawResponse)
  return validResponse.json()
}

Proche de la syntaxe Python, on retrouve le mot-clé yield qui sert à récupérer les différents éléments de l’itérateur généré. Ici il y a exactement le même traitement que précédemment de manière un peu plus explicite à mon goût. Il reste à définir notre runner :

function run (generator, ...args) {
  const iterator = generator(...args)
  return Promise.resolve()
    .then(function handleNext (value) {
      const next = iterator.next(value)
      return (function handleResult (next) {
        if (next.done) {
          return next.value
        } else {
          return Promise.resolve(next.value)
            .then(
              handleNext,
              (error) => {
                return Promise.resolve(iterator.throw(error))
                  .then(handleResult)
              }
            )
        }
      })(next)
    })
}

Ici c’est la gestion des erreurs qui rend le script un peu velu mais en gros ça suit l’itérateur créé à partir du générateur de manière récursive en retournant des promesses (de promesses (de promesses (etc))). C’est là où se cache maintenant la complexité de vouloir faire de l’asynchrone séquentiel. L’avantage c’est que c’est localisé et réutilisable ensuite pour toutes vos séquences. En attendant async/await (cache). Ou pas (cache).

Le gros point noir dans tout ça, c’est lorsque vous tentez la conversion en ES5 avec Babel. Vous devez utiliser babel-polyfill qui rajoute immédiatement 300Kb à votre joli code… ce qui est envisageable côté serveur ou pour une application complète devient rédhibitoire pour une simple bibliothèque. Même si on ne va au final pas l’utiliser pour cette raison là, ça aura été une incursion intéressante dans l’univers asynchrone de JavaScript en attendant que les fonctionnalités d’ES6/ES7 soient davantage implémentées nativement.

Je suis un débutant en JS donc n’hésitez pas à me faire vos retours si j’ai raconté des bêtises !