Ander San Miguel

Desarrollador Web

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.

Desarrollo Web

Esto es a lo que me dedico, así que es lo que más vas a encontrar en esta página.

Contacto

Esta sección está clara, si quieres contactar conmigo, este es un buen sitio.