Article écrit par Victor Darras
Bonjour à tous, aujourd’hui un sujet à cheval entre intégration et développement front, nous allons faire en sorte d’améliorer les performances de rendu de vos pages web. Je pense pouvoir avancer sans trop de risque qu’en général une page web est bien plus alourdie par ses images que par ses CSS ou JS. Il nous est tous déjà arrivé d’avoir besoin d’afficher un nombre important d’images sur une même page et c’est là que les choses se corsent.
Tant en termes de SEO que pour l’accessibilité (coucou toi avec ta 3G en rade), il est vite primordial de réduire le temps de chargement de vos pages. Pour cela, il existe plusieurs techniques complémentaires telles que :
- la réduction des fichiers textes ;
- la compression des images (je vous conseille le très bon ImageOptim) ;
- l’insertion inline de CSS indispensables à un premier chargement de page ;
- ou encore le chargement dynamique d’image sur lequel je vais m’attarder aujourd’hui.
Nous allons voir plusieurs techniques, chacune ayant ses bénéfices et ses défauts ; donc ne vous arrêtez pas à la première, qui pourrait ne pas être la plus performante (qui sait ?).
Côté donne, qu’est-ce que ça DOM ?
Pour nous simplifier la tâche, je vous propose de commencer par l’élément img
adapté à notre défi. Dans le src
mettons un placeholder pouvant être une image très légère permettant de remplir l’espace en attendant plus d’infos, ou une data-url
en base64 qui permettra d’éviter une requête HTTP supplémentaire. Ce src
étant temporaire, nous aurons besoin de l’URL réelle. Pourquoi pas définir un attribut data-src
que nous utiliserons à la volée ? On va aussi lui administrer un peu de classe pour pouvoir la retrouver plus facilement parmi les autres images chargées de manière standard (pour les plus attentifs, nous pourrions aussi utiliser un sélecteur comme [data-src]
).
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-src="/url-image.png" class="lazy" alt="Description" />
À part si vous utilisez un placeholder de la même taille que votre image finale (une version floue à la Medium par exemple), je vous conseille à cette étape d’ajouter des dimensions à votre image — avec des attributs ou en CSS — pour éviter de probables effets de redimensionnement de page.
Maintenant passons aux choses sérieuses…
Quelques bases avec JavaScript
Voyons dans un premier temps comment implémenter de manière basique notre solution. Demandons-nous si l’image que l’on veut charger est bien visible à l’écran avec la méthode isImgVisible
. Celle-ci compare la distance entre le bord haut de l’image et le haut de la fenêtre de l’utilisateur, si elle est nulle ou négative, cette image devrait être visible.
function isImgVisible(img) { return img.getBoundingClientRect().top <= window.innerHeight; }
Ensuite nous sélectionnons l’ensemble des images ayant la classe lazy
définie précédemment. La fonction showImgs()
passera sur chacune des images et lorsque l’une d’entre elles sera visible, l’attribut src
sera mis à jour avec l’URL de l’image recherchée.
const imgs = document.querySelectorAll("img.lazy"); function showImgs() { imgs.forEach(function(img) { if (isImgVisible(img)){ img.src = img.dataset.src; } }); }
Enfin on vérifie régulièrement si les images doivent être visibles ou non :
window.setInterval(showImgs, 1000)
Beaucoup de défauts à cette solution :
- On ne supprime pas l’interval, et plus le nombre d’images est grand, moins ça fonctionne ;
- la méthode
getBoundingClientRect
appelée trop souvent peut créer des effets de ralentissement ; - on réassigne les sources de toutes les images, même celles déjà chargées ;
- on ne gère pas les différentes sources possibles, comme on pourrait le faire avec l’attribut
srcset
ou le duopicture
/source
.
Elle me permet néanmoins de poser des bases de logique à améliorer par la suite.
La solution la plus compatible
Quelques points d’amélioration notables pour cette seconde version :
Node loop
querySelectorAll().forEach()
n’est pas compatible avec Safari ou IE/Edge, l’utilisation de Array.prototype.slice.call()
permet de transformer un ensemble de NODE en un tableau standard (plus d’infos)
var lazyImages = Array.prototype.slice.call(document.querySelectorAll("img.lazy"));
setTimeout
Une variable (flag) active
et un setTimeout
pour empêcher d’exécuter la fonction plus souvent que toutes les 200ms (aussi appelé throttling) et réduire ainsi les risques de ralentissement. Avec ES6, on peut aussi utiliser Array.from()
, plus explicite.
var active = false; var lazyLoad = function() { if (!active) { active = true; setTimeout(function() { // Actions active = false; }, 200); } }
L’image est-elle visible ?
La fonction isImgVisible
est ici plus complète et vérifie que le haut et le bas de l’image sont bien dans la fenêtre, et si l’image n’a pas de style display:none
.
var isImgVisible = function (lazyImage) { return lazyImage.getBoundingClientRect().top <= window.innerHeight && lazyImage.getBoundingClientRect().bottom >= 0 && getComputedStyle(lazyImage).display !== "none" }
Gestion de sources
L’utilisation de data-src
est toujours la même, si ce n’est qu’on lui ajoute une gestion de srcset
dans le cas de sources multiple, pour la gestion d’écrans Retina notamment.
lazyImage.src = lazyImage.dataset.src; lazyImage.srcset = lazyImage.dataset.srcset;
Réduire le tableau d’images
À chaque occurrence nous réduisons l’Array
contenant notre liste d’images, ainsi la boucle est de plus en plus courte, et on économise de la mémoire.
lazyImages = lazyImages.filter(function(image) { return image !== lazyImage; });
Activer le chargement dynamique
Pour finaliser ce script il ne nous reste qu’à activer notre fonction. Puisqu’elle n’est plus exécutée au lancement de la page comme dans le script précédent, nous allons l’attacher aux événements scroll
, resize
et orientationchange
(pour mobile).
window.addEventListener("scroll", lazyLoad); window.addEventListener("resize", lazyLoad); window.addEventListener("orientationchange", lazyLoad);
Toutes les images sont chargées, on détruit tout
En fin de boucle, on prend le temps de vérifier s’il reste des images à afficher. Dans le cas contraire, on supprime l’ensemble des EventListener
associés.
if (lazyImages.length === 0) { window.removeEventListener("scroll", lazyLoad); window.removeEventListener("resize", lazyLoad); window.removeEventListener("orientationchange", lazyLoad); }
Le script au complet
Maintenant que nous avons vu chaque détail d’amélioration, voici la version complète :
var lazyImages = Array.prototype.slice.call(document.querySelectorAll("img.lazy")); var active = false; var isImgVisible = function (lazyImage) { return lazyImage.getBoundingClientRect().top <= window.innerHeight && lazyImage.getBoundingClientRect().bottom >= 0 && getComputedStyle(lazyImage).display !== "none" } var lazyLoad = function() { if (!active) { active = true; setTimeout(function() { lazyImages.forEach(function(lazyImage) { if (isImgVisible(lazyImage)) { lazyImage.src = lazyImage.dataset.src; lazyImage.srcset = lazyImage.dataset.srcset; lazyImages = lazyImages.filter(function(image) { return image !== lazyImage; }); if (lazyImages.length === 0) { window.removeEventListener("scroll", lazyLoad); window.removeEventListener("resize", lazyLoad); window.removeEventListener("orientationchange", lazyLoad); } } }); active = false; }, 200); } }; window.addEventListener("scroll", lazyLoad); window.addEventListener("resize", lazyLoad); window.addEventListener("orientationchange", lazyLoad);
Avec cette solution plus évoluée, vous prendrez en compte un maximum de navigateurs (IE9+), mais les performances ne seront toujours pas les meilleures que vous puissiez avoir.
Voyons maintenant comment rendre tout ça blazing fast.
Avec IntersectionObserver, plus de performance et plus de bonheur !
Grâce à l’API IntersectionObserver nous pouvons ajouter un observer asynchrone aux images que nous voulons charger. Celui-ci nous fournit des détails sur les intersections entre éléments, parents, et le document. Dans un premier temps, nous vérifions la disponibilité de l’API avec ("IntersectionObserver" in window)
. Nous créons ensuite un nouvel objet IntersectionObserver
qui prend pour premier argument le callback qui modifiera les attributs de l’élément img
comme vu précédemment.
Ce nouveau callback nous donne les différentes infos et états de l’observer :
IntersectionObserverEntry: [ boundingClientRect: {x: 1327, y: 709, width: 436, height: 436, top: 709, …}, intersectionRatio: 1, intersectionRect: {x: 1327, y: 709, width: 436, height: 436, top: 709, …}, isIntersecting: true, rootBounds: {x: -50, y: -50, width: 2645, height: 1405, top: -50, …}, target: img.lazy, time: 559.4999999993888, ]
Nous n’aurons besoin que de isIntersecting
pour cet exercice qui, une fois vérifié, nous permet de désactiver l’observer avec unobserve
. Enfin nous pouvons activer l’observer pour chacune de nos images.
const lazyImages = [].slice.call(document.querySelectorAll("img.lazy")); if ("IntersectionObserver" in window) { const lazyImageObserver = new IntersectionObserver(function(entries) { entries.forEach(function(entry) { if (entry.isIntersecting) { const lazyImage = entry.target; lazyImage.src = lazyImage.dataset.src; lazyImage.srcset = lazyImage.dataset.srcset; lazyImageObserver.unobserve(lazyImage); } }); }); lazyImages.forEach(function(lazyImage) { lazyImageObserver.observe(lazyImage); }); } else { // Possibly fall back to a more compatible method here }
En fallback, on pourrait utiliser la solution précédente, mais il semblerait que l’utilisation d’un polyfill soit une bonne idée aussi.
Nous voici avec une solution clé-en-main pour charger dynamiquement nos images, ou pourquoi pas mettre en place un infinite scrolling efficace !
Petits bonus
Il peut vous arriver de devoir définir un élément scrollable différent de body
(par défaut), ou encore de vouloir prendre de l’avance sur le scroll de l’utilisateur, ou même de n’afficher l’image que lorsqu’elle est affichée d’au moins 25%. Pour cela, il suffit d’ajouter des options à l’instanciation de l’objet IntersectionObserver
:
const options = { root: document.querySelector('#scrollArea'), rootMargin: '0px', threshold: 1.0 } const lazyImageObserver = new IntersectionObserver(callback, options);
Conclusion
Maintenant que nous avons fait le tour, j’espère que cette déclinaison étape par étape était claire et vous a plu. Dorénavant nous savons faire travailler le navigateur pour le rendre plus paresseux (avouez que c’est cocasse) ! Le lazy-loading c’est bon pour vos visiteurs, bon pour le référencement, c’est même bon pour la planète. Mangez-en !