Infinite scroll + Virtualization на прикладі ReactJS+ RTK Query

React

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

React scroll

Також при скролі вгору не відбуваються запити на отримання попередніх постів, тому що RTK Query їх кешує, що дозволяє знову ж таки збільшити продуктивність.

На закінчення статті можна зробити висновок, що запропонована реалізація дозволила виграти в часі першого відтворення контенту – одна з метрик Google PageSpeed ​​Insights, що впливає на рейтинг сайту в інтернеті. Також нескінченний скролл+віртуалізація є гідною альтернативою пагінації та іншим технологіям, які передбачають видачу інформації порціями.

Джерело