Refactoring JavaScript
Chapitre 9 / 10 · 14 min de lecture

Le refactoring asynchrone

Sortir du callback hell : refactorer les callbacks en promesses puis en async/await, sans changer le comportement.

JavaScript n'a qu'un seul fil d'exécution (mono-thread). Pour ne pas « figer le monde » (stop the world) pendant qu'on attend une réponse réseau, un fichier ou une minuterie, le langage repose sur du code non bloquant orchestré par une boucle d'événements (event loop). On lance une opération longue, la fonction rend la main immédiatement, et le résultat nous revient « plus tard » — par le biais d'un rappel (callback). C'est puissant, mais cela bouleverse complètement la façon de raisonner que nous avons cultivée dans les chapitres précédents : on ne renvoie plus de valeurs, on confie la suite du programme à une fonction tierce.

Evan Burchard consacre ce chapitre à apprivoiser cette complexité. Le fil rouge est une même logique déclinée en trois versions successives — callbacks imbriqués, puis promesses chaînées, puis async/await — chacune se lisant un peu mieux que la précédente. Nous suivrons cet ordre pédagogique en montrant systématiquement le code AVANT et APRÈS, et en rappelant que ces transformations se font par petits pas, sous la protection des tests.

Pourquoi l'asynchrone ?

Imaginons un appel HTTP distant. S'il était synchrone, il bloquerait tout le programme jusqu'à la réponse. L'API non bloquante de Node nous force au contraire un style à base de callbacks :

// Style asynchrone : http.get rend la main tout de suite.
const http = require('http');

http.get('http://refactoringjs.com', (result) => {
  result.on('data', (chunk) => {
    console.log(chunk.toString());
  });
});

Note

Pourquoi toString() ? Parce que chunk est un Buffer d'octets. Sans la conversion, vous verriez s'afficher quelque chose comme <Buffer 3c 21 44 4f 43 ...> au lieu du texte.

Le piège classique est de vouloir garder un style synchrone au-delà de cet appel. On serait tenté d'accumuler les morceaux dans un tableau puis de l'afficher :

// ❌ Avant : on raisonne « comme si » c'était synchrone.
let theResult = [];
http.get('http://refactoringjs.com', (result) => {
  result.on('data', (chunk) => {
    theResult.push(chunk.toString());
  });
});
console.log(theResult); // affiche [] — un tableau vide !

Le tableau est vide. http.get retourne aussitôt, et console.log s'exécute bien avant que le callback n'ait poussé quoi que ce soit. L'ordre réel est contre-intuitif :

http.get('http://refactoringjs.com', (result) => {
  result.on('data', () => {
    console.log("ceci s'affiche après (et deux fois)");
  });
});
console.log("ceci s'affiche en premier");

On pourrait croire qu'il suffit d'« attendre un peu » avec un setTimeout. Mais combien de temps ? 500 ms suffiront-ils ? Impossible à dire de façon fiable : trop court, on rate des données ; trop long, on bloque le programme pour rien. Cette solution est imprévisible et repose sur un effet de bord (modifier une variable externe).

Attention

setTimeout(fn, 0) n'exécute pas fn immédiatement. Il dépose fn dans la boucle d'événements pour plus tard. Ainsi, dans setTimeout(() => console.log('poule'), 0); console.log('oeuf');, c'est toujours « oeuf » qui sort en premier. L'ordre de plusieurs setTimeout est même variable d'un environnement à l'autre. Ne comptez jamais sur la temporisation pour synchroniser du code.

La pyramide de la mort

Quand un callback en appelle un autre, qui en appelle un autre, le code « glisse » vers la droite, niveau d'indentation après niveau d'indentation. C'est la pyramide de la mort (pyramid of doom), cousine de l'enfer des callbacks (callback hell) :

levelOne(function () {
  levelTwo(function () {
    levelThree(function () {
      levelFour(function () {
        // du code, perdu tout au fond
      });
    });
  });
});

La « pyramide » désigne la forme triangulaire de l'indentation ; l'« enfer » désigne plutôt la profusion de couches de callbacks, quelle que soit la forme. Il n'y a pas de seuil officiel — c'est une question de lisibilité.

Aplatir en nommant et extrayant

Le premier remède est déjà connu des chapitres précédents : dé-anonymiser et extraire les fonctions imbriquées. On part de notre appel imbriqué :

