4주차 - 무한 스크롤
본문 바로가기
항해 중/5주차 리액트 심화반

4주차 - 무한 스크롤

by 은돌1113 2021. 12. 2.

1. 무한 스크롤 동작 원리

 

👉 게시글을 한번에 여러개 가져오면?

→ 페이지 로드 속도가 엄청 나빠집니다 😢

서버에서 주는 속도도 느려지겠지만, 클라이언트에서 엄청나게 많은 컴포넌트를 그려 느려지기도 해요.

 

무한 스크롤은 자주 쓰는 트릭!

사실 한 화면에 담기는 게시글은 그렇게 많지 않아요. 우리가 만드는 이미지 커뮤니티의 경우에도 10개도 못들어가죠! → 화면에 담기지 않는 게시글은 불러오지 않고 있다가, 스크롤이 아래로 내려가서 '이제쯤 다음 걸 불러와야겠다' 싶을 때 (보통 마지막이나 마지막 1개 전 게시글에 스크롤이 닿았을 때!)

다음 게시글을 불러오면 사용자가 귀찮게 더보기나 다음 페이지 버튼을 누르지 않고도 쭉쭉 읽을 수 있겠죠!

 

(공식 문서)

https://firebase.google.com/docs/firestore?authuser=0 

 

Cloud Firestore  |  Firebase Documentation

유연하고 확장 가능한 NoSQL 클라우드 데이터베이스를 사용해 클라이언트 측 개발 및 서버 측 개발에 사용되는 데이터를 저장하고 동기화하세요.

firebase.google.com

 

+ firestore에서 쿼리를 써보자

: 작성일을 기준으로 하여 데이터를 불러오기를 해보겠습니다.

const getPostFB = () => {
  return function (dispatch, getState, { history }) {
    const postDB = firestore.collection("post");

	// 쿼리 날려서 날짜순으로 불러오기
    let query = postDB.orderBy("insert_dt", "desc").limit(2);
    // postDB : 어떤 collection에서 가져 올 지
    // orderBy : 어떤 걸 기준으로 어떻게 정렬 할 지
    // limit(갯수) : 최대 몇개를 가져올 지

    query.get().then(docs => {
      let post_list = [];
      
      // redux에 넣기 전에 가져온 데이터와 redux의 initialState의 형식을 맞춰준다.
      docs.forEach((doc) => {
        let _post = doc.data();
	
        // ['commenct_cnt', 'contents', ..]
        let post = Object.keys(_post).reduce(
          (acc, cur) => {
            if (cur.indexOf("user_") !== -1) {
              return {
                ...acc,
                user_info: { ...acc.user_info, [cur]: _post[cur] },
              };
            }
            return { ...acc, [cur]: _post[cur] };
          },
          { id: doc.id, user_info: {} }
        );

        post_list.push(post);
      });
      
	// 데이터를 redux에 넣는다.
      dispatch(setPost(post_list));
    });
  };
};

2. 무한 스크롤 만들기 1

 

👉 [How to?]

 

무한스크롤 컴포넌트를 만들고, 아래 4가지를 넘겨줍니다.

  • 게시글 컴포넌트들 (리스트!)
  • 다음 목록을 불러올 함수
  • 파이어스토어에서 불러오는 중인 지, 아닌 지 판별자
  • 다음 글이 있나, 없나 판별자

앗, 그러면 미리 사전에 준비할 건 뭐가 있을까요? 🙂

  • 게시글을 가져올 때(파이어스토어 통신할 때), 어디까지 가져왔는 지, 다음엔 뭐부터 가져올 지 페이징 정보!
  • 게시글을 가져오는 중인 지, 아닌 지 구분해야겠다!(스크롤마다 계속 다시 불러오면 안되니까!)
  • 게시글을 가져오는 중이구나~ 알 수 있도록 스피너 컴포넌트를 만들자!

- paging 처리하기

 

1) initialState에 데이터 추가

const initialState = {
  list: [],
  paging: { // 페이징 정보를 담는다.
    start: null, // 시작점
    next: null, // 다음 게 있는 지 없는 지
    size: 3, // 몇 개 가져 올건지
  },
  is_loading: false, // 로딩 중인지 아닌 지 판별
};

2) getPostFB에서 페이징대로 데이터를 가져오기

