Refactoring JavaScript
Chapitre 6 / 10 · 12 min de lecture

Refactorer les fonctions et les objets

Le cœur du métier : extraire des fonctions, dompter les arguments et `this`, et restructurer les objets avec reduce.

C'est le chapitre central, et le plus dense, de Burchard. Tout y converge : un petit classifieur bayésien naïf (un programme qui devine la difficulté d'une chanson à partir de ses accords) sert de terrain d'entraînement à un long enchaînement de refactorings. Le fil rouge est simple à énoncer mais exigeant à tenir : transformer un script procédural, truffé de variables globales et de boucles à accumulateur, en un objet propre composé de petites fonctions.

Deux techniques dominent : extraire une fonction (extract function), que l'auteur qualifie de « technique de refactoring la plus utile et la plus sous-utilisée », et la maîtrise du contexte this, ce piège récurrent de JavaScript. Autour de ces deux piliers gravitent la chasse aux arguments superflus, la distinction entre fonctions pures et à effets de bord, et la construction d'objets avec reduce plutôt qu'avec une boucle mutable. Comme toujours chez Burchard, chaque pas se fait sous la protection des tests et par petits incréments.

Note

Avant ce chapitre, l'auteur installe un filet de sécurité : des tests de caractérisation (characterization tests) écrits avec mocha et wish. On lance le code, on observe la valeur réelle, on la fige dans une assertion. Une fois ces tests verts, on peut « refactorer agressivement et en confiance ». Sans eux, rien de ce qui suit ne serait sûr.

Extraire une fonction

Pourquoi extraire

Au départ, le programme n'est pas vraiment structuré : des données (les chansons), des fonctions, et des appels nus s'enchaînent au niveau du fichier. Tout ce qui n'est pas dans une fonction ou dans le code de test « est un problème », car on exécute un fichier au lieu d'appeler des fonctions. Extraire une fonction sert trois objectifs : nommer un comportement, le réutiliser, et le rendre testable.

Premier exemple : trois appels qui vont toujours ensemble. On les emballe dans une fonction au nom parlant.

// ❌ Avant : trois appels nus, exécutés en séquence dans le fichier.
setLabelProbabilities();
setChordCountsInLabels();
setProbabilityOfChordsInLabels();
// ✅ Après : un nom décrit l'intention, et on peut le rejouer.
function setLabelsAndProbabilities() {
  setLabelProbabilities();
  setChordCountsInLabels();
  setProbabilityOfChordsInLabels();
}
setLabelsAndProbabilities();

Burchard note qu'on pourrait aussi utiliser une IIFE (immediately invoked function expression, fonction immédiatement invoquée), (function () { ... })();. Mais on y perd le contrôle : impossible de rejouer le bloc sans le nommer. Donner un nom est presque toujours préférable.

Extraire pour nommer une fonction anonyme

L'extraction ne sert pas qu'à regrouper : elle sert aussi à nommer une fonction anonyme. Voici du jQuery courant, avec une duplication évidente.

// ❌ Avant : le même corps répété dans deux gestionnaires.
$('.my-button').on('click', function () {
  window.location = 'http://refactoringjs.com';
});
$('.other-button').on('click', function () {
  window.location = 'http://refactoringjs.com';
});

Beaucoup s'arrêtent à l'extraction d'une variable (var siteUrl = ...). C'est mieux, mais on peut aller plus loin en extrayant une fonction et en passant sa référence, sans l'envelopper inutilement.

// ✅ Après : une fonction nommée, passée par référence.
var siteUrl = 'http://refactoringjs.com';
function visitSite() {
  window.location = siteUrl;
}
$('.my-button').on('click', visitSite);
$('.other-button').on('click', visitSite);

À retenir

On passe visitSite (une référence), pas visitSite() (un appel). Confondre les deux est l'erreur classique de qui débute en JavaScript. on('click', visitSite()) exécute la fonction immédiatement et passe son résultat (souvent undefined) au gestionnaire. Si la fonction a besoin d'un argument, enveloppez l'appel : on('click', function () { visitSite(url); }).

Quand RETARDER l'extraction

Extraire n'est pas toujours un progrès. L'opération inverse existe : incorporer (inline) une fonction. Burchard montre des extractions qu'il déconseille — découper setup en setSongsVariable, ou grouper arbitrairement des lignes dans setSome / setOthers.

// ❌ À NE PAS faire : un découpage qui n'apporte aucune clarté.
function setSome() {
  songs = [];
  allChords = new Set();
  labelCounts = new Map();
}
function setOthers() {
  labelProbabilities = new Map();
  chordCountsInLabels = new Map();
}

