Imágenes de carga diferida con directivas Vue.js y observador de intersecciones | Programar Plus

Cuando pienso en el rendimiento web, lo primero que me viene a la mente es cómo las imágenes son generalmente los últimos elementos que aparecen en una página. Hoy en día, las imágenes pueden ser un problema importante en lo que respecta al rendimiento, lo cual es lamentable, ya que la velocidad de carga de un sitio web tiene un impacto directo en que los usuarios hagan con éxito lo que vinieron a hacer en la página (piense en las tasas de conversación).

Muy recientemente, Rahul Nanwani escribió una guía extensa sobre imágenes de carga diferida. Me gustaría cubrir el mismo tema, pero desde un enfoque diferente: usando atributos de datos, Intersection Observer y directivas personalizadas en Vue.js.

Lo que esto hará básicamente es permitirnos resolver dos cosas:

  1. Almacenar el src de la imagen que queremos cargar sin cargarla en primer lugar.
  2. Detecta cuándo la imagen se vuelve visible para el usuario y activa la solicitud para cargar la imagen.

El mismo concepto básico de carga diferida, pero otra forma de hacerlo.

Creé un ejemplo, basado en un ejemplo descrito por Benjamin Taylor en su publicación de blog. Contiene una lista de artículos aleatorios, cada uno con una breve descripción, una imagen y un enlace a la fuente del artículo. Pasaremos por el proceso de creación de un componente que se encargue de mostrar esa lista, renderizar un artículo y cargar la imagen de forma diferida para un artículo específico.

¡Vamos a ser perezosos! O al menos descomponga este componente pieza por pieza.

Paso 1: crea el componente ImageItem en Vue

Comencemos por crear un componente que mostrará una imagen (pero sin carga diferida involucrada todavía). Llamaremos a este archivo ImageItem.vue. En la plantilla de componentes, usaremos un figure etiqueta que contiene nuestra imagen: la etiqueta de la imagen en sí recibirá el src atributo que apunta a la URL de origen del archivo de imagen.

<template>
  <figure class="image__wrapper">
    <img
      class="image__item"
      :src="https://css-tricks.com/lazy-loading-images-with-vue-js-directives-and-intersection-observer/source"
      alt="random image"
    >
  </figure>
</template>

En la sección de script del componente, recibimos el prop source que usaremos para el src URL de la imagen que estamos mostrando.

export default {
  name: "ImageItem",
  props: {
    source: {
      type: String,
      required: true
    }
  }
};

Todo esto está perfectamente bien y renderizará la imagen normalmente como está. Pero, si lo dejamos aquí, la imagen se cargará de inmediato sin esperar a que se renderice todo el componente. Eso no es lo que queremos, así que vayamos al siguiente paso.

Paso 2: evitar que la imagen se cargue cuando se crea el componente

Puede sonar un poco gracioso que queramos evitar que algo se cargue cuando queremos mostrarlo, pero se trata de cargarlo en el momento adecuado en lugar de bloquearlo indefinidamente. Para evitar que la imagen se cargue, debemos deshacernos de la src atributo del img etiqueta. Pero, todavía tenemos que almacenarlo en algún lugar para poder utilizarlo cuando lo deseemos. Un buen lugar para guardar esa información es en un data- atributo. Estos nos permiten almacenar información en elementos HTML semánticos estándar. De hecho, es posible que ya esté acostumbrado a usarlos como selectores de JavaScript.

En este caso, ¡se ajustan perfectamente a nuestras necesidades!

<!--ImageItem.vue-->
<template>
  <figure class="image__wrapper">
    <img
      class="image__item"
      :data-url="https://css-tricks.com/lazy-loading-images-with-vue-js-directives-and-intersection-observer/source" // yay for data attributes!
      alt="random image"
    >
  </figure>
</template>

Con eso, nuestra imagen no se cargará porque no hay una URL de origen de la que extraer.

Es un buen comienzo, pero aún no es lo que queremos. Queremos cargar nuestra imagen en condiciones específicas. Podemos solicitar que se cargue la imagen reemplazando el src atributo con la URL de origen de la imagen guardada en nuestro data-url atributo. Esa es la parte fácil. El verdadero desafío es averiguar cuándo reemplazarlo con la fuente real.

Nuestro objetivo es fijar la carga a la ubicación de la pantalla del usuario. Entonces, cuando el usuario se desplaza hasta un punto donde la imagen aparece a la vista, ahí es donde se carga.

¿Cómo podemos detectar si la imagen está a la vista o no? Ese es nuestro próximo paso.

Paso 3: detectar cuándo la imagen es visible para el usuario

Es posible que tenga experiencia en el uso de JavaScript para determinar cuándo un elemento está a la vista. También puede tener experiencia terminando con un guión retorcido.

Por ejemplo, podríamos usar eventos y controladores de eventos para detectar la posición de desplazamiento, el valor de compensación, la altura del elemento y la altura de la ventana gráfica, luego calcular si una imagen está en la ventana gráfica o no. Pero eso ya suena retorcido, ¿no?

