Le chargement asynchrone des feuilles de styles. Voilà un beau sujet dont on parle régulièrement !
Il existe tout un tas d'approches. Histoire d'en ajouter une, voici une proposition.
Le pire, c'est que ça fonctionne !
Le concept
Utilisation du localStorage pour entreposer les data au plus près du consommateur. Un circuit court, c'est à la mode.
Au chargement de la page, les feuilles de styles sont appelées si le stockage local ne les a pas déjà. Le stockage local a une expiration configurée à une heure. Donc, durant ce délai, aucune requête HTTP n'est effectuée.
Passé ce délait, le script va utiliser les en-têtes HTTP pour récupérer le Etag et le stocker. Bien entendu, il faut que la ressource CSS envoie bien cet en-tête. Logique.
Si Etag n'a pas changé, le script ré-injecte ce qu'il a stocké. C'est tout.
De fait, dès qu'une mise à jour du build de CSS sera effectuée, le Etag va changer et le localStorage sera mis à jour. Simple.
Dans le HTML
Dans le head, il suffit de placer une ou des balises style.
Par exemple :
<style id="async-css-global" data-url="/path/to/assets/styles/css-1.css"></style>
<style id="async-css-content" data-url="/path/to/assets/styles/css-2.css"></style>
Les attributs id doivent commencer par async-css. Ou bien il faut adapter le script. En toute logique, l'attribut data-url va contenir le chemin vers la CSS attendue.
Le script gère automatiquement une valeur d'expiration. Pour chaque bloc, il est possible d'ajouter un attribut data-expire="[numberMs]" en millisecondes pour adapter selon les besoins.
Jusque là, ça va, on n'est pas dans la hi-tech.
Le script
À placer au plus haut possible dans le head, directement dans le code, mais après les balises style concernées. Sinon, ça va faire bizarre.
Taille brute : 10,73 kilo-octets (~3,53 kilo-octets gzip)
(() => {
'use strict';
const selfScript = document.currentScript;
const displayConsole = false;
/**
* Nettoie le domaine complet (enlève 'www.', remplace les points par des tirets)
* puis crée un numéronyme à partir de cette chaîne nettoyée.
*
* @param {string} domainString - La chaîne de domaine à traiter (typiquement document.domain).
* @returns {string} Le numéronyme du domaine, ou la chaîne non traitée si trop courte ou invalide.
*/
function getDomainNumerology(domainString) {
if (!domainString || typeof domainString !== 'string') {
return '';
}
let cleanedString = domainString.toLowerCase();
cleanedString = cleanedString.replace(/^www\./, '');
cleanedString = cleanedString.replace(/\./g, '-');
if (cleanedString.length < 3) {
return cleanedString;
}
return `${cleanedString[0]}${cleanedString.length - 2}${cleanedString[cleanedString.length - 1]}`;
}
// Préfixes des ≠ ressources
const prefixes = {
css: `${getDomainNumerology(document.domain)}-css-`,
svg: `${getDomainNumerology(document.domain)}-svg-`
};
// Moteurs de stockage disponibles, sélectionnés par balise via data-storage="local|session"
const engines = {local: localStorage, session: sessionStorage};
const resolveEngine = (target) => engines[target.getAttribute('data-storage')] ?? localStorage;
/**
* Fonction utilitaire pour nettoyer les entrées expirées d'un moteur de stockage selon un préfixe donné.
* Supprime les clés dont la date d'expiration est dépassée ou dont le contenu est corrompu.
* Nettoie également les anciennes versions de fichiers avec des hash différents.
*
* @param {Storage} engine - Moteur de stockage à nettoyer (localStorage ou sessionStorage)
* @param {string} prefix - Préfixe des clés à vérifier
* @param {Array<string>} activeKeys - Liste des clés actuellement utilisées (optionnel)
*/
const cleanExpiredCache = (engine, prefix, activeKeys = []) => {
for (const key in engine) {
if (key.startsWith(prefix)) {
let shouldRemove = false;
// Vérifier si la clé est expirée ou corrompue
try {
const {expires} = JSON.parse(engine.getItem(key));
if (expires && Date.now() > expires) {
shouldRemove = true;
displayConsole && console.log(`Clé expirée supprimée : ${key}`);
}
} catch (e) {
shouldRemove = true;
displayConsole && console.warn(`Clé corrompue supprimée : ${key}`);
}
// Vérifier si c'est une ancienne version (hash différent)
if (!shouldRemove && activeKeys.length > 0) {
// Extraire le nom de base du fichier (sans le hash)
const keyBaseName = key.replace(prefix, '').replace(/\.[a-f0-9]+\./, '.');
// Vérifier si ce fichier existe dans les clés actives avec un hash différent
if (activeKeys.some(activeKey => {
const activeBaseName = activeKey.replace(prefix, '').replace(/\.[a-f0-9]+\./, '.');
return keyBaseName === activeBaseName && key !== activeKey;
})) {
shouldRemove = true;
displayConsole && console.log(`Ancienne version supprimée : ${key}`);
}
}
if (shouldRemove) {
engine.removeItem(key);
}
}
}
};
// Nettoyage des caches expirés au démarrage sur les clés actives, pour chaque moteur
const cssActiveKeys = Array.from(document.querySelectorAll('style[id^="async-css"]'))
.map(styleTag => prefixes.css + styleTag.getAttribute('data-url')?.split('/').pop())
.filter(Boolean);
const svgActiveKeys = Array.from(document.querySelectorAll('[data-url$=".svg"]'))
.map(el => prefixes.svg + el.getAttribute('data-url')?.split('/').pop())
.filter(Boolean);
Object.values(engines).forEach(engine => {
cleanExpiredCache(engine, prefixes.css, cssActiveKeys);
cleanExpiredCache(engine, prefixes.svg, svgActiveKeys);
});
/**
* Fonction générique pour injecter une ressource dans le DOM avec gestion du cache, de l'expiration, de l'ETag et des mesures de performance.
*
* @param {Object} options - Paramètres de configuration
* @param {string} options.id - Identifiant unique de la ressource (utilisé pour les messages de console)
* @param {string} options.url - URL de la ressource à charger
* @param {string} options.storagePrefix - Préfixe pour la clé de cache
* @param {number} options.expirationMs - Durée de validité du cache en millisecondes (fallback si data-expire absent)
* @param {string} options.targetSelector - Sélecteur CSS du nœud DOM cible. Le moteur de stockage (localStorage/sessionStorage) est résolu depuis l'attribut data-storage de ce nœud
* @param {Function} [options.inject] - Fonction personnalisée d'injection (optionnelle si useInnerHTML est utilisé)
* @param {string} options.measureLabel - Nom utilisé pour les mesures de performance
* @param {boolean} [options.useInnerHTML=true] - Définit si le contenu doit être injecté via innerHTML (true) ou textContent (false)
*/
const injectResource = async ({
id,
url,
storagePrefix,
expirationMs,
targetSelector,
inject,
measureLabel,
useInnerHTML = true
}) => {
const target = document.querySelector(targetSelector);
if (!target || !url) return;
// Utilise le nom de la ressource dans l'URL pour une clé de cache robuste
const STORAGE_KEY = storagePrefix + url.split('/').pop();
const engine = resolveEngine(target);
const startMark = `${measureLabel}:start`;
const endMark = `${measureLabel}:end`;
displayConsole && performance?.mark(startMark);
const cachedData = engine.getItem(STORAGE_KEY);
let shouldReload = true;
/**
* Fonction interne pour récupérer la ressource via fetch
* et mettre à jour le cache si nécessaire.
*/
const fetchAndInject = async (storedEtag) => {
try {
const headers = storedEtag ? new Headers({'If-None-Match': storedEtag}) : new Headers();
const response = await fetch(url, {headers});
// Si le serveur renvoie une réponse 304 Not Modified, on n'a rien à faire.
if (response.status === 304) {
return false;
}
const newEtag = response.headers.get('ETag');
const content = await response.text();
// Lecture de l'attribut data-expire (en ms), avec fallback sur expirationMs
const expireAttr = target.getAttribute('data-expire');
const expireMs = expireAttr ? Math.max(parseInt(expireAttr, 10), 1000) : expirationMs;
const expirationTime = Date.now() + (isNaN(expireMs) ? expirationMs : expireMs);
engine.setItem(STORAGE_KEY, JSON.stringify({
expires: expirationTime, etag: newEtag, content
}));
// Injection du contenu dans le DOM
content?.trim()
&& (inject
? inject(target, content)
: useInnerHTML ? target.innerHTML = content : target.textContent = content
);
return true;
} catch (error) {
displayConsole && console.error(`Erreur de chargement pour ${id}:`, error);
return false;
}
};
// Si le cache est valide, on injecte directement sans fetch
if (cachedData) {
try {
const {content, etag, expires} = JSON.parse(cachedData);
if (content && expires && Date.now() < expires) {
if (content?.trim()) {
inject
? inject(target, content)
: (useInnerHTML ? target.innerHTML = content : target.textContent = content);
}
shouldReload = false;
}
} catch (e) {
displayConsole && console.warn(`Cache corrompu pour ${id}, rechargement forcé.`);
engine.removeItem(STORAGE_KEY); // Nettoyage de la clé corrompue
}
}
// Si le cache est expiré ou absent, on tente un fetch
const reloaded = shouldReload ? await fetchAndInject(cachedData ? JSON.parse(cachedData).etag : null) : false;
displayConsole && performance?.mark(endMark);
// Log console si une ressource a été rechargée
(reloaded && displayConsole) && console.log(`${id} chargé depuis ${url}`);
// Mesure personnalisée (si disponible)
try {
displayConsole && window.measure?.(measureLabel, startMark, endMark);
} catch (e) {
displayConsole && console.warn('Erreur de mesure :', e);
}
target.removeAttribute('data-url');
target.removeAttribute('data-expire');
target.removeAttribute('data-storage');
};
/**
* Injection dans les balises <style> dynamiques dans le HEAD de page.
* Utilise innerHTML pour que le navigateur interprète les règles CSS.
* Utilise Promise.all pour attendre la fin de toutes les injections.
*/
const stylePromises = Array.from(document.querySelectorAll('style[id^="async-css"]'))
.map(styleTag => {
const id = styleTag.id;
return injectResource({
id,
url: styleTag.getAttribute('data-url'),
storagePrefix: prefixes.css,
expirationMs: 1800000, // 30 minutes par défaut
targetSelector: `style#${id}`,
measureLabel: `Injection ${id}`,
useInnerHTML: true, // CSS doit être interprété
inject: (target, content) => {
// Nettoyer le @charset avant injection
const cleanContent = content.replace(/@charset\s+["'][^"']+["']\s*;?/gi, '');
target.innerHTML = cleanContent.trim();
}
});
});
Promise.all(stylePromises)
.then(() => selfScript?.remove())
.catch(err => {
displayConsole && console.error(`Erreur lors de l'injection des styles : ${err}`);
});
/**
* Fonction globale pour injecter un SVG dans un <element> donné.
* Utilise innerHTML pour que le SVG soit interprété comme balises.
*/
window.injectSVG = async (elementId, scriptId = `async-loader-${elementId}`) => {
const container = document.querySelector(`#${elementId}`);
if (!container) return;
await injectResource({
id: elementId,
url: container.getAttribute('data-url'),
storagePrefix: prefixes.svg,
expirationMs: 7 * 86400000,
targetSelector: `#${elementId}`,
measureLabel: `Injection ${elementId}`,
useInnerHTML: true
});
document.querySelector(`script[id="${scriptId}"]`)?.remove();
};
})();
Télécharger kortic-asyncLoader.js
Tester
Ben là, vous venez déjà de le faire, puisque ça fonctionne de cette manière sur ces pages.
On peut aussi se demander « pourquoi ne pas utiliser API Cache ? ». C'est justifié. Mais il s'avère qu'étant parti sur cette piste, ce n'est pas la plus performante. Si une personne compétente sait expliquer pourquoi, il y a une page contact dans le menu.
Et les SVG
Ah oui, on peut aussi le faire pour les symboles SVG. C'est dingue…
Par exemple, tous les pictogrammes (ou pas loin) qui sont affichés dans cette page proviennent d'un SVG directement affiché dans le code, juste après la balise body.
Le code ressemble à ça.
<span aria-hidden="true" id="svg-symbols" data-url="/svg/symbols.svg"></span>
<script>injectSVG('svg-symbols')</script>
Simple, c'est la même fonction qui est utilisée.
Pour l'utiliser, ce n'est pas plus compliqué.
Plus d'explications sur la gestion des SVG sur Comment utiliser les SVG dans HTML et CSS.
That's all Folks.