Реализация бесконечной прокрутки и ленивой загрузки изображений в React

Опубликовано: 2022-03-10
Краткое резюме ↬ В этом руководстве мы узнаем, как использовать HTML Intersection Observer API для реализации бесконечной прокрутки и отложенной загрузки изображений в функциональном компоненте React. В процессе мы узнаем, как использовать некоторые хуки React и как создавать собственные хуки.

Если вы ищете альтернативу нумерации страниц, бесконечная прокрутка — хороший выбор. В этой статье мы рассмотрим некоторые варианты использования Intersection Observer API в контексте функционального компонента React. Читатель должен иметь практические знания о функциональных компонентах React. Некоторое знакомство с хуками React будет полезным, но не обязательным, так как мы рассмотрим некоторые из них.

Наша цель состоит в том, чтобы в конце этой статьи мы реализовали бесконечную прокрутку и ленивую загрузку изображений с помощью собственного HTML API. Мы также узнали бы еще кое-что о React Hooks. Благодаря этому вы сможете реализовать бесконечную прокрутку и ленивую загрузку изображений в вашем приложении React, где это необходимо.

Давайте начнем.

Создание карт с помощью React и Leaflet

Извлекать информацию из файла CSV или JSON не только сложно, но и утомительно. Представление тех же данных в виде наглядного пособия проще. Шаджиа Абиди объясняет, насколько мощным инструментом является Leaflet и как можно создать множество различных типов карт. Читать статью по теме →

Еще после прыжка! Продолжить чтение ниже ↓

API-интерфейс Intersection Observer

Согласно документам MDN, «API Intersection Observer предоставляет способ асинхронного наблюдения за изменениями в пересечении целевого элемента с элементом-предком или с областью просмотра документа верхнего уровня».

Этот API позволяет нам реализовать такие интересные функции, как бесконечная прокрутка и отложенная загрузка изображений. Наблюдатель пересечения создается путем вызова его конструктора и передачи ему обратного вызова и объекта параметров. Обратный вызов вызывается всякий раз, когда один элемент, называемый target , пересекает либо окно просмотра устройства, либо указанный элемент, называемый root . Мы можем указать собственный корень в аргументе options или использовать значение по умолчанию.

 let observer = new IntersectionObserver(callback, options);

API прост в использовании. Типичный пример выглядит так:

 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 — это список объектов IntersectionObserverEntry . Объект IntersectionObserverEntry описывает изменение пересечения для одного наблюдаемого целевого элемента. Обратите внимание, что обратный вызов не должен обрабатывать какие-либо трудоемкие задачи, поскольку он выполняется в основном потоке.

Intersection Observer API в настоящее время имеет широкую поддержку браузеров, как показано на caniuse.

Поддержка браузера Intersection Observer. (Большой превью)

Вы можете узнать больше об API по ссылкам, указанным в разделе ресурсов.

Давайте теперь посмотрим, как использовать этот API в реальном приложении React. Окончательная версия нашего приложения будет страницей с изображениями, которая прокручивается бесконечно, и каждое изображение будет загружаться лениво.

Выполнение вызовов API с помощью useEffect

Чтобы приступить к работе, клонируйте начальный проект с этого URL-адреса. Он имеет минимальную настройку и несколько определенных стилей. Я также добавил ссылку на CSS Bootstrap в файл public/index.html , так как я буду использовать его классы для стилизации.

Не стесняйтесь создавать новый проект, если хотите. Убедитесь, что у вас установлен менеджер пакетов yarn , если вы хотите использовать репозиторий. Инструкции по установке для вашей конкретной операционной системы вы можете найти здесь.

В этом руководстве мы собираемся получать изображения из общедоступного API и отображать их на странице. Мы будем использовать API Lorem Picsum.

В этом руководстве мы будем использовать конечную точку https://picsum.photos/v2/list?page=0&limit=10 , которая возвращает массив объектов изображения. Чтобы получить следующие десять изображений, мы меняем значение страницы на 1, затем на 2 и так далее.

