Asynchrone
moderne en JS

Un atelier de Christophe Porteneuve à Paris Web 2016

whoami


var christophe = {
  age:        38.90896646132786,
  family:     { wife: 'Élodie', son: 'Maxence' },
  city:       'Paris, FR',
  company:    'Delicious Insights',
  trainings:  ['JS Total', 'Node.js', 'Git Total'],
  webSince:   1995,
  claimsToFame: [
    'Prototype.js',
    'Ruby On Rails',
    'Bien Développer pour le Web 2.0',
    'Prototype and Script.aculo.us',
    'Paris Web'
  ]
}
          

Petit rappel : le

Callback Hell

c’est relou.

Un exemple simple


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, comments, title: post.title })
    })
  })
}
            

Un exemple plus réaliste


function normalizeSKUs (skus, cb) {
  let normalizations = {}, count, xhrs = []

  function handleError (err) {
    _(xhrs).invoke('abort')
    cb(err)
  }

  function handleSKU (payload) {
    normalizations[payload.from] = payload.to
    if (++count < xhrs.length) return

    cb(null, normalizations)
  }

  function normalizeFromServer (sku) {
    xhrs.push($.ajax({
      url: '/api/v1/skus/normalize',
      data: { sku },
      onSuccess: handleSKU,
      onError: handleError
    }))
  }
}
            

C’est quoi le problème ?

Flux du code compliqué à suivre

Correspondance esprit / code super foireuse
(surtout parce qu’on n’a pas de donnée retournée)

Gestion d’erreur bien bordélique.

Confiance ébranlée

Est-ce que ça va tourner ?

Est-ce que ça va bien appeler un seul des callbacks d’erreur ou de succès, et jamais les deux ?

Est-ce que ça va tourner une seule fois ?

Très dur à composer

Les promesses

c’est trop cool. Juré.

Ça fait un bail

Elles sont là depuis longtemps, sous divers noms et formes.

Arrivées en JS en 2007 avec Dōjō.

Promises/A proposé dans CommonJS en 2009 (Kris Zyp)

Promises/A+ en 2012 (Brian Cavalier, Domenic Denicola et al.)


Les APIs web récentes sont basées promesses (ex. fetch, ServiceWorker…)

Des tas de libs (Q, rsvp, bluebird…), polyfills… Désormais natives à ES2015.

Promises/A+

There can be only one

La spec de référence. Définit :

  • la terminologie (promesse, états et destins, thenable, etc.)
  • les transitions d’état possibles
  • le fonctionnement de then (chaînage, propagation, etc.)
  • la sémantique de résolution

Promesses

États

  • En attente (pending) : n’a pas encore abouti à un résultat
  • Accomplie (fulfilled) : a abouti à un résultat (value), figé
  • Rejetée (rejected) : a eu un souci et fournit la raison, figée
  • Établie (settled) : accomplie ou rejetée (pas en attente, donc)

Transition unique

Une promesse ne change d’état qu’une seule fois, d’en attente à accomplie ou rejetée.

States and Fates

Chaînage

Une promesse a une méthode then(…)

Elle prend en fait jusqu’à 2 arguments, tous 2 optionnels :

  1. Le callback de succès (accomplissement)
  2. Le callback d’erreur (rejet)

Notez qu’une API peut s’accomplir sans valeur de résultat (ex. lookup en base de données) : ça ne constitue pas forcément un échec.

Chaînage


lookupUser(userId)
.then(
  (user) => console.log('USER:', user.getName()),  // Callback de succès
  (err)  => console.error(err)                    // Callback d’erreur
)
            

Mais n’oubliez pas : ils sont exclusifs !


lookupUser(unknownUserId)
.then(
  (user) => console.log('USER:', user.getName()), // user === null -> BLAM
  (err)  => console.error(err)                     // Pas appelé !
)
              

Alors on fait comment ?

Toujours vérifier à la fin

(au minimum…)


lookupUser(unknownUserId)
.then((user) => console.log('USER:', user.getName()))  // Étape 1
.then(null, (err) => console.error(err))              // Étape 2
            

Mais then(null, …) c’est fugly. Alors…


lookupUser(unknownUserId)
.then((user) => console.log('USER:', user.getName()))  // Étape 1
.catch((err) => console.error(err))                   // Étape 2
              

Comme pour un try…catch…, tout callback d’erreur neutralise l’erreur, et on pourrait continuer derrière du coup.

Conversions automatiques

Une étape de la chaîne peut renvoyer une valeur synchrone : elle sera automatiquement convertie en promesse accomplie :


lookupUser(userId)
.then((user) => user.getName()) // Renvoi synchrone de valeur !
.then(console.log)               // …et pourtant `.then(…)` :-)
            

Et une levée d’exception sera convertie en promesse rejetée :


xhrWrap('http://delicious-insights.com')
.then((text) => JSON.parse(text)) // KA-BOOM! (`ParseError`)
.catch(console.error)             // Tadaaaaa…
            