Pero podría empeorar. Esto tiene implicaciones directas sobre el rendimiento. Esos cálculos se dispararían en cada evento de desplazamiento. Peor aún, imagina unas pocas docenas de imágenes, cada una de las cuales tiene que recalcular si es visible o no en cada evento de desplazamiento. ¡Locura!

¡Intersection Observer al rescate! Esto proporciona una forma muy eficaz de detectar si un elemento está visible en la ventana gráfica. Específicamente, le permite configurar un llamar de vuelta que se activa cuando un elemento, llamado el objetivo : Se cruza con la ventana gráfica del dispositivo o con un elemento especificado.

Entonces, ¿qué debemos hacer para usarlo? Unas pocas cosas:

  • crear un nuevo observador de intersecciones
  • Observe el elemento que deseamos cargar de forma diferida para ver los cambios de visibilidad.
  • cargar el elemento cuando el elemento está en la ventana gráfica (reemplazando src con nuestro data-url)
  • dejar de estar atento a los cambios de visibilidad (unobserve) después de que se completa la carga

Vue.js tiene directivas personalizadas para agrupar toda esta funcionalidad y usarla cuando la necesitemos, tantas veces como la necesitemos. Poner eso en práctica es nuestro próximo paso.

Paso 4: crea una directiva personalizada de Vue

¿Qué es una directiva personalizada? La documentación de Vue lo describe como una forma de obtener acceso DOM de bajo nivel en elementos. Por ejemplo, cambiar un atributo de un elemento DOM específico que, en nuestro caso, podría estar cambiando el src atributo de un img elemento. ¡Perfecto!

Desglosaremos esto en un momento, pero esto es lo que estamos viendo en cuanto al código:

export default {
  inserted: el => {
    function loadImage() {
      const imageElement = Array.from(el.children).find(
      el => el.nodeName === "IMG"
      );
      if (imageElement) {
        imageElement.addEventListener("load", () => {
          setTimeout(() => el.classList.add("loaded"), 100);
        });
        imageElement.addEventListener("error", () => console.log("error"));
        imageElement.src = imageElement.dataset.url;
      }
    }

    function handleIntersect(entries, observer) {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          loadImage();
          observer.unobserve(el);
        }
      });
    }

    function createObserver() {
      const options = {
        root: null,
        threshold: "0"
      };
      const observer = new IntersectionObserver(handleIntersect, options);
      observer.observe(el);
    }
    if (window["IntersectionObserver"]) {
      createObserver();
    } else {
      loadImage();
    }
  }
};

Bien, abordemos esto paso a paso.

El función de gancho nos permite disparar una lógica personalizada en un momento específico del ciclo de vida de un elemento vinculado. Usamos el inserted hook porque se llama cuando el elemento vinculado se ha insertado en su nodo principal (esto garantiza que el nodo principal está presente). Dado que queremos observar la visibilidad de un elemento en relación con su padre (o cualquier antepasado), necesitamos usar ese gancho.

export default {
  inserted: el => {
    ...
  }
}

El loadImage función es la responsable de sustituir el src valor con data-url. En él, tenemos acceso a nuestro elemento (el) que es donde aplicamos la directiva. Podemos extraer el img de ese elemento.

A continuación, verificamos si la imagen existe y, si existe, agregamos un oyente que activará una función de devolución de llamada cuando finalice la carga. Esa devolución de llamada será responsable de ocultar la ruleta y agregar la animación (efecto de aparición gradual) a la imagen usando una clase CSS. También agregamos un segundo oyente al que se llamará en caso de que la URL no se cargue.

Finalmente, reemplazamos el src de nuestro img elemento con la URL de origen de la imagen y muéstralo!

function loadImage() {
  const imageElement = Array.from(el.children).find(
    el => el.nodeName === "IMG"
  );
  if (imageElement) {
    imageElement.addEventListener("load", () => {
      setTimeout(() => el.classList.add("loaded"), 100);
    });
    imageElement.addEventListener("error", () => console.log("error"));
    imageElement.src = imageElement.dataset.url;
  }
}

Usamos Intersection Observer’s handleIntersect función, que es responsable de disparar loadImage cuando se cumplen determinadas condiciones. Específicamente, se activa cuando Intersection Observer detecta que el elemento entra en la ventana gráfica o en un elemento del componente principal.

La función tiene acceso a entries, que es una matriz de todos los elementos que son observados por el observador y observer sí mismo. Iteramos a través de entries y comprobar si una sola entrada se vuelve visible para nuestro usuario con isIntersecting – y dispara el loadImage función si lo es. Una vez solicitada la imagen, unobserve el elemento (elimínelo de la lista de observación del observador), lo que evita que la imagen se cargue nuevamente. Y otra vez. Y otra vez. Y…

function handleIntersect(entries, observer) {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      loadImage();
      observer.unobserve(el);
    }
  });
}

La última pieza es la createObserver función. Este tipo es responsable de crear nuestro Intersection Observer y adjuntarlo a nuestro elemento. El IntersectionObserver constructor acepta una devolución de llamada (nuestro handleIntersect función) que se dispara cuando el elemento observado pasa el especificado threshold y el options objeto que lleva nuestras opciones de observador.

