À propos de ce cours
Chapitre 17 — Les modules et l'organisation du code · 17.6 TP 17 : Découper une application en modules
• 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.

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();
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) :
- Listez toutes les fonctions et les constantes du fichier. Pour chacune, indiquez : touche-t-elle au DOM ? au
localStorage? modifie-t-elleeleves? est-elle pure ? - Regroupez-les par responsabilité. Combien de groupes trouvez-vous ? Donnez un nom de fichier à chaque groupe.
- 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) ? - 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.
- 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) :
- Que devez-vous changer dans
index.htmlpour queapp.jssoit chargé comme un module ? Déplacez-le danssrc/main.js. - L'application fonctionne-t-elle toujours ? Avec quel type d'adresse l'ouvrez-vous ? Que se passe-t-il en
file://? - Créez
src/config.jsavec les constantes. Lesquelles y mettez-vous ? Importez-les dansmain.jset supprimez-les de celui-ci. - Dans la console du navigateur, tapez
elevespuisMATIERES. 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) :
- Quelles fonctions déplacez-vous ?
noteValidedépend de deux constantes : comment y accède-t-elle maintenant ? - Écrivez
tests/calculs.test.jsqui importe vos fonctions et vérifie au moins 8 cas avecconsole.assert(moyenne d'une liste vide, mention pour 15,99 et 16, note 20,5…). Que faut-il pour l'exécuter avecnode? - Un test échoue-t-il ? Si oui, est-ce votre test ou le code d'origine qui est faux ?
- Pourquoi ce test aurait-il été impossible à écrire avec le fichier d'origine ?
Je vérifie / je rends :
- ☐
calculs.jset 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) :
charger()modifiait directementeleves. Comment la réécrire pour qu'elle renvoie les données ? Etsauvegarder(): que doit-elle recevoir en paramètre ?- Où déclarez-vous maintenant la liste des élèves ? Est-elle exportée ? Comment les autres modules la lisent-ils ?
supprimerEleveréassigne la liste (eleves = eleves.filter(…)). Pourquoi cette ligne ne peut-elle fonctionner que dans le module qui déclare la variable ?- Quels modules ont maintenant le droit de modifier les données ? Comment le garantissez-vous ?
- 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) :
moyenneClasse()etmeilleurEleve()lisaient la variable globale. Vaut-il mieux les faire importerdata/eleves.js, ou leur passer la liste en paramètre ? Comparez du point de vue des tests et des dépendances, puis choisissez.- De quels modules
statistiques.jsdépend-il ? Vérifiez votre graphe : la flèche va-t-elle dans le bon sens ? - Ajoutez trois tests de
meilleurElevedans 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) :
- Quelles fonctions vont dans
affichage.js? Lesquelles doivent être exportées, lesquelles peuvent rester privées ? - Les trois gestionnaires d'événements répètent le même schéma
try / afficherMessage / rafraichir / catch. Comment factoriser cette répétition dansmain.js? - Combien de lignes fait maintenant
main.js? Et le plus long de vos modules ? Respectez-vous la limite de 60 lignes ? affichage.jsimporte-t-ildata/eleves.js? Si oui, est-ce nécessaire ? Que gagneriez-vous à lui passer la liste en paramètre ?- Ajoutez
import { getEleves } from "./data/eleves.js"dansutils/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.jsqui 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) :
- Quelles fonctions déplacez-vous ? Choisissez-vous un export par défaut ou des exports nommés ? Justifiez.
- Écrivez le gestionnaire du bouton. Pourquoi doit-il être
async? - Ouvrez l'onglet Réseau (F12) et rechargez la page :
export-csv.jsest-il téléchargé ? Cliquez sur « Exporter » deux fois : combien de fois est-il téléchargé ? - 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) :
- Créez un projet Vite vanilla et copiez-y votre
src/et votreindex.html. Que devez-vous adapter ? - Installez
dayjset ajoutez la date du jour au nom du fichier CSV (carnet-notes-2026-11-13.csv). Dans quel module importez-vousdayjs? - Lancez
npm run build. Combien de fichiers JavaScript contientdist/assets/? Retrouvez-vous un fichier séparé pour l'export CSV ? Pourquoi ? - 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ère | Points |
|---|---|
| Analyse : tableau des responsabilités, graphe sans cycle | 3 |
| Application modulaire : comportement identique (scénario de test) | 4 |
| Découpage : une responsabilité par module, modules courts, noms clairs | 3 |
| Sens des dépendances respecté, aucune variable globale | 3 |
| Fonctions pures testées dans Node.js | 3 |
| Export CSV chargé à la demande, avec gestion d'erreur | 2 |
| Compte rendu : réponses aux questions | 2 |
| Total (bonus partie 8 : jusqu'à +3) | 20 |
← 17.5 Utiliser des paquets npm et un outil de build (Vite)