const getPostFB = (start = null, size=3) => {
  return function (dispatch, getState, { history }) {

    // state에서 페이징 정보 가져오기
    let _paging = getState().post.paging;

    // 시작정보가 기록되었는데 다음 가져올 데이터가 없다면? 앗, 리스트가 끝났겠네요!
    // 그럼 아무것도 하지말고 return을 해야죠!
    if (_paging.start && !_paging.next) {
      return;
    }
    
    // 가져오기 시작~!
    dispatch(loading(true));
    
    const postDB = firestore.collection("post");

    let query = postDB.orderBy("insert_dt", "desc");

    // 시작점 정보가 있으면? 시작점부터 가져오도록 쿼리 수정!
    if(start){
      query = query.startAt(start);
    }

    // 사이즈보다 1개 더 크게 가져옵시다. 
    // 3개씩 끊어서 보여준다고 할 때, 4개를 가져올 수 있으면? 앗 다음 페이지가 있겠네하고 알 수 있으니까요.
    // 만약 4개 미만이라면? 다음 페이지는 없겠죠! :)
    query.limit(size+1).get().then((docs) => {
      let post_list = [];

      // 새롭게 페이징 정보를 만들어줘요.
      // 시작점에는 새로 가져온 정보의 시작점을 넣고,
      // next에는 마지막 항목을 넣습니다.
      // (이 next가 다음번 리스트 호출 때 start 파라미터로 넘어올거예요.)
      let paging = {
        start: docs.docs[0],
        next: docs.docs.length === size+1? docs.docs[docs.docs.length - 1] : null,
        size: size,
      };

      docs.forEach((doc) => {
        let _post = doc.data();

        let post = Object.keys(_post).reduce(
          (acc, cur) => {
            if (cur.indexOf("user_") !== -1) {
              return {
                ...acc,
                user_info: { ...acc.user_info, [cur]: _post[cur] },
              };
            }
            return { ...acc, [cur]: _post[cur] };
          },
          { id: doc.id, user_info: {} }
        );

        post_list.push(post);
      });

      // 마지막 하나는 빼줍니다.
      // 그래야 size대로 리스트가 추가되니까요!
      // 마지막 데이터는 다음 페이지의 유무를 알려주기 위한 친구일 뿐! 리스트에 들어가지 않아요!
      post_list.pop();

      dispatch(setPost(post_list, paging));
    });
  };
};

3) 리덕스에서 기존 리스트에 데이터가 추가 되도록 리듀서 수정

[SET_POST]: (state, action) =>
      produce(state, (draft) => {
        draft.list.push(...action.payload.post_list);
        draft.paging = action.payload.paging;
        draft.is_loading = false;
      }),

4) PostList 컴포넌트에 적용하기

// PostList.js
import React from "react";

import Post from "../components/Post";
import { useSelector, useDispatch } from "react-redux";
import { actionCreators as postActions } from "../redux/modules/post";

const PostList = (props) => {
  const dispatch = useDispatch();
  const user_info = useSelector((state) => state.user.user); // 사용자 정보
  const post_list = useSelector((state) => state.post.list); // 게시물 정보
  const is_loading = useSelector((state) => state.post.is_loading); // 로딩 정보
  const paging = useSelector((state) => state.post.paging); // 페이징 정보

  React.useEffect(() => {
    if(post_list.length === 0){
        dispatch(postActions.getPostFB());
    }
  }, []);

  return (
    <React.Fragment>
      
        {post_list.map((p, idx) => {
          if (user_info && p.user_info.user_id === user_info.uid) {
            return <Post key={p.id} {...p} is_me />;
          }
          return <Post key={p.id} {...p} />;
        })}
     
    </React.Fragment>
  );
};

export default PostList;

3. 무한 스크롤 만들기 2

 

- infinityScroll 만들기

 

1) 컴포넌트 만들기

import React from "react";
import _ from "lodash";

const InfinityScroll = (props) => {
  const { children, callNext, is_next, loading } = props;

  // 쓰로틀을 적용합시다!
  const _handleScroll = _.throttle(() => {
      callNext();
    }
  }, 300);

  const handleScroll = React.useCallback(_handleScroll, [loading]);

  React.useEffect(() => {
    // 로딩 중이면, return!
    if (loading) {
      return;
    }

    // 다음 게 있으면 이벤트를 붙이고, 없으면 이벤트를 삭제해요!
    if (is_next) {
      window.addEventListener("scroll", handleScroll);
    } else {
      window.removeEventListener("scroll", handleScroll);
    }

    // 이 부분은 컴포넌트가 사라질 때 호출되는 부분입니다! (클린업이라고도 해요.)
    return () => window.removeEventListener("scroll", handleScroll);
  }, [is_next, loading]);

  return (
    <React.Fragment>
      {children}
    </React.Fragment>
  );
};

InfinityScroll.defaultProps = {
  children: null,
  callNext: () => {},
  is_next: false,
  loading: false,
};

export default InfinityScroll;