Ces fonctions ne font que regrouper des lignes arbitraires. Elles ajoutent de l'indirection sans rendre le code plus clair. « Quand une fonction extraite ne fait pas vraiment quelque chose, il est sensé de l'incorporer. » La règle : extraire et incorporer doivent vous venir aussi naturellement que d'introduire ou de fusionner une variable. Ce sont deux choix réversibles, pas un dogme à sens unique.

Maîtriser les arguments

Réduire leur nombre

Une fonction qui réclame trop d'arguments trahit souvent un concept manquant. Dans le programme, chaque chanson promène trois données qui voyagent ensemble : son nom, ses accords, sa difficulté. Plutôt que de les balader séparément, on les regroupe en objet poussé dans un tableau.

// ❌ Avant : un tableau positionnel, song[0] et song[1] opaques.
songs.push([label, chords]);
// ... plus loin :
if (chordCountsInLabels[song[0]] === undefined) { /* ... */ }
song[1].forEach(/* ... */);
// ✅ Après : un objet aux clés nommées (raccourci de propriété).
songs.push({ label, chords });
// ... plus loin :
if (chordCountsInLabels[song.label] === undefined) { /* ... */ }
song.chords.forEach(/* ... */);

Le raccourci de propriété d'objet (object property shorthand) { label, chords } est équivalent à { label: label, chords: chords }. Au-delà de l'économie, song.label et song.chords documentent l'intention là où song[0] et song[1] exigeaient de deviner.

L'objet-paramètre

Le même principe s'applique à une signature. Quand on introduit un objet songList doté d'une méthode addSong, on lui passe trois arguments — mais ils forment désormais un tout cohérent regroupé à l'intérieur.

// ✅ Un objet-paramètre construit à partir d'arguments groupés.
var songList = {
  songs: [],
  addSong: function (name, chords, difficulty) {
    this.songs.push({ name, chords, difficulty });
  }
};

Passer la donnée brute plutôt que le libellé

Burchard remplace ensuite la difficulté textuelle (easy, medium, hard) par un index numérique résolu en interne, ce qui supprime trois variables globales de bruit. Le tableau difficulties devient la source de vérité.

// ✅ La donnée brute (l'index) entre, le libellé est résolu en interne.
var songList = {
  difficulties: ['easy', 'medium', 'hard'],
  songs: [],
  addSong: function (name, chords, difficulty) {
    this.songs.push({
      name,
      chords,
      difficulty: this.difficulties[difficulty]
    });
  }
};
// Appel : songList.addSong('imagine', ['c', 'cmaj7', 'f'], 0);

Astuce

Burchard insiste sur le pouvoir des valeurs par défaut côté API : « les appels de fonction JavaScript fonctionnent très bien avec trop ou trop peu d'arguments ». Un paramètre non fourni vaut simplement undefined. Consultez toujours l'API d'une fonction qui prend une fonction (forEach, map…) pour connaître ses arguments optionnels — comme le thisArg que nous verrons plus bas.

Le piège de this

C'est le morceau le plus subtil du chapitre. Une fois le code regroupé dans un objet classifier, les méthodes utilisent this. Mais à l'intérieur d'un forEach ou d'un reduce, le this se perd : la fonction anonyme passée en rappel (callback) a son propre contexte, détaché de l'objet.

// ❌ Avant : ce this pointe vers le mauvais contexte.
classify: function (chords) {
  this.labelProbabilities.forEach(function (_p, difficulty) {
    // ICI : this n'est plus le classifier !
    var p = this.probabilityOfChordsInLabels.get(difficulty)[chord];
    // ... TypeError ...
  });
}

La rustine self = this (à éviter)

La parade « la plus courante et la plus balourde », selon l'auteur, consiste à capturer le contexte dans une variable avant d'entrer dans le rappel.

// ❌ Fonctionne, mais maladroit : on traîne un alias self.
classify: function (chords) {
  var self = this;
  this.labelProbabilities.forEach(function (_p, difficulty) {
    var p = self.probabilityOfChordsInLabels.get(difficulty)[chord];
    // ...
  });
}

Cela marche, mais ajoute une variable parasite. Burchard la présente pour mieux la dépasser.

Les bonnes solutions : thisArg, bind, fonctions fléchées

Première amélioration : forEach accepte un second argument, le thisArg, qui fixe le this du rappel. reduce, lui, n'a pas cette option — il faut alors bind.

// ✅ Mieux : thisArg pour forEach, bind pour reduce.
classify: function (chords) {
  var classified = new Map();
  this.labelProbabilities.forEach(function (_p, difficulty) {
    var total = chords.reduce(function (acc, chord) {
      var p = this.probabilityOfChordsInLabels.get(difficulty)[chord];
      return p ? acc * (p + this.smoothing) : acc;
    }.bind(this), this.labelProbabilities.get(difficulty) + this.smoothing);
    classified.set(difficulty, total);
  }, this); // <- thisArg de forEach
  return classified;
}

