The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.
by MDN
target element와 ancestor element 또는 top-level document's viewport 사이의 교차지점(intersection)의 변화를 비동기적으로 관찰(observe)하는 기능을 제공한다고 한다.
다음과 같이 대표적인 4가지 문제를 해결하는 데 도움이 된다.
- Lazy-loading of images or other content as a page is scrolled.
- Implementing "infinite scrolling" web sites, where more and more content is loaded and rendered as you scroll, so that the user doesn't have to flip through pages.
- Reporting of visibility of advertisements in order to calculate ad revenues.
- Deciding whether or not to perform tasks or animation processes based on whether or not the user will see the result.
by MDN
이 글에서는 이 중 infinite scrolling 기능을 구현하면서 intersection observer를 사용한 경험을 정리해보려 한다.
📦 codesandbox에서 실습이 가능합니다.
무한 스크롤이란 사용자가 페이지 하단 즉 리스트의 마지막 아이템이 보이기 시작하면 이를 감지해서 다음 리스트 아이템들이 자동으로 로드되는 방식이다.
이를 위해 마지막 아이템을 감시할 관찰자가 필요하므로 IntersectionObserver 클래스를 인스턴스화한 observer 객체를 만들어 보자.
const observer = new IntersectionObserver(callback[, options]);
첫 번째 인자(callback)는 대상이 root
와 교차할 때 화면에 보이는 부분의 백분율이 threshold
를 넘어서면 호출되는 콜백함수로 entries
파라미터의 isIntersecting
속성을 활용하면 대상이 root
와 교차하는지 여부를 알 수 있다.
const 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
});
};
두 번째 인자(options)는 callback
이 호출되는 상황을 제어하기 위한 옵션을 설정하는 객체이다. root
란 뷰포트로 사용되는 조상 엘리먼트이고rootMargin
은 root
의 범위를 늘이거나 줄이는 옵션이다. threshold
는 대상이 root
에 보이는 부분을 백분율로 표시할 때 넘어야 하는 역치값이다.
// default value
const options = {
root: null, // browser viewport
rootMargin: '0px',
threshold: 0.0,
};
위 내용을 기반으로 대상이 화면에 보이는지 알려주는 커스텀 훅 useOnScreen을 만들어 보자.
화면에 보이는지 여부를 원하기 때문에 Intersection Observer의 callback
함수에서 entry의 isIntersecting
값을 가져오도록 했다.
그리고 node(관찰 대상)가 존재하면 observer는 관찰(observe)을 시작한다. 이때, observer를 보관하는 이유는 더이상 대상을 관찰할 필요가 없을 때 관찰을 끝내기(disconnect) 위해서이다.
// src/hooks/useOnScreen.js
const useOnScreen = ({
root = null,
rootMargin = '0px',
threshold = 0,
} = {}) => {
const [observer, setOserver] = useState();
const [isIntersecting, setIntersecting] = useState(false);
const measureRef = useCallback(
(node) => {
// node가 존재하면
if (node) {
// observer를 만들고
const observer = new IntersectionObserver(
// callback
([entry]) => {
setIntersecting(entry.isIntersecting);
},
// options
{ root, rootMargin, threshold }
);
// 대상을 관찰하기 시작한다.
observer.observe(node);
// 이후 관찰 해제를 위해 observer를 보관한다.
setOserver(observer);
}
},
[root, rootMargin, threshold]
);
return { measureRef, isIntersecting, observer };
};
그런데 잠깐! 위의 코드에서 낯선 모습이 보인다. ref가 컴포넌트가 아니라 훅 안에 선언 되어 있고 useRef
대신 useCallback
을 사용하고 있다. 이를 callback ref
라고 하는데 동적으로 ref를 참조할 때 사용한다.
콜백 ref에 대한 설명과 예제는 React | Callback refs와 React | How can I measure a DOM node?를 참조해주세요.
아래에서 리스트의 JSX 구조를 보며 이해해보자. 조건을 걸어 리스트의 마지막 아이템에만 ref를 설정하고 있다. 그리고 이 노드에 대한 참조(ref)는 위치가 감지된 후에는 더 이상 관찰할 필요가 없기 때문에 해제되어야 한다. 이처럼 노드에 ref 설정과 해제를 조절해야 하는 상황일 때는 콜백 ref를 사용하면 된다.
// src/components/CommentList.js
const { measureRef, isIntersecting, observer } = useOnScreen();
useEffect(() => {
// 대상이 감지되었고 데이터가 추가로 존재하면
if (isIntersecting && hasMore) {
// 추가 리스트 아이템을 로드하고
loadMore();
// observer는 관찰을 해제한다.
observer.disconnect();
}
}, [isIntersecting, hasMore, loadMore]);
return (
<ul className='comment-list'>
{comments.map((comment, index) => {
// 마지막 리스트 아이템에 measureRef 넘겨준다
if (index === comments.length - 1) {
return (
<Comment mesureRef={measureRef} key={comment.id} comment={comment} />
);
}
return <Comment key={comment.id} comment={comment} />;
})}
{isLoading && <li>Loading...</li>}
</ul>
);
Comment 컴포넌트에서는 props로 받은 mesureRef를 리스트 아이템에 ref로 설정했다. 마지막 리스트 아이템의 경우에는 정의했던 함수가 들어가고 아닌 경우에는 undefined가 들어간다. 최종적으로 마지막 리스트 아이템에만 ref를 설정하고 observer가 관찰하도록 했다.
// src/components/Comment
function Comment({ mesureRef, comment }) {
return (
// 넘겨받은 measureRef를 리스트에 ref로 설정한다
<li className='comment-item' ref={mesureRef}>
<span>
[{comment.id}] {comment.email}
</span>
<p>{comment.body}</p>
</li>
);
}
잘 동작한다!
잘못된 부분에 대한 지적은 언제든 환영합니다! 😉