2) 적용 해보기 (PostList.js)

 

- 스크롤 계산하기

더보기

👉 [브라우저 창 크기 알아보기]

페이지 끝에 닿았는 지 알려면 어떻게 할까요?

→ 스크롤할 수 있는 영역이 얼마나 남았나 계산을 해보면 됩니다!

 

계산을 하려면, 웹 페이지 전체 크기도 알아야하고 얼마나 스크롤했는 지도 알아야겠죠!

 

window.innerHeight은 가시적으로 보이는 브라우저 창 높이예요.(메뉴바 툴바 제외한 크기!) document.body.scrollHeight는 스크롤할 수 있는 높이예요.(눈에 안보이는 영역도 포함!)

document의 scrollTop은 스크롤이 얼마나 움직였나 알려주는 값이에요.

 

화면 크기 관련 내용 더 알아보기→ (링크)

// shared/InfinityScroll.js
const _handleScroll = _.throttle(() => {
    const { innerHeight } = window;
    const { scrollHeight } = document.body;

    // 스크롤 계산!
    const scrollTop =
      (document.documentElement && document.documentElement.scrollTop) ||
      document.body.scrollTop;

    if (scrollHeight - innerHeight - scrollTop < 200) {
      // 로딩 중이면 다음 걸 부르면 안되겠죠!
      if (loading) {
        return;
      }

      callNext();
    }
  }, 300);

 

- 다음 목록 불러오기

// PostList.js
import React from "react";

import Post from "../components/Post";
import { useSelector, useDispatch } from "react-redux";
import { actionCreators as postActions } from "../redux/modules/post";
import InfinityScroll from "../shared/InfinityScroll";

const PostList = (props) => {
  const dispatch = useDispatch();
  const post_list = useSelector((state) => state.post.list);
  const user_info = useSelector((state) => state.user.user);
  const is_loading = useSelector((state) => state.post.is_loading);
  const paging = useSelector((state) => state.post.paging);

  React.useEffect(() => {
    if(post_list.length === 0){
        dispatch(postActions.getPostFB()); // ***
    }
  }, []);

  return (
    <React.Fragment>
      <InfinityScroll
        callNext={() => {
          console.log("next!");
          dispatch(postActions.getPostFB(paging.next));
        }}
        is_next={paging.next? true : false}
        loading={is_loading}
      >
        {post_list.map((p, idx) => {
          if (user_info && p.user_info.user_id === user_info.uid) {
            return <Post key={p.id} {...p} is_me />;
          }
          return <Post key={p.id} {...p} />;
        })}
      </InfinityScroll>
    </React.Fragment>
  );
};

export default PostList;

 

- Spinner 붙이기

// src/elements/infinityScroll.js

import React from "react";
import _ from "lodash";
import { Spinner } from "../elements";

const InfinityScroll = (props) => {
  const { children, callNext, is_next, loading } = props;

  const _handleScroll = _.throttle(() => {
    const { innerHeight } = window;
    const { scrollHeight } = document.body;
    const scrollTop =
      (document.documentElement && document.documentElement.scrollTop) ||
      document.body.scrollTop;

    if (scrollHeight - innerHeight - scrollTop < 200) {
      if (loading) {
        // 로딩 중이면 다음 리스트를 불러오지 않는다.
        return;
      }

      callNext(); // 받아온 다음 리스트로 넘긴다.
    }
  }, 300);

  const handleScroll = React.useCallback(_handleScroll, [loading]);

  React.useEffect(() => {
    if (loading) {
      // 로딩 중이면 다음 리스트를 불러오지 않는다.
      return;
    }

    if (is_next) {
      window.addEventListener("scroll", handleScroll); // 스크롤 이벤트를 구독한다.
    } else {
      window.removeEventListener("scroll", handleScroll); // 스크롤 이벤트를 구독 해제한다.
    }

    return () => window.removeEventListener("scroll", handleScroll); // 클린업
  }, [is_next, loading]);

  return (
    <React.Fragment>
        {props.children}
        {is_next && (<Spinner></Spinner>)}
    </React.Fragment>
  )
};

InfinityScroll.defaultProps = {
  children: null,
  callNext: () => {},
  is_next: false,
  loading: false,
};

export default InfinityScroll;

 

+ 스피너 적용하기

import React from "react";

import Spinner from "../elements/Spinner"; // ***
import _ from "lodash"; 

const InfinityScroll = (props) => {
	
  { 변경사항 없음 }
    
  return (
    <React.Fragment>
      {children}
      {is_next && <Spinner />} // ***
    </React.Fragment>
  );
};

export default InfinityScroll;

 

댓글