12/12/2017
Lazy loading images, full rabbit hole
Empezamos con una imagen:
<div class="picture">
<img src="./images/204515.jpg" alt="Train">
</div>
Pero como somos muy majos y muy listos pues vamos a redimensionarla en varios tamaños y así podemos decirle al navegador que cargue la imagen más adecuada al tamaño del viewport y así no tenemos que cargar una imagen gigantesca si no hace falta (aka: Responsive images):
<div class="picture">
<img src="./images/204515-500.jpg" srcset="./images/204515-375.jpg 375w, ./images/204515-500.jpg 500w, ./images/204515-900.jpg 900w" alt="Train">
</div>
Esto está mucho mejor, no nos vamos a meter en art direction así que no nos hace falta pensar en <picture></picture>
. Y además la gracia de srcset
es que sigue siendo compatible con navegadores antiguos.
Ok, ahora vamos a ir un paso más allá y vamos a utilizar el API de Intersection Observer para sólo cargar las imágenes que estén dentro del viewport (aka: lazy loading), para esto quitamos los atributros src
y scrset
y los volvemos a cambiar con javascript
cuando la imagen entre en el viewport:
<div class="picture">
<img class="lazy" data-src="./images/204515-500.jpg" data-srcset="./images/204515-375.jpg 375w, ./images/204515-500.jpg 500w, ./images/204515-900.jpg 900w" alt="Train">
</div>
// JS script tag
function render_images(image) {
image.srcset = image.getAttribute('data-srcset);
image.src = image.getAttribute('data-src);
}
const observer = new IntersectionObserver((entries) => {
if (entries[0].intersectionRatio <= 0) return;
render_images(entries[0].target.querySelector('img'));
});
var pictures = document.querySelectorAll('.picture');
pictures.forEach(function(picture) {
observer.observe(picture);
});
Pero claro, no todos los navegadores son compatibles con el API de intersection observer, entonces para esos casos tenemos dos opciones: usar un polyfill u olvidarnos de lo del lazy loading y cargar las imágenes al cargar la página. En este caso me decanto por la última, pero dependerá de cada uno:
// JS script tag
function render_images(image) {
if (image) {
image.srcset = image.getAttribute('data-srcset);
image.src = image.getAttribute('data-src);
} else {
// Si no hay imágenes pues recorro todas y les cambio los atributos
var images = document.getElementsByClassName('lazy');
for (var i = 0; i < images.length; i++) {
images[i].srcset = images[i].getAttribute('data-srcset');
images[i].src = images[i].getAttribute('data-src');
}
}
}
// Compruebo el soporte del navegador
if ('IntersectionObserver' in window &&
'IntersectionObserverEntry' in window &&
'intersectionRatio' in window.IntersectionObserverEntry.prototype) {
const observer = new IntersectionObserver((entries) => {
if (entries[0].intersectionRatio <= 0) return;
render_images(entries[0].target.querySelector('img'));
});
var pictures = document.querySelectorAll('.picture');
pictures.forEach(function(picture) {
observer.observe(picture);
});
} else {
// Si no hay soporte llamo a la función sin parámetro
render_images();
}
Luego, claro, tenemos que ocuparnos de quien navega sin javascript activado, en este caso es muy fácil, porque podemos usar la etiqueta <noscript>
para mostrar la imagen con los atributos originales:
<div class="picture">
<img class="lazy" data-src="./images/204515-500.jpg" data-srcset="./images/204515-375.jpg 375w, ./images/204515-500.jpg 500w, ./images/204515-900.jpg 900w" alt="Train">
<noscript>
<img src="./images/204515-500.jpg" srcset="./images/204515-375.jpg 375w, ./images/204515-500.jpg 500w, ./images/204515-900.jpg 900w" alt="Train">
</noscript>
</div>
Y con eso tendríamos un sistema majo en marcha, pero, podemos hacerlo mejor (y/o más complicado). Ahora mismo en el mejor de los casos un usario que accede a la página sólo verá las imágenes que aparezcan en el viewport, pero tenemos el problema este horrible de que el navegador no ha reservado sitio en la página para las imágenes faltantes y según como sea su patrón de navegación pues el usuario verá desesperado como el texto se va recolocando conforme se cargan las imágenes (moviendo el viewport y haciendo perder el foco de lectura). Y como eso no es aceptable pues hay que jugar con un placeholder. Aquí hay dos técnicas principales: poner una imagen en baja resolución (y adecuadamente escalada) o poner un objeto que represente la imagen pero que sea sólo un fondo (por ejemplo). Yo voy a ir por la primera vía y no, no me voy a liar demasiado en cuanto a como hacer las imagenes, aunque la funcionalidad... Custom Element, yeiii.
<pic-img src="./images/204515.jpg" alt="Custom Element - Train - https://unsplash.com/@echogrid" sizes="[100, 375, 500, 900]">
<div class="picture">
<img class="lazy" data-src="./images/204515-500.jpg" data-srcset="./images/204515-375.jpg 375w, ./images/204515-500.jpg 500w, ./images/204515-900.jpg 900w" alt="Train">
<noscript>
<img src="./images/204515-500.jpg" srcset="./images/204515-375.jpg 375w, ./images/204515-500.jpg 500w, ./images/204515-900.jpg 900w" alt="Train">
</noscript>
</div>
</pic-img>
// pic-img.js
class PicImage extends HTMLElement {
// Al crear la instancia del elemento inicializamos algunos valores
constructor() {
super();
// parseamos los tamaños de imagen disponibles
this.sizes = JSON.parse(this.getAttribute('sizes'));
// al inicio no está cargada la imagen grande
this.is_big_loaded = false;
// pero tenemos la ruta
this.src = this.getAttribute('src');
// Y empezamos con lienzo en blanco
this.innerHTML = '';
}
connectedCallback() {
// más valores iniciales
this.src = this.getAttribute('src');
this.ext = this.src.substring(this.src.lastIndexOf('.'));
this.name = this.src.substring(0, this.src.lastIndexOf('.'));
// render inicial
this.renderShell();
// observar la intersección con el viewport
this.bindEvents();
}
get imageURL() {
// helper para construir las rutas de las imágenes según el tamaño necesario
return this.name + '-' + this.currentSize + this.ext;
}
get currentSize() {
// inicializo el tamaño como pequeño
if (!this._currentSize) {
this.currentSize = 'small';
}
return this._currentSize;
}
set currentSize(value) {
if (value === 'big') {
// si quiero la imagen grande...
// calculo el tamaño necesario (según lo que ocupa el placeholder)
const imgWidth = this.querySelector('img').getBoundingClientRect().width;
// filtro las imágenes para obtener la inmediatamente superior al tamaño
// si no hay match (el tamaño es más grande que cualquiera de mis imágenes)
// cargo la mayor que tenfo
this._currentSize = this.sizes.find(function(item) {
return item > imgWidth;
}) || Math.max.apply(null, this.sizes);
} else {
// si no quiero la grande quiero la más pequeña
this._currentSize = Math.min.apply(null, this.sizes);
}
}
bindEvents() {
// Creo un IO para las imágenes
this.observer = new IntersectionObserver((entries) => { this.loadBigImage(entries) });
this.observer.observe(this.querySelector('.picture'));
}
loadBigImage(entries) {
// callback para cuando tengo que mostrar las imágenes
if (entries[0].intersectionRatio <= 0) return;
var curImg = new Image();
curImg.onload = () => {
this.is_big_loaded = true;
// una vez cargada la imagen vuelvo a renderizar mi DOM
this.render();
}
this.currentSize = 'big';
curImg.src = this.imageURL;
// una vez cargada la imagen no necesito el observer
this.observer.disconnect();
}
renderShell() {
// render inicial, div.picture vacío
this.root = document.createElement('div');
this.root.classList.add('picture');
this.appendChild(this.root);
this.render();
}
render() {
// cargo la imagen correspondiente
var dom = `
<img src="${this.imageURL}" alt="${this.getAttribute('alt')}" class="${this.is_big_loaded ? '' : 'blured'}">
<p class="caption">${this.getAttribute('alt')}</p>
`;
this.root.innerHTML = dom;
}
}
// defino el elemento
customElements.define('pic-img', PicImage);
Y claro, me falta cargar este archivo pic-img.js
, pero sólo lo cargo si el navegador es compatible:
// script tag
function render_images(image) {
if (image) {
image.srcset = image.getAttribute('data-srcset');
image.src = image.getAttribute('data-src');
} else {
var images = document.getElementsByClassName('lazy');
for (var i = 0; i < images.length; i++) {
images[i].srcset = images[i].getAttribute('data-srcset');
images[i].src = images[i].getAttribute('data-src');
}
}
}
if (window.customElements) {
// si tengo soporte cargo el script
var def = document.createElement('script');
def.src = './pic-img.js';
document.body.appendChild(def);
} else {
// si no lo tengo hago lo de lazy load
if ('IntersectionObserver' in window &&
'IntersectionObserverEntry' in window &&
'intersectionRatio' in window.IntersectionObserverEntry.prototype) {
const observer = new IntersectionObserver((entries) => {
if (entries[0].intersectionRatio <= 0) return;
render_images(entries[0].target.querySelector('img'));
});
var pictures = document.querySelectorAll('.picture');
pictures.forEach(function(picture) {
observer.observe(picture);
});
} else {
// no intersection observer... fallback a cargar las imágenes
render_images();
}
}
Y con esto si que sí, la gracia está en tener el panel de red de las herramientas de desarrollo y ver como se van cargando las imágenes en la demo.