3 분 소요

✔️ Infinite Scroll?

무한스크롤(Infinite Scroll) 이란 말 그대로 스크롤을 무한으로 할 수 있는 기능을 말합니다. 불러와야 하는 게시글 목록이 많은 경우 무한스크롤을 활용하면 사용자 편의성과 클라이언트 부담을 덜 수 있다는 장점이 있습니다.

✔️ Intersection Observer API 란?

Intersection Observer API는 타겟 요소와 상위 요소 또는 최상위 document 의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 API입니다.

MDN에서는 Intersection Observer API를 위와 같이 정의하고 있습니다. 쉽게 말하면 관찰 중인 요소가 사용자가 보고 있는 영역 안에 들어왔는지 알려주는 API라고 할 수 있습니다.

✔️ Options

1
const io = new IntersectionObserver(callback, options);

옵션부에는 root, rootMargin, threshold 3가지가 있습니다.

  • root : 타겟 엘리먼트가 보이는지 안보이는지 결정할 뷰포트로 사용되는 엘리먼트 (기본값은 Browser Viewport)
  • rootMargin : root에 정의된 Element가 가진 마진값 (기본값은 0)
  • threshold : 타겟 엘리먼트가 root에 정의된 Element에 얼마나 노출되었을 때 Callback 함수를 실행할지 정의하는 옵션 (기본값은 0)
1
2
const io = new IntersectionObserver(callback, { threshold: 0.7 });
io.observe(관찰할 요소);

예를 들어 관찰하고자 하는 대상이 화면 내 70% 안에 들어왔을 경우 위와 같이 작성할 수 있습니다.

✔️ 콜백함수

1
2
3
4
5
6
7
8
9
10
11
const callback = (entries, io) => {
  entries.forEach((entry) => {
    // 화면 안에 요소가 들어왔는지 체크
    if (entry.isIntersecting) {
      // 기존 관찰하던 요소는 더 이상 관찰하지 않음
      io.unobserve(entry.target);
      // TODO: 새로운 컨텐츠 추가
      // TODO: 새로운 컨텐츠의 마지막 요소를 관찰 시작
    }
  });
};

콜백 함수는 위와 같이 entries와 io를 매개변수로 갖습니다.
Intersection Observer를 쓰기 이전에는 무한스크롤을 구현할 때 성능 문제로 인해 debouncethrottle 과 같은 호출수 제한 최적화 작업이 반드시 이뤄져야 했지만 지금은 필요하지 않습니다.

✔️ 무한스크롤 구현

이제 본격적으로 무한스크롤 구현 코드를 작성해보도록 하겠습니다.

1
2
$ npx create-react-app infinite-scroll-example
$ npm i styled-components react-loading

우선 위와 같은 CRA 명령어를 입력하여 초기 세팅을 하고 styled와 react-loading 라이브러리를 설치해주도록 하겠습니다.

  • Item.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React from "react";
import styled from "styled-components";

const ItemWrap = styled.div`
  width: 350px;
  height: 370px;
  padding-top: 140px;
  font-size: 70px;
  display: flex;
  flex-direction: column;
  background-color: #ffffff;
  margin: 1rem;
  box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;
  border-radius: 6px;
`;

const Item = ({ number }) => {
  return <ItemWrap>{number}</ItemWrap>;
};

export default Item;
  • Loader.js => 로딩 스피너 컴포넌트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from "react";
import ReactLoading from "react-loading";
import styled from "styled-components";

const LoaderWrap = styled.div`
  width: 100%;
  height: 80%;
  display: flex;
  justify-content: center;
  text-align: center;
  align-items: center;
`;

const Loader = () => {
  return (
    <LoaderWrap>
      <ReactLoading type="spin" color="#455560" />
    </LoaderWrap>
  );
};

export default Loader;
  • App.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import React, { useEffect, useState } from "react";
import styled, { createGlobalStyle } from "styled-components";
import Item from "./Item";
import Loader from "./Loader";

const GlobalStyle = createGlobalStyle`
  *, *::before, *::after {
    box-sizing: border-box;
    padding: 0;
    margin: 0;
  }

  body {
    background-color: #f2f5f7;
  }
`;

const AppWrap = styled.div`
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  text-align: center;
  align-items: center;

  .Target-Element {
    width: 100vw;
    height: 140px;
    display: flex;
    justify-content: center;
    text-align: center;
    align-items: center;
  }
`;

const App = () => {
  const [target, setTarget] = useState(null);
  const [isLoaded, setIsLoaded] = useState(false);
  const [itemLists, setItemLists] = useState([1]);

  useEffect(() => {
    console.log(itemLists);
  }, [itemLists]);

  const getMoreItem = async () => {
    setIsLoaded(true);
    await new Promise((resolve) => setTimeout(resolve, 1500));
    let Items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    setItemLists((itemLists) => itemLists.concat(Items));
    setIsLoaded(false);
  };

  const onIntersect = async ([entry], observer) => {
    if (entry.isIntersecting && !isLoaded) {
      observer.unobserve(entry.target);
      await getMoreItem();
      observer.observe(entry.target);
    }
  };

  useEffect(() => {
    let observer;
    if (target) {
      observer = new IntersectionObserver(onIntersect, {
        threshold: 0.4,
      });
      observer.observe(target);
    }
    return () => observer && observer.disconnect();
  }, [target]);

  return (
    <>
      <GlobalStyle />
      <AppWrap>
        {itemLists.map((v, i) => {
          return <Item number={i + 1} key={i} />;
        })}
        <div ref={setTarget} className="Target-Element">
          {isLoaded && <Loader />}
        </div>
      </AppWrap>
    </>
  );
};

export default App;

✔️ 적용 화면

Nov-22-2022 23-59-52

✔️ 참고 사이트

🔔포스팅 공지
개인 공부 기록용 블로그 입니다.
잘못된 부분이 있을 시 메일이나 댓글로 지적해주시면 감사드리겠습니다 :)

댓글남기기