Pourquoi éviter le polling du DOM
Dans une interface web dynamique, le DOM change constamment : un composant affiche une erreur, une liste reçoit de nouveaux éléments, une bibliothèque tierce injecte un bouton, un attribut aria-expanded est modifié, une modale apparaît, puis disparaît. La tentation classique consiste à vérifier régulièrement l’état de la page avec setInterval. Cette approche fonctionne parfois, mais elle est rarement élégante.
Le polling consomme des ressources même quand rien ne change. Il introduit aussi une latence artificielle : si vous vérifiez toutes les 500 ms, votre code peut réagir avec un demi-seconde de retard. À l’inverse, si vous vérifiez toutes les 16 ms, vous surchargez inutilement le thread principal.
MutationObserver permet de résoudre ce problème proprement. Au lieu de demander sans cesse « est-ce que quelque chose a changé ? », vous demandez au navigateur de vous prévenir quand une mutation pertinente se produit. C’est une API native, asynchrone, adaptée aux changements de structure, d’attributs et de texte dans le DOM.
Cette logique complète bien d’autres API d’observation. Pour détecter la visibilité d’un élément, on préférera Intersection Observer. Pour mesurer la performance réelle, PerformanceObserver sera plus adapté. MutationObserver, lui, répond à une question précise : que vient-il de changer dans le DOM ?
Créer un premier MutationObserver
Un observateur reçoit une fonction de callback appelée avec une liste de mutations. Chaque mutation décrit un changement : ajout ou suppression de nœuds, modification d’attribut, changement de texte.
const target = document.querySelector("#notifications");
if (!target) {
throw new Error("Conteneur introuvable");
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
console.log("Type de mutation :", mutation.type);
}
});
observer.observe(target, {
childList: true,
subtree: true
});
Ici, childList: true indique que l’on souhaite observer les ajouts et suppressions d’enfants. L’option subtree: true étend l’observation à toute la descendance du nœud ciblé. Sans subtree, seules les modifications directes du conteneur seraient surveillées.
Le callback n’est pas appelé immédiatement à chaque instruction JavaScript qui modifie le DOM. Le navigateur regroupe les mutations et appelle l’observateur de manière asynchrone, ce qui évite une avalanche d’appels inutiles.
Comprendre les principales options
La méthode observe accepte une configuration qui doit rester aussi précise que possible. Observer tout le document avec toutes les options activées est rarement une bonne idée.
observer.observe(document.body, {
childList: true,
attributes: true,
attributeFilter: ["aria-expanded", "hidden", "data-state"],
subtree: true
});
Les options les plus courantes sont :
childListpour détecter les nœuds ajoutés ou supprimés ;attributespour détecter les changements d’attributs ;attributeFilterpour limiter les attributs surveillés ;characterDatapour observer les changements de texte ;subtreepour inclure les descendants.
attributeFilter est particulièrement important. Si votre logique ne dépend que de aria-expanded et data-state, inutile de réagir aux changements de class, style, id ou title. Plus la configuration est ciblée, plus le code reste prévisible.
Réagir à l’ajout d’éléments dynamiques
Un cas fréquent consiste à détecter l’apparition d’éléments injectés après le chargement initial : résultats de recherche, messages, composants montés tardivement, widgets tiers, etc.
function enhanceButton(button: HTMLButtonElement) {
button.addEventListener("click", () => {
console.log("Bouton dynamique cliqué");
});
button.dataset.enhanced = "true";
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
const buttons = node.matches("button[data-action]")
? [node]
: Array.from(node.querySelectorAll("button[data-action]"));
for (const button of buttons) {
if (!(button instanceof HTMLButtonElement)) continue;
if (button.dataset.enhanced === "true") continue;
enhanceButton(button);
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
Cet exemple illustre deux précautions importantes. D’abord, on vérifie le type réel des nœuds, car addedNodes peut contenir autre chose que des éléments HTML. Ensuite, on marque les boutons déjà traités avec data-enhanced afin d’éviter d’attacher plusieurs fois le même comportement.
Cette approche peut être utile dans une logique de progressive enhancement : le HTML reste fonctionnel, puis JavaScript ajoute des comportements lorsque les éléments deviennent disponibles. Si ce principe vous intéresse, vous pouvez approfondir avec l’article sur le progressive enhancement.
Observer les attributs utiles à l’accessibilité
MutationObserver peut aussi servir à synchroniser une interface avec des états ARIA ou des attributs natifs. Imaginons un panneau dont l’état dépend d’un bouton accordéon.
const accordion = document.querySelector("[data-accordion]");
if (accordion) {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type !== "attributes") continue;
if (mutation.attributeName !== "aria-expanded") continue;
const button = mutation.target;
if (!(button instanceof HTMLElement)) continue;
const isOpen = button.getAttribute("aria-expanded") === "true";
console.log(isOpen ? "Section ouverte" : "Section fermée");
}
});
observer.observe(accordion, {
attributes: true,
attributeFilter: ["aria-expanded"],
subtree: true
});
}
Cette technique ne remplace pas une bonne structure HTML ni une gestion directe de l’état dans un framework. Elle devient pertinente lorsque vous devez vous brancher sur du DOM que vous ne contrôlez pas entièrement : composant legacy, widget externe, CMS, script tiers ou zone rendue côté serveur.
Pour les interfaces dynamiques accessibles, MutationObserver peut être combiné avec des patterns plus spécialisés, par exemple les ARIA live regions pour annoncer les changements importants aux lecteurs d’écran.
Nettoyer correctement l’observateur
Un observateur actif conserve une référence vers sa cible et continue de recevoir des mutations tant qu’il n’est pas déconnecté. Dans une application à composants, il faut donc prévoir un nettoyage explicite.
const observer = new MutationObserver(handleMutations);
observer.observe(container, {
childList: true,
subtree: true
});
function destroy() {
observer.disconnect();
}
Dans React, le nettoyage se place naturellement dans le retour de useEffect.
import { useEffect, useRef } from "react";
export function DynamicListWatcher() {
const ref = useRef<HTMLUListElement | null>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === "childList") {
console.log("La liste a changé");
}
}
});
observer.observe(element, {
childList: true
});
return () => {
observer.disconnect();
};
}, []);
return <ul ref={ref} />;
}
Ce nettoyage est aussi important que pour les requêtes ou effets asynchrones annulables, que l’on peut gérer avec AbortController dans d’autres contextes.
Éviter les boucles de mutation
Le piège classique consiste à modifier le DOM dans le callback de l’observateur, ce qui déclenche une nouvelle mutation, qui rappelle le callback, qui modifie encore le DOM.
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
const element = mutation.target;
if (!(element instanceof HTMLElement)) continue;
if (element.dataset.processed === "true") continue;
element.dataset.processed = "true";
}
});
Ici, le garde-fou data-processed évite de retraiter plusieurs fois le même élément. Dans des cas plus complexes, vous pouvez aussi déconnecter temporairement l’observateur, appliquer une modification, puis le reconnecter.
function safelyUpdateDom(element: HTMLElement) {
observer.disconnect();
element.classList.add("is-ready");
observer.observe(element, { attributes: true });
}
Cette solution doit rester ponctuelle. Si vous devez souvent déconnecter et reconnecter un observateur, c’est peut-être le signe que la responsabilité est mal placée dans l’architecture.
Structurer un utilitaire réutilisable
Pour éviter de disperser la logique d’observation, il est souvent préférable de créer une petite fonction utilitaire.
type ObserveDomOptions = {
target: Node;
onElementAdded: (element: HTMLElement) => void;
selector?: string;
};
export function observeAddedElements({
target,
selector,
onElementAdded
}: ObserveDomOptions) {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
const elements = selector
? Array.from(node.querySelectorAll(selector))
: [node];
if (selector && node.matches(selector)) {
elements.unshift(node);
}
for (const element of elements) {
if (element instanceof HTMLElement) {
onElementAdded(element);
}
}
}
}
});
observer.observe(target, {
childList: true,
subtree: true
});
return () => observer.disconnect();
}
Utilisation :
const stop = observeAddedElements({
target: document.body,
selector: "[data-tooltip]",
onElementAdded(element) {
element.setAttribute("tabindex", "0");
}
});
// Plus tard
stop();
Cette forme rend le contrat clair : on démarre une observation, puis on récupère une fonction de nettoyage. C’est simple à intégrer dans du JavaScript natif, React, Vue ou un Custom Element. Pour les composants natifs, l’approche se marie bien avec les callbacks de cycle de vie décrits dans l’article sur les Custom Elements.
Bonnes pratiques de performance
MutationObserver est performant quand il est utilisé avec retenue. Les problèmes apparaissent surtout lorsque l’on observe une zone trop large, avec trop d’options, puis que l’on exécute du code coûteux dans le callback.
Quelques règles pratiques :
- observez le conteneur le plus proche possible, pas forcément
document.body; - utilisez
attributeFilterdès que vous surveillez des attributs ; - évitez les gros
querySelectorAllrépétés sur toute la page ; - marquez les éléments déjà traités ;
- déconnectez l’observateur quand il n’est plus utile ;
- déléguez les traitements lourds à une file ou à un worker si nécessaire.
Si le callback doit traiter beaucoup d’éléments, vous pouvez regrouper le travail et le différer.
const pending = new Set<HTMLElement>();
let scheduled = false;
function scheduleWork() {
if (scheduled) return;
scheduled = true;
queueMicrotask(() => {
scheduled = false;
for (const element of pending) {
element.classList.add("is-enhanced");
}
pending.clear();
});
}
Pour des traitements réellement coûteux, il faudra plutôt envisager une séparation plus forte, par exemple avec les Web Workers, afin de ne pas bloquer l’interaction utilisateur.
Quand utiliser MutationObserver
MutationObserver est pertinent lorsque le DOM est votre source d’information : intégration avec un CMS, composant externe, script tiers, contenu injecté, migration progressive d’une ancienne base de code, amélioration d’un HTML déjà rendu.
En revanche, si vous contrôlez entièrement l’état applicatif dans React, Vue ou un autre framework, il vaut mieux réagir directement à cet état plutôt que d’observer le DOM produit. Observer le DOM pour retrouver une information déjà disponible dans votre modèle de données ajoute souvent une couche indirecte inutile.
La bonne question à se poser est donc simple : est-ce que je veux observer une mutation du DOM parce que le DOM est la seule source fiable, ou parce que mon architecture ne me donne pas accès au bon état ? Dans le premier cas, MutationObserver est un excellent outil. Dans le second, il faut probablement revoir le flux de données.
Conclusion
MutationObserver est une API discrète mais très utile pour construire des interfaces web robustes. Elle permet de remplacer le polling par une observation native, ciblée et asynchrone des changements du DOM.
Bien utilisée, elle améliore la fiabilité du code sans alourdir l’interface. Mal utilisée, elle peut devenir une rustine qui masque une architecture confuse. Comme souvent en développement frontend, la différence tient moins à l’API elle-même qu’à la précision de son usage : observer peu, observer juste, nettoyer systématiquement.