Refactoring JavaScript
Chapitre 4 / 10 · 13 min de lecture

Les objectifs d'un bon refactoring

Ce qu'on vise concrètement : réduire le volume des fonctions, clarifier entrées/sorties, supprimer les effets de bord et nommer juste.

Avant de plonger dans les techniques, il faut savoir ce que l'on cherche à obtenir. Refactorer sans cible, c'est déplacer du désordre d'un endroit à un autre. Evan Burchard consacre un chapitre entier à définir des objectifs élémentaires de refactoring (basic refactoring goals) : un petit ensemble de qualités mesurables vers lesquelles tendre, quel que soit le paradigme — orienté objet, fonctionnel ou asynchrone. Tout repose sur la brique la plus importante et la plus retorse de JavaScript : la fonction.

Burchard part d'une histoire d'horreur familière : un fichier main.js de 2 000 lignes, des fonctions accrochées à $, des variables globales créées et modifiées au fil de l'eau, une tentative de framework abandonnée par un coéquipier parti monter une start-up de litières connectées. Il appelle cela le « JavaScript Jenga » : chaque modification ajoute un bloc à une tour instable, sans filet de tests, jusqu'à ce qu'un bug fasse tout s'effondrer en direct devant un utilisateur. Pour en sortir, il faut comprendre six composants d'une fonction et apprendre à les améliorer : le volume (bulk), les entrées (inputs), les sorties (outputs), les effets de bord (side effects), le contexte (this, l'entrée implicite) et la portée privée (privacy).

Note

Burchard outille tout le chapitre avec un schéma : une fonction est un cercle. On lui ajoute un nom et un nombre de lignes, des parts de tarte pour les chemins de code (sombres si testés, clairs sinon), des flèches d'entrée et de sortie, et un anneau pour la portée privée. Visualiser ces composants aide à voir, d'un coup d'œil, où se cache la complexité.

Le volume des fonctions

Le volume (bulk) décrit le corps d'une fonction selon deux caractéristiques liées : le nombre de lignes et la complexité (le nombre de chemins de code, ou complexité cyclomatique). Un chemin de code (code path) est un déroulé d'exécution possible ; le moyen le plus simple d'en créer plusieurs est le if. Les linters JavaScript détectent les deux excès.

Il n'existe pas de règle stricte. Certaines équipes plafonnent les fonctions à 25 lignes, d'autres à 10 ; pour la complexité, la limite haute tourne autour de six chemins. Trop d'une forme de volume annonce généralement l'autre : une fonction de 100 lignes a probablement trop de branches, et une fonction bourrée de switch et d'affectations sera longue. Le problème du volume est qu'il rend le code plus difficile à comprendre et à tester — et ce manque de confiance est exactement ce qui mène au JavaScript Jenga.

La compétence la plus importante du refactoring, dit Burchard, c'est — une fois les tests en place — de savoir extraire de nouvelles fonctions pour dégrossir celles qui existent. Mais il faut le faire en soignant l'interface : nommer correctement, et donner des entrées/sorties sensées.

// ❌ Avant : une fonction qui fait tout, plusieurs chemins.
function processOrder(order) {
  var total = 0;
  for (var i = 0; i < order.items.length; i++) {
    total += order.items[i].price * order.items[i].qty;
  }
  if (order.customer.isVip) {
    total = total * 0.9;
  }
  if (total > 100) {
    total = total - 5; // frais de port offerts
  }
  console.log("Total: " + total);
  return total;
}
// ✅ Après : chaque idée dans sa propre fonction nommée.
function subtotal(items) {
  return items.reduce(function (sum, item) {
    return sum + item.price * item.qty;
  }, 0);
}

function applyVipDiscount(amount, isVip) {
  return isVip ? amount * 0.9 : amount;
}

function applyFreeShipping(amount) {
  return amount > 100 ? amount - 5 : amount;
}

function orderTotal(order) {
  var base = subtotal(order.items);
  var discounted = applyVipDiscount(base, order.customer.isVip);
  return applyFreeShipping(discounted);
}

Chaque petite fonction est désormais testable isolément, avec moins de contexte à garder en tête.

Astuce

Si jamais le code va trop loin dans la délégation — des fonctions minuscules qui ne font qu'appeler une autre fonction —, Burchard rappelle que l'on peut inliner une fonction : recopier le corps de l'appelée dans l'appelante. Inliner puis ré-extraire est aussi une excellente façon d'explorer du code que l'on ne comprend pas. Toujours avec des tests, et prêt à revenir en arrière.