La meilleure solution reste la fonction fléchée (arrow function), qui capture le this de la portée englobante. Plus de thisArg, plus de bind.

// ✅ Idéal : les flèches héritent du this extérieur.
classify: function (chords) {
  var classified = new Map();
  this.labelProbabilities.forEach((_p, difficulty) => {
    var total = chords.reduce((acc, chord) => {
      var p = this.probabilityOfChordsInLabels.get(difficulty)[chord];
      return p ? acc * (p + this.smoothing) : acc;
    }, this.labelProbabilities.get(difficulty) + this.smoothing);
    classified.set(difficulty, total);
  });
  return classified;
}

Attention

La flèche n'est pas qu'un sucre syntaxique. Burchard le prouve : reconvertissez chord => { this.allChords.add(chord) } en function (chord) { this.allChords.add(chord); } et les tests cassent, car le this redevient celui du rappel. Pour la forme longue, il faudrait repasser un thisArg. La capture de this est le comportement propre aux fonctions fléchées.

Fonctions pures contre effets de bord

Le chapitre culmine sur une conviction forte : « nous ne voulons pas de fonctions qui posent des valeurs par effets de bord (side effects). Nous voulons des fonctions qui retournent des valeurs. » Une fonction pure (pure function) ne dépend que de ses entrées et ne modifie aucun état extérieur ; elle est bien plus facile à suivre, tester et refactorer.

Le programme stockait des comptages dans une Map mutable, chordCountsInLabels, remplie par une fonction setChordCountsInLabels à effet de bord. Burchard la remplace par une requête qui recalcule à la demande.

// ❌ Avant : effet de bord, on remplit une Map partagée.
setChordCountsInLabels: function () {
  songList.songs.forEach(function (song) {
    if (this.chordCountsInLabels.get(song.difficulty) === undefined) {
      this.chordCountsInLabels.set(song.difficulty, {});
    }
    song.chords.forEach(function (chord) {
      var map = this.chordCountsInLabels.get(song.difficulty);
      map[chord] = (map[chord] || 0) + 1;
    }, this);
  }, this);
}
// ✅ Après : une fonction qui calcule et retourne, sans état partagé.
chordCountForDifficulty: function (difficulty, testChord) {
  let counter = 0;
  songList.songs.forEach(function (song) {
    if (song.difficulty === difficulty) {
      song.chords.forEach(function (chord) {
        if (chord === testChord) counter += 1;
      });
    }
  });
  return counter;
}

Piège courant

Un piège mémorable du chapitre : classifier.probabilityOfChordsInLabels = classifier.chordCountsInLabels ne copie pas l'objet ; les deux noms désignent le même objet en mémoire. Modifier l'un modifie l'autre. Comme le résume l'auteur : « ce sont juste deux doigts pointant le même objet ». Affecter avec = n'est jamais une façon de cloner. Pour copier réellement, regardez du côté de la copie profonde, Object.assign ou des bibliothèques immuables.

Construire des objets avec reduce

Le motif récurrent du chapitre : « quand vous vous surprenez à préparer un conteneur, à le modifier dans une boucle, puis à le retourner, reduce (ou map) vous servira souvent mieux ». La boucle à accumulateur mutable se condense en une seule expression.

Burchard rappelle d'abord le fonctionnement de reduce par trois traces minimales :

[2, 3, 4].reduce((res, el) => res, 10)        // => 10
[2, 3, 4].reduce((res, el) => res + el)        // => 9  (sans valeur initiale)
[2, 3, 4].reduce((res, el) => res + el, 10)    // => 19 (avec valeur initiale)

Appliqué au comptage, on passe d'une boucle forEach + variable counter à un reduce dont l'accumulateur est la valeur initiale.

// ❌ Avant : forEach + variable mutable.
chordCountForDifficulty: function (difficulty, testChord) {
  let counter = 0;
  songList.songs.forEach(function (song) {
    if (song.difficulty === difficulty) {
      song.chords.forEach(function (chord) {
        if (chord === testChord) counter += 1;
      });
    }
  });
  return counter;
}
// ✅ Après : reduce, accumulateur passé en paramètre, retour direct.
chordCountForDifficulty: function (difficulty, testChord) {
  return songList.songs.reduce(function (counter, song) {
    if (song.difficulty === difficulty) {
      counter += song.chords.filter(function (chord) {
        return chord === testChord;
      }).length;
    }
    return counter;
  }, 0);
}

Notez la valeur initiale 0 en fin d'appel, et le filter qui isole les accords correspondants avant d'en prendre la length — moins de mises à jour manuelles du compteur.

Même technique pour construire une Map : au lieu d'initialiser une Map vide, de la remplir dans un forEach et de la retourner, on alimente directement le constructeur avec le tableau produit par map.