Теперь мы будем создавать компонент приложения по частям.

Откройте src/App.js и введите следующий код.

 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 }

Во-первых, мы определяем функцию редуктора imgReducer . Этот редюсер выполняет два действия.

  1. Действие STACK_IMAGES объединяет массив images .
  2. Действие FETCHING_IMAGES переключает значение переменной fetching между true и false .

Следующим шагом является подключение этого редюсера к useReducer . Как только это будет сделано, мы получим две вещи:

  1. imgData , который содержит две переменные: images — это массив объектов изображения. fetching — это логическое значение, которое сообщает нам, выполняется ли вызов API или нет.
  2. imgDispatch — функция для обновления объекта редуктора.

Вы можете узнать больше о useReducer в документации React.

В следующей части кода мы делаем вызов API. Вставьте следующий код под предыдущим блоком кода в 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

Внутри хука useEffect мы делаем вызов конечной точки API с помощью fetch API. Затем мы обновляем массив изображений результатом вызова API, отправляя действие STACK_IMAGES . Мы также отправляем действие FETCHING_IMAGES после завершения вызова API.

Следующий блок кода определяет возвращаемое значение функции. Введите следующий код после хука 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> );

Чтобы отобразить изображения, мы сопоставляем массив изображений в объекте imgData .

Теперь запустите приложение и просмотрите страницу в браузере. Вы должны увидеть изображения, хорошо отображаемые в адаптивной сетке.

Последний бит — экспорт компонента приложения.

 export default App;
Картинки в адаптивной сетке. (Большой превью)

Соответствующая ветвь на этом этапе — 01-make-api-calls.

Теперь давайте расширим это, отображая больше изображений по мере прокрутки страницы.

Реализация бесконечной прокрутки

Мы стремимся представить больше изображений по мере прокрутки страницы. Из URL-адреса конечной точки API, https://picsum.photos/v2/list?page=0&limit=10 , мы знаем, что для получения нового набора фотографий нам нужно всего лишь увеличить значение page . Нам также нужно сделать это, когда у нас закончились картинки для показа. Для нашей цели мы узнаем, что у нас закончились изображения, когда мы дойдем до нижней части страницы. Пришло время посмотреть, как Intersection Observer API помогает нам в этом.

Откройте src/App.js и создайте новый редьюсер, pageReducer , под 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 })

Мы определяем только один тип действия. Каждый раз, когда запускается действие ADVANCE_PAGE , значение page увеличивается на 1.

Обновите URL-адрес в функции fetch , чтобы динамически принимать номера страниц, как показано ниже.

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

Добавьте pager.page в массив зависимостей вместе с imgData . Это гарантирует, что вызов API будет выполняться при каждом изменении pager.page .

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

После хука useEffect для вызова API введите приведенный ниже код. Также обновите строку импорта.

 // 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]);

Мы определяем переменную bottomBoundaryRef и присваиваем ей значение useRef(null) . useRef позволяет переменным сохранять свои значения при рендеринге компонентов, т. е. текущее значение переменной сохраняется при повторном рендеринге содержащего компонента. Единственный способ изменить его значение — переназначить свойство .current для этой переменной.

В нашем случае bottomBoundaryRef.current начинается со значения null . По мере выполнения цикла рендеринга страницы мы устанавливаем его текущим свойством узел <div id='page-bottom-boundary'> .

Мы используем оператор присваивания ref={bottomBoundaryRef} , чтобы указать React установить bottomBoundaryRef.current как div, в котором объявлено это присваивание.

Таким образом,

 bottomBoundaryRef.current = null

в конце цикла рендеринга становится:

 bottomBoundaryRef.current = <div></div>

Через минуту мы увидим, где выполняется это задание.