Les entrées : explicites, implicites, non locales

Burchard distingue trois types d'entrées. Les entrées explicites (explicit inputs) sont les paramètres de la définition : dans function add(a, b), a et b. L'entrée implicite (implicit input) est le this — l'objet dans lequel la fonction vit. Les entrées non locales (nonlocal inputs), aussi appelées variables libres, sont des variables capturées depuis une portée englobante, dont la forme ultime et redoutée est la variable globale.

L'exemple a l'air innocent :

// ❌ Avant : deux entrées non locales, name et punctuation.
var name = "Max";
var punctuation = "!";

function sayHi() {
  return "Hi " + name + punctuation;
}

Est-ce si innocent ? name et punctuation peuvent être redéfinis n'importe quand, et sayHi n'a aucune opinion là-dessus. Sur cinq lignes, le problème ne saute pas aux yeux ; dans un fichier de 300 lignes, ces variables qui flottent rendent la vie impossible, car elles peuvent changer à tout moment. La fonction devient imprévisible et difficile à tester : pour la mettre sous test, il faut reconstituer tout cet état caché.

// ✅ Après : les entrées deviennent explicites.
function sayHi(name, punctuation) {
  return "Hi " + name + punctuation;
}

sayHi("Max", "!");

La recommandation de Burchard est un classement clair : privilégier les entrées explicites (ce qui penche vers le style fonctionnel), puis les entrées implicites via this (style orienté objet), et en lointain troisième l'état non local ou global. Un moyen simple de se prémunir contre l'état non local est d'emballer le code dans des modules, des fonctions et des classes (ces dernières étant, au fond, des fonctions déguisées).

Moins d'arguments, des noms réels

JavaScript est extrêmement souple sur les paramètres : add(2, 3) renvoie 5, mais add(2, 3, 4) aussi, et add(2) renvoie NaN. Cette flexibilité complique les tests. Méfiez-vous en particulier de l'objet d'options fourre-tout :

// ❌ Avant : un objet mystère, params/options.
function doesSomething(options) {
  if (options.a) {
    var a = options.a;
  }
  if (options.b) {
    var b = options.b;
  }
  // ...
}

Cacher les valeurs dans un objet au nom générique comme params ou options gonfle les cas de test sans qu'on s'en rende compte. Rien n'interdit de passer un objet entier, mais l'appeler options trahit souvent une fonction qui en fait trop. Mieux vaut des paramètres aux vrais noms, qui documentent la signature. Avant ES2015, passer un objet illustrait au moins les arguments à l'appel ; depuis, la déstructuration (destructuring) permet la clarté des deux côtés :

// ✅ Après : déstructuration, clair à l'appel ET à la définition.
function search({ query, pageSize }) {
  console.log(query, pageSize);
}

search({ query: "something", pageSize: 20 });

Attention

Passer une fonction en argument (un callback) peut ajouter énormément de volume : chaque appel devient porteur d'une infinité de cas de test potentiels. C'est parfois rationnel, mais ne pas voir l'arbitrage entre flexibilité et simplicité des tests est une erreur. Règle générale : moins on a d'entrées, plus il est facile de contrôler le volume et de tester.

Les sorties : retourner une vraie valeur

Pour Burchard, le style idéal retourne toujours quelque chose. L'erreur la plus courante est d'ignorer la sortie en oubliant le return :

// ❌ Avant : pas de return, la fonction renvoie undefined.
function add(a, b) {
  a + b;
}
// ✅ Après : une vraie valeur de retour.
function add(a, b) {
  return a + b;
}

L'oubli est facile : les rubyistes, dont le langage retourne implicitement la dernière ligne, l'omettent par habitude ; et dans une base de code orientée effets de bord (les codebases jQuery où presque chaque ligne est un gestionnaire de clic), la valeur de retour paraît secondaire. C'est précisément le problème.

On veut une valeur de retour décente (ni null ni undefined) et du même type sur tous les chemins de code. Renvoyer tantôt une chaîne, tantôt un nombre vous promet des if-else chez l'appelant :

// ❌ Avant : renvoie un booléen OU une chaîne.
function moreThanThree(number) {
  if (number > 3) {
    return true;
  } else {
    return "No. The number was only " + number + ".";
  }
}

