Intersection Observer - Lazy loading, animações em scroll e scroll infinito de forma nativa e sem libs
William Gonçalves
Posted on December 22, 2022
Salve, devs e divas!
Esse post inicia uma série que visa explorar as Web APIs, descobrindo e apresentando funcionalidades que podem ser alcançadas a partir delas.
E considerando o costume de utilizarmos abstrações que trazem o mesmo resultado, queremos empoderar as opções nativas a fim de reduzir dependências em projetos e aprofundar os conhecimentos sobre os recursos disponíveis na Web.
Como Front-Ender, já esbarrei em alguns desafios para aumentar a interatividade da página com infinite scrollings e animações de elementos quando eles entram e saem do viewport, ou até mesmo questões que impactam performance como lazy-loading em imagens, a partir das ações do usuário.
Em casos como esse, tudo se resumiria a verificar a intersecção entre um elemento alvo e um elemento pai ou até mesmo entre ele e o viewport (área visível para o usuário) do documento e, a partir do estado e da visibilidade do alvo observado, aplicar as mudanças necessárias.
Detectar a visibilidade de um elemento (ou entre dois deles) envolvia soluções não muito confiáveis e que tendiam a gerar problemas de performance nas páginas, já que precisávamos de handlers e loops aplicados a cada elemento afetado e chamando métodos como o Element.getBoundingClientRect()
, o que gerava um peso na main thread da aplicação, deixando a página e o próprio navegador mais lentos.
Conceitos e uso
A Intersection Observer API fornece uma maneira de observar alterações de intersecção de forma assíncrona. Com sua implementação, o site não precisa mais lidar com essa responsabilidade na main thread e o navegador fica livre para gerenciar as observações como achar melhor.
É possível declarar uma função de callback que é executada nas seguintes circunstâncias:
Um elemento alvo cruza (total ou parcialmente, conforme configuração) com o elemento
root
.A primeira vez que o Observer é solicitado a observar um elemento alvo.
Essa API tem compatibilidade total com todos os navegadores modernos, com ressalvas para o Safari (Desktop e iOS) e o Firefox for Android onde o elemento root
não pode ser um documento.
Criando um Intersection Observer
Para criar um intersection observer você deve chamar seu construtor, enviando uma função de callback como primeiro parâmetro e um objeto options
como parâmetro (opcional) seguinte:
let options = {
root: document.querySelector('#rootElement'),
rootMargin: '0px',
threshold: 1.0
}
let observer = new IntersectionObserver(callback, options);
Intersection observer options
O objeto options
passado no construtor IntersectionObserver()
te permite controlar as circunstâncias em que a função de callback será executada:
root
- Um elemento ancestral especificado ou o próprio viewport, na ausência de elemento declarado ou se o valor fornull
.rootMargin
- Define os limites de margem do elemento root, aumentando ou diminuindo a delimitação desse elemento, antes de computar uma intersecção. Pode ter valores similares ao CSS, como"10px 20px 30px 40px"
(top, right, bottom, left).-
threshold
- A taxa de interseção (intersection ratio), que representa o percentual de visibilidade do elemento alvo em relação ao root: um valor entre 0,0 e 1,0. O callback será executado sempre que a visibilidade do alvo ultrapassar o valor declarado, para cima ou para baixo. Pode ser declarado como:- Um número. Ex:
0.5
. Callback executado quando a visibilidade ultrapassar 50%. - Um Array de números: Ex:
[0, 0.25, 0.5, 0.75, 1]
. O callback será executado em cada percentual relacionado aos valores declarados. Nesse caso, a cada 25% de visibilidade.
- Um número. Ex:
Declarando um elemento para ser observado
Agora que criou o observer
, você precisa declarar um elemento a ser observado por ele:
let target = document.querySelector('#targetElement');
observer.observe(target);
Nesse momento, o callback é executado pela primeira vez, mesmo que o elemento alvo não esteja visível.
Sempre que a visibilidade do alvo ultrapassar o valor de threshold
, o callback é invocado, recebendo uma lista de objetos IntersectionObserverEntry
e o próprio observer
.
Esteja ciente de que esse callback, em si, será executado na main thread. Então tente não complicar a lógica executada nesse escopo:
let callback = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
/* Verificamos o estado da 'entry' e efetuamos
as alterações necessárias caso ela esteja visível */
}
});
};
Boa parte das aplicações desse Observer podem ser feitas verificando apenas a propriedade isIntersecting
da entry, que retorna um boolean indicando se o elemento alvo está, ou não, cruzando com o elemento root
, considerando os parâmetros declarados no objeto options
.
Para ver mais propriedades da interface IntersectionObserverEntry
, confira a documentação na MDN.
Partindo do princípio que temos a base necessária para avançar, vamos aos casos de uso.
Arquivos utilizados
Você pode usar o repositório desse artigo com os arquivos finais divididos em pastas para cada caso.
Lazy-loading
Imagine carregar todos os assets de uma página inteira e o usuário nem visualizá-los, porque decidiu desviar a navegação para outra página. Vira um desperdício de recurso para ele que no caso de estar em uma rede móvel, consumiu dados à toa e para você que precisou servir arquivos que não foram utilizados de fato.
Partindo disso, vamos criar uma página em que as imagens só serão carregadas se estiverem visíveis.
Começando pelo arquivo index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
<title>Lazy Loading</title>
</head>
<body>
<section>
<img class="lazy" src="placeholder.png" data-src="https://picsum.photos/300/200" />
</section>
<section>
<img class="lazy" src="placeholder.png" data-src="https://picsum.photos/300/201" />
</section>
<section>
<img class="lazy" src="placeholder.png" data-src="https://picsum.photos/300/202" />
</section>
<section>
<img class="lazy" src="placeholder.png" data-src="https://picsum.photos/300/203" />
</section>
<script src="script.js"></script>
</body>
</html>
Nas tags img
, declaramos um placeholder no atributo src
, que será renderizado inicialmente. No atributo data-src
, colocamos a URL da imagem desejada. Além disso, declaramos a classe lazy
que será usada para selecionarmos as imagens.
Temperamos com o style.css
:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
font-family: "Roboto", sans-serif;
background-color: #f5f5f5;
}
section {
height: 100%;
width: 100%;
align-items: center;
display: flex;
justify-content: center;
}
Agora precisamos observar as imagens e, quando elas estiverem visíveis, trocar o placeholder para a URL desejada. No arquivo script.js
:
Começamos selecionando as imagens.
const images = document.querySelectorAll('.lazy');
Criamos nosso Observer.
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const image = entry.target;
image.src = image.dataset.src;
image.classList.remove('lazy');
observer.unobserve(image);
}
});
});
Dentro do callback, usamos o forEach
nas entries
e para cada entry
verificamos se ela está cruzando a área visível (entry.isIntersecting
). Se positivo, declaramos o entry.target
como image
, substituímos o src
pelo data-src
, removemos a classe lazy
da imagem e mandamos o observer
deixar de observar a imagem.
Em seguida, utilizamos um forEach
na NodeList
gerada com nosso seletor do início, observando cada uma das imagens:
images.forEach(image => {
observer.observe(image);
});
As imagens que já foram visualizadas têm o src
com a URL desejada e as que ainda não apareceram na tela, seguem com o placeholder:
Abrindo a aba Rede/Network no Dev Tools, você verá as imagens sendo carregadas conforme aparecem na tela.
Você pode conferir o resultado nesse link.
Animações em scroll
Esse caso é interessante para aumentar a interatividade e imersividade da página. Quando um elemento se torna visível, adicionamos uma classe CSS dando o efeito desejado. Podemos ainda removê-la, caso o elemento não esteja mais visível, repetindo o efeito a cada novo scroll.
Começamos com o index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
<title>Lazy Loading</title>
</head>
<body>
<section>
<p class="animate">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Minima, impedit explicabo sunt omnis veritatis quia
soluta alias sed animi earum error recusandae maxime, at reiciendis amet magnam perspiciatis iure dolorem.
</p>
</section>
<section>
<p class="animate">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Minima, impedit explicabo sunt omnis veritatis quia
soluta alias sed animi earum error recusandae maxime, at reiciendis amet magnam perspiciatis iure dolorem.
</p>
</section>
<section>
<p class="animate">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Minima, impedit explicabo sunt omnis veritatis quia
soluta alias sed animi earum error recusandae maxime, at reiciendis amet magnam perspiciatis iure dolorem.
</p>
</section>
<section>
<p class="animate">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Minima, impedit explicabo sunt omnis veritatis quia
soluta alias sed animi earum error recusandae maxime, at reiciendis amet magnam perspiciatis iure dolorem.
</p>
</section>
<script src="script.js"></script>
</body>
</html>
As tags p
serão capturadas pelo observer através da classe animate
.
Adicionamos o style.css
, incluindo as classes animate
e animate--active
. Essa segunda será responsável pelo efeito desejado.
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
font-family: "Roboto", sans-serif;
background-color: #f5f5f5;
}
section {
height: 100%;
width: 100%;
padding: 20px;
align-items: center;
display: flex;
justify-content: center;
}
.animate {
width: 300px;
opacity: 0;
transform: translateX(-100px);
transition: all 0.5s ease-in-out;
}
.animate--active {
opacity: 1;
transform: translateX(0);
transition: all 0.5s ease-in-out;
}
No script.js
, começamos selecionando os textos através da classe animate
.
const animatedTexts = document.querySelectorAll('.animate');
Criamos o observer e para cada entry
, verificamos se ela está cruzando a tela. Se positivo, adicionamos a classe animate--active
. Caso contrário, removemos essa classe.
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate--active');
} else {
entry.target.classList.remove('animate--active');
}
});
});
Por fim, usamos o forEach
na lista de textos, para adicioná-los ao observer.
animatedTexts.forEach(text => {
observer.observe(text);
});
O efeito será o texto deslizando a partir da esquerda, até o centro do flex-container
.
O resultado pode ser visto nesse link.
A partir desse conceito, você tem a liberdade de fazer o que quiser com qualquer elemento, seja adicionando ou removendo classes, ou até usando animações CSS, para chegar ao efeito desejado.
Scroll Infinito
Nesse caso, vamos criar uma página com rolagem infinita. Sempre que chegarmos ao último item da lista, novos itens serão adicionados, infinitamente.
É uma aplicação boa para lista de produtos, por exemplo, em que o usuário pode simplesmente rolar a página e continuar visualizando os itens disponíveis, sem precisar navegar ou usar paginação.
No index.html
criamos uma div com a classe container
, onde os itens serão adicionados. Abaixo dela, uma tag p
com o texto loading... vai indicar o fim da lista, trazendo um retorno para o usuário de que há mais para ser visto.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
<title>Document</title>
</head>
<body>
<main>
<div class="container"></div>
<p>loading...</p>
</main>
<script src="script.js"></script>
</body>
</html>
No style.css
, incluímos os estilos, incluindo os das imagens que serão carregadas.
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
font-family: "Roboto", sans-serif;
background-color: #f5f5f5;
}
.container {
height: 100%;
width: 100%;
margin: 40px 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 40px;
}
img {
width: 320px;
height: 320px;
object-fit: cover;
}
No script.js
, selecionamos o container:
const container = document.querySelector('.container');
Vamos criar uma função chamada getTenRandomImages
, que vai retornar 10 imagens, com URLs aleatórias. Essa função será responsável por popular o container. Em cenários reais, ela pode ser substituída por uma chamada a uma API que retorna dados a serem usados no aplicativo, por exemplo.
const getTenRandomImages = () => {
const images = [];
for (let i = 0; i < 10; i++) {
const image = document.createElement('img');
image.src = `https://picsum.photos/300/300?random=${Math.random()}`;
images.push(image);
}
return images;
};
Criamos o observer. No callback
, se a entry observada (que será o último elemento filho do container
) estiver cruzando a área visível, a função getTenRandomImages
será usada para adicionar mais 10 imagens no container
, a entry
deixará de ser observada e o novo último filho (lastElementChild
) do container passará a ser observado.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
container.append(...getTenRandomImages());
observer.unobserve(entry.target);
observer.observe(container.lastElementChild);
}
});
});
Por fim, adicionamos as 10 imagens iniciais no container
e declaramos o último filho dele para ser observado, para que as novas imagens só sejam carregadas quando ele estiver visível.
container.append(...getTenRandomImages());
observer.observe(container.lastElementChild);
O resultado pode ser visto aqui.
Conclusão
Os casos apresentados aqui podem ser adaptados a contextos do mundo real, sem maiores dificuldades.
Considerando que a Intersection Observer API tira da main thread da aplicação essa responsabilidade de observar os elementos alvos, conseguimos escalar essa solução mesmo em aplicações com porte maior.
Ela também é aplicável a frameworks como React e Vue, desde que você saiba como selecionar os elementos nos DOMs que são gerados por eles. É basicamente substituir o querySelector
e o querySelectorAll
pela abordagem da ferramenta que você utiliza.
Para acompanhar outros posts, siga meus perfis nas redes sociais disponíveis em owillgoncalves.dev.
Um grande abraço e até a próxima!
Referências:
Intersection Observer API - Web APIs | MDN
Intersection Observer | W3C
Posted on December 22, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
January 9, 2024
December 22, 2022