Hablando del options objeto, utiliza root como nuestro objeto de referencia, que usamos para basar la visibilidad de nuestro elemento observado. Podría ser cualquier antepasado del objeto o la ventana de nuestro navegador si pasamos null. El objeto también especifica un threshold valor que puede variar de 0 a 1 y nos dice en qué porcentaje de visibilidad del objetivo observer la devolución de llamada debe ejecutarse, con 0 es decir, tan pronto como un píxel sea visible y 1 lo que significa que todo el elemento debe ser visible.

Y luego, después de crear el Intersection Observer, lo adjuntamos a nuestro elemento usando el observe método.

function createObserver() {
  const options = {
    root: null,
    threshold: "0"
  };
  const observer = new IntersectionObserver(handleIntersect, options);
  observer.observe(el);
}

Paso 5: Directiva de registro

Para usar nuestra directiva recién creada, primero debemos registrarla. Hay dos formas de hacerlo: globalmente (disponible en todas partes de la aplicación) o localmente (en un nivel de componente específico).

Registro global

Para el registro global, importamos nuestra directiva y usamos el Vue.directive método para pasar el nombre que queremos llamar a nuestra directiva y la directiva en sí. Eso nos permite agregar un v-lazyload atributo a cualquier elemento de nuestro código.

// main.js
import Vue from "vue";
import App from "./App";
import LazyLoadDirective from "./directives/LazyLoadDirective";

Vue.config.productionTip = false;

Vue.directive("lazyload", LazyLoadDirective);

new Vue({
  el: "#app",
  components: { App },
  template: "<App/>"
});

Registro local

Si queremos usar nuestra directiva solo en un componente específico y restringir el acceso a él, podemos registrar la directiva localmente. Para hacer eso, necesitamos importar la directiva dentro del componente que la usará y registrarla en el directives objeto. Eso nos dará la posibilidad de agregar un v-lazyload atributo a cualquier elemento de ese componente.

import LazyLoadDirective from "./directives/LazyLoadDirective";

export default {
  directives: {
    lazyload: LazyLoadDirective
  }
}

Paso 6: use una directiva en el componente ImageItem

Ahora que nuestra directiva ha sido registrada, podemos usarla agregando v-lazyload en el elemento padre que lleva nuestra imagen (el figure etiqueta en nuestro caso).

<template>
  <figure v-lazyload class="image__wrapper">
    <ImageSpinner
      class="image__spinner"
    />
    <img
      class="image__item"
      :data-url="https://css-tricks.com/lazy-loading-images-with-vue-js-directives-and-intersection-observer/source"
      alt="random image"
    >
  </figure>
</template>

Soporte del navegador

Seríamos negligentes si no tomáramos nota sobre la compatibilidad con el navegador. Aunque la API de Intersection Observe no es compatible con todos los navegadores, cubre el 73% de los usuarios (al momento de escribir este artículo).

Estos datos de soporte del navegador son de Caniuse, que tiene más detalles. Un número indica que el navegador admite la función en esa versión y posteriores.

Escritorio

Cromo Firefox ES DECIR Borde Safari
58 55 No dieciséis 12,1

Móvil / Tableta

Android Chrome Android Firefox Androide Safari de iOS
96 94 96 12,2-12,5

Nada mal. No está mal.

¡Pero! Teniendo en cuenta que queremos mostrar imágenes a todos los usuarios (recuerda que usando data-url evita que la imagen se cargue), necesitamos agregar una pieza más a nuestra directiva. Específicamente, debemos verificar si el navegador es compatible con Intersection Observer, y no lo hace, dispara loadImage en lugar de. Este será nuestro respaldo.

if (window["IntersectionObserver"]) {
    createObserver();
} else {
    loadImage();
}

Resumen

La carga diferida de imágenes puede mejorar significativamente el rendimiento de la página porque toma el peso de la página acaparado por las imágenes y las carga solo cuando el usuario realmente las necesita.

Para aquellos que aún no están convencidos de si vale la pena jugar con la carga diferida, aquí hay algunos números sin procesar del ejemplo simple que hemos estado usando. La lista contiene 11 artículos con una imagen por artículo. Eso es un total de 11 imágenes (¡matemáticas!). No es como si fueran un montón de imágenes, pero aún podemos trabajar con ellas.

Esto es lo que obtenemos al reproducir las 11 imágenes sin carga diferida en una conexión 3G:

Las 11 solicitudes de imágenes contribuyen a un tamaño de página total de 3,2 MB. Atracción sexual.

Aquí está la misma página que pone en práctica la carga diferida:

¿Que qué? Solo una solicitud para una imagen. Nuestra página ahora tiene 1,4 MB. Guardamos 10 solicitudes y redujo el tamaño de la página en un 56%.

¿Es un ejemplo simple y aislado? Sí, pero los números aún hablan por sí mismos. Es de esperar que la carga diferida sea una forma efectiva de luchar contra la hinchazón de la página y que este enfoque específico que usa Vue con Intersection Observer sea útil.