À propos de ce cours
Comprenez le fonctionnement des applications monopage (SPA) : différence avec un site classique, API History (pushState, replaceState, popstate), écriture d'un mini-routeur en JavaScript avec routes à paramètres, configuration du serveur (Nginx, Apache), routage par hash, et découverte de la Navigation API, Baseline depuis janvier 2026. Avec des activités corrigées.
-
Objectifs
- Comprendre la différence entre un site classique et une application monopage (SPA).
- Manipuler l'historique du navigateur avec
pushState(),replaceState()et l'événementpopstate. - Écrire un petit routeur en JavaScript et configurer le serveur qui l'héberge.
- Découvrir la Navigation API, la remplaçante moderne de l'API History.
-
Site classique et application monopage
- Dans un site classique (multi-pages), chaque clic sur un lien demande une nouvelle page HTML au serveur : la page entière est rechargée, l'écran blanchit un instant et l'état de JavaScript (variables, formulaire en cours) est perdu.
- Dans une application monopage (Single Page Application, SPA), le navigateur charge une seule page HTML. Ensuite, JavaScript intercepte les clics et remplace seulement le contenu qui change. Les données manquantes sont demandées avec
fetch. - Avantages : navigation instantanée, transitions fluides, état conservé (lecteur audio qui continue, panier…). Inconvénients : premier chargement plus lourd, référencement et accessibilité à soigner.
- C'est le fonctionnement d'Angular, React et Vue (chapitre 24), de Gmail ou de YouTube. Leurs routeurs (Angular Router, React Router, Vue Router) reposent sur ce que vous allez apprendre ici.

