Largeur du viewport, orientation, dimensions du document, nombre d'éléments, profondeur du DOM : le navigateur sait tout ça. Le problème, c'est que chaque script le redemande à sa façon, comme une réunion où tout le monde pose la même question au même collègue. Poser ces informations une fois pour toutes en attributs data-* sur <html> donne une source unique, lisible par le CSS, par le JavaScript, par les outils de test et par les humains.
Le problème : l'état est dispersé
Sur un projet qui dure, le même état est redéterminé à plusieurs endroits. Un composant appelle matchMedia('(min-width: 52.5rem)'), un autre lit window.innerWidth, un troisième duplique le seuil dans une constante. Quand un palier change, il faut retrouver chaque copie, et il y en a toujours une d'oubliée. En général, celle qui casse la page que personne n'ouvre jamais, sauf le client.
Trois défauts reviennent :
- des seuils dupliqués qui divergent ;
- des écouteurs
resizemultipliés, souvent sansdebounce; - un état invisible : impossible de savoir à l'œil quelle plage est active.
Une source unique, lisible dans le balisage
Un seul script calcule l'état et le pose sur la racine :
<html data-viewport="large"
data-orientation="landscape"
data-document-width="1024"
data-document-height="3120"
data-elements="842"
data-depth="14" […le reste]>
Chaque attribut a un rôle précis :
data-viewportetdata-media: la classe de taille (smallàxxx-large) et la media query qui l'a produite ;data-orientation:portraitoulandscape;data-document-widthetdata-document-height: les dimensions du contenu de la racine, barre de défilement exclue ;data-elements: le nombre d'éléments dubody;data-depth: la profondeur maximale du DOM, posée tant que l'option de débogage correspondante est active.
L'inspecteur affiche l'état en direct, sans outil supplémentaire.
Côté performances, pas d'écouteur resize sur window et pas de polling. Les paliers passent par matchMedia : un écouteur par media query, qui ne se réveille qu'au franchissement d'un seuil. Seules les dimensions du document passent par un ResizeObserver, elles sont donc réécrites à chaque changement de taille.
Le script
Un seul fichier, autonome, sans dépendance. Où le poser est détaillé en fin d'article.
Taille brute : 8,76 kilo-octets (~3,64 kilo-octets gzip)
/**
* Expose l'état du document sur la racine via des attributs data-* :
* - data-viewport : classe de taille (small ... xxx-large)
* - data-media : media query correspondante
* - data-orientation : portrait | landscape
* - data-document-width, data-document-height : dimensions de la racine (px CSS)
* - data-elements : nombre d'éléments dans le body
* - data-depth : profondeur maximale du DOM, html = 1 (uniquement si DEBUG.depth)
*
* Les seuils sont en rem : ils suivent le réglage de taille de texte du
* navigateur (zoom texte), ce qui est cohérent avec WCAG 1.4.4 et 1.4.10.
* La conversion suppose une taille par défaut de 16 px, seule valeur que les
* media queries utilisent pour le rem (le font-size de html est ignoré).
*
* @param {HTMLElement} root Élément racine (document.documentElement).
*/
((root) => {
/**
* Sorties console actives (true pour activer, false pour couper).
* - init : durée de l'initialisation synchrone (console.info)
* - ready : instant de DOMContentLoaded depuis le début de la navigation (console.info)
* - dom : suivi du nombre d'éléments, coloré selon les seuils (console.info)
* - xpath : localisation de la zone modifiée, ajoutée à la sortie dom (nécessite dom)
* - depth : profondeur maximale du DOM, XPath et nœud cliquable, pose data-depth (console.info)
* - table : récapitulatif des data-* de la fonction au load (console.table)
*
* @type {{init: boolean, ready: boolean, dom: boolean, xpath: boolean, depth: boolean, table: boolean}}
*/
const DEBUG = {init: false, ready: false, dom: true, xpath: false, depth: true, table: true};
// Seuils du nombre d'éléments : au-delà de WARN orange, à partir de ALERT rouge
const DOM_WARN = 1300;
const DOM_ALERT = 2000;
// Profondeur au-delà de laquelle la sortie passe en orange (seuil repris de Lighthouse, à ajuster)
const DEPTH_WARN = 32;
/**
* Paliers [classe, borne haute exclusive en px CSS].
* La borne basse est celle de l'entrée précédente.
* Découpage proche des window size classes de Material 3, plus un palier 1920.
*
* @type {ReadonlyArray<readonly [string, number]>}
*/
const viewports = [
// < 600 : téléphones en portrait (320 à 430), écrans de couverture de pliables (280 à 360)
['small', 600],
// 600 à 840 : iPad mini et iPad en portrait (744 à 834), petits téléphones en paysage, pliables ouverts
['medium', 840],
// 840 à 1200 : tablettes 11" à 13" (1024), petits laptops
['large', 1200],
// 1200 à 1600 : majorité des laptops (1280, 1366, 1440, 1536 sous Windows à 125 %)
['x-large', 1600],
// 1600 à 1920 : desktops Full HD à 100 % de mise à l'échelle
['xx-large', 1920],
// >= 1920 : grands écrans (2560, 4K non mis à l'échelle), Infinity sert de sentinelle
['xxx-large', Infinity]
];
// Taille de police initiale de référence : les media queries ignorent le font-size de html
const BASE = 16;
const t0 = performance.now();
viewports.forEach(([name, max], i, list) => {
// Syntaxe range : bornes semi-ouvertes, aucun trou pour les largeurs fractionnaires.
// Condition basse absente pour le premier palier, haute absente pour le dernier.
const query = [i && `(width >= ${list[i - 1][1] / BASE}rem)`, max < Infinity && `(width < ${max / BASE}rem)`]
.filter(Boolean).join(' and ');
const mql = matchMedia(query);
const apply = () => mql.matches && Object.assign(root.dataset, {media: query, viewport: name});
mql.addEventListener('change', apply);
apply();
});
// Une seule requête suffit : ce qui n'est pas portrait est landscape
const portrait = matchMedia('(orientation: portrait)');
const setOrientation = () => root.dataset.orientation = portrait.matches ? 'portrait' : 'landscape';
portrait.addEventListener('change', setOrientation);
setOrientation();
// Dimensions du contenu de la racine, les nombres sont convertis en chaîne par dataset.
// La barre de défilement n'est pas comptée, contrairement aux media queries.
new ResizeObserver(([{contentBoxSize: [{inlineSize, blockSize}]}]) =>
Object.assign(root.dataset, {documentWidth: inlineSize, documentHeight: blockSize})
).observe(root);
// Durée de l'initialisation synchrone uniquement (hors changements ultérieurs)
DEBUG.init && console.info('viewport : init en ' + (performance.now() - t0).toFixed(2) + 'ms');
// Suivi du DOM : le body n'existe pas encore si le script est dans le head.
const track = () => {
// Temps écoulé depuis le début de la navigation
DEBUG.ready && console.info('DOM prêt à ' + performance.now().toFixed(0) + 'ms');
const {body} = document;
const locate = DEBUG.dom && DEBUG.xpath;
// Parents dont les enfants ont changé depuis le dernier comptage (collecté si locate)
const targets = new Set();
let previous = 0;
let previousDepth = 0;
let frame = 0;
// XPath de l'élément, raccourci à l'id le plus proche quand il existe
const xpath = (el) => {
const parts = [];
for (; el?.nodeType === 1; el = el.parentElement) {
if (el.id) return '//*[@id="' + el.id + '"]' + (parts.length ? '/' + parts.join('/') : '');
let i = 1;
for (let s = el.previousElementSibling; s; s = s.previousElementSibling) s.localName === el.localName && i++;
parts.unshift(el.localName + '[' + i + ']');
}
return '/' + parts.join('/');
};
const update = () => {
frame = 0;
// Un parent retiré entre-temps n'a plus de chemin utile
const zones = locate ? [...targets].filter((t) => t.isConnected) : [];
targets.clear();
const all = body.getElementsByTagName('*');
const count = all.length;
if (count === previous) return;
const delta = count - previous;
const color = count >= DOM_ALERT ? 'red' : count > DOM_WARN ? 'orange' : '';
root.dataset.elements = count;
// Une valeur de couleur vide est ignorée par la console : texte hérité.
// L'élément passé en dernier argument est affiché comme nœud cliquable dans les DevTools.
DEBUG.dom && console.info(
'%c' + (performance.now() | 0) + 'ms : ' + count + ' éléments (' + (previous ? (delta > 0 ? '+' : '') + delta : 'init') + ')'
+ (zones.length ? ' ' + xpath(zones[0]) + (zones.length > 1 ? ' et ' + (zones.length - 1) + ' autre(s) zone(s)' : '') : ''),
'color:' + color + ';font-family:monospace;',
...zones.slice(0, 1)
);
// Profondeur : un seul parcours en ordre du document, le parent est donc toujours déjà mesuré.
// html vaut 1, body 2. Coût linéaire, d'où l'activation par option.
// Recalculée uniquement quand le nombre d'éléments change.
if (DEBUG.depth) {
const depths = new Map([[body, 2]]);
let deepest = body;
let max = 2;
for (const el of all) {
const depth = depths.get(el.parentElement) + 1;
depths.set(el, depth);
depth > max && (max = depth, deepest = el);
}
root.dataset.depth = max;
// Sortie uniquement quand la profondeur maximale change
if (max !== previousDepth) {
console.info(
'%c' + (performance.now() | 0) + 'ms : profondeur ' + max + ' ' + xpath(deepest),
'color:' + (max > DEPTH_WARN ? 'orange' : '') + ';font-family:monospace;',
deepest
);
previousDepth = max;
}
}
previous = count;
};
// Seuls les ajouts et retraits changent le nombre d'éléments : pas d'écoute des attributs.
// Le comptage (parcours du body) est limité à une fois par frame.
new MutationObserver((records) => {
locate && records.forEach(({target}) => targets.add(target));
frame ||= requestAnimationFrame(update);
}).observe(body, {subtree: true, childList: true});
update();
};
// Script chargé en async ou injecté tard : DOMContentLoaded est peut-être déjà passé
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', track, {once: true})
: track();
// Au load, tous les data-* de la fonction sont posés (dimensions, éléments et profondeur inclus).
// Un attribut absent apparaît à undefined (data-depth sans DEBUG.depth, par exemple).
const table = () => console.table(
Object.fromEntries(['viewport', 'media', 'orientation', 'documentWidth', 'documentHeight', 'elements', 'depth']
.map((key) => [key, root.dataset[key]]))
);
DEBUG.table && (document.readyState === 'complete' ? table() : addEventListener('load', table, {once: true}));
})(document.documentElement);
Télécharger kortic-DOM surveillance.js
Usage JavaScript
Lire l'état se résume à une propriété, sans nouveau matchMedia :
// Aucune requête à recréer : l'état est déjà calculé
if (document.documentElement.dataset.viewport === 'small') {
// mode compact
}
Pour réagir aux changements, un MutationObserver filtré sur l'attribut suffit :
new MutationObserver(() => rebuild(document.documentElement.dataset.viewport))
.observe(document.documentElement, {attributes: true, attributeFilter: ['data-viewport']});
Un composant n'a plus à connaître les seuils, seulement le nom de la classe. Déplacer un palier de 840 à 900 px se fait à un seul endroit, et plus dans neuf fichiers dont un nommé final-v2-new.js.
Usage CSS
Ce que le CSS fait mieux seul
Pour la mise en page, il faut garder les media queries et les container queries. Elles s'appliquent sans JavaScript, dès le premier rendu, et continuent de fonctionner si le script échoue. Faire dépendre la mise en page d'un attribut posé par un script casse l'amélioration progressive. Le CSS se débrouille très bien seul, inutile de lui faire attendre un script pour savoir s'il y a de la place.
Où les attributs ont leur place
Les attributs servent quand le CSS doit s'aligner sur un état que le JavaScript a déjà décidé. Un composant qui bascule de comportement à medium en JS peut présenter le même état en CSS, sans que les deux seuils soient déclarés séparément :
/* Plages larges : x-large, xx-large et xxx-large partagent le préfixe */
:root[data-viewport^="x"] .carousel {
--carousel-columns: 4;
}
Les sélecteurs d'attribut permettent aussi de regrouper des plages sans répéter de requête :
:root:is([data-viewport="small"], [data-viewport="medium"]) .toolbar {
position: static;
}
Pour les tests et les rapports de bug
Un état exposé dans le DOM se teste. Un test de bout en bout peut attendre qu'une plage soit active avant de capturer :
await page.waitForSelector('html[data-viewport="medium"]');
La même logique sert aux rapports de bug : copier la balise <html> d'une capture donne la plage, l'orientation et le poids du DOM au moment du défaut. Le rapport devient reproductible sans échange supplémentaire, ce qui change des « chez moi, ça marche ».
Garder le DOM sous contrôle
Le nombre d'éléments
data-elements rend le poids du DOM mesurable en continu. Le script le recompte uniquement quand des nœuds sont ajoutés ou retirés, au plus une fois par frame, et le signale dans la console en orange au-delà de 1300 éléments, en rouge à partir de 2000. Une option ajoute le XPath du conteneur concerné :
438ms : 900 éléments (+221) //*[@id="main"]/section[2]/ul[1]
Une liste non paginée ou un widget tiers qui injecte 200 nœuds se repère au moment où il arrive, avec son adresse. Difficile de nier quand la console cite le coupable.
La profondeur, deuxième indicateur
Le nombre d'éléments ne dit pas tout : un DOM de 800 nœuds imbriqués sur 40 niveaux coûte plus cher en calcul de style qu'un DOM plat de 1200. Le script mesure aussi la profondeur maximale, la pose dans data-depth et affiche le XPath de l'élément le plus profond :
1204ms : profondeur 14 //*[@id="main"]/div[3]/ul[1]/li[7]/a[1]/span[1]
L'élément est passé à console.info, donc cliquable dans les DevTools. La ligne passe en orange au-delà de 32 niveaux, seuil de l'audit Lighthouse sur la taille du DOM. L'élément html vaut 1, body 2.
Le calcul se fait en un seul parcours du body, dans l'ordre du document : la profondeur d'un élément est celle de son parent plus un, déjà mesurée. Le coût reste linéaire, mais il se paie à chaque changement du nombre d'éléments. C'est pourquoi data-depth se désactive avec l'option depth.
Accessibilité
Les paliers sont écrits en rem. Dans une media query, le rem se calcule sur la taille de police initiale du navigateur, jamais sur le font-size de html. Quand une personne agrandit sa taille de texte par défaut, les seuils se décalent avec elle, ce qu'attendent les critères 1.4.4 et 1.4.10.
Pour un audit, data-viewport devient le témoin : on zoome, on regarde si la classe change. Si la mise en page bouge sans que la classe suive, un seuil est resté en pixels.
Tester
Ouvrir l'inspecteur, sélectionner <html>, puis redimensionner la fenêtre ou zoomer le texte. Les attributs changent en direct, sans recharger la page. Pour le suivi du DOM, passer dom à true dans DEBUG, ouvrir la console et injecter un gros bloc de HTML. Elle râle, c'est normal.
Réglages et limites
-
Sorties console pilotées par un objet de booléens, avec ces valeurs par exemple :
const DEBUG = {init: true, ready: false, dom: false, xpath: true, depth: true, table: true};Pour suivre l'évolution du DOM, passer
domàtrue. En production, tout passe àfalse: la collecte des cibles, le calcul des XPath et celui de la profondeur ne sont plus exécutés. Seuls restent les attributs et le comptage des éléments. -
Instantané au
load:tableaffiche les attributs tels qu'ils sont auload, pas à l'initialisation. Des nœuds injectés entreDOMContentLoadedetloadsont déjà comptés. -
Placement : dans le
<head>, inline et synchrone, si du CSS dépend des attributs. Sinon le bas dubodysuffit. Chargé enasyncou injecté aprèsDOMContentLoaded, le script démarre le suivi du DOM immédiatement. -
Largeur du document :
data-document-widthexclut la barre de défilement, alors que les media queries la comptent. On peut lire 1195 avecdata-viewport="x-large"(seuil à 1200), sans que ce soit un bug. -
Profondeur : elle n'est recalculée que lorsque le nombre d'éléments change. Déplacer un nœud dans un conteneur plus profond ne met pas
data-depthà jour. -
Autonomie : aucune dépendance à un autre script ni à une variable globale.
-
Compatibilité : la syntaxe range des media queries demande Chrome 104, Firefox 63 et Safari 16.4.
-
Limite : les attributs décrivent un état, ils ne remplacent pas la logique responsive du CSS.
Conclusion
Voilà. Un seul endroit pour l'état de la page, et plus de seuil caché dans un composant oublié depuis trois refontes.
Astuce pour le développement « front » : quand on a besoin de savoir quelles sont les dimensions d'un viewport pour du debug, les données sont disponibles en permanence.