На сегодняшний день любое уважающее себя предприятие, будь то магазин строительных товаров или компания по предоставлению услуг в сфере бизнеса, все они стремятся «выложить» свои товары и услуги в интернет. Это и понятно – мы живем в век бурно развивающихся технологий и доступ в интернет имеет более 65% населения мира (около 5.3 млрд. человек), а к 2025 году это число увеличится до 6.54 млрд. (внушительно, не правда ли?). Так, о чем я, всех их нужно обслуживать, всем им нужно предлагать услуги, товары и т.д. Как говорится: «На вкус и цвет – товарища нет» и правда сколько людей – столько мнений, а в нашем случае товаров и услуг. На фоне этого возникает резонный вопрос: «А как все это отобразить у меня на сайте, чтобы пользователь не ждал до следующего года загрузки страницы сайта, когда к тому времени успеют появиться еще товары, которые необходимо будет подгрузить?». При такой картине мира и самых оптимистичных прогнозах о темпах появления новых вещей, мы имеем неосторожность войти в некую рекурсию.
С детства нас учили есть маленькими порциями и тщательно пережевывать, так почему бы и в сложившейся ситуации получать всю информацию не одним скопом, а порционно? Именно такое решение предлагаю рассмотреть в своей статье. И если уж касаться темы еды (видимо, не стоит писать на голодный желудок), то стоит проглатывать еду, которую мы уже прожевали, а не копить ее во рту, иначе когда-нибудь он порвется (Джокер, к тебе претензий нет). Так и мы будем удалять элементы из DOM-дерева, которые не доступны взору пользователя, чтобы не перегружать наш сайт.
Технологии, которые я выбрал, а, вернее сказать, которые мне выбрал работодатель для выполнения тестового задания: React, RTK Query. Моя тяга к знаниям и познанию, а также желание писать, заставили меня пойти немного далее, поэтому ниже произведу сравнение времени рендеринга при бесконечном скролле с виртуализацией и если бы мы загрузили все наши данные сразу.
Перейдем к реализации. Нашими подопытными данными будут посты из https://jsonplaceholder.typicode.com. Создаем приложение с помощью команды create-react-app, устанавливаем RTK Query (у меня: "@reduxjs/toolkit": "^1.9.5"). Оборачиваем наш корневой компонент в провайдер и переходим к настройке store.
Создаем api:
export const postApi=createApi({
reducerPath:'post',
baseQuery:fetchBaseQuery({baseUrl:'https://jsonplaceholder.typicode.com'}),
endpoints:(build)=>({
fetchAllPosts: build.query<IPost[],{limit:number,start:number}>({
query:({limit=5, start=0 })=>({
url:'/posts',
params:
{
_limit:limit,
_start:start,
}
})
}),
fetchPostById: build.query<IPost,number>({
query:(id:number=1)=>({
url:`/posts/${id}`,
})
})
})
})
Прокидываем его в rootReducer и определяем функцию setupStore, которая установит нам store для провайдера:
const rootReducer= combineReducers({
[postApi.reducerPath]:postApi.reducer
})
export const setupStore=()=>{
return configureStore({
reducer:rootReducer,
middleware:(getDefaultMidleware)=> getDefaultMidleware().concat(postApi.middleware)
})
}
Index.tsx
const store=setupStore()
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<Provider store={store}>
<App/>
</Provider>
);
Создаем наш компонент для одного поста:
interface IPostItemProps{
post:IPost
}
const PostItem:FC<IPostItemProps> = ({post}) => {
const navigate=useNavigate()
return (
<div className='container__postItem'>
<div>№ {post.id}</div>
<div className='postitem__title'>Title: {post.title}</div>
<div className='postitem__body'>
Body: {post.body.length>20?post.body.substring(0,20)+'...':post.body}
</div>
</div>
);
};
Переходим непосредственно к логике отрисовки наших компонентов.
Определим в контейнере постов два состояния: одно для определения момента, когда скролл достиг верхней части страницы, другое – когда нижней. А также хук, который нам сформировал RTK Query, куда мы передаем наши лимит (число постов) и стартовый индекс (индекс первого поста):
const [isMyFetching,setIsFetchingDown]=useState(false)
const [isMyFetchingUp,setIsMyFetchingUp]=useState(false)
const {data:posts, isLoading} =postApi.useFetchAllPostsQuery({limit:7,start:currentPostStart})
Создадим функцию, которая будет высчитывать достижение верха или низа и возвращать скролл в среднее положение:
const scrollHandler=(e:any):void=>{
if(e.target.documentElement.scrollTop<50)
{
setIsMyFetchingUp(true)
}
if(e.target.documentElement.scrollHeight-e.target.documentElement.scrollTop-window.innerHeight<50)
{
setIsFetchingDown(true)
window.scrollTo(0,(e.target.documentElement.scrollHeight + e.target.documentElement.scrollTop));
}
}
Где
e.target.documentElement.scrollHeight – высота всего скролла;
e.target.documentElement.scrollTop – сколько мы уже прокрутили от верхней части;
window.innerHeight – высота видимой части страницы.
Затем необходимо при первом рендеринге этого компонента навесить слушатели событий на скролл и убрать его при размонтировании, чтобы не накапливать их:
useEffect(()=>{
document.addEventListener('scroll',scrollHandler)
return ()=>{
document.removeEventListener('scroll',scrollHandler)
}
},[])
Определим хук useEffect, который отрабатывает при достижении нижней части экрана:
useEffect(()=>{
if(isMyFetching)
{
setCurrentPostStart(prev=>{
return prev<93?prev+1:prev
})
setIsFetchingDown(false)
}
},[isMyFetching])
Стоит здесь отметить, что при изменении стартового индекса мы работаем с предыдущим значением и если оно уже меньше 93, то есть мы достигли некого максимума (JSONPlaceholder нам предоставляет только 100 постов), то возвращаем текущее значение, иначе увеличиваем индекс на единицу.
Аналогично поступаем при достижении верхней части страницы:
useEffect(()=>{
if(isMyFetchingUp)
{
setCurrentPostStart(prev=>{
return prev>0?prev-1:prev
})
setIsMyFetchingUp(false)
}
},[isMyFetchingUp])
Код всей компоненты:
const PostContainer: FC = () => {
const [currentPostStart,setCurrentPostStart]=useState(0)
const {data:posts, isLoading} =postApi.useFetchAllPostsQuery({limit:7,start:currentPostStart})
const [isMyFetching,setIsFetchingDown]=useState(false)
const [isMyFetchingUp,setIsMyFetchingUp]=useState(false)
useEffect(()=>{
if(isMyFetching)
{
setCurrentPostStart(prev=>{
return prev<93?prev+1:prev
})
setIsFetchingDown(false)
}
},[isMyFetching])
useEffect(()=>{
if(isMyFetchingUp)
{
setCurrentPostStart(prev=>{
return prev>0?prev-1:prev
})
setIsMyFetchingUp(false)
}
},[isMyFetchingUp])
useEffect(()=>{
document.addEventListener('scroll',scrollHandler)
return ()=>{
document.removeEventListener('scroll',scrollHandler)
}
},[])
const scrollHandler=(e:any):void=>{
if(e.target.documentElement.scrollTop<50)
{
setIsMyFetchingUp(true)
}
if(e.target.documentElement.scrollHeight-e.target.documentElement.scrollTop-window.innerHeight<50)
{
setIsFetchingDown(true)
window.scrollTo(0,(e.target.documentElement.scrollHeight + e.target.documentElement.scrollTop));
}
}
return (
<div>
<div className='post__list'>
{posts?.map(post=><PostItem key={post.id} post={post}/>)}
</div>
{isLoading&&<div>Загрузка данных</div>}
</div>
);
};
Настало время для проведения экспериментов.
Рассмотрим пример, когда мы загружаем все 100 постов одним запросом. Общее время рендера, которое отображается во вкладке Profiler в React DevTools, в данном случае составил 44,1 мс.
Если же загружать порциями по 7 постов, то время сократится до 23,2 мс.
Помимо выигрыша по времени до первой отрисовки контента, мы также выигрываем по производительности, за счет того, что нам не нужно хранить в DOM все элементы, а только те, которые видны пользователю на экране.
Если посмотреть на ниже расположенный рисунок, то можно увидеть, что у нас количество узлов, отображающих посты, остается постоянным равным 7.
Также при скролле вверх не происходят запросы на получение предыдущих постов, потому что RTK Query их кеширует, что позволяет опять же увеличить производительность.
В заключение статьи можно сделать вывод, что предложенная реализация позволила выиграть во времени первой отрисовки контента – одна из метрик Google PageSpeed Insights, влияющая на рейтинг сайта в интернете. Также бесконечный скролл + виртуализация является достойной альтернативой пагинации и другим технологиям, которые предполагают выдачу информации порциями.