Intersection Observer란 그리고 Infinite scroll
What is the Intersection Observer? and Infinte scroll
Intersection Observer 란
Intersection Observer API 는 타겟 요소와 상위 요소 또는 최상위 document 의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 방법입니다.
MDN 에서 설명하는 Intersection Observer
의 설명은 위와 같습니다.
그렇다면 엘리먼트 특정 Element 사이의 intersection 변화를 알 수 있는 다른 방법은 무엇이 있고 Intersection Observer
와 무엇이 다르고 어떻게 활용할 수 있을까요?
Intersection Observer vs ScrollEvent
널리 알려지고 흔히 사용되는 방법은 ScrollEvent
를 이용하는 방법입니다.
ScrollEvent
의 단점인 이벤트가 빈번하게 발생하는 모습
위와 같이 ScrollEvent
는 빈번하게 발생될 수 있기 때문에, 이벤트 핸들러는 DOM 수정과 같은 계산이 많이 필요한 연산을 실행하지 않아야 합니다. 대신에 requestAnimationFrame
, setTimeout
, customEvent
등을 사용해 이벤트를 스로틀(throttle) 하는것이 좋습니다.
반면 Intersection Observer
는 타겟 엘리먼트가 다른 엘리먼트의 뷰포트에 들어가거나 나갈때 또는 요청한 부분만큼(threshold
) 두 엘리먼트의 교차부분이 변경될 때 마다 실행될 콜백 함수를 실행되도록 합니다. 즉, 브라우저는 원하는 대로 교차 영역 관리를 최적화 할 수 있습니다.
Intersection Observer 컨셉
Intersection Observer
를 사용하면 다음과 같은 상황에서 callback
함수를 사용 할 수 있습니다.
- 타겟 엘리먼트가 또 다른 특정 엘리먼트 혹은
root
와 intersection 될 때. observer
가 타겟 엘리먼트를 처음 관측할 때.
타겟 엘리먼트가 특정 엘리먼트 혹은 root
사이에 원하는 만큼 교차할때, 미리 정의해둔 callback
함수가 실행됩니다. 여기서 원하는 만큼 교차하는 정도를 intersection ratio
라고 하고, 0.0 ~ 1.0 사이의 숫자로 표현합니다.
간단한 예제를 확인해보겠습니다.
let options = {
root: document.querySelector("#scrollArea"),
rootMargin: "10px 0px 3px 5px",
threshold: 1
};
let observer = new IntersectionObserver(callback, options);
options
root
: 타겟 엘리먼트의 가시성을 확인할 뷰포트를 의미합니다. 해당 값을 입력하지 않거나null
로 정의할 경우 기본값으로 브라우저root
가 됩니다.rootMargin
:root
엘리먼트가 가지는margin
을 의미합니다. 이 값은CSS margin
속성과 유사하게 ‘top right bottom left’ 순서로 배열합니다.threshold
: 타겟 엘리먼트와root
엘리먼트 사이에 교차 정도를 의미합니다. 기본값은0
이고 이 때는 타겟 엘리먼트가1px
이라도 교차되면callback
함수를 실행합니다. 이 값이1
이라면 타겟 엘리먼트 전체가root
엘리먼트와 교차될 때callback
함수가 실행됩니다.
만약 여러 포인트에서callback
함수의 실행을 원한다면[0.25, 0.5, 0.75, 1]
같이 원하는 값의 배열로 표시할 수 있습니다.
callback
let callback = (entries, observer) => {
entries.forEach(entry => {
// Each entry describes an intersection change for one observed
// target element:
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.target
// entry.time
});
};
callback
함수는 위와 같이 표시 할 수 있습니다. 파라미터의 entries
에는 다양한 메서드와 프로퍼티들이 존재하지만 우리가 집중해야 할 값은 entry.isIntersecting
입니다. 이 값은 타겟 엘리먼트와 root
엘리먼트가 교차하고있는지 아닌지를 boolean
값으로 표시합니다.
let callback = ([entry]) => {
if (entry.isIntersecting) {
onIntersect();
}
};
위와 같이 교차 했는지를 확인하여 callback
을 실행 할 수 있습니다.
이 외의 다양한 entry 의 값은 IntersectionObserverEntry 에서 확인 할 수 있습니다.
그렇다면 실제로 어디서 활용 할 수 있을까?
위 컨셉에서 미리 설명 한 callback
이 실행되는 부분
타겟 엘리먼트가 또 다른 특정 엘리먼트 혹은
root
와 intersection 될 때.
을 활용하여 특정지점에서 필요한 애니메이션을 실행한다거나, 무한스크롤을 구현할 수 있습니다.
Intersection Observer 활용해보기
먼저 아래 예제들은 예제 코드 에 모두 저장 되어있습니다.
Intersection Observer
를 이용하여 무한 스크롤을 구현해 보겠습니다.
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
type UserType = {
avatar: string;
email: string;
first_name: string;
id: number;
last_name: number;
};
type UsersType = {
data: UserType[];
page: number;
total_pages: number;
};
export default function Example() {
const [pageInfo, setPageInfo] = useState({
page: 1,
totalPage: 1
});
const [users, setUsers] = useState<UsersType>();
// (1)
const [target, setTarget] = useState<Element | null>(null);
// (2)
const handleIntersect = useCallback(
([entry]: IntersectionObserverEntry[]) => {
if (entry.isIntersecting) {
setPageInfo((prev) => {
if (prev.totalPage > prev.page) {
return {
...prev,
page: prev.page + 1
};
}
return prev;
});
}
},
[]
);
useEffect(() => {
const instance = axios.get<UsersType>(
`https://reqres.in/api/users?page=${pageInfo.page}`
);
instance.then((response) => {
if (response.status === 200) {
setUsers((prev) => {
if (prev && prev.data?.length > 0) {
return {
...response.data,
data: [...prev.data, ...response.data.data]
};
}
return response.data;
});
setPageInfo((prev) => ({
...prev,
totalPage: response.data.total_pages
}));
}
});
}, [pageInfo.page]);
// (3)
useEffect(() => {
const observer = new IntersectionObserver(handleIntersect, {
threshold: 0,
root: null
});
target && observer.observe(target);
return () => {
observer.disconnect();
};
}, [handleIntersect, target]);
return (
<div>
<ul>
{users?.data?.map((user, i) => (
<li
key={user.id}
ref={users.data.length - 1 === i ? setTarget : null}
>
{user.first_name}
</li>
))}
</ul>
</div>
);
}
3 단계로 나눠서 볼 수 있습니다. 각 단계는 주석으로 표시해두었습니다.
- 타겟 엘리먼트 설정하기
...
const [target, setTarget] = useState<Element | null>(null);
...
...
return (
<div>
<ul>
{users?.data?.map((user, i) => (
<li
key={user.id}
ref={users.data.length - 1 === i ? setTarget : null}
>
{user.first_name}
</li>
))}
</ul>
</div>
);
엘리먼트 ref
를 이용하여 타겟 엘리먼트를 설정합니다. 일반적인 리스트에서 가장 아래쪽 엘리먼트를 타겟 엘리먼트로 설정합니다.
callback
함수 정의하기
const handleIntersect = useCallback(([entry]: IntersectionObserverEntry[]) => {
if (entry.isIntersecting) {
setPageInfo(prev => {
if (prev.totalPage > prev.page) {
return {
...prev,
page: prev.page + 1
};
}
return prev;
});
}
}, []);
entry.isIntersecting
을 활용하여 원하는만큼 엘리먼트가 교차했는지 확인한 뒤에 page
정보를 업데이트합니다.
Intersection Observer
생성하기
useEffect(
() => {
const observer = new IntersectionObserver(handleIntersect, {
threshold: 0,
root: null
});
target && observer.observe(target);
return () => {
observer.disconnect();
};
},
[handleIntersect, target]
);
useEffect
를 이용하여 Intersection Observer
를 생성합니다. options
에 root
를 null
로 정의하면 화면 전체가 뷰포트가 됩니다. threshold
값을 0
으로 정의하면 타겟 엘리먼트 전체가 뷰포트에 교차해야 callback
이 실행됩니다.
구현된 무한스크롤
커스텀 훅
더 나아가 커스텀 훅으로 발전시켜 보겠습니다.
import { useEffect, useState, useCallback } from "react";
export default function useInfiniteScroll(
onIntersect: () => void,
options?: IntersectionObserverInit
) {
const [target, setTarget] = useState<Element | null>(null);
const handleIntersect = useCallback(
([entry]: IntersectionObserverEntry[]) => {
if (entry.isIntersecting) {
onIntersect();
}
},
[onIntersect]
);
useEffect(
() => {
const observer = new IntersectionObserver(handleIntersect, options);
target && observer.observe(target);
return () => {
observer.disconnect();
};
},
[handleIntersect, target, options]
);
return [setTarget];
}
이렇게 훅을 만들고…
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import useInfiniteScroll from '../hooks/useInfiniteScroll';
type UserType = {
avatar: string;
email: string;
first_name: string;
id: number;
last_name: number;
};
type UsersType = {
data: UserType[];
page: number;
total_pages: number;
};
export default function Example() {
const [pageInfo, setPageInfo] = useState({
page: 1,
totalPage: 1
});
const [users, setUsers] = useState<UsersType>();
const handleIntersect = useCallback(() => {
setPageInfo((prev) => {
if (prev.totalPage > prev.page) {
return {
...prev,
page: prev.page + 1
};
}
return prev;
});
}, []);
// (1)
const [setTarget] = useInfiniteScroll(handleIntersect, {
threshold: 0
});
useEffect(() => {
const instance = axios.get<UsersType>(
`https://reqres.in/api/users?page=${pageInfo.page}`
);
instance.then((response) => {
if (response.status === 200) {
setUsers((prev) => {
if (prev && prev.data?.length > 0) {
return {
...response.data,
data: [...prev.data, ...response.data.data]
};
}
return response.data;
});
setPageInfo((prev) => ({
...prev,
totalPage: response.data.total_pages
}));
}
});
}, [pageInfo.page]);
return (
<div>
<ul>
{users?.data?.map((user, i) => (
<li
key={user.id}
ref={users.data.length - 1 === i ? setTarget : null}
>
{user.first_name}
</li>
))}
</ul>
</div>
);
}
이렇게 handleIntersect
와 config
를 받아서 코드를 재활용해주는 훅을 만들어 보았습니다.
주석 (1)에서 처럼 타겟 엘리먼트 설정 부분만 따로 빼주어서 ref
에 넣어주었습니다.
마치며
이렇게 Intersection Observer
를 이용하면 ScrollEvent
에 비해 브라우저에 부하가 더 적게 최적화 할 수 있고 추가로 커스텀 훅 까지 만들어 보았습니다.
하지만 ScrollEvent
도 위에 설명한 스로틀을 잘 적용하면 좀 더 사용자에게 좋은 경험을 만들 수 있을것 같습니다.