На сегодняшний день любое уважающее себя предприятие, будь то магазин строительных товаров или компания по предоставлению услуг в сфере бизнеса, все они стремятся «выложить» свои товары и услуги в интернет. Это и понятно – мы живем в век бурно развивающихся технологий и доступ в интернет имеет более 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, влияющая на рейтинг сайта в интернете. Также бесконечный скролл + виртуализация является достойной альтернативой пагинации и другим технологиям, которые предполагают выдачу информации порциями.

Комментарии (11)


  1. nin-jin
    01.10.2023 07:59
    +3

    Бесконечный скролл ограниченный 100 элементами, мдам. Почему-то решили, что 7 элементов подойдут для любого размера экрана. Капитально сломали инерциальный скролл. Плюс в браузерах без поддержки привязки скролла (привет Сафари) будут постоянные скачки контента.

    Статья по теме.


    1. Fodin
      01.10.2023 07:59

      Было интересно в статье про скролл сначала поискать его на странице, а потом наблюдать, как он бешено скачет при прокрутке и подгрузке.


  1. acsent1
    01.10.2023 07:59

    Почему нужно делать вот так
    useEffect(()=>{...} )
    а не просто прописать свойство onScroll


    1. Supervit
      01.10.2023 07:59
      +1

      Пожалуйста изучи все хуки реакта, это очень важно. useEffect с пустым массивом зависимостей выполнится только один раз — при первом рендеринге. Без использования useEffect event listener будет перезаписывать я при каждом рендеринге. Так же обрати внимание что в колбэке useEffect возвращается функция. Она выполняется перед новым выполнением хука и при размонтировании компонента (когда он больше не используется). В данном примере автор очищает event listener, который больше не нужен.


  1. Fodin
    01.10.2023 07:59
    +1

    Почему "infinity scroll", а не "infinite scroll"? Infinity - существительное.
    Я не просто доколупываюсь, у меня повод есть. Как-то в ревью нашему синьору написал похожий вопрос (про Infinity loader), а он проигнорил. Вот у меня с тех пор сомнение, может, я что-то отчаянно глупое спросил?


  1. firehacker
    01.10.2023 07:59

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

    Надеюсь для изобретателей бесконечного скролла в аду предусмотрен отдельный котел с бесконечным ростом температуры.


    1. Maxim__Ss Автор
      01.10.2023 07:59

      Из-за чего такой негатив в сторону бесконечного скролла?


  1. kacetal
    01.10.2023 07:59

    Советую вам почитать про keyset пагинацию, идеальное решение для бесконечной прокрутки. Особенно для базы данных, да и на фронте ее проще имплементировать.


    1. Maxim__Ss Автор
      01.10.2023 07:59

      В конце статьи я указал, что приведенный в статье метод является альтернативой пагинации.

      Базы данных здесь тоже нет, но если вы посмотрите внимательно, то увидите, что логика из статьи аналогична логике keyset pagination.


  1. solodzh
    01.10.2023 07:59

    А почему не стал использовать Intersection observer?


  1. Maxim__Ss Автор
    01.10.2023 07:59

    Хотел продемонстрировать работу без этой технологии. С IO дела, кончено же, обстоят лучше.

    Более подробно вы можете ознакомиться, например, в этой статье: https://www.google.com/amp/s/habr.com/ru/amp/publications/316136/