Programmation asynchrone en JavaScript

En poursuivant la lecture de Modern JavaScript for the Impatient, je me suis dit que j'allais en profiter pour croiser les informations du chapitre 9 avec mes précédentes notes.

Il en résulte ce billet qui propose une introduction à la programmation asynchrone en JavaScript.

La concurrence en JavaScript

Voici, dans les grandes lignes, comment JavaScript gère la concurrence.

JavaScript est un langage qui s'exécute dans un thread unique. Ça veut dire qu'il y a une seule pile d'appels (stack) et un seul tas de mémoire (heap).

Le code est exécuté de manière synchrone, ligne après ligne en attendant la fin de l'exécution de la ligne précédente.

Or, quand une opération met du temps à s'exécuter, comme un appel réseau par exemple, tout est bloqué jusqu'à la fin de l'opération.

Pour éviter ça et permettre une exécution asynchrone, JavaScript délègue les opérations longues à des APIs implémentées par les navigateurs ou les environnements d'exécution (runtimes).

Les appels à ces APIs prennent en argument une fonction de rappel (callback).

Le navigateur (ou l'environnement d'exécution) déclenche ces appels APIs en dehors du thread principal avec sa propre tambouille.

Lorsque l'appel est terminé, peu importe son résultat, la fonction de rappel est poussée dans une file d'attente (queue).

Une boucle d'événements (event loop) interroge la file d'attente en permanence pour voir si une fonction de rappel y a été poussée. Une interrogation est nommée un tick.

Si oui, la fonction de rappel est sortie de la file d'attente et poussée dans la pile d'appels.

Vous pouvez voir ce mécanisme en action ici, ou la vidéo What the heck is the event loop anyway? pour vous en faire une représentation visuelle.

Fonctions de rappel ou callbacks

Historiquement, les fonctions de rappel étaient la seule façon d'écrire du code asynchrone mais elles posaient des problèmes d'imbrication du code.

Dans cet exemple d'utilisation de l'API XMLHttpRequest, on prépare deux fonctions de rappel (loadCallback et errorCallback) qui seront invoquées soit quand des données seront disponibles, soit quand une erreur se produira :

const loadCallback = event => {
    const request = event.target
    if (request.status == 200) {
        const blob = new Blob([request.response], {type: 'image/gif'})
        const img = document.createElement('img')
        img.src = URL.createObjectURL(blob)
        document.body.prepend(img)
    } else {
        console.log(`${request.status}: ${request.statusText}`)
    }
}

const errorCallback = () => {
    console.log('Network error')
}

const getGif = url => {
    const request = new XMLHttpRequest()
    request.open('GET', url)
    request.responseType = 'blob'
    request.addEventListener('load', loadCallback)
    request.addEventListener('error', errorCallback)
    request.send()
}

Promesses

Les promesses (ES2015) permettent d'anéantir l'effet Callback Hell.

Un objet Promise représente le résultat éventuel d'une opération asynchrone.