-
Le défi : garder des adresses qui fonctionnent
- Si l'on se contente de changer le contenu avec JavaScript, l'adresse ne bouge pas. Résultat : impossible de mettre une page en favori, de partager un lien, ou d'utiliser les boutons Précédent et Suivant.
- L'API History (objet
history) permet de modifier l'adresse et l'historique sans recharger la page.
-
Les outils de l'API History
| Instruction | Effet |
|---|---|
history.pushState(etat, "", url) | Ajoute une entrée à l'historique et affiche url dans la barre d'adresse, sans recharger ni contacter le serveur. |
history.replaceState(etat, "", url) | Remplace l'entrée actuelle (pratique pour mettre à jour un filtre dans l'adresse sans créer une entrée à chaque frappe). |
history.state | L'objet etat associé à l'entrée actuelle. |
history.back(), forward(), go(-2) | Équivalent des boutons du navigateur. |
Événement popstate (sur window) | Déclenché quand l'utilisateur utilise Précédent / Suivant; event.state contient l'objet enregistré. |
- Le 2e paramètre (titre) est ignoré par les navigateurs : on passe
"". - L'
urldoit être de la même origine (même domaine) que la page. pushState()ne déclenche paspopstate: c'est à vous d'afficher le nouveau contenu.etatdoit rester petit et « clonable » (nombres, textes, objets simples) : pas de fonctions ni d'éléments du DOM.
-
Exemple complet : un mini-routeur
Le site d'un centre de formation avec quatre types de pages : accueil, liste des formations, fiche d'une formation (/formations/2) et contact, plus une page 404.
index.html:
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<title>Centre de formation</title>
<script type="module" src="/routeur.js"></script>
</head>
<body>
<nav>
<a href="/" data-lien>Accueil</a> |
<a href="/formations" data-lien>Formations</a> |
<a href="/contact" data-lien>Contact</a>
</nav>
<main id="app"></main>
</body>
</html>
routeur.js:
const app = document.getElementById("app");
const formations = ["Développement web", "Développement mobile", "Réseaux"];
// Une "page" = une fonction qui construit le contenu
const routes = {
"/": () => creer("h1", "Bienvenue au centre de formation"),
"/formations": () => {
const ul = document.createElement("ul");
formations.forEach((nom, i) => {
const li = document.createElement("li");
const lien = creer("a", nom);
lien.href = `/formations/${i + 1}`;
lien.dataset.lien = "";
li.append(lien);
ul.append(li);
});
return [creer("h1", "Nos formations"), ul];
},
"/contact": () => creer("h1", "Contact : contact@centre.tn"),
};
function creer(balise, texte) {
const element = document.createElement(balise);
element.textContent = texte;
return element;
}
function pageFormation(numero) {
const nom = formations[numero - 1];
return nom ? [creer("h1", nom), creer("p", `Fiche de la formation n° ${numero}.`)] : null;
}
function afficher(chemin) {
const detail = chemin.match(/^\/formations\/(\d+)$/);
const contenu = detail ? pageFormation(Number(detail[1])) : routes[chemin]?.();
app.replaceChildren(...[contenu ?? creer("h1", "404 – Page introuvable")].flat());
document.title = `${app.querySelector("h1").textContent} – Centre`;
}
function naviguer(chemin) {
if (chemin !== location.pathname) {
history.pushState({ chemin }, "", chemin);
}
afficher(chemin);
}
// Délégation : un seul écouteur pour tous les liens marqués data-lien
document.addEventListener("click", (event) => {
const lien = event.target.closest("a[data-lien]");
if (lien) {
event.preventDefault();
naviguer(lien.getAttribute("href"));
}
});
// Boutons Précédent / Suivant
window.addEventListener("popstate", () => afficher(location.pathname));
// Premier affichage (y compris si l'utilisateur arrive directement sur /contact)
afficher(location.pathname);
- Table des routes: chaque adresse est associée à une fonction qui construit le contenu (avec
createElementettextContent, sansinnerHTML). - Route avec paramètre:
/formations/2est reconnue par l'expression régulière/^\/formations\/(\d+)$/, qui capture le numéro. - Délégation: un seul écouteur sur
documentintercepte tous les liens marquésdata-lien, même ceux créés plus tard (liens de la liste des formations). Les autres liens (sites externes) gardent leur comportement normal. popstate: aux boutons Précédent / Suivant, on réaffiche simplement la page correspondant àlocation.pathname.- Premier affichage:
afficher(location.pathname)au chargement permet d'arriver directement sur/contactdepuis un favori. - Le titre de l'onglet est mis à jour à chaque page, ce qui aide l'historique, les favoris et les lecteurs d'écran.
Le serveur : renvoyer toujours index.html
- Testez l'exemple, allez sur « Contact », puis appuyez sur F5. Avec un serveur ordinaire (Live Server), vous obtenez une erreur 404: le navigateur demande le fichier
/contact, qui n'existe pas sur le disque. - Le serveur d'une SPA doit donc renvoyer
index.htmlpour toutes les adresses de l'application (règle de « repli » ou fallback), et laisser le routeur JavaScript choisir le contenu. C'est aussi pourquoi le script est chargé avec un chemin absolu (/routeur.js). - Pour tester, ce petit serveur Python suffit (
python serveur.py, puishttp://127.0.0.1:8000) :
# Petit serveur de test : renvoie index.html pour toute adresse sans extension
import http.server, os
class Spa(http.server.SimpleHTTPRequestHandler):
def send_head(self):
if "." not in os.path.basename(self.path.split("?")[0]):
self.path = "/index.html"
return super().send_head()
http.server.ThreadingHTTPServer(("127.0.0.1", 8000), Spa).serve_forever()
- En production, une seule ligne de configuration :
# Nginx
location / {
try_files $uri $uri/ /index.html;
}
# Apache (fichier .htaccess)
FallbackResource /index.html
- Vite (chapitre 24) applique déjà cette règle pendant le développement. Sur GitHub Pages, qui ne permet pas cette configuration, on copie
index.htmlen404.html, ou on utilise des adresses avec #.
L'alternative : le routage par hash (#)
- Avec des adresses comme
index.html#/contact, la partie après#n'est jamais envoyée au serveur : aucune configuration n'est nécessaire et cela fonctionne même avec Live Server. - On lit
location.hashet on écoute l'événementhashchangeau lieu depopstate; les liens s'écrivent simplement<a href="#/contact">, sanspushState(). - Inconvénient : des adresses moins propres et moins bien traitées par les moteurs de recherche. Angular et Vue proposent les deux modes.
Nouveauté : la Navigation API
- L'API History a des défauts connus :
pushState()ne déclenche aucun événement, il faut intercepter soi-même chaque lien et chaque formulaire, etpopstatene couvre que Précédent / Suivant. - La Navigation API (objet
navigation) la remplace : un événementnavigateunique est déclenché pour toute navigation (clic sur un lien, formulaire, Précédent / Suivant,navigation.navigate()), etevent.intercept()permet de la traiter en JavaScript. - Disponible dans Chrome et Edge depuis 2022, elle est arrivée dans Safari 26.2 (décembre 2025) puis Firefox 147 (janvier 2026) : elle est Baseline depuis le 13 janvier 2026. Le même routeur s'écrit alors :
// Même routeur avec la Navigation API (Baseline depuis janvier 2026)
navigation.addEventListener("navigate", (event) => {
const url = new URL(event.destination.url);
if (!event.canIntercept || url.origin !== location.origin) {
return; // lien externe, téléchargement… : comportement normal
}
event.intercept({
handler() {
afficher(url.pathname);
},
});
});
- Plus besoin de
data-lien, depreventDefault()ni depushState(): l'adresse est mise à jour par le navigateur. Comme elle est récente, gardez une version avechistorypour les navigateurs plus anciens (if ("navigation" in window)). L'API History reste à connaître : elle est partout, et encore utilisée par les frameworks.
Activités
Activité 1 : tester le routeur
- Dans le dossier
spade votre dépôtjs-api-navigateur, reproduisez l'exemple et lancezpython serveur.py. - Naviguez entre les pages et ouvrez la console : tapez
history.lengthaprès chaque clic. Utilisez Précédent et Suivant. - Lancez la même page avec Live Server, allez sur « Contact » et appuyez sur F5. Expliquez l'erreur.
Activité 2 : compléter le routeur
- Ajoutez une page
/a-proposet un lien dans le menu. - Ajoutez la classe
activeau lien du menu correspondant à la page affichée. - Remplacez la liste
formationspar un fichierformations.jsonchargé avecfetch, en affichant « Chargement… » pendant la requête.
Activité 3 : filtre dans l'adresse
- Sur la page des formations, ajoutez un champ de recherche. À chaque saisie, filtrez la liste et mettez à jour l'adresse en
/formations?q=webavecreplaceState(). - Vérifiez qu'en ouvrant directement cette adresse, le champ est prérempli et la liste filtrée (
URLSearchParams). Pourquoi utilise-t-onreplaceState()plutôt quepushState()ici ?
Activité 4 : version hash
- Adaptez le routeur au routage par hash (
#/formations), puis vérifiez qu'il fonctionne avec Live Server, y compris après F5.
Corrigés. Activité 1 :
history.length augmente de 1 à chaque pushState(); avec Live Server, F5 demande au serveur le fichier /contact, qui n'existe pas, d'où la 404 : il manque la règle de repli vers index.html. Activité 3 : avec pushState(), chaque lettre tapée créerait une entrée dans l'historique et le bouton Précédent deviendrait inutilisable ; replaceState() met l'adresse à jour sans ajouter d'entrée. Activité 4 : afficher(location.hash.slice(1) || "/"), appelée au chargement et sur l'événement hashchange. À retenir
- Une SPA charge une seule page et remplace son contenu en JavaScript.
history.pushState()change l'adresse sans recharger ;popstategère Précédent / Suivant ;replaceState()met à jour sans nouvelle entrée.- Un routeur associe chaque adresse à une fonction d'affichage ; le serveur doit renvoyer
index.htmlpour toutes les adresses. - La Navigation API (Baseline depuis janvier 2026) simplifie le routage avec un seul événement
navigate.
Sources : MDN Web Docs, web.dev, W3C, WHATWG, open-meteo.com.