Web frontend · L2 · Section 2/6
JavaScript
Progression
#JavaScript : comportements sobres
Le JavaScript côté navigateur enrichit un document qui doit déjà fonctionner sans lui. Cette discipline porte un nom : amélioration progressive. Formulaire validé en JS, envoi par fetch, menu dépliable : chacun doit rester atteignable si le script échoue, car il échouera (script bloqué par une extension, réseau qui coupe, bundle corrompu). Écrire « ce script améliore, il n'est pas indispensable » change tout : où vont les handlers, ce qu'ils tolèrent, ce qu'ils annoncent.
#Événements et validation : un handler complet
Reprenons le formulaire du chapitre précédent. Le handler ci-dessous valide, annonce les erreurs, gère l'état d'envoi, et reste court :
1// Récupérer le formulaire, tolérer son absence (page sans formulaire)2const form = document.querySelector('form')3 4form?.addEventListener('submit', async (event) => {5 event.preventDefault() // on gère l'envoi nous-mêmes6 const email = form.elements.email7 const password = form.elements.password8 9 const problems = []10 if (!email.validity.valid) problems.push(['email', 'Adresse e-mail invalide.'])11 if (password.value.length < 8) problems.push(['password', '8 caractères minimum.'])12 13 if (problems.length > 0) {14 showErrors(problems)Quatre détails qui distinguent un code de production d'une démo : form.elements.champ nomme les champs au lieu de piocher des identifiants ; le bouton est désactivé pendant l'envoi pour empêcher le double-clic ; le finally restaure toujours l'état, même en erreur ; la zone d'annonce parle à l'utilisateur, la console parle au développeur.
#Annoncer les changements aux technologies d'assistance
Les lecteurs d'écran ne « regardent » pas la page : ils écoutent les changements des régions marquées vivantes. La fonction announce écrit dans une zone role="status" :
1function announce(message) {2 let live = document.getElementById('live-status')3 if (!live) {4 live = document.createElement('div')5 live.id = 'live-status'6 live.setAttribute('role', 'status') // équivalent aria-live="polite"7 live.classList.add('visually-hidden') // présent mais non visible8 document.body.append(live)9 }10 live.textContent = message // textContent remplace, ne s'accumule pas11}12 13function showErrors(problems) {14 for (const [name, message] of problems) {Un message d'erreur qui n'est que rouge n'existe pas pour un lecteur d'écran. Ici chaque erreur est un texte, relié au champ par aria-describedby, le champ est marqué aria-invalid, et le focus va au premier coupable : l'utilisateur entend l'erreur dès qu'elle apparait.
#Le modèle mental : la boucle d'événements
Tout votre code JS s'exécute sur un seul fil. Quand ce fil est occupé, rien d'autre ne se passe : ni clic traité, ni rendu. D'où la hiérarchie à connaitre :
Observation directe : ce mini-program rend compte de l'ordre, copiez-le dans la console.
1console.log('1 script')2setTimeout(() => console.log('4 task (timer)'), 0)3Promise.resolve().then(() => console.log('2 microtask'))4console.log('3 script')5// Affiche : 1 script, 3 script, 2 microtask, 4 taskLes promesses passent donc avant les timers : c'est pourquoi une chaîne de promesses mal bornée affame les timers et le rendu.
#Erreurs fréquentes et remèdes
- Boucle synchrone lourde : un tri de 100 000 éléments gèle la page. Découpez le travail ou passez le calcul à un Web Worker si le résultat n'est pas immédiatement nécessaire.
- Mutation du DOM dans une boucle : chaque écriture peut forcer une relecture du layout. Collectez d'abord, écrivez ensuite en une passe via un
DocumentFragment. innerHTMLavec des données externes : c'est une porte XSS. PréféreztextContent, ou une sanitization explicite quand le HTML riche est vraiment requis.- Écouteurs orphelins : des handlers ajoutés à chaque navigation et jamais retirés fuient. Utilisez
{ once: true }quand c'est pertinent, ou retirez l'écouteur là où vous l'avez ajouté.
1// Rendu en une passe : lit tout, écrit tout2function renderList(items) {3 const list = document.querySelector('ul')4 const fragment = document.createDocumentFragment()5 for (const item of items) {6 const li = document.createElement('li')7 li.textContent = item.label // textContent : pas d'injection possible8 fragment.append(li)9 }10 list.replaceChildren(fragment) // une seule mutation du DOM visible11}Pour les appels réseau, AbortController permet timeouts et annulation propre :
1async function getWithTimeout(url, ms = 5000) {2 const controller = new AbortController()3 const timer = setTimeout(() => controller.abort(), ms)4 try {5 return await fetch(url, { signal: controller.signal })6 } finally {7 clearTimeout(timer)8 }9}#Exercice : menu dépliable accessible (disclosure)
Transformez un bouton « Bénévoles » qui ouvre une liste en composant complet : état ouvert/fermé annoncé, contrôlable au clavier.
Cahier des charges : un button avec aria-expanded mis à jour, une liste reliée par aria-controls, la touche Échap qui referme et rend le focus au bouton, le tout en moins de 25 lignes.
Indice : l'attribut hidden cache réellement le contenu aux lecteurs d'écran, et aria-expanded prend les chaines "true"/"false".
Correction guidée
<button id="disclosure-btn" aria-expanded="false" aria-controls="team-list">
Bénévoles
</button>
<ul id="team-list" hidden>
<li>Nour</li><li>Idriss</li><li>Camille</li>
</ul>
<script>
const btn = document.getElementById('disclosure-btn')
const list = document.getElementById('team-list')
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true'
btn.setAttribute('aria-expanded', String(!open))
list.hidden = open
})
btn.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && btn.getAttribute('aria-expanded') === 'true') {
btn.setAttribute('aria-expanded', 'false')
list.hidden = true
btn.focus()
}
})
</script>Vérifiez : Espace et Entrée activent le bouton (natif), NVDA ou VoiceOver annonce « Bénévoles, bouton, déplié/replié », Échap referme et rend le focus au bouton.