Optimização de apresentação de imagens — background em svg, lazy loading e media queries

Optimização de apresentação de  imagens — background em svg, lazy loading e media queries

Recentemente tornou-se popular um codepen de lazy loading com SVGs inline. Esta técnica permite apresentar algo ao utilizador imediatamente após o HTML ser descarregado sem que se tenha de esperar pelas imagens serem descarregadas.

Baseado neste conceito, e noutras pesquisas sobre esta técnica, especialmente o Using SVG as placeholders — More Image Loading Techniques, implementei esta funcionalidade, mas acrescentando a tag para proporcionar a disponibilização de imagens em função das media queries e usando a nova API JavaScript IntersectionObserver para apenas descarregar as imagens do servidor quando estas se tornam visíveis no viewport.

O exemplo do vídeo acima é da página da Blacktime.

O código completo usado no HTML é o seguinte:

  <picture
    style="background-image: url('data:image/svg+xml;charset=utf-8,{{ readFile (print "/static/converted/" .Site.Data.video ".uri") | safeURL }}');"
  >
    <source
      data-srcset="//converted/{{ .Site.Data...heroes.video }}-1440.webp"
        media="(min-width: 1200px)" type="image/webp"
        title={{.Title}}
      />
    <source
        data-srcset="//cconverted/image-1440.jpg"
        media="(min-width: 1200px)" type="image/jpg"
        title={{.Title}}
      />
    <source
        data-srcset="//cconverted/image-1200.webp"
        media="(max-width: 1200px)" type="image/webp"
        title={{.Title}}
      />
      <source
        data-srcset="//cconverted/image-1200.jpg"
        media="(max-width: 1200px)" type="image/jpg"
        title={{.Title}}
      />
      <source
        data-srcset="//cconverted/image-1024.webp"
        media="(max-width: 1024px)" type="image/webp"
        title={{.Title}}
      />
      <source
        data-srcset="//cconverted/image-1024.jpg"
        media="(max-width: 1024px)" type="image/jpg"
        title={{.Title}}
      />
      <source
        data-srcset="//cconverted/image-640.webp"
        media="(max-width: 640px)" type="image/webp"
        title={{.Title}}
      />
      <source
        data-srcset="//cconverted/image-640.jpg"
        media="(max-width: 640px)"
        title={{.Title}}
      />
      <img data-src="//cconverted/image.jpg" alt="{{.Title}}" />
    </picture>

Podemos ver que temos inline algumas referências. Estas são específicas do Hugo, mas podem ser facilmente substituídas. A background-image, específicamente, pode ser substituída por um ficheiro como imagem em base64, por exemplo. O que convém é ser inline para termos os dois benefícios principais desta técnica: enviar os dados comprimidos do servidor — aproveitando ainda o facto de o texto do svg ser comprimido mais facilmente — e ser apresentado imediatamente ao utilizador: se usássemos uma imagem não inline, mesmo com uma ligação rápida, veríamos sempre as transições de um espaço branco para o svg e a seguir para a imagem final.

O objectivo é enviarmos esta imagem inicial juntamente com o html. Assim que o html for recebido no navegador o svg irá ser apresentado imediatamente, não sendo necessário esperar pelo descarregamento da imagem para que o utilizador veja algo.

Inicialmente utilizei o potrace para a a criação do svg, mas não tive bons resultados com as imagens que tinha de processar. O resultado era pouco atractivo e o ficheiro final muito pesado.
Ao pesquisar sobre este tema descobri a biblioteca primitive no artigo do José Perez que nos dá um resultado muito interessante (na minha opinião), a cores e com um ficheiro de tamanho muito reduzido quando comparado com o potrace.

Uma particularidade desta técnica é que para que funcione com uma imagem em background temos de definir o tamanho do elemento como tendo uma altura pré-definida. Consegui isto usando o seguinte css de forma a preencher o espaço todo com a imagem de fundo:

.wrapper {
  position: relative;
  overflow: hidden;
}

picture {
  width: 100%;
  height: 100%;
  display: block;
  background-repeat: no-repeat;
  background-size: cover;
  background-position: center center;
}

para que a imagem final (jpeg ou wepb) tenha a mesma característica de preencher o fundo todo usei o object-fit:

picture img {
  width: 100%;
  height: 100%;
  -o-object-fit: cover;
  object-fit: cover;
}

O facto de usarmos várias sources vai ainda permitir que imagem seja escolhida pelo navegador em função da media query, neste caso em função do comprimento do viewport. Isto permite reduzir os recursos necessários ao usar apenas a imagem com o tamanho necessária para o espaço disponível.

Nos navegadores que suportam o formato (os Chrome…) a imagem utilizada é o webp, um formato que comprime melhor a imagem.

De forma a automatizar o processo uso um pequeno script que lê processa todas as imagens de uma pasta e converte para os tamanhos necessários, convertendo para o formato webp todos os ficheiros. É por isso que a imagem está definida como data-srcset — o que o script faz é remover o data- quando a imagem se torna visível. O ganho aqui é só descarregarmos a imagem do servidor quando (e apenas se) esta for realmente ser mostrada. Se não for então vamos poupar recursos ao utilizador.

const createObservers = () => {
  const options = {
    root: null,
    threshold: 0,
  };

  let observer;

  const callback = (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const { target } = entry;
        const element = target;
        const sources = element.querySelectorAll('source');
        for (let i = 0; i < sources.length; i += 1) {
          const source = sources[i];
          const attribute = source.getAttribute('data-srcset');
          source.setAttribute('srcset', attribute);
        }

        const images = element.querySelectorAll('img');
        for (let i = 0; i < images.length; i += 1) {
          const image = images[i];
          const attribute = image.getAttribute('data-src');
          image.setAttribute('src', attribute);
        }

        observer.unobserve(entry.target);
      }
    });
  };

  const pictures = document.querySelectorAll('.picture-observer');
  observer = new IntersectionObserver(callback, options);
  pictures.forEach((picture) => {
    observer.observe(picture);
  });
};

createObservers();

O IntersectionObserver tem a vantagem de ser mais eficaz, já que não precisamos de estar constantemente a "ler" o scroll que o utilizador faz e calcular se a imagem já está visível ou não.

As desvantagens destas técnicas poderão ser a não implementação de algumas destas funcionalidades em alguns navegadores que não sejam os mais actuais.

O menos implementado é o IntersectionObserver: só funciona no Edge, Chrome, Firefox, Chrome Android e Samsung Internet, mas felizmente existe um polyfill da W3C que podemos usar para os outros. A tag picture e o respectivo srcset funcionam em todos os navegadores mais recentes excepto no IE e no Opera Mini. O object-fit só não funciona no IE.