useRef,IntersectionObserver을 이용하여 Infinityscroll을 만들어보자.

topics 201 React
references
types 실습

라이브러리를 쓰지말고 직접 infinity scroll을 구현해보자

어떻게 구현할까?

1.  scrollEvent

scrollEvent를 사용하게되면 스크롤이 발생할 때마다 함수가 호출된다. throttling으로 이를 좀 더 개선시킬 수는 있으나 역시나 최선의 방법은 아니다.

2. intersectionObserver

const io = new IntersectionObserver((entries, observer) => {}, options)
io.observe(element)

IntersectionObserver API는 해당요소가 최초로 관측되거나 특정 요소와 교차할 때 호출된다. option을 통해 콜백이 호출되는 상황을 조작할 수 있다.
option

  • root : 해당 요소를 기준으로 관측하고, 교차됨을 감지한다. default로 viewport가 지정된다.
  • rootMargin : 이를 통해 root의 범위를 확장하거나 축소할 수 있다.
  • threshold : 해당요소가 얼마나 보였을 때 콜백을 호출하는지 조절 가능하다
      0 -> 보이자말자
      [0,0.5,1] -> 보이자 말자, 절반 보일때, 완전히 다 보일때 콜백이 호출된다.

callback

  • entries : entries는 교차점 관찰자들을 배열로 담고 있습니다.  읽기만 가능하다.
    <해당 기능을 위해 쓰는 속성>
    isIntersecting : 관측하는 요소이 기준요소와 교차되는가를 bool로 판단

여기에 쓰지않지만 entries의 자세한 속성들은 아래링크에서 설명되어있다.
https://heropy.blog/2019/10/27/intersection-observer/

메서드

  • observe(el) : 해당요소 관찰시작
  • unobserve(el) : 해당요소 관찰 중지
  • disconnect() : 인스턴트가 관찰하는 모든요소 관찰 중지

구현방법!!

1. 페이징 api 처리를 위한 커스텀 훅을 제작해보자

  • backend에서 돌려주는 응답값
{
  "data": [
    {
      ....
    }
  ],
  "next": 0
}

next부분에 이후 페이징 될 페이지를 응답해준다.
그리고 그 값을 다시 get요청의 파라미터로 넣어 다음페이지를 받는 구조로 되어 있었다.

Custom hook 제작

💡 customhook을 이용한 이유 
인피니티의 복잡한로직을 구분하고 여러군대 재사용이 가능한 로직이기에 분리하였다

커스텀훅 전체코드 
코드에 주석을 자세히 달아놓았으니 주석을 참고하길 바란다.

import { useEffect, useState } from 'react';
import diaryService from '../services/diary_api';

const useHomePosts = (nextPage) => {
    //받아야할 데이터리스트들이다.
    const [diaryList, setDiaryList] = useState([]);
    
    //다음페이지를 담고있는 state이다
    const [next, setNext] = useState(null);
    
    //데이터를 주고 받는 상태를 표시하는 state이다
    const [error, setError] = useState(null); //에러있으면 string ,아니면 null
    const [loading, setLoading] = useState(false);//로딩중이면 true ,아니면 false
    
    
    const sendQuery = () => {
        console.log('sendQueryStart', nextPage, diaryList);
        
        //로딩중임을 설정
        setError(null);
        setLoading(true);
        
        //api호출
        diaryService
            .getFollowingDiary(nextPage)
            .then((res) => {
                //데이터가 있으먄
                if (res?.data.data) {
                    setDiaryList((prev) => {
                        //이전다이어리에 현재 다이어리 값을 추가한다.
                        let list = [...prev, ...res.data.data];
                        
                        //같은 id값을 필터링하는 함수
                        const filteredData = list.reduce(function (acc, current) {
                            if (
                            // 만약 같은아이디가 없으면
                                acc.findIndex(
                                    (e) => parseInt(e.diaryId) === parseInt(current.diaryId),
                                ) === -1
                            ) {
                            //추가
                                acc.push(current);
                            }
                            //아니면 그냥 반환
                            return acc;
                        }, []);
                        list = filteredData;
                        return list;
                    });
                    
                    setNext(res.data.next);//다음 페이지를 api에서 전달해준 정보로 바꾼다
                } else {
                    //데이터가 안불러 와 졌으면 error 설정
                    setError('일기를 불러오는데 실패했습니다.');
                }
                //데이터 처리가 끝났으므로 로딩 false설정
                setLoading(false);
            })
            .catch((e) => {
                //데이터가 안불러 와 졌으면 error 설정 , 로딩 false설정
                setError(e);
                setLoading(false);
            });
    };

    //다음페이지가 바뀌면 sendQuery를 실행한다.
    useEffect(() => {
        sendQuery(nextPage);
    }, [nextPage]);
    
    return [diaryList, next, error, loading]; 
};

export default useHomePosts;

2. IntersectionObserver을 이용하여 마지막 컴포넌트에 도달하였는지 인식하고 새로운 데이터 받기

useRef를 이용하여  컴포넌트안에서 조회 수정하는 변수를 관리할 수 있다 
따라서 여기서는 ref.current에 IntersectionObserver객체를 지정를 지정하여 해당 노드가 뷰포트와 교차되는지 관찰하였다.

🌊 flow

  1. 현재 페이지를 0으로 지정하고 커스텀훅에 현재페이지를 인자로 넣어준다.
  2. 데이터의 로딩상태에 따라 콜백이 실행되는 useCallback을만들어준다.
  3. 로딩이거나,노드가 없거나, 다음 페이지가 없다면 그냥 반환을 해준다.
    -> 에러는 논리적으로 로딩의 변화에 종속되기 때문에 제외햇다.
  4. 만약현재 관찰중인 것이 있다면 전부다 커넥션을 끊어준다
  5. ref.current에 IntersectionObserver를 지정하고 만약 교차가 되었고 다음페이지가 있으면
    현재 페이지를 다음페이지로 지정하여 page값을 dependency를 가지고 있는 customhook를 실행하도록 한다.
  6. 노드가 있다면 해당노드를 관찰하다록 한다. (여기서는 페이지 마지막에 요소를 노드로 지정햇다)

전체 코드

export default function Home() {
    const [currentPage, setCurrentPage] = useState(0);
    const [diaryList, next, error, loading] = useHomePosts(currentPage);

    const observer = useRef();
    const lastPostElementRef = useCallback(
        (node) => {
            console.log('lastPostElementRef', node, loading, next, observer.current);
            if (loading || next == null||node==null) return;
            if (observer.current) observer.current.disconnect();
            observer.current = new IntersectionObserver((entries) => {
                if (entries[0].isIntersecting && next != null) {
                    console.log('ref,entries[0].isIntersecting', entries[0]);
                    setCurrentPage(next);
                }
            });
            if (node) observer.current.observe(node);
        },
        [loading, next],
    );
       return (
        <Box sx={{ maxWidth: 760, minWidth: 350, width: '100%' }}>
              {diaryList.length > 0 ? (
                 <>
                {diaryList.map((data) => (
                    <Post
                         key={data['diaryId']}
                        data={data}
                        style={boxStyle}
                    />
                 ))}
                    <Typography variant="h5" align={'center'} ref={lastPostElementRef}>
                        {loading && 'Loading...'}
                        {error != null && 'Error...'}
                    </Typography>
                 </>
                    ) : (
                     <Typography
                         variant="h5"
                         align={'center'}
                         sx={{ paddingY: '30vh', whiteSpace: 'pre-line' }}
                         gutterBottom>
                           {'팔로잉 된 글이 없습니다.\n팔로우해보세요.'}
                     </Typography>
               )}
          </Box>
    );