Intersection Observer는 타겟 요소가 최상위 document 요소 뷰포트와 교차하는지 아닌지를 판단하는 기능을 한다. 만약 교차한다면, 혹은 가시성(보이는지 안 보이는지)에 변경 사항이 생긴다면 그에 해당하는 콜백 함수를 비동기적으로 호출한다.
new 키워드를 통해 생성자를 호출하여 IntersectionObserver 인스턴스를 생성한다. callback과 options를 인자로 받는다. callback은 가시성에 변경이 생겼을 때 호출되는 콜백 함수이고, options는 해당 인스턴스에서 콜백이 호출될 때 사용자가 조작할 수 있는 여러 설정들이 포함된다.
let observer = new IntersectionObserver(callback, options);
let target = document.querySelector('#listItem');
observer.observe(target);
callback
타겟 요소가 관찰이 시작되거나(IntersectionObserver.observe) 가시성의 변화가 생기면 바로 콜백이 호출된다.
let callback = (entries, observer) => {
entries.forEach(entry => {
// target element:
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.target
// entry.time
});
};
entries
IntersectionObserverEntry 인스턴스를 담은 배열이다. callback에 파라미터로 전달이 된다. 이 인스턴스는 타겟 요소와 루트 요소가 서로 교차했을 때의 상황을 나타내는 인스턴스이다. 즉 교차가 일어났을 때 그 때 할 수 있는 행동들 이 IntersectionObserverEntry 인스턴스 내 메서드나 속성에 담겨 있는 것.
options
옵저버에 대한 설정 옵션들이다.
root
타겟 요소의 가시성(보이는지 보이지 않는지)를 판단하는 루트 뷰포트 요소이다. 이 root 옵션에 설정된 요소가 이 옵저버의 뷰포트가 된다. 만약 설정하지 않는다면 document 뷰포트가 기본 값으로 설정된다.
rootMargin
기준이 되는 root 요소에 부여되는 마진 값이다. 이 마진 내에 들어오면 뷰포트에 들어온 거라고 판단하는 오프셋을 의미한다. default 값은 0, 즉 여백이 없는 값이다.
threshold
요소가 얼마만큼 뷰포트에 보여질 때 콜백이 실행되는지에 대한 조건이다. 만약 0.5였으면 뷰포트에 요소가 50% 보여질 때 콜백 함수가 실행된다는 것이다.
실생활에서는 어떻게 사용하나
image를 서버에서부터 받아와서 렌더링시키는 프로그램을 만든다고 해 보자.
한 번에 정해진 숫자의 데이터를 받아온다. 한꺼번에 받아오지 않는다. 데이터 단위를 페이지라고 부르기로 하자.
각각 이미지 데이터를 렌더링할 때 각 컴포넌트에 IntersectionObserver 인스턴스를 만들어서 감시한다.
감시하는 방법은 해당 컴포넌트의 ref 값을 받아와서 이 ref를 observe하는 방식으로 진행한다.
만약 받아온 페이지의 마지막 데이터가 화면에 들어왔을 때 새로 페이지를 받아온다 → isLast && entry.intersectionRatio
마지막 데이터를 확인하는 방법 : 해당 요소의 index가 총 데이터 길이 - 1 의 값과 같은지 확인한다.
새로 페이지를 받아오는 과정
isLast && entry.isIntersecting
이면 nextPage() 함수를 호출. nextPage는 부모 요소로부터 Image 컴포넌트로 전달된 props 중 하나이다. 부모 요소의 nextPage()가 실행되면서 page state를 1 증가시키고, page에 dependency가 있는 useEffect가 실행되면서 서버에 request 요청
코드
// ImageContainer.js
function ImageContainer() {
const [imageDatas, setImageDatas] = useState([]);
const [page, setPage] = useState(1);
// 다음 페이지 처리
const nextPage = () => {
setPage((prev) => prev + 1);
};
// 데이터 GET
useEffect(() => {
getDatas(page).then((images) =>
setImageDatas((prev) => [...prev, ...images])
);
}, [page]);
return (
<div className={styles.wrap}>
<div className={styles.container}>
{imageDatas.map((image, index) => (
<div
key={image.id}
>
<Image
image={image}
isLast={index === imageDatas.length - 1}
nextPage={nextPage}
/>
</div>
))}
</div>
</div>
);
}
// Image.js
function Image({ image, isLast, nextPage }) {
const imageRef = useRef();
const [imageUrl, setImageUrl] = useState("");
const entry = useObserver(imageRef, { rootMargin: "100px" });
useEffect(() => {
/* entry가 뷰포트와 intersecting하는지 여부 */
const observer = new IntersectionObserver(([entry]) => {
if (isLast && entry.intersectionRatio) {
nextPage()
observer.unobserve(entry.target)
}
});
observer.observe(imageRef.current); // imageRef에 해당되는 요소를 계속 observe한다.
}, [imageRef, isLast]);
return (
<div className={styles.wrap}>
<img
ref={imageRef}
src={image.download_url}
alt={image.author}
className={styles.img}
/>
</div>
);
}