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:
- Almacenar el
src
de la imagen que queremos cargar sin cargarla en primer lugar. - 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 nuestrodata-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.