Затем мы определяем функцию scrollObserver , в которой устанавливается наблюдатель. Эта функция принимает узел DOM для наблюдения. Здесь важно отметить, что всякий раз, когда мы попадаем на наблюдаемый перекресток, мы отправляем действие ADVANCE_PAGE . В результате значение pager.page на 1. Как только это происходит, хук useEffect , который имеет его как зависимость, запускается повторно. Этот повторный запуск, в свою очередь, вызывает вызов fetch с новым номером страницы.

Процессия мероприятия выглядит так.

Ударить перекресток под наблюдением → вызвать действие ADVANCE_PAGE → увеличить значение pager.page на 1 → useEffect для выполнения вызова fetch → возвращенные изображения объединяются в массив images .

Мы вызываем scrollObserver в useEffect , чтобы функция запускалась только при изменении любой из зависимостей хука. Если бы мы не вызывали функцию внутри хука useEffect , функция выполнялась бы при каждой отрисовке страницы.

Напомним, что bottomBoundaryRef.current относится к <div id="page-bottom-boundary" style="border: 1px solid red;"></div> . Мы проверяем, что его значение не равно null, прежде чем передать его в scrollObserver . В противном случае конструктор IntersectionObserver вернет ошибку.

Поскольку мы использовали scrollObserver в useEffect , мы должны обернуть его в хук useCallback , чтобы предотвратить бесконечный повторный рендеринг компонента. Вы можете узнать больше об useCallback в документации React.

Введите приведенный ниже код после <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>

Когда начинается вызов API, мы устанавливаем fetching в true , и текст Получение изображений становится видимым. Как только он завершится, мы установим для fetching значение false , и текст будет скрыт. Мы также можем инициировать вызов API до того, как точно достигнем границы, установив другой threshold в объекте опций конструктора. Красная линия в конце позволяет нам точно увидеть, когда мы достигли границы страницы.

Соответствующая ветвь в этом месте — 02-бесконечная прокрутка.

Теперь мы реализуем ленивую загрузку изображений.

Реализация отложенной загрузки изображений

Если вы просматриваете вкладку сети при прокрутке вниз, вы увидите, что как только вы нажмете красную линию (нижняя граница), произойдет вызов API, и все изображения начнут загружаться, даже если вы еще не просмотрели их. Существует множество причин, по которым такое поведение может быть нежелательным. Мы можем захотеть сохранить сетевые вызовы, пока пользователь не захочет увидеть изображение. В таком случае мы могли бы выбрать ленивую загрузку изображений , т. е. мы не будем загружать изображение до тех пор, пока оно не появится в поле зрения.

Откройте src/App.js . Чуть ниже функций бесконечной прокрутки введите следующий код.

 // 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]);

Как и в случае с scrollObserver , мы определяем функцию imgObserver , которая принимает узел для наблюдения. Когда страница достигает пересечения, что определяется en.intersectionRatio > 0 , мы меняем источник изображения на элементе. Обратите внимание, что перед заменой мы сначала проверяем, существует ли новый источник изображения. Как и в случае с функцией scrollObserver , мы заключаем imgObserver в хук useCallback , чтобы предотвратить бесконечный повторный рендеринг компонента.

Также обратите внимание, что мы прекращаем наблюдать за элементом img , когда закончим замену. Делаем это методом unobserve .

В следующем useEffect мы получаем все изображения с классом .card-img-top на странице с помощью document.querySelectorAll . Затем мы перебираем каждое изображение и устанавливаем на нем наблюдателя.

Обратите внимание, что мы добавили imgData.images как зависимость хука useEffect . Когда это изменяется, он запускает хук useEffect и, в свою очередь imgObserver с каждым <img className='card-img-top'> .

Обновите <img className='card-img-top'/> , как показано ниже.

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

Мы устанавливаем источник по умолчанию для каждого <img className='card-img-top'/> и сохраняем изображение, которое хотим показать, в свойстве data-src . Изображение по умолчанию обычно имеет небольшой размер, поэтому мы загружаем как можно меньше. Когда элемент <img/> появляется в поле зрения, значение свойства data-src заменяет изображение по умолчанию.