L'appelant devra tester quel type a été renvoyé. Pour les fonctions à action destructrice (modifier un tableau, changer le DOM), il est souvent élégant de retourner un objet décrivant l'effet produit — parfois simplement this. Retourner quelque chose d'informatif, même quand rien n'était explicitement demandé, est une bonne habitude qui facilite test et débogage.

SortieÉvaluation
Une valeur réelle et cohérenteIdéal.
Le même type sur tous les cheminsRecommandé.
this (après un effet de bord)Acceptable, et utile.
null / undefinedÀ éviter : une meilleure solution existe presque toujours.
Des types mélangés selon les branchesÀ proscrire : conditionnels chez l'appelant.

Les effets de bord : les isoler

JavaScript adore les effets de bord (side effects) : la mission principale de jQuery est de manipuler le DOM, et cela passe par des effets de bord. Le bon côté, c'est qu'ils sont responsables de tout le travail qui compte : ils mettent à jour le DOM, écrivent en base, font fonctionner console.log.

Malgré tout, l'objectif est de les isoler et d'en limiter la portée, pour deux raisons : les fonctions à effets de bord sont plus difficiles à tester, et elles dépendent (ou agissent) sur un état qui complique le design. Idéalement, le moins d'effets de bord possible, et là où ils doivent exister, qu'ils soient regroupés. Une seule mise à jour sur une interface bien définie (disons, une seule ligne de base de données) est plus facile à tester que des mises à jour multiples dispersées.

// ❌ Avant : logique métier et effet de bord entremêlés.
function chargeCard(cart, gateway) {
  var total = 0;
  for (var i = 0; i < cart.items.length; i++) {
    total += cart.items[i].price;
  }
  gateway.charge(total); // effet de bord au milieu du calcul
}
// ✅ Après : le calcul est pur, l'effet de bord est à la frontière.
function cartTotal(cart) {
  return cart.items.reduce(function (sum, item) {
    return sum + item.price;
  }, 0);
}

// L'effet de bord, isolé et qui retourne un résultat.
function chargeCart(cart, gateway) {
  var total = cartTotal(cart);
  return gateway.charge(total);
}

cartTotal est devenue une fonction pure (pure function) : mêmes entrées, même sortie, aucun effet observable. On la teste sans aucune mise en place. L'effet de bord, lui, est repoussé à la frontière et retourne désormais le résultat de l'opération au lieu de undefined.

À retenir

Même une méthode à effet de bord devrait retourner quelque chose d'utile. Quand l'effet modifie le contexte, retourner this est une excellente option : on obtient une confirmation de ce qui s'est passé, on simplifie le test, et on ouvre la voie à une interface fluide (fluent interface) permettant le chaînage obj.setIt(3).setIt(4).

Le contexte : this, l'entrée implicite

Le this est, de l'aveu de Burchard, « la chose la plus déroutante de JavaScript ». Au niveau supérieur, this désigne l'objet de base de l'environnement (window dans un navigateur). En mode strict ('use strict'), une fonction simple appelée seule voit son this valoir undefined plutôt que l'objet global — ce qui est en réalité plus sûr.

Le point pratique pour le refactoring : préférer attacher this de façon contrôlée plutôt que de coder en dur des noms d'objets en entrées non locales. On crée un contexte avec un simple objet littéral, et l'on peut réassigner this explicitement via call, apply ou bind :

// ✅ Retourner this rend l'effet de bord vérifiable.
var counterObject = {
  count: 0,
  increment: function () {
    this.count = this.count + 1;
    return this; // au lieu de undefined
  },
};

counterObject.increment().increment();
console.log(counterObject.count); // 2

Burchard insiste : faire le choix de passer this explicitement ou de le lier au moment de l'appel, plutôt que de figer un nom d'objet global, réduit les entrées non locales. Avoir un this bien défini — en rattachant les fonctions à des classes ou au moins à des objets — diminue d'autant le recours aux variables globales.

Existe-t-il de la « privacy » en JavaScript ?

Le dernier objectif touche à la portée privée (privacy). L'intérêt est double : si l'on accepte que les fonctions privées sont des « détails d'implémentation » qui ne requièrent pas de test, on peut cacher l'essentiel de la logique et n'exposer qu'une petite API publique — moins de tests, moins de maintenance, une interface plus claire.

