Implementando rolagem infinita e carregamento preguiçoso de imagem no React

Publicados: 2022-03-10
Resumo rápido ↬ Neste tutorial, vamos aprender como usar a API HTML Intersection Observer para implementar rolagem infinita e carregamento lento de imagem em um componente funcional React. No processo, aprenderemos como usar alguns dos hooks do React e como criar Custom Hooks.

Se você está procurando uma alternativa à paginação, a rolagem infinita é uma boa consideração. Neste artigo, vamos explorar alguns casos de uso para a API Intersection Observer no contexto de um componente funcional do React. O leitor deve possuir um conhecimento prático dos componentes funcionais do React. Alguma familiaridade com os hooks do React será benéfica, mas não necessária, pois veremos alguns deles.

Nosso objetivo é que, ao final deste artigo, tenhamos implementado rolagem infinita e carregamento lento de imagem usando uma API HTML nativa. Também teríamos aprendido mais algumas coisas sobre React Hooks. Com isso, você pode implementar rolagem infinita e carregamento lento de imagem em seu aplicativo React, quando necessário.

Vamos começar.

Criando mapas com React e Leaflet

Capturar informações de um arquivo CSV ou JSON não é apenas complicado, mas também tedioso. Representar os mesmos dados na forma de auxílio visual é mais simples. Shajia Abidi explica o quão poderosa é uma ferramenta Leaflet e como muitos tipos diferentes de mapas podem ser criados. Leia um artigo relacionado →

Mais depois do salto! Continue lendo abaixo ↓

A API do Observador de Interseção

De acordo com os documentos do MDN, “a API Intersection Observer fornece uma maneira de observar de forma assíncrona as alterações na interseção de um elemento de destino com um elemento ancestral ou com a janela de visualização de um documento de nível superior”.

Essa API nos permite implementar recursos interessantes, como rolagem infinita e carregamento lento de imagem. O observador de interseção é criado chamando seu construtor e passando a ele um retorno de chamada e um objeto de opções. O retorno de chamada é invocado sempre que um elemento, chamado target , cruza a viewport do dispositivo ou um elemento especificado, chamado root . Podemos especificar uma raiz personalizada no argumento de opções ou usar o valor padrão.

 let observer = new IntersectionObserver(callback, options);