На картинке ниже мы видим изображение маяка по умолчанию, которое все еще отображается в некоторых местах.

Изображения лениво загружаются. (Большой превью)

Соответствующая ветвь на данный момент — 03-lazy-loading.

Давайте теперь посмотрим, как мы можем абстрагировать все эти функции, чтобы их можно было использовать повторно.

Абстрагирование выборки, бесконечной прокрутки и отложенной загрузки в пользовательские хуки

Мы успешно реализовали выборку, бесконечную прокрутку и отложенную загрузку изображений. У нас может быть другой компонент в нашем приложении, который нуждается в аналогичной функциональности. В этом случае мы могли бы абстрагироваться и повторно использовать эти функции. Все, что нам нужно сделать, это переместить их в отдельный файл и импортировать туда, где они нам нужны. Мы хотим превратить их в Custom Hooks.

Документация React определяет пользовательский хук как функцию JavaScript, имя которой начинается с "use" и которая может вызывать другие хуки. В нашем случае мы хотим создать три хука: useFetch , useInfiniteScroll , useLazyLoading .

Создайте файл внутри папки src/ . Назовите его customHooks.js и вставьте внутрь приведенный ниже код.

 // 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

useFetch принимает функцию отправки и объект данных. Функция диспетчеризации передает данные из вызова API компоненту App , а объект данных позволяет нам обновить URL-адрес конечной точки 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

useInfiniteScroll принимает scrollRef и функцию dispatch . scrollRef помогает нам настроить наблюдателя, как уже обсуждалось в разделе, где мы его реализовали. Функция отправки позволяет инициировать действие, которое обновляет номер страницы в URL-адресе конечной точки 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]) }

useLazyLoading получает селектор и массив. Селектор используется для поиска изображений. Любое изменение в массиве запускает хук useEffect , который устанавливает наблюдателя для каждого изображения.

Мы видим, что те же функции, что и в src/App.js , мы извлекли в новый файл. Теперь хорошо то, что мы можем передавать аргументы динамически. Давайте теперь используем эти пользовательские хуки в компоненте приложения.

Откройте src/App.js . Импортируйте пользовательские хуки и удалите функции, которые мы определили для выборки данных, бесконечной прокрутки и отложенной загрузки изображений. Оставьте редьюсеры и разделы, где мы используем useReducer . Вставьте приведенный ниже код.

 // 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 ( ... )

Мы уже говорили о bottomBoundaryRef в разделе о бесконечной прокрутке. Мы передаем объект pager и функцию imgDispatch в useFetch . useLazyLoading принимает имя класса .card-img-top . Обратите внимание на . включены в имя класса. При этом нам не нужно указывать document.querySelectorAll . useInfiniteScroll принимает как функцию ref, так и функцию отправки для увеличения значения page .

Соответствующая ветвь на данный момент — 04-custom-hooks.

Заключение

HTML становится все лучше в предоставлении удобных API для реализации интересных функций. В этом посте мы увидели, как легко использовать наблюдатель пересечений в функциональном компоненте React. В процессе мы узнали, как использовать некоторые хуки React и как писать свои собственные хуки.

Ресурсы

  • «Бесконечная прокрутка + ленивая загрузка изображений», Орджи Чиди Мэтью, GitHub
  • «Бесконечная прокрутка, нумерация страниц или кнопки «Загрузить еще»? Выводы об удобстве использования в электронной коммерции», Кристиан Холст, Smashing Magazine
  • «Лорем Пиксум», Дэвид Марби и Нидзико Йонскай
  • «Появление IntersectionObserver», Surma, Web Fundamentals
  • Могу ли я использовать… IntersectionObserver
  • «Intersection Observer API», веб-документы MDN
  • «Компоненты и реквизит», React
  • « useCallback », Реагировать
  • « useReducer », Реагировать