Aller au contenu principal

Web frontend · L2 · Section 2/6

JavaScript

Progression

Points d’expérience : XPSérie de jours consécutifs : · —Progression du module : — / —compris

#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 :

jsjs

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" :

jsjs

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 :

Call stack
Le script courant s'exécute en synchrone
Microtasks
Promises, queueMicrotask : vidées en priorité
Tasks
Timers, événements, I/O : une tâche à la fois
Render
Style, layout, paint : entre deux tâches

Observation directe : ce mini-program rend compte de l'ordre, copiez-le dans la console.

jsjs

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 task

Les 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.
  • innerHTML avec des données externes : c'est une porte XSS. Préférez textContent, 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é.
jsjs

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 :

jsjs

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.

Quel est l'ordre d'affichage ? log('a'); setTimeout(()=>log('b'),0); Promise.resolve().then(()=>log('c')); log('d')
Quel est l'ordre d'affichage ? log('a'); setTimeout(()=>log('b'),0); Promise.resolve().then(()=>log('c')); log('d')