La réponse honnête de Burchard est nette : au moment où il écrit, il n'y a pas de vraie confidentialité en JavaScript. Les choses sont soit dans la portée et adressables, soit hors de portée et inaccessibles. Il reste deux conventions. La première consiste à cacher l'état dans une closure (closure) via le revealing module pattern : une fonction anonyme immédiatement invoquée (IIFE) crée une portée où se cachent variables et fonctions, et l'on ne renvoie que l'API publique.

// ✅ Compteur encapsulé via closure : count est inaccessible.
var counter = (function () {
  var count = 0; // privé : pas d'accès direct

  function increment() {
    count = count + 1;
    return count;
  }

  function reset() {
    count = 0;
    return count;
  }

  return {
    increment: increment,
    reset: reset,
  };
})();

counter.increment(); // 1
counter.increment(); // 2
counter.count; // undefined : vraiment caché

La seconde convention, plus rustique, consiste à laisser les membres « privés » s'attacher au même this que les membres publics, mais à signaler l'intention par un préfixe _ (_unlock, _secrets). Rien n'est réellement caché, mais l'indice visuel communique l'intention — et garde tout adressable, donc testable.

// ✅ Convention du préfixe _ : intention signalée, tout reste testable.
class Diary {
  constructor() {
    this.open = false;
    this._key = 12345;
    this._secrets = "rosebud";
  }

  _unlock(attempt) {
    if (this._key === attempt) this.open = true;
    return this;
  }

  tryLock(attempt) {
    return this._unlock(attempt);
  }

  read() {
    return this.open ? this._secrets : null;
  }
}

La véritable confidentialité passe par les modules : exporter une classe avec module.exports est une opération de whitelisting — seul ce qu'on exporte est importable, et l'état laissé dans la portée du module reste hors d'atteinte. L'inconvénient, comme le note Burchard, c'est que tester ces fonctions vraiment privées redevient compliqué (retour au préfixe _, ou modules dédiés).

Piège courant

En JavaScript, les mécanismes de confidentialité impactent nécessairement l'accès : rendre une fonction réellement privée la rend aussi inaccessible aux tests. C'est un arbitrage assumé, pas un détail. Choisissez consciemment entre « petite API publique » et « facilité de test ».

Le nommage et la lisibilité humaine

Un fil rouge traverse le chapitre : si extraire des fonctions ne fait que disperser l'implémentation, le volume était préférable. Et qu'est-ce qui distingue une extraction utile d'un éparpillement nuisible ? Le nom. Si chaque fonction s'appelait une variante de passResultOfDataToNextFunction, l'extraction ne ferait qu'embrouiller la logique. La lisibilité humaine est la mesure première de la qualité : suivre le code de fonction en fonction demande un peu de pratique, mais il y a alors moins de contexte à garder en tête, et tester de petites fonctions devient bien plus simple.

L'objectif de tous ces objectifs réunis — volume réduit, entrées explicites, sorties claires, effets de bord isolés, état encapsulé — est de produire du code qui se lit comme une intention nommée. C'est ce qui rend les schémas de Burchard plus simples, et le code avec.

Note

Le style adopté dans ce chapitre est « ami de this » : il utilise des objets qui changent de valeur, ce qui entre en tension avec le style fonctionnel. En orienté objet (classes ou prototypes), on emploie this fréquemment ; en fonctionnel, on s'appuiera davantage sur des fonctions pures et des entrées explicites. JavaScript, lui, ne tranche pas : à vous de choisir selon le projet et l'équipe.

À retenir

  • Réduire le volume : des fonctions courtes, peu de chemins de code. Une fois les tests en place, extraire des fonctions est la compétence n°1 du refactoring.
  • Entrées explicites d'abord : privilégier les paramètres explicites, puis this, et en dernier l'état non local ou global. Moins d'arguments, des noms réels plutôt qu'un objet options fourre-tout.
  • Sorties réelles et cohérentes : toujours retourner quelque chose d'informatif, du même type sur tous les chemins, ni null ni undefined. Après un effet de bord, retourner this.
  • Effets de bord isolés : les réduire au minimum et les repousser aux frontières ; garder le cœur en fonctions pures, faciles à tester.
  • Contexte maîtrisé : passer ou lier this explicitement plutôt que coder en dur des noms globaux ; rattacher les fonctions à des objets ou classes.
  • Encapsulation et nommage : closures et modules cachent l'état interne ; mais le vrai juge de la qualité reste la lisibilité humaine — refactorez par petits pas, sous la protection des tests.