Injection de promesse dynamique

On peut renvoyer une promesse depuis un callback, et celle-ci s’incruste à cet endroit dans la chaîne.


fetch(url)
.then((res) => res.json()) // Promesse de décodage JSON une fois tout le contenu reçu
.then(console.log)
            

getUser(userId)
.then((user) => user.getFollowers())
.then((followers) => render('user/followers', { followers }))
            

Parallélisme

On risque, à tort, de faire des séquences par réflexe, là où le parallélisme devient possible, puisqu’on est non bloquants…


let tags, entries
getTags()
.then((t) =>          { tags = t; return getEntries() })
.then((es) =>         { entries = es; return getEntryCount() })
.then((entryCount) => { render('entries/index', { tags, entries, entryCount }) })
            

Eh, c’est JS, pas Java/PHP/Ruby/Python les gens !


Promise.all([getTags(), getEntries(), getEntryCount()])
.then(([tags, entries, entryCount]) => {
  render('entries/index', { tags, entries, entryCount })
})
              

Plus rarement, Promise.race.

Créer nos propres promesses

Motif du Revealing Constructor :


function promiseMe (itWontHurt) {
  return new Promise(function (resolve, reject) {
    var req = new XMLHttpRequest()
    req.open('GET', url)
    req.onload = function () {
      if (req.status === 200) {
        resolve(req.response)
      } else {
        reject(new Error(req.statusText))
      }
    }
    req.onerror = function() { reject(new Error('Network error')) }
    req.send()
  })
}
            

L’implémentation interne préserve les garanties (transition unique et exclusive, asynchronicité de l’appel des callbacks, etc.)

Pratiquons !


$ npm i -g promise-it-wont-hurt
$ promise-it-wont-hurt
            

Avantages & inconvénients

Glop :

Fiable : seulement un des callbacks, une seule fois, garanti. Et la gestion des exceptions en prime !

Composable : chainable, avec injection et conversion.

Pas glop :

Nouvelles abstractions

Légère pénalité de performance (va disparaître)

Le flux du code reste non-intuitif / très différent du code synchrone.

Le mot de la fin sur les promesses…

Adapté à des traitements one-shot : pas pour du récurrent / multiple / indéfini (type flux, événements, etc.). On préfèrera alors des observables, comme avec RxJS ou BaconJS, par exemple.

Le TC39 bosse sur le fait de rendre les promesses annulables, ce qui est critique pour tout un tas de cas. Facilite aussi le fait de les soumettre à un timeout, qui aujourd’hui nécessite un peu un hack :


const timeoutPromise = Promise.race([
  fetch(someSlowURL), timeout(3000)
])

function timeout (delay) {
  return new Promise((_, reject) => { setTimeout(reject, delay, new Error('timeout!')) })
}
            

async / await

Wopitain.

Ayé.

C’est un peu le Saint Graal du JS asynchrone.

À peu près copié-collé de C# 5.0 / .NET 4.5 (2012).

(Oui, y’a des choses bien dans les langages de chez MS ; merci Anders)

Synchrone ou asynchrone ?


async function loadStory () {
  try {
    const story = await getJSON('story.json')
    addHtmlToPage(story.heading)

    for (let chapterPromise of story.chapterUrls.map(getJSON)) {
      const chapter = await chapterPromise
      addHtmlToPage(chapter.html)
    }

    addTextToPage('All done')
  } catch (err) {
    addTextToPage(`Argh, broken: ${err.message}`)
  }
  hideSpinner()
})
            

Synchrone ou asynchrone ?


async function login (username, password, session) {
  const user = await getUser(username)
  const hash = await crypto.hashAsync(password + user.salt)
  if (user.hash !== hash) {
    throw new Error('Incorrect password')
  }
  session.setUser(user)
})
            

Synchrone ou asynchrone ?


async function loadDashboard (req, res) {
  const user = await loadUser(req.session.userId)
  const [profile, notifs] = await Promise.all([
    user.getProfile(), user.getNotifications()
  ])
  res.render('dashboard', { user, profile, notifs })
}
            

Avantages & Inconvénients

La vie elle est trop beeeellllleeee ! ❤️

Comme toutes les nouvelles fonctionnalités (ex. promesses, générateurs, proxies…) il faut encore optimiser la performance. Mais ça vaut trop le coup. Et ça sera performant. Regardez la tendance de JS en général.

J’EN VEEEUUUX !

Sera officiellement dans ES2017. Finalisé en juillet dernier.

Déjà dispo en natif dans Edge 13+ et Chrome 53+… derrière un flag.

Babel est ton ami (polyfille à coup de promesses + générateurs).

Allez, pratiquons !

Merci !

Et que JS soit avec vous.


Christophe Porteneuve

@porteneuve

Les slides sont sur bit.ly/pw16-async