// ❌ Avant : callbacks anonymes imbriqués.
const http = require('http');

http.get('http://refactoringjs.com', (result) => {
  result.on('data', (chunk) => {
    console.log(chunk.toString());
  });
});

On extrait chaque callback dans une fonction nommée, ce qui aplatit la pyramide :

// ✅ Après : des fonctions nommées, plus d'imbrication.
const http = require('http');

function printBody(chunk) {
  console.log(chunk.toString());
}

function getResults(result) {
  result.on('data', printBody);
}

http.get('http://refactoringjs.com', getResults);

Comme l'API est en flux (streaming) et livre des morceaux, afficher chaque chunk séparément ajoute des sauts de ligne parasites. On agrège donc dans un tableau et on n'affiche qu'à l'événement 'end' :

// ✅ Agrégation dans un tableau, affichage final unique.
const http = require('http');
let bodyArray = [];

const saveBody = function (chunk) {
  bodyArray.push(chunk);
};
const printBody = function () {
  console.log(bodyArray.join(''));
};
const getResults = function (result) {
  result.on('data', saveBody);
  result.on('end', printBody);
};

http.get('http://refactoringjs.com', getResults);

Le join('') est assez malin pour fondre les Buffer en une seule chaîne — plus besoin de toString().

Attention au this quand on regroupe dans un objet

On peut vouloir ranger ces fonctions dans un objet pour mieux les organiser. Mais le contexte this est très facilement perdu quand on passe une méthode comme callback :

// ❌ Avant : this s'évapore une fois la méthode passée en callback.
const getBody = {
  bodyArray: [],
  saveBody(chunk) {
    this.bodyArray.push(chunk);
  },
  printBody() {
    console.log(this.bodyArray.join(''));
  },
  getResult(result) {
    result.on('data', this.saveBody);
    result.on('end', this.printBody);
  },
};

// TypeError plus loin : « Cannot read property 'push' of undefined »
http.get('http://refactoringjs.com', getBody.getResult);

Il faut lier explicitement this (bind) à chaque endroit où une méthode devient un callback détaché de son objet :

// ✅ Après : bind partout où la méthode quitte son objet.
const getBody = {
  bodyArray: [],
  saveBody(chunk) {
    this.bodyArray.push(chunk);
  },
  printBody() {
    console.log(this.bodyArray.join(''));
  },
  getResult(result) {
    result.on('data', this.saveBody.bind(this));
    result.on('end', this.printBody.bind(this));
  },
};

http.get('http://refactoringjs.com',
  getBody.getResult.bind(getBody));

Cet effort en vaut-il la peine pour une pyramide aussi peu profonde ? Pas forcément. Mais Burchard insiste sur deux points cruciaux. Premier point : en confiant le vrai travail à des callbacks, on dépend d'effets de bord. On a quitté le monde simple où l'on renvoie des valeurs ; on lance des fonctions qui rendent la main aussitôt « sans rien de valeur », et le travail réel a lieu « plus tard ». Or notre confiance dans le code repose sur le fait de savoir ce qu'il fait — et l'asynchrone, tel quel, sape cette confiance. Deuxième point, lié : nous n'avons aucun test.

Tester du code asynchrone

Que tester, justement ? Une hypothèse simple : après exécution, bodyArray ne devrait pas être vide. Avec la bibliothèque tape (plus légère que mocha), le test suivant échoue :

// ❌ Le test s'exécute AVANT que bodyArray soit rempli.
const test = require('tape');

test('notre routine async', (assert) => {
  http.get('http://refactoringjs.com',
    getBody.getResult.bind(getBody));
  assert.notEqual(getBody.bodyArray.length, 0);
  assert.end();
});

L'assertion est évaluée avant que le réseau n'ait répondu. La tentation est de revenir au monde synchrone avec un setTimeout… mais le test prendrait alors 3 secondes et resterait fragile.