Le constructeur de Promise prend un seul argument : une fonction exécuteur (executor function) qui prend deux arguments (les gestionnaires en cas de réussite ou d'échec) :

const myPromise = new Promise((resolve, reject) => {
    // Body of the "executor function".
})

La façon la plus courante de manipuler des promesses et d'utiliser des méthodes d'APIs qui en retournent, comme l'API Fetch par exemple.

Exemple de fonction qui retourne une promesse :

const getGif = url => {
    return new Promise((resolve, reject) => {
        const request = new XMLHttpRequest()
        const callback = () => {
            if (request.status == 200) {
                const blob = new Blob([request.response], {type: 'image/gif'})
                const img = document.createElement('img')
                img.src = URL.createObjectURL(blob)
                resolve(img)
            } else {
                reject(Error(`${request.status}: ${request.statusText}`))
            }
        }
        request.open('GET', url)
        request.responseType = 'blob'
        request.addEventListener('load', callback)
        request.addEventListener('error', () => reject(Error('Network error')))
        request.send()
    })
}

États d'une promesse

Comprendre les différents états d'une Promise est un peu laborieux. C'est plus simple en divisant les termes en deux catégories :

  1. les états :
    • pending (en attente)
    • fulfilled (tenue)
    • rejected (rompue)
  2. les destins :
    • resolved (résolue)
    • unresolved (non résolue)

Dans le code ci-dessus, un callback appelle le gestionnaire resolve ou reject pour passer la promesse à l'état tenue (fulfilled) ou rompue (rejected). Dans les deux cas, la promesse est maintenant acquittée (settled).

Si on avait passé une autre promesse à resolve à la place d'img, la promesse serait alors résolue (resolved) mais pas tenue (fulfilled) car elle resterait en attente (pending) jusqu'à ce que la promesse suivante soit acquittée (settled).

Il faut toujours appeler resolve ou reject, sinon la promesse ne sort jamais de l'état pending.

Les états d'une promesse en JavaScript

Obtenir le résultat d'une promesse

La méthode then est le seul moyen d'obtenir un résultat à partir d'une promesse. Elle prend une fonction qui consomme le résultat :

const url = 'https://media0.giphy.com/media/sIIhZliB2McAo/giphy.gif'
const gifPromise = getGif(url)
gifPromise.then(img => document.body.prepend(img))

Enchaînement de promesses

Le résultat d'une promesse peut donc être passé à une autre promesse.

Il est aussi possible de mélanger des tâches synchrones et asynchrones car then retourne une promesse tenue (fulfilled) quand il produit un résultat qui n'est pas une promesse :

const url1 = 'https://media0.giphy.com/media/sIIhZliB2McAo/giphy.gif'
const url2 = 'https://media3.giphy.com/media/K6e6D4nX0iLOqmimFs/giphy.gif'

getGif(url1)                                  // Async
    .then(img => document.body.prepend(img))  // Sync
    .then(() => getGif(url2))                 // Async
    .then(img => document.body.prepend(img))  // Sync

On peut améliorer le style du code ci-dessus pour le rendre plus "symétrique" en commençant par une promesse immédiatement tenue (fulfilled) :

Promise.resolve()
    .then(() => getGif(url1))
    .then(img => document.body.prepend(img))
    .then(() => getGif(url2))
    .then(img => document.body.prepend(img))

Traitement des erreurs

La signature de then() admet un second argument permettant de gérer les erreurs.

Mais en pratique, c'est mieux d'utiliser la méthode catch afin de traiter aussi les erreurs qui pourraient se produire dans le gestionnaire resolve :

Promise.resolve()
    .then(() => getGif(url1))
    .then(img => document.body.prepend(img))
    .then(() => getGif(url2))
    .then(img => document.body.prepend(img))
    .catch(reason => console.log({reason}))

Enchaîner une promesse rejetée avec un autre then propage la promesse rejetée. Du coup le gestionnaire catch à la fin traitera une erreur à n'importe quelle étape de l'enchaînement.

Fonctions asynchrones avec async/await

La syntaxe async/await (ES2017) cache systématiquement les promesses. Il n'y a plus besoin de configurer explicitement des enchaînement de promesses, ce qui rend le code plus simple à suivre :

const url = 'https://media0.giphy.com/media/sIIhZliB2McAo/giphy.gif'

const putImage = async url => {
    const img = await getGif(url)
    document.body.prepend(img)
}

putImage(url)

Le code ci-dessus est transformé par le compilateur en :

const putImage = url => {
    getGif(url)
        .then(img => document.body.prepend(img))
}

Il est possible d'utiliser plusieurs await :

const url1 = 'https://media0.giphy.com/media/sIIhZliB2McAo/giphy.gif'
const url2 = 'https://media3.giphy.com/media/K6e6D4nX0iLOqmimFs/giphy.gif'

const putImages = async (url1, url2) => {
    const img1 = await getGif(url1)
    document.body.prepend(img1)
    const img2 = await getGif(url2)
    document.body.prepend(img2)
}
putImages(url1, url2)

Ou des boucles :

const urls = [
    'https://media0.giphy.com/media/sIIhZliB2McAo/giphy.gif',
    'https://media3.giphy.com/media/K6e6D4nX0iLOqmimFs/giphy.gif',
]

const putImages = async (urls) => {
    for (url of urls) {
        const img = await getGif(url)
        document.body.prepend(img)
    }
}

putImages(urls)

Valeur de retour d'une fonction async

Une fonction async renvoie toujours une promesse implicitement, pas une valeur.

const getCatImageURL = async () => {
    const result = await fetch('https://aws.random.cat/meow')
    const imageJson = await result.json()
    return imageJson.file
}

const getDogImageURL = async () => {
    const result = await fetch('https://dog.ceo/api/breeds/image/random')
    const imageJson = await result.json()
    return imageJson.message
}

Pour obtenir le résultat, on peut utiliser then ou await :

getCatImageURL().then(console.log)

await getDogImageURL()

Les exceptions dans une fonction async

Lever une exception dans une fonction async retourne une promesse rejetée :

const getCatImageURL = async () => {
    // ...
    } else {
        throw Error('Bad type.')  // Async function returns rejected promise.
    }
}

Quand await reçoit une promesse rejetée, il lève une exception qui peut être catchée.

Avant Héritage et classes en JavaScript (bis) Après Configurer un Mac "comme j'aime"

Tag Kemar Joint