A API é simples de usar. Um exemplo típico se parece com isso:

 var intObserver = new IntersectionObserver(entries => { entries.forEach(entry => { console.log(entry) console.log(entry.isIntersecting) // returns true if the target intersects the root element }) }, { // default options } ); let target = document.querySelector('#targetId'); intObserver.observe(target); // start observation

entries é uma lista de objetos IntersectionObserverEntry . O objeto IntersectionObserverEntry descreve uma mudança de interseção para um elemento de destino observado. Observe que o retorno de chamada não deve manipular nenhuma tarefa demorada, pois é executada no thread principal.

A API Intersection Observer atualmente possui amplo suporte ao navegador, conforme mostrado em caniuse.

Suporte ao navegador Intersection Observer. (Visualização grande)

Você pode ler mais sobre a API nos links fornecidos na seção de recursos.

Vejamos agora como usar essa API em um aplicativo React real. A versão final do nosso aplicativo será uma página de fotos que rola infinitamente e terá cada imagem carregada preguiçosamente.

Fazendo chamadas de API com o gancho useEffect

Para começar, clone o projeto inicial a partir desta URL. Tem configuração mínima e alguns estilos definidos. Também adicionei um link para o CSS do Bootstrap no arquivo public/index.html , pois usarei suas classes para estilizar.

Sinta-se à vontade para criar um novo projeto, se desejar. Certifique-se de ter o gerenciador de pacotes yarn instalado se quiser seguir com o repositório. Você pode encontrar as instruções de instalação para seu sistema operacional específico aqui.

Para este tutorial, vamos pegar imagens de uma API pública e exibi-las na página. Estaremos usando as APIs Lorem Picsum.

Para este tutorial, usaremos o endpoint, https://picsum.photos/v2/list?page=0&limit=10 , que retorna uma matriz de objetos de imagem. Para obter as próximas dez fotos, alteramos o valor de page para 1, depois 2 e assim por diante.

Agora vamos construir o componente App peça por peça.

Abra src/App.js e digite o seguinte código.

 import React, { useEffect, useReducer } from 'react'; import './index.css'; function App() { const imgReducer = (state, action) => { switch (action.type) { case 'STACK_IMAGES': return { ...state, images: state.images.concat(action.images) } case 'FETCHING_IMAGES': return { ...state, fetching: action.fetching } default: return state; } } const [imgData, imgDispatch] = useReducer(imgReducer,{ images:[], fetching: true}) // next code block goes here }

Primeiramente, definimos uma função redutora, imgReducer . Este redutor lida com duas ações.

  1. A ação STACK_IMAGES concatena a matriz de images .
  2. A ação FETCHING_IMAGES alterna o valor da variável de fetching entre true e false .

A próxima etapa é conectar esse redutor a um gancho useReducer . Feito isso, retornamos duas coisas:

  1. imgData , que contém duas variáveis: images é a matriz de objetos de imagem. fetching é um booleano que nos diz se a chamada da API está em andamento ou não.
  2. imgDispatch , que é uma função para atualizar o objeto redutor.

Você pode aprender mais sobre o hook useReducer na documentação do React.

A próxima parte do código é onde fazemos a chamada da API. Cole o código a seguir abaixo do bloco de código anterior em App.js .

 // make API calls useEffect(() => { imgDispatch({ type: 'FETCHING_IMAGES', fetching: true }) fetch('https://picsum.photos/v2/list?page=0&limit=10') .then(data => data.json()) .then(images => { imgDispatch({ type: 'STACK_IMAGES', images }) imgDispatch({ type: 'FETCHING_IMAGES', fetching: false }) }) .catch(e => { // handle error imgDispatch({ type: 'FETCHING_IMAGES', fetching: false }) return e }) }, [ imgDispatch ]) // next code block goes here

Dentro do hook useEffect , fazemos uma chamada para o endpoint da API com fetch API. Em seguida, atualizamos o array de imagens com o resultado da chamada da API despachando a ação STACK_IMAGES . Também despachamos a ação FETCHING_IMAGES assim que a chamada da API for concluída.

O próximo bloco de código define o valor de retorno da função. Digite o seguinte código após o gancho useEffect .

 return ( <div className=""> <nav className="navbar bg-light"> <div className="container"> <a className="navbar-brand" href="/#"> <h2>Infinite scroll + image lazy loading</h2> </a> </div> </navv <div id='images' className="container"> <div className="row"> {imgData.images.map((image, index) => { const { author, download_url } = image return ( <div key={index} className="card"> <div className="card-body "> <img alt={author} className="card-img-top" src={download_url} /> </div> <div className="card-footer"> <p className="card-text text-center text-capitalize text-primary">Shot by: {author}</p> </div> </div> ) })} </div> </div> </div> );

Para exibir as imagens, mapeamos o array de imagens no objeto imgData .

Agora inicie o aplicativo e visualize a página no navegador. Você deve ver as imagens bem exibidas em uma grade responsiva.

O último bit é exportar o componente App.

 export default App;
Imagens em grade responsiva. (Visualização grande)

A ramificação correspondente neste ponto é 01-make-api-calls.

Vamos agora estender isso exibindo mais fotos à medida que a página rola.

Implementando a rolagem infinita

Nosso objetivo é apresentar mais fotos à medida que a página rola. A partir da URL do endpoint da API, https://picsum.photos/v2/list?page=0&limit=10 , sabemos que para obter um novo conjunto de fotos, precisamos apenas incrementar o valor de page . Também precisamos fazer isso quando ficarmos sem fotos para mostrar. Para nosso propósito aqui, saberemos que ficamos sem imagens quando chegarmos ao final da página. É hora de ver como a API Intersection Observer nos ajuda a conseguir isso.

Abra src/App.js e crie um novo redutor, pageReducer , abaixo de imgReducer .

 // App.js const imgReducer = (state, action) => { ... } const pageReducer = (state, action) => { switch (action.type) { case 'ADVANCE_PAGE': return { ...state, page: state.page + 1 } default: return state; } } const [ pager, pagerDispatch ] = useReducer(pageReducer, { page: 0 })

Definimos apenas um tipo de ação. Cada vez que a ação ADVANCE_PAGE é acionada, o valor da page é incrementado em 1.

Atualize o URL na função de fetch para aceitar os números de página dinamicamente, conforme mostrado abaixo.

 fetch(`https://picsum.photos/v2/list?page=${pager.page}&limit=10`)

Adicione pager.page à matriz de dependências junto com imgData . Isso garante que a chamada da API seja executada sempre que pager.page for alterado.

 useEffect(() => { ... }, [ imgDispatch, pager.page ])

Após o gancho useEffect para a chamada da API, insira o código abaixo. Atualize sua linha de importação também.

 // App.js import React, { useEffect, useReducer, useCallback, useRef } from 'react'; useEffect(() => { ... }, [ imgDispatch, pager.page ]) // implement infinite scrolling with intersection observer let bottomBoundaryRef = useRef(null); const scrollObserver = useCallback( node => { new IntersectionObserver(entries => { entries.forEach(en => { if (en.intersectionRatio > 0) { pagerDispatch({ type: 'ADVANCE_PAGE' }); } }); }).observe(node); }, [pagerDispatch] ); useEffect(() => { if (bottomBoundaryRef.current) { scrollObserver(bottomBoundaryRef.current); } }, [scrollObserver, bottomBoundaryRef]);

Definimos uma variável bottomBoundaryRef e definimos seu valor como useRef(null) . useRef permite que as variáveis ​​preservem seus valores nas renderizações dos componentes, ou seja, o valor atual da variável persiste quando o componente que as contém é renderizado novamente. A única maneira de alterar seu valor é reatribuindo a propriedade .current nessa variável.

Em nosso caso, bottomBoundaryRef.current começa com um valor null . À medida que o ciclo de renderização da página prossegue, definimos sua propriedade atual como o nó <div id='page-bottom-boundary'> .

Usamos a declaração de atribuição ref={bottomBoundaryRef} para dizer ao React para definir bottomBoundaryRef.current como o div onde esta atribuição é declarada.

Portanto,

 bottomBoundaryRef.current = null

no final do ciclo de renderização, torna-se:

 bottomBoundaryRef.current = <div></div>

Veremos onde essa tarefa é feita em um minuto.

Em seguida, definimos uma função scrollObserver , na qual definir o observador. Esta função aceita um nó DOM para observar. O ponto principal a ser observado aqui é que sempre que atingimos a interseção sob observação, despachamos a ação ADVANCE_PAGE . O efeito é incrementar o valor de pager.page em 1. Quando isso acontece, o gancho useEffect que o tem como dependência é executado novamente. Essa nova execução, por sua vez, invoca a chamada de busca com o novo número de página.

A procissão do evento se parece com isso.

Atinge a interseção sob observação → chama a ação ADVANCE_PAGE → incrementa o valor de pager.page em 1 → gancho useEffect para executar a chamada de busca → a chamada de fetch é executada → as imagens retornadas são concatenadas ao array de images .

scrollObserver em um gancho useEffect para que a função seja executada apenas quando qualquer uma das dependências do gancho for alterada. Se não chamássemos a função dentro de um gancho useEffect , a função seria executada em cada renderização de página.

Lembre-se de que bottomBoundaryRef.current se refere a <div id="page-bottom-boundary" style="border: 1px solid red;"></div> . Verificamos se seu valor não é null antes de passá-lo para scrollObserver . Caso contrário, o construtor IntersectionObserver retornaria um erro.

Como usamos scrollObserver em um gancho useEffect , temos que envolvê-lo em um gancho useCallback para evitar re-renderizações sem fim de componentes. Você pode aprender mais sobre useCallback nos documentos do React.

Digite o código abaixo após a <div id='images'> .

 // App.js <div id='image'> ... </div> {imgData.fetching && ( <div className="text-center bg-secondary m-auto p-3"> <p className="m-0 text-white">Getting images</p> </div> )} <div id='page-bottom-boundary' style={{ border: '1px solid red' }} ref={bottomBoundaryRef}></div>

Quando a chamada da API é iniciada, definimos fetching como true e o texto Getting images torna-se visível. Assim que termina, definimos fetching para false e o texto fica oculto. Também poderíamos acionar a chamada da API antes de atingir o limite exatamente definindo um threshold diferente no objeto de opções do construtor. A linha vermelha no final nos permite ver exatamente quando atingimos o limite da página.

O ramo correspondente neste ponto é 02-infinite-scroll.

Agora vamos implementar o carregamento lento da imagem.

Implementando o carregamento lento de imagem

Se você inspecionar a guia de rede enquanto rola para baixo, verá que assim que atingir a linha vermelha (o limite inferior), a chamada da API acontece e todas as imagens começam a carregar mesmo quando você não conseguiu visualizar eles. Há uma variedade de razões pelas quais isso pode não ser um comportamento desejável. Podemos querer salvar chamadas de rede até que o usuário queira ver uma imagem. Nesse caso, poderíamos optar por carregar as imagens de forma preguiçosa, ou seja, não carregaremos uma imagem até que ela role para a visualização.

Abra src/App.js . Logo abaixo das funções de rolagem infinita, digite o seguinte código.

 // App.js // lazy loads images with intersection observer // only swap out the image source if the new url exists const imagesRef = useRef(null); const imgObserver = useCallback(node => { const intObs = new IntersectionObserver(entries => { entries.forEach(en => { if (en.intersectionRatio > 0) { const currentImg = en.target; const newImgSrc = currentImg.dataset.src; // only swap out the image source if the new url exists if (!newImgSrc) { console.error('Image source is invalid'); } else { currentImg.src = newImgSrc; } intObs.unobserve(node); // detach the observer when done } }); }) intObs.observe(node); }, []); useEffect(() => { imagesRef.current = document.querySelectorAll('.card-img-top'); if (imagesRef.current) { imagesRef.current.forEach(img => imgObserver(img)); } }, [imgObserver, imagesRef, imgData.images]);

Assim como scrollObserver , definimos uma função, imgObserver , que aceita um nó para observar. Quando a página atinge uma interseção, conforme determinado por en.intersectionRatio > 0 , trocamos a fonte da imagem no elemento. Observe que primeiro verificamos se a nova fonte de imagem existe antes de fazer a troca. Assim como na função scrollObserver , envolvemos o imgObserver em um gancho useCallback para evitar a rerenderização sem fim do componente.

Observe também que paramos de observar um elemento img quando terminamos a substituição. Fazemos isso com o método unobserve .

No gancho useEffect a seguir, pegamos todas as imagens com uma classe de .card-img-top na página com document.querySelectorAll . Em seguida, iteramos sobre cada imagem e definimos um observador nela.

Observe que adicionamos imgData.images como uma dependência do gancho useEffect . Quando isso muda, ele aciona o gancho useEffect e, por sua vez, imgObserver é chamado com cada elemento <img className='card-img-top'> .

Atualize o elemento <img className='card-img-top'/> conforme mostrado abaixo.

 <img alt={author} data-src={download_url} className="card-img-top" src={'https://picsum.photos/id/870/300/300?grayscale&blur=2'} />

Definimos uma fonte padrão para cada elemento <img className='card-img-top'/> e armazenamos a imagem que queremos mostrar na propriedade data-src . A imagem padrão geralmente tem um tamanho pequeno para que baixemos o mínimo possível. Quando o elemento <img/> é exibido, o valor na propriedade data-src substitui a imagem padrão.

Na imagem abaixo, vemos a imagem padrão do farol ainda aparecendo em alguns dos espaços.

Imagens sendo carregadas lentamente. (Visualização grande)

A ramificação correspondente neste ponto é 03-lazy-loading.

Vamos agora ver como podemos abstrair todas essas funções para que sejam reutilizáveis.

Abstração de busca, rolagem infinita e carregamento preguiçoso em ganchos personalizados

Implementamos com sucesso a busca, rolagem infinita e carregamento lento de imagem. Podemos ter outro componente em nosso aplicativo que precisa de funcionalidade semelhante. Nesse caso, poderíamos abstrair e reutilizar essas funções. Tudo o que precisamos fazer é movê-los para um arquivo separado e importá-los onde precisarmos. Queremos transformá-los em Ganchos Personalizados.

A documentação do React define um Custom Hook como uma função JavaScript cujo nome começa com "use" e que pode chamar outros hooks. No nosso caso, queremos criar três ganchos, useFetch , useInfiniteScroll , useLazyLoading .

Crie um arquivo dentro da pasta src/ . Nomeie-o como customHooks.js e cole o código abaixo.

 // customHooks.js import { useEffect, useCallback, useRef } from 'react'; // make API calls and pass the returned data via dispatch export const useFetch = (data, dispatch) => { useEffect(() => { dispatch({ type: 'FETCHING_IMAGES', fetching: true }); fetch(`https://picsum.photos/v2/list?page=${data.page}&limit=10`) .then(data => data.json()) .then(images => { dispatch({ type: 'STACK_IMAGES', images }); dispatch({ type: 'FETCHING_IMAGES', fetching: false }); }) .catch(e => { dispatch({ type: 'FETCHING_IMAGES', fetching: false }); return e; }) }, [dispatch, data.page]) } // next code block here

O gancho useFetch aceita uma função de despacho e um objeto de dados. A função dispatch passa os dados da chamada da API para o componente App , enquanto o objeto de dados nos permite atualizar a URL do endpoint da API.

 // infinite scrolling with intersection observer export const useInfiniteScroll = (scrollRef, dispatch) => { const scrollObserver = useCallback( node => { new IntersectionObserver(entries => { entries.forEach(en => { if (en.intersectionRatio > 0) { dispatch({ type: 'ADVANCE_PAGE' }); } }); }).observe(node); }, [dispatch] ); useEffect(() => { if (scrollRef.current) { scrollObserver(scrollRef.current); } }, [scrollObserver, scrollRef]); } // next code block here

O gancho useInfiniteScroll aceita um scrollRef e uma função de dispatch . O scrollRef nos ajuda a configurar o observador, conforme já discutido na seção onde o implementamos. A função dispatch oferece uma maneira de acionar uma ação que atualiza o número da página na URL do endpoint da API.

 // lazy load images with intersection observer export const useLazyLoading = (imgSelector, items) => { const imgObserver = useCallback(node => { const intObs = new IntersectionObserver(entries => { entries.forEach(en => { if (en.intersectionRatio > 0) { const currentImg = en.target; const newImgSrc = currentImg.dataset.src; // only swap out the image source if the new url exists if (!newImgSrc) { console.error('Image source is invalid'); } else { currentImg.src = newImgSrc; } intObs.unobserve(node); // detach the observer when done } }); }) intObs.observe(node); }, []); const imagesRef = useRef(null); useEffect(() => { imagesRef.current = document.querySelectorAll(imgSelector); if (imagesRef.current) { imagesRef.current.forEach(img => imgObserver(img)); } }, [imgObserver, imagesRef, imgSelector, items]) }

O hook useLazyLoading recebe um seletor e um array. O seletor é usado para encontrar as imagens. Qualquer alteração na matriz aciona o gancho useEffect que configura o observador em cada imagem.

Podemos ver que são as mesmas funções que temos em src/App.js que extraímos para um novo arquivo. O bom agora é que podemos passar argumentos dinamicamente. Vamos agora usar esses ganchos personalizados no componente App.

Abra src/App.js . Importe os ganchos personalizados e exclua as funções que definimos para buscar dados, rolagem infinita e carregamento lento de imagem. Deixe os redutores e as seções onde usamos useReducer . Cole no código abaixo.

 // App.js // import custom hooks import { useFetch, useInfiniteScroll, useLazyLoading } from './customHooks' const imgReducer = (state, action) => { ... } // retain this const pageReducer = (state, action) => { ... } // retain this const [pager, pagerDispatch] = useReducer(pageReducer, { page: 0 }) // retain this const [imgData, imgDispatch] = useReducer(imgReducer,{ images:[], fetching: true }) // retain this let bottomBoundaryRef = useRef(null); useFetch(pager, imgDispatch); useLazyLoading('.card-img-top', imgData.images) useInfiniteScroll(bottomBoundaryRef, pagerDispatch); // retain the return block return ( ... )

Já falamos sobre bottomBoundaryRef na seção sobre rolagem infinita. Passamos o objeto pager e a função imgDispatch para useFetch . useLazyLoading aceita o nome da classe .card-img-top . Observe o . incluído no nome da classe. Ao fazer isso, não precisamos especificá-lo document.querySelectorAll . useInfiniteScroll aceita uma função ref e dispatch para incrementar o valor de page .

A ramificação correspondente neste ponto é 04-ganchos personalizados.

Conclusão

O HTML está ficando melhor em fornecer APIs interessantes para implementar recursos interessantes. Neste post, vimos como é fácil usar o observador de interseção em um componente funcional React. No processo, aprendemos como usar alguns dos ganchos do React e como escrever nossos próprios ganchos.

Recursos

  • “Rolagem infinita + carregamento lento de imagem”, Orji Chidi Matthew, GitHub
  • “Rolagem infinita, paginação ou botões “Carregar mais”? Descobertas de usabilidade no comércio eletrônico”, Christian Holst, Smashing Magazine
  • “Lorem Picsum”, David Marby & Nijiko Yonskai
  • “IntersectionObserver está entrando em cena”, Surma, Web Fundamentals
  • Posso usar… IntersectionObserver
  • “Intersection Observer API”, documentos da web MDN
  • “Componentes e adereços”, React
  • useCallback ,” Reagir
  • useReducer ,” Reagir