Avez-vous une question? (216) 97 656 803 hajjriadh@gmail.com
JavaScript

TP 17 : Découper une application en modules

Découper une application en modules. Dans cette leçon du cours JavaScript, vous apprendrez à : analyser un code monolithique et y repérer les responsabilités ; le découper en modules cohérents sans ch...

0 chapitres 0 QCM Accès gratuit
Dans cet article

À propos de ce cours

Découper une application en modules. Dans cette leçon du cours JavaScript, vous apprendrez à : analyser un code monolithique et y repérer les responsabilités ; le découper en modules cohérents sans changer son comportement ; appliquer l'architecture en couches et éviter les cycles ; charger une fonctionnalité à la demande avec import().

Chapitre 17 — Les modules et l'organisation du code · 17.6 TP 17 : Découper une application en modules

🎯 Objectifs de la leçon
• analyser un code monolithique et y repérer les responsabilités ;
• le découper en modules cohérents sans changer son comportement ;
• appliquer l'architecture en couches et éviter les cycles ;
• charger une fonctionnalité à la demande avec import().

Présentation

Un enseignant a écrit une application « Carnet de notes » qui fonctionne. Elle permet d'ajouter des élèves, de saisir leurs notes par matière et d'afficher les moyennes, les mentions et des statistiques. Les données sont conservées dans le localStorage et peuvent être exportées en CSV.

Le problème : tout le code est dans un seul fichier app.js de 199 lignes. Votre mission est de le transformer en application modulaire, sans modifier son comportement. Les questions vous guident : aucune solution n'est donnée.

Le fichier monolithique à découper et les contraintes du TP
Figure 1 — Point de départ et contraintes.

Fichiers de départ

⬇ Télécharger les fichiers de départ du TP (tp17-depart.zip)

Créez un dossier carnet-notes/ contenant ces deux fichiers, puis ouvrez index.html avec un serveur local et testez l'application.