La bonne approche consiste à se donner un point d'accroche qui se déclenche quand tout est terminé. Plutôt que d'écraser printBody (qu'on voudra peut-être tester par ailleurs), on introduit une fonction allDone vide, dont l'unique raison d'être est d'être surchargée dans le test :

// ✅ Une fonction « allDone » sert de point d'accroche au test.
const getBody = {
  // ...
  printBody() {
    console.log(this.bodyArray.join(''));
    this.allDone();
  },
  allDone() {},
};

test('notre routine async', (assert) => {
  getBody.allDone = function () {
    assert.equal(getBody.bodyArray.length, 2);
    assert.end();
  };
  http.get('http://refactoringjs.com',
    getBody.getResult.bind(getBody));
});

Comme allDone n'a pas d'implémentation par défaut, la surcharger n'a aucune conséquence. Il reste à réinitialiser l'état entre les tests via des fonctions setup et teardown explicites :

// ✅ setup/teardown explicites = contrôle total de l'état partagé.
function setup() {
  getBody.bodyArray = [];
}
function teardown() {
  getBody.allDone = function () {};
}

test('notre routine async', (assert) => {
  setup();
  getBody.allDone = function () {
    assert.equal(getBody.bodyArray.length, 2);
    teardown();
    assert.end();
  };
  http.get('http://refactoringjs.com',
    getBody.getResult.bind(getBody));
});

À retenir

Aucun framework ne vous protège de tests qui tournent en parallèle et se piétinent sur un état partagé. La parade : lancer en série les tests qui partagent de l'état (un fichier tape le fait), et découper le code en modules indépendants — ce que vous vouliez probablement faire de toute façon — pour retrouver du parallélisme sain.

Dans beaucoup de code asynchrone, les valeurs de retour sont moins parlantes que les fonctions appelées et les effets de bord produits. D'où le recours fréquent aux doublures de test (test doubles), via une bibliothèque comme testdouble, pour simuler allDone ou vérifier qu'une fonction a bien été appelée.

Inversion de contrôle : le vrai problème

Pour bien voir le défaut de fond, prenons l'exemple le plus simple possible. En style direct (direct style), une fonction reçoit une valeur et la renvoie :

// Style direct : addOne agit sur une entrée.
function addOne(addend) {
  console.log(addend + 1);
}
addOne(2); // 3

En style à continuation (continuation passing style, CPS), l'appelant fournit le callback et la fonction se contente de lui passer une valeur :

function two(callback) {
  callback(2);
}
function addOne(addend) {
  console.log(addend + 1);
}
two(addOne); // 3

Le poids de l'algorithme bascule dans le callback. Cette inversion de contrôle (inversion of control, IoC) devient indispensable dès qu'une valeur arrive de façon asynchrone :

// three doit attendre : impossible de simplement « return 3 ».
function addOne(addend) {
  console.log(addend + 1);
}
function three(callback) {
  setTimeout(function () {
    callback(3);
  }, 500);
}
three(addOne); // 4, après 500 ms

Si l'on tentait addOne(three()) en espérant que three renvoie 3, on obtiendrait NaN : addOne finit son calcul avant que three n'ait rien à donner, donc on ajoute 1 à undefined.

Burchard liste les inconvénients de cette inversion de contrôle, même utile :

  • C'est déroutant : il faut penser à l'envers, le temps de s'y habituer.
  • Les signatures se compliquent : les paramètres ne sont plus de simples « entrées », ils portent aussi les sorties.
  • Enfer des callbacks et pyramide guettent sans une discipline d'organisation.
  • La gestion d'erreurs devient plus compliquée, dispersée dans chaque callback.

S'ajoutent les difficultés propres à l'asynchrone : tests plus ardus, mélange malaisé avec le code synchrone, et valeurs de retour qui perdent leur sens tout au long de la chaîne.

Tester en style callback

Avec un callback, on ne teste plus une valeur de retour mais le paramètre reçu par le callback :

const test = require('tape');

// addOne en style callback : il passe le résultat au callback.
function addOne(addend, callback) {
  callback(addend + 1);
}

test('notre fonction addOne', (assert) => {
  addOne(3, (result) => {
    assert.equal(result, 4);
    assert.end();
  });
});

Comparez à la version synchrone, autrement plus directe :

function addOneSync(addend) {
  return addend + 1;
}

test('notre fonction addOneSync', (assert) => {
  assert.equal(addOneSync(3), 4);
  assert.end();
});

L'un des deux processus est nettement plus simple — mais on vit souvent dans un monde asynchrone, où three n'a pas d'équivalent synchrone utilisable.

Des callbacks aux promesses

Si vous aimez l'asynchrone mais détestez le désordre de l'inversion de contrôle, les promesses (promises) sont faites pour vous. L'idée : déplacer la complexité côté définition de la fonction, en laissant à l'appelant une API simple. On enchaîne des valeurs au lieu d'imbriquer des callbacks.

Voici l'interface, limpide :

// On déballe et enchaîne les valeurs avec .then().
four()
  .then(addOne)
  .then(console.log);

four() renvoie un 4 enveloppé dans une promesse ; addOne agit dessus et renvoie à son tour une promesse ; console.log consomme le résultat. On peut écrire cette « forme 1 » (sans point, point-free) ou une « forme 2 » plus explicite :

// forme 1 : préférable quand c'est possible (point-free).
four().then(addOne).then(console.log);

// forme 2 : la même chose avec des fonctions littérales.
four()
  .then((valeurDeFour) => addOne(valeurDeFour))
  .then((valeurDeAddOne) => console.log(valeurDeAddOne));

Passer de la forme 2 à la forme 1 revient à nommer-et-extraire un callback anonyme : les fonctions existent déjà, on laisse juste tomber l'enrobage anonyme.

Astuce

Toute la souplesse des promesses tient dans le chaînage. four().then(addOne).then(addOne).then(addOne).then(console.log) enchaîne autant d'étapes qu'on veut, en restant lisible et compatible avec l'asynchrone. La même chose en callbacks vous mène droit à la pyramide de la mort et à des résultats intermédiaires intestables.

Créer des promesses

Côté implémentation, on retrouve enfin nos return. Une promesse, c'est comme un grille-pain : on y met une valeur (ou le moyen de la produire), elle « saute » quand c'est prêt, et on récupère le résultat avec .then().

// ❌ Avant : tout reposait sur des callbacks et des effets de bord.
function three(callback) {
  setTimeout(function () {
    callback(3);
  }, 500);
}

// ✅ Après : on renvoie des promesses.
function addOne(addend) {
  return Promise.resolve(addend + 1);
}

function four() {
  return new Promise((resolve, _reject) => {
    setTimeout(() => resolve(4), 500);
  });
}

four().then(addOne).then(console.log); // 5, après 500 ms
  • Promise.resolve(valeur) crée une promesse déjà tenue, suffisant quand on n'a qu'une valeur.
  • new Promise((resolve, reject) => ...) est plus souple : le callback (l'executor) appelle resolve en cas de succès et reject en cas d'échec.

Note

then accepte une promesse ou une simple valeur. addOne marcherait aussi avec return addend + 1. En revanche, le premier maillon d'une chaîne doit être « then-able » : four ne peut pas renvoyer un nombre brut. Au besoin, démarrez par Promise.resolve().then(() => 4).then(...).

L'API reste réduite : resolve/reject, then, et un catch pour les erreurs. Il existe aussi Promise.all (se résout quand toutes les promesses sont tenues — idéal pour le parallèle) et Promise.race (renvoie la première qui se résout).

Le parallèle avec Promise.all

Un piège fréquent est de confondre séquentiel et parallèle. Enchaîner des .then() exécute les étapes l'une après l'autre. Si trois appels sont indépendants, lancez-les de front :

// ❌ Avant : séquentiel inutilement (≈ somme des durées).
chargerUtilisateur(id)
  .then((u) =>
    chargerCommandes(id).then((c) =>
      chargerFactures(id).then((f) => ({ u, c, f }))
    )
  );

// ✅ Après : en parallèle (≈ durée du plus lent).
Promise.all([
  chargerUtilisateur(id),
  chargerCommandes(id),
  chargerFactures(id),
]).then(([u, c, f]) => ({ u, c, f }));

Tester des promesses

Les tests s'adaptent en renvoyant et chaînant des promesses ; catch capture les erreurs :

test('notre fonction four', (assert) => {
  four().then((result) => {
    assert.equal(result, 4);
    assert.end();
  });
});

test('test de bout en bout', (assert) => {
  four()
    .then(addOne)
    .then(console.log)
    .then(() => {
      assert.pass();
      assert.end();
    })
    .catch((e) => {
      console.log(e);
    });
});

À retenir

Transformer du code en callbacks vers des promesses n'est, à proprement parler, pas du refactoring : on change l'interface, donc le comportement observable, et il faut écrire de nouveaux tests. Mais c'est une interface qu'il faut préférer, car elle produit des valeurs de retour utiles plutôt que de s'appuyer sur des effets de bord. Connaître ces interfaces et savoir les tester est un prérequis à tout vrai refactoring ultérieur.

Des promesses à async/await

L'étape suivante consiste à écrire du code asynchrone qui se lit comme du synchrone, en ajoutant simplement deux mots-clés. Une fonction marquée async renvoie toujours une promesse ; await met en pause jusqu'à ce qu'une promesse soit tenue et en extrait la valeur.

// ❌ Avant : chaîne de .then().
function programme() {
  return four()
    .then(addOne)
    .then(console.log);
}

// ✅ Après : aplati, séquentiel et lisible.
async function programme() {
  const valeur = await four();
  const incremente = await addOne(valeur);
  console.log(incremente);
}

La gestion d'erreurs, dispersée dans les catch de chaque promesse, redevient un bloc try/catch familier, regroupé en un seul endroit :

// ❌ Avant : .catch() en bout de chaîne.
four()
  .then(addOne)
  .then(console.log)
  .catch((e) => console.error('Échec :', e));

// ✅ Après : try/catch comme en synchrone.
async function programme() {
  try {
    const valeur = await four();
    console.log(await addOne(valeur));
  } catch (e) {
    console.error('Échec :', e);
  }
}

Les pièges de async/await

La syntaxe est si proche du synchrone qu'on en oublie qu'on manipule des promesses. Trois pièges reviennent sans cesse.

Oublier un await (ou un return). Sans await, on manipule la promesse elle-même, pas sa valeur — et une erreur qu'elle rejette est silencieusement avalée :

// ❌ Avant : pas d'await => valeur = une promesse, erreur perdue.
async function total() {
  const valeur = chargerMontant(); // oubli de await
  return valeur + 100; // "[object Promise]100" (concaténation, pas le calcul attendu)
}

// ✅ Après : on attend la valeur, l'erreur remonte au try/catch.
async function total() {
  const valeur = await chargerMontant();
  return valeur + 100;
}

Confondre séquentiel et parallèle dans une boucle. Un await dans une boucle exécute les itérations l'une après l'autre. Pour des opérations indépendantes, c'est du temps perdu :

// ❌ Avant : séquentiel — chaque appel attend le précédent.
async function chargerTous(ids) {
  const resultats = [];
  for (const id of ids) {
    resultats.push(await chargerUtilisateur(id));
  }
  return resultats;
}

// ✅ Après : parallèle via Promise.all.
async function chargerTous(ids) {
  return Promise.all(ids.map((id) => chargerUtilisateur(id)));
}

Piège courant

Une promesse rejetée et non attendue (ni await, ni .catch()) déclenche un « unhandled rejection » et peut planter le processus — ou pire, passer inaperçue. Attendez toujours vos promesses, ou attachez-leur un catch. La règle d'or : si une expression produit une promesse, vous devez décider de son sort.

Le for...of séquentiel garde toutefois sa place quand chaque itération dépend de la précédente, ou pour limiter la charge sur un service fragile. Séquentiel et parallèle ne sont pas bons ou mauvais dans l'absolu : choisissez en connaissance de cause.

À retenir

  • JavaScript est mono-thread : la boucle d'événements rend le code non bloquant, mais le résultat arrive « plus tard ». N'utilisez jamais setTimeout pour synchroniser du code.
  • Pyramide de la mort et enfer des callbacks viennent de l'imbrication et de l'inversion de contrôle : dé-anonymisez et extrayez les fonctions, surveillez le this perdu, isolez l'état partagé.
  • Des callbacks aux promesses : on retrouve les return, on enchaîne avec .then()/.catch(), on aplatit la pyramide ; Promise.all pour le parallèle. Changer d'interface impose d'écrire de nouveaux tests.
  • Des promesses à async/await : du code asynchrone qui se lit comme du synchrone, avec un try/catch unique pour les erreurs.
  • Pièges : un await ou un return oublié avale les erreurs ; un await en boucle sérialise ce qui pouvait être parallèle ; une promesse rejetée non gérée plante ou disparaît.
  • Chaque étape se fait par petits pas, sous la protection des tests : connaître et tester l'interface est le prérequis de tout refactoring asynchrone.