// ❌ Avant : Map vide + remplissage + retour.
classify: function (chords) {
  var classified = new Map();
  this.labelProbabilities.forEach((_p, difficulty) => {
    var total = chords.reduce(/* ... */);
    classified.set(difficulty, total);
  });
  return classified;
}
// ✅ Après : map produit les paires, le constructeur Map les ingère.
classify: function (chords) {
  return new Map(
    Array.from(this.labelProbabilities.entries()).map((entry) => {
      var difficulty = entry[0];
      return [difficulty, chords.reduce((acc, chord) => {
        return acc * this.valueForChordDifficulty(difficulty, chord);
      }, this.labelProbabilities.get(difficulty) + this.smoothing)];
    })
  );
}

Note

Comme Map n'a pas de méthode map, on convertit ses entrées en tableau via Array.from(... .entries()), on transforme, puis on reconstruit. Le rappel de reduce doit toujours retourner l'accumulateur — y compris dans la branche « sinon ». Sans else, un tour qui ne renvoie rien produirait undefined et casserait le calcul ; renvoyer total revient à « passer au suivant ».

De la grosse fonction à l'objet-méthode

Le dernier grand mouvement du chapitre consiste à remplacer une fonction par un objet-méthode (un refactoring du catalogue Fowler nommé Replace Method with Method Object, distinct de Replace Function with Command ; Burchard, lui, parle simplement de « rationaliser l'API autour d'un objet global »). Quand une fonction accumule trop d'état local et de variables globales, on en fait une méthode d'un objet qui porte cet état. C'est tout le passage du script éclaté vers l'objet classifier.

// ❌ Avant : fonctions et variables éparpillées dans le scope global.
var songs = [];
var labelCounts = {};
function train(chords, label) { /* lit/écrit les globales */ }
function classify(chords) { /* idem */ }
// ✅ Après : un seul objet porte l'état ET le comportement.
const classifier = {
  labelCounts: new Map(),
  labelProbabilities: new Map(),
  smoothing: 1.01,
  songList: {
    allChords: new Set(),
    difficulties: ['easy', 'medium', 'hard'],
    songs: [],
    addSong(name, chords, difficulty) {
      this.songs.push({
        name, chords, difficulty: this.difficulties[difficulty]
      });
    }
  },
  train(chords, label) {
    chords.forEach((chord) => this.songList.allChords.add(chord));
    if (Array.from(this.labelCounts.keys()).includes(label)) {
      this.labelCounts.set(label, this.labelCounts.get(label) + 1);
    } else {
      this.labelCounts.set(label, 1);
    }
  },
  valueForChordDifficulty(difficulty, chord) {
    const value = this.likelihoodFromChord(difficulty, chord);
    return value ? value + this.smoothing : 1;
  }
  // ... classify, trainAll, etc.
};

Cette transformation réduit la portée des variables globales (objectif majeur de Burchard) et définit une interface claire : les tests n'appellent plus que classifier.trainAll() et classifier.classify(...). La méthode classify, qui empilait var first réassigné, devient enfin composée de petites fonctions-requêtesvalueForChordDifficulty, likelihoodFromChord — chacune extraite avec un nom qui dit ce qu'elle fait. (Ces méthodes dépendent de this, elles ne sont donc pas « pures » au sens strict ; ce sont des requêtes qui calculent et retournent une valeur sans muter l'état partagé.)

À retenir

Burchard prévient : « le code n'a pas d'état final, parfait, "refactoré" ». Incorporer et extraire sont des opérations inverses ; on pourrait refactorer à l'infini. Densifier le code (inliner des variables, multiplier bind et les flèches) peut même le rendre plus dur à lire pour certains coéquipiers. La règle d'or du livre reste : écrire pour les humains d'abord, optimiser ensuite, et calibrer vos standards de qualité avec votre équipe.

À retenir

  • Extraire une fonction pour nommer, réutiliser, tester — mais sachez aussi incorporer (inline) : c'est l'opération inverse, et l'extraction arbitraire de lignes n'aide personne.
  • Domptez les arguments : regroupez les valeurs qui voyagent ensemble dans un objet-paramètre, exploitez le raccourci de propriété, passez la donnée brute (l'indice) et résolvez le libellé en interne.
  • Méfiez-vous de this dans les rappels : le contexte se perd. Préférez les fonctions fléchées (qui capturent this) ou, à défaut, thisArg/bind ; évitez la rustine self = this.
  • Visez des fonctions pures : qui retournent une valeur plutôt que de muter un état partagé. Et rappelez-vous que = ne copie pas un objet, il partage une référence.
  • Remplacez les boucles à accumulateur par reduce (ou map/filter) : pour compter, transformer, ou alimenter directement le constructeur d'une Map.
  • Transformez une grosse fonction en objet-méthode quand elle accumule trop d'état : un seul objet porte données et comportement, la portée des globales fond, l'interface se clarifie — le tout par petits pas, tests au vert.