index.html
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <title>Carnet de notes</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 900px; margin: 30px auto; padding: 0 16px; }
    form { display: flex; gap: 8px; margin: 10px 0; flex-wrap: wrap; }
    table { border-collapse: collapse; width: 100%; margin-top: 16px; }
    th, td { border: 1px solid #ccc; padding: 6px 10px; text-align: left; }
    th { background: #e3f2fd; }
    #message { min-height: 1.4em; font-weight: bold; }
    .erreur { color: #c62828; } .succes { color: #2e7d32; }
    #stats { background: #f5f5f5; padding: 10px; border-radius: 6px; margin-top: 16px; }
  </style>
</head>
<body>
  <h1>Carnet de notes</h1>

  <form id="form-eleve">
    <input id="nom-eleve" placeholder="Nom de l'élève">
    <button>Ajouter l'élève</button>
  </form>

  <form id="form-note">
    <select id="choix-eleve"></select>
    <select id="choix-matiere"></select>
    <input id="valeur-note" type="number" step="0.25" placeholder="Note /20">
    <button>Ajouter la note</button>
  </form>

  <p id="message"></p>
  <table id="tableau"></table>
  <div id="stats"></div>
  <p><button id="btn-export">Exporter en CSV</button></p>

  <script src="app.js"></script>
</body>
</html>
app.js (199 lignes)
// =====================================================================
//  Carnet de notes — version MONOLITHIQUE (tout dans un seul fichier)
//  TP 17 : découpez ce fichier en modules. Ne changez pas le comportement.
// =====================================================================

const CLE_STOCKAGE = "carnet-notes";
const NOTE_MIN = 0;
const NOTE_MAX = 20;
const MATIERES = ["Maths", "Physique", "Informatique"];

let eleves = [];

// ---------- Calculs ----------
function arrondir(x, decimales = 2) {
  const p = 10 ** decimales;
  return Math.round(x * p) / p;
}

function moyenne(liste) {
  if (liste.length === 0) return null;
  return arrondir(liste.reduce((s, x) => s + x, 0) / liste.length);
}

function mention(moy) {
  if (moy === null) return "—";
  if (moy >= 16) return "Très bien";
  if (moy >= 14) return "Bien";
  if (moy >= 12) return "Assez bien";
  if (moy >= 10) return "Passable";
  return "Insuffisant";
}

function noteValide(note) {
  return Number.isFinite(note) && note >= NOTE_MIN && note <= NOTE_MAX;
}

// ---------- Stockage ----------
function charger() {
  try {
    eleves = JSON.parse(localStorage.getItem(CLE_STOCKAGE)) ?? [];
  } catch {
    eleves = [];
  }
}

function sauvegarder() {
  localStorage.setItem(CLE_STOCKAGE, JSON.stringify(eleves));
}

// ---------- Données ----------
function ajouterEleve(nom) {
  nom = nom.trim();
  if (!nom) throw new Error("Le nom est obligatoire.");
  if (eleves.some((e) => e.nom.toLowerCase() === nom.toLowerCase())) {
    throw new Error(`${nom} existe déjà.`);
  }
  eleves.push({ id: crypto.randomUUID(), nom, notes: {} });
  sauvegarder();
}

function supprimerEleve(id) {
  eleves = eleves.filter((e) => e.id !== id);
  sauvegarder();
}

function ajouterNote(id, matiere, note) {
  if (!noteValide(note)) {
    throw new Error(`La note doit être comprise entre ${NOTE_MIN} et ${NOTE_MAX}.`);
  }
  const eleve = eleves.find((e) => e.id === id);
  if (!eleve) throw new Error("Élève introuvable.");
  (eleve.notes[matiere] ??= []).push(note);
  sauvegarder();
}

// ---------- Statistiques ----------
function moyenneEleve(eleve) {
  return moyenne(Object.values(eleve.notes).flat());
}

function moyenneClasse() {
  const moyennes = eleves.map(moyenneEleve).filter((m) => m !== null);
  return moyenne(moyennes);
}

function meilleurEleve() {
  let meilleur = null;
  for (const e of eleves) {
    const m = moyenneEleve(e);
    if (m !== null && (meilleur === null || m > meilleur.moyenne)) {
      meilleur = { nom: e.nom, moyenne: m };
    }
  }
  return meilleur;
}

// ---------- Export CSV ----------
function versCSV() {
  const entete = ["Nom", ...MATIERES, "Moyenne", "Mention"];
  const lignes = eleves.map((e) => {
    const m = moyenneEleve(e);
    return [e.nom, ...MATIERES.map((mat) => (e.notes[mat] ?? []).join(" ")), m ?? "", mention(m)];
  });
  return [entete, ...lignes].map((l) => l.join(";")).join("\n");
}

function telechargerCSV() {
  const blob = new Blob([versCSV()], { type: "text/csv;charset=utf-8" });
  const lien = document.createElement("a");
  lien.href = URL.createObjectURL(blob);
  lien.download = "carnet-notes.csv";
  lien.click();
  URL.revokeObjectURL(lien.href);
}

// ---------- Affichage ----------
function afficherMessage(texte, type = "succes") {
  const zone = document.querySelector("#message");
  zone.textContent = texte;
  zone.className = type;
}

function afficherSelects() {
  document.querySelector("#choix-eleve").innerHTML = eleves
    .map((e) => `<option value="${e.id}">${e.nom}</option>`)
    .join("");
  document.querySelector("#choix-matiere").innerHTML = MATIERES
    .map((m) => `<option>${m}</option>`)
    .join("");
}

function afficherTableau() {
  const entete = `<tr><th>Nom</th>${MATIERES.map((m) => `<th>${m}</th>`).join("")}
    <th>Moyenne</th><th>Mention</th><th></th></tr>`;
  const lignes = eleves.map((e) => {
    const m = moyenneEleve(e);
    const cellules = MATIERES.map((mat) => `<td>${(e.notes[mat] ?? []).join(", ")}</td>`).join("");
    return `<tr><td>${e.nom}</td>${cellules}<td>${m ?? "—"}</td><td>${mention(m)}</td>
      <td><button data-supprimer="${e.id}">Supprimer</button></td></tr>`;
  });
  document.querySelector("#tableau").innerHTML = entete + lignes.join("");
}

function afficherStats() {
  const meilleur = meilleurEleve();
  document.querySelector("#stats").innerHTML = `
    Élèves : <strong>${eleves.length}</strong> ·
    Moyenne de la classe : <strong>${moyenneClasse() ?? "—"}</strong> ·
    Meilleur élève : <strong>${meilleur ? `${meilleur.nom} (${meilleur.moyenne})` : "—"}</strong>`;
}

function rafraichir() {
  afficherSelects();
  afficherTableau();
  afficherStats();
}

// ---------- Événements ----------
document.querySelector("#form-eleve").addEventListener("submit", (ev) => {
  ev.preventDefault();
  const champ = document.querySelector("#nom-eleve");
  try {
    ajouterEleve(champ.value);
    afficherMessage(`${champ.value.trim()} a été ajouté.`);
    champ.value = "";
    rafraichir();
  } catch (err) {
    afficherMessage(err.message, "erreur");
  }
});

document.querySelector("#form-note").addEventListener("submit", (ev) => {
  ev.preventDefault();
  const id = document.querySelector("#choix-eleve").value;
  const matiere = document.querySelector("#choix-matiere").value;
  const note = parseFloat(document.querySelector("#valeur-note").value);
  try {
    ajouterNote(id, matiere, note);
    afficherMessage(`Note ${note} ajoutée en ${matiere}.`);
    rafraichir();
  } catch (err) {
    afficherMessage(err.message, "erreur");
  }
});

document.querySelector("#tableau").addEventListener("click", (ev) => {
  const id = ev.target.dataset.supprimer;
  if (id) {
    supprimerEleve(id);
    afficherMessage("Élève supprimé.");
    rafraichir();
  }
});

document.querySelector("#btn-export").addEventListener("click", telechargerCSV);

// ---------- Démarrage ----------
charger();
rafraichir();
ℹ️ À retenir
Avant chaque étape, notez un scénario de test à refaire après chaque modification : ajouter Amina et Sami, ajouter « amina » (refusé), mettre 16 et 14 à Amina, 25 à Sami (refusé), puis 11, vérifier les statistiques, recharger la page, exporter le CSV. Si le résultat change, vous avez cassé quelque chose.

🛠 TP · Partie 1 — Analyser avant de découper

Mission : Lisez app.js sans le modifier et produisez une carte du code.

Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :

  1. Listez toutes les fonctions et les constantes du fichier. Pour chacune, indiquez : touche-t-elle au DOM ? au localStorage? modifie-t-elle eleves? est-elle pure ?
  2. Regroupez-les par responsabilité. Combien de groupes trouvez-vous ? Donnez un nom de fichier à chaque groupe.
  3. Quelles fonctions utilisent la variable globale eleves? Lesquelles la modifient? Pourquoi est-ce un problème si un module importé peut la réassigner (rappel : leçon 17.2, liaisons vivantes) ?
  4. Dessinez le graphe des dépendances entre vos futurs modules. Vérifiez qu'aucune flèche ne remonte et qu'il n'y a pas de cycle.
  5. Quelle fonctionnalité est rarement utilisée et pourrait être chargée seulement à la demande ?

Je vérifie / je rends :

  • ☐ Le tableau d'analyse.
  • ☐ L'arborescence prévue et le graphe des dépendances.

🛠 TP · Partie 2 — Préparer le terrain

Mission : Transformez le projet pour qu'il charge un module, sans encore rien découper.

Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :

  1. Que devez-vous changer dans index.html pour que app.js soit chargé comme un module ? Déplacez-le dans src/main.js.
  2. L'application fonctionne-t-elle toujours ? Avec quel type d'adresse l'ouvrez-vous ? Que se passe-t-il en file://?
  3. Créez src/config.js avec les constantes. Lesquelles y mettez-vous ? Importez-les dans main.js et supprimez-les de celui-ci.
  4. Dans la console du navigateur, tapez eleves puis MATIERES. Le résultat est-il le même qu'avec la version d'origine ? Pourquoi ?

Je vérifie / je rends :

  • ☐ Le scénario de test passe toujours.

🛠 TP · Partie 3 — Les fonctions pures

Mission : Extrayez les calculs dans src/utils/calculs.js et testez-les sans navigateur.

Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :

  1. Quelles fonctions déplacez-vous ? noteValide dépend de deux constantes : comment y accède-t-elle maintenant ?
  2. Écrivez tests/calculs.test.js qui importe vos fonctions et vérifie au moins 8 cas avec console.assert (moyenne d'une liste vide, mention pour 15,99 et 16, note 20,5…). Que faut-il pour l'exécuter avec node?
  3. Un test échoue-t-il ? Si oui, est-ce votre test ou le code d'origine qui est faux ?
  4. Pourquoi ce test aurait-il été impossible à écrire avec le fichier d'origine ?

Je vérifie / je rends :

  • ☐ calculs.js et son fichier de test qui passe.

🛠 TP · Partie 4 — Les données et le stockage

Mission : Créez src/data/stockage.js (lecture et écriture du localStorage) et src/data/eleves.js (la liste et ses modifications).

Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :

  1. charger() modifiait directement eleves. Comment la réécrire pour qu'elle renvoie les données ? Et sauvegarder(): que doit-elle recevoir en paramètre ?
  2. Où déclarez-vous maintenant la liste des élèves ? Est-elle exportée ? Comment les autres modules la lisent-ils ?
  3. supprimerEleve réassigne la liste (eleves = eleves.filter(…)). Pourquoi cette ligne ne peut-elle fonctionner que dans le module qui déclare la variable ?
  4. Quels modules ont maintenant le droit de modifier les données ? Comment le garantissez-vous ?
  5. Si demain les données viennent d'une API, quels fichiers changeront ? Le reste de l'application sera-t-il touché ?

Je vérifie / je rends :

  • ☐ Le scénario de test passe ; les données survivent au rechargement.

🛠 TP · Partie 5 — Les statistiques

Mission : Créez src/services/statistiques.js.

Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :

  1. moyenneClasse() et meilleurEleve() lisaient la variable globale. Vaut-il mieux les faire importer data/eleves.js, ou leur passer la liste en paramètre ? Comparez du point de vue des tests et des dépendances, puis choisissez.
  2. De quels modules statistiques.js dépend-il ? Vérifiez votre graphe : la flèche va-t-elle dans le bon sens ?
  3. Ajoutez trois tests de meilleurEleve dans votre fichier de tests. Pourquoi sont-ils faciles à écrire si la fonction reçoit la liste en paramètre ?

Je vérifie / je rends :

  • ☐ Les tests des statistiques passent.

🛠 TP · Partie 6 — L'affichage et le point d'entrée

Mission : Créez src/ui/affichage.js et réduisez src/main.js à l'initialisation et aux événements.

Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :

  1. Quelles fonctions vont dans affichage.js? Lesquelles doivent être exportées, lesquelles peuvent rester privées ?
  2. Les trois gestionnaires d'événements répètent le même schéma try / afficherMessage / rafraichir / catch. Comment factoriser cette répétition dans main.js?
  3. Combien de lignes fait maintenant main.js? Et le plus long de vos modules ? Respectez-vous la limite de 60 lignes ?
  4. affichage.js importe-t-il data/eleves.js? Si oui, est-ce nécessaire ? Que gagneriez-vous à lui passer la liste en paramètre ?
  5. Ajoutez import { getEleves } from "./data/eleves.js" dans utils/calculs.js. Cela marche-t-il ? Pourquoi est-ce une mauvaise idée d'après la leçon 17.4 ? Retirez-le.

Je vérifie / je rends :

  • ☐ Plus aucun code dans main.js qui ne soit pas de l'initialisation ou de la gestion d'événements.
  • ☐ Le scénario de test passe.

🛠 TP · Partie 7 — L'export CSV à la demande

Mission : Déplacez l'export dans src/services/export-csv.js et chargez-le avec import() uniquement au clic.

Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :

  1. Quelles fonctions déplacez-vous ? Choisissez-vous un export par défaut ou des exports nommés ? Justifiez.
  2. Écrivez le gestionnaire du bouton. Pourquoi doit-il être async?
  3. Ouvrez l'onglet Réseau (F12) et rechargez la page : export-csv.js est-il téléchargé ? Cliquez sur « Exporter » deux fois : combien de fois est-il téléchargé ?
  4. Comment afficher un message d'erreur si le chargement du module échoue (coupez le réseau ou renommez le fichier pour tester) ?

Je vérifie / je rends :

  • ☐ Capture de l'onglet Réseau avant et après le clic.

🛠 TP · Partie 8 — Bonus : passage à Vite et mise en ligne

Mission : Faites du carnet de notes un projet Vite, ajoutez un paquet npm et publiez-le.

Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :

  1. Créez un projet Vite vanilla et copiez-y votre src/ et votre index.html. Que devez-vous adapter ?
  2. Installez dayjs et ajoutez la date du jour au nom du fichier CSV (carnet-notes-2026-11-13.csv). Dans quel module importez-vous dayjs?
  3. Lancez npm run build. Combien de fichiers JavaScript contient dist/assets/? Retrouvez-vous un fichier séparé pour l'export CSV ? Pourquoi ?
  4. Publiez le contenu de dist/ sur un hébergement (serveur de l'école, GitHub Pages, Netlify). Quelle adresse obtenez-vous ?

Je vérifie / je rends :

  • ☐ L'adresse de l'application en ligne.

Grille d'évaluation indicative

CritèrePoints
Analyse : tableau des responsabilités, graphe sans cycle3
Application modulaire : comportement identique (scénario de test)4
Découpage : une responsabilité par module, modules courts, noms clairs3
Sens des dépendances respecté, aucune variable globale3
Fonctions pures testées dans Node.js3
Export CSV chargé à la demande, avec gestion d'erreur2
Compte rendu : réponses aux questions2
Total (bonus partie 8 : jusqu'à +3)20

← 17.5 Utiliser des paquets npm et un outil de build (Vite)