VanillaJs로 무한 스크롤 구현하기(feat: Intersection Observer)
모바일 UI에서 많이 보이는 무한 스크롤!
ex) 페이스북, 인스타 등
해당 UI를 구현하기 위해서는 크게 두 가지 방법이 있다.
1. 스크롤 이벤트 이용
2. Intersection Observer 이용
필자는 observer api를 이용하여 구현할 예정이다.
스크롤 이벤트를 이용하여 구현하는 방식은,
매번 window의 스크롤 이벤트를 감지하여 스크롤 위치가
컨텐츠의 끝에 닿았는지 확인하는 절차가 필요하기에 성능 상 좋지 않다고 한다.
그래서 Intersection Observer로 무한스크롤을 구현하기 전에,
먼저 Intersection Observer가 무엇인지 알아보자
1. Intersection Observer
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
mdn에 잘 설명되어있긴 하지만, 그냥 보고 이해하긴 쫌 어렵다.
Intersection Observer는 브라우저의 뷰포트와 설정한 요소간의 교차관계에서 변경사항을 관찰하는 api이다.
쉽게 설명하자면, 브라우저 화면에서 내가 지정한 요소가 보이는지 여부에 따라 원하는 동작을 수행할 수 있게 해준다.
mdn에서는 해당 예시에서 사용하기 적합하다고 한다.
- 페이지가 스크롤될 때 이미지 또는 기타 콘텐츠가 지연 로드됩니다.
- 사용자가 페이지를 넘길 필요가 없도록 스크롤할 때 점점 더 많은 콘텐츠가 로드되고 렌더링되는 "무한 스크롤" 웹 사이트를 구현합니다.
- 광고 수익을 계산하기 위해 광고의 가시성을 보고합니다.
- 사용자가 결과를 볼 수 있는지 여부에 따라 작업 또는 애니메이션 프로세스를 수행할지 여부를 결정합니다.
이제 어떤 api인지 알았으니 어떻게 쓰는지 알아보자.
1) 사용 방법
let observer = new IntersectionObserver(callback, options);
observer.observe(element);
해당 api는 new를 통해 선언하며, observe메서드를 통해 요소를 관찰할 수 있다.
선언 시 callback과 options의 두 개의 인자를 필요로 한다.
callback함수는 관찰할 대상이 등록되거나, 가시성에 변화가 생길 때 실행되는 함수이다.
const observer = new IntersectionObserver((entries, observer) => {}, options)
entries는 IntersectionObserverEntry 인스턴스의 배열이다.
그래서 이게 뭐냐고 하면 관찰되는 요소들을 가진 배열이다.
각 entry는 아래 속성들을 가지고 있다. (변경은 불가능 하다)
- boundingClientRect: 관찰 대상의 사각형 정보(DOMRectReadOnly)
- intersectionRect: 관찰 대상의 교차한 영역 정보(DOMRectReadOnly)
- intersectionRatio: 관찰 대상의 교차한 영역 백분율(intersectionRect 영역에서 boundingClientRect 영역까지 비율, Number)
- isIntersecting: 관찰 대상의 교차 상태(Boolean)
- rootBounds: 지정한 루트 요소의 사각형 정보(DOMRectReadOnly)
- target: 관찰 대상 요소(Element)
- time: 변경이 발생한 시간 정보(DOMHighResTimeStamp)
대충 이러한 속성들을 가졌다고만 이해하고
우리는 무한 스크롤을 구현하기 위해 isIntersecting 속성을 사용할 것이라는 것만 기억하자
option은 콜백이 호출되는 상황에 대해 정의할 수 있는 옵션이다.
- root : 타겟의 가시성을 검사하기 위해 뷰포트 대신 사용할 요소 객체(루트 요소)를 지정
- rootMargin : 바깥 여백(Margin)을 이용해 Root 범위를 확장하거나 축소 가능 (css margin처럼 설정하면 됨)
- threshold : 옵저버가 실행되기 위해 타겟의 가시성이 얼마나 필요한지 백분율로 표시 (몇 퍼센트일 때 옵저버를 실행할 건지)
그다음 해당 api의 메소드 들을 알아보자
크게 observe와 unobserve가 있다.
(그 외에 disconnect, takeRecords가 있다)
observe는 말그대로 대상 요소의 관찰을 시작하는 메소드 이다.
const observer = new IntersectionObserver(callback, options)
const li = document.querySelector('li')
io2.observe(li) // li 요소 관찰
unobserve는 대상 요소의 관찰을 중지하는 메서드 이다.
io2.unobserve(li) // li 요소 관찰 중지
이제 Intersection Observer가 어떻게 동작하는 지 알았으니 본격적으로 무한 스크롤을 구현해보자 !
2. 무한 스크롤 구현
<최종적으로 구현한 형태>
보통 무한스크롤을 구현하려면 백엔드와의 협의를 통해 이미지를 아래 형태로 가져올 수 있도록 api를 호출하게 된다.
/images?startIndex=0?size=6
startIndex는 가져올 데이터의 시작 인덱스이고 size는 몇 개를 가져올 껀지에 대한 정보이다.
그래서 위 형태로 호출을 하게되면 0번째에서 6개의 데이터를 가져오게 된다.
그래서 무한 스크롤의 로직을 간단하게 설명하면
페이지의 마지막 요소를 관찰하게 되면 api호출을 통해 데이터를 받아와 렌더링 하는 방식이다.
+ 필자의 경우, 현 프로젝트를 리액트의 컴포넌트 방식처럼 구현하고 있어서
interface IObject {
[property: string]: any;
}
export default class Component {
$target: HTMLElement;
props: IObject;
state: IObject = {};
constructor($target: HTMLElement, props: object = {}) {
this.$target = $target;
this.props = props;
this.setup();
this.render();
}
setup() {}
template() {
return '';
}
render() {
this.$target.innerHTML = this.template();
this.setEvent();
this.mounted();
}
mounted() {}
setEvent() {}
setState(newState: object) {
this.state = { ...this.state, ...newState };
this.render();
}
}
페이지/컴포넌트 형태가 해당 구조를 가지고 있다.
(해당 구조를 extends 해서 사용하고 있다)
나는 해당 페이지가 가져야하는 state로 아래와 같이 설정했다.
this.state = {
isInit: true,
isLoading: false,
idEnd: false,
images: [],
length: 6,
index: 0,
};
- isInit: 맨 처음 불러올 때(렌더링) 사용하는 state
- isEnd : 불러올 요소가 없을 때
- length : 불러올 사진의 개수(스켈레톤 개수)
먼저 observer를 선언해준다.
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (
entry.isIntersecting &&
!this.state.isLoading &&
!this.state.isEnd
) {
this.fetchImages(); // 이미지를 가져오는 함수
}
});
},
{
threshold: 0.5,
}
);
나는 맨 아래 요소가 50%보였을 때 api를 호출하도록 설정하였다.
관찰 요소가 교차되고있고 로딩상태가 아니고 불러올 요소가 있다면 api를 호출하게 된다.
그 다음 render로직을 살펴보자
render(): void {
const { images, isLoading, isInit } = this.state;
if (isInit) {
this.fetchImages();
return;
}
const spinner = document.querySelector('.spinner');
if (isLoading) {
this.makeSkeleton();
spinner.classList.add('active');
} else {
this.removeSkeleton();
spinner.classList.remove('active');
this.appendImages(images);
}
}
처음 페이지가 불러올 때 이미지를 가져와야 하므로 위에있는 해당 로직을 사용했고,
만약 로딩중이라면
스켈레톤을 만들고, spinner가 보이게 한다.
* skeleton : 로딩시 보이는 회색 상자
* spinner : 로딩 중 이미지
만약 로딩중이 아니라면 만들었던 요소들을 지우고
새로 받아온 데이터를 토대로 돔에 이미지를 추가한다.
그다음 appendImages()의 로직이다.
appendImages(images: []) {
images.forEach(({ postId, imageUrl }: IImage) => {
if (this.$target.querySelector(`img[data-id="${postId}"]`) === null) {
this.$target.insertAdjacentHTML(
'beforeend',
` <img class="gallery--image" src="${imageUrl}" data-id="${postId}"></img>`
);
}
});
const nextImg = this.$target.querySelector('img:last-child');
if (nextImg !== null) { // 새로운 마지막 요소가 있다면
if (this.lastImg !== null) { // 이전 마지막 요소가 있다면
this.observer.unobserve(this.lastImg); // 이전 마지막 요소의 관찰을 중지
}
this.lastImg = nextImg; // 새로운 마지막 요소는 이전 마지막 요소로 변경
this.observer.observe(this.lastImg); // 새롭게 관찰 시작
}
}
images(this.state.images)에서 렌더링 되지 않은 이미지 요소라면
this.$target에 이미지들을 추가한다.
(이미 렌더링한 요소를 재 렌더링 하지 않도록 하는 로직)
그다음 로직은,
마지막 요소가 관찰이되면 해당 요소는 관찰이 중지되고 불러온 요소중 마지막 요소가 관찰되어야 하기 때문에
새로운 마지막 요소를 찾고, 기존 마지막 요소가 null이 아닐 때
기존 마지막 요소를 관찰 해지하고
새로운 마지막 요소가 관찰대상이 되게 된다.(observe)
전체 코드는 아래와 같다.
import { basicAPI } from '@/api';
import { Component } from '@/core';
import { IImage } from '@/interfaces';
export default class PostList extends Component {
observer: any;
lastImg: any;
setup(): void {
this.lastImg = null;
this.state = {
isInit: true,
isLoading: false,
idEnd: false,
images: [],
length: 6,
index: 0,
};
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (
entry.isIntersecting &&
!this.state.isLoading &&
!this.state.isEnd
) {
this.fetchImages();
}
});
},
{
threshold: 0.5,
}
);
}
render(): void {
const { images, isLoading, isInit } = this.state;
if (isInit) {
this.fetchImages();
return;
}
const spinner = document.querySelector('.spinner');
if (isLoading) {
this.makeSkeleton();
spinner.classList.add('active');
} else {
this.removeSkeleton();
spinner.classList.remove('active');
this.appendImages(images);
}
}
async fetchImages() {
const { index } = this.state;
this.setState({ isLoading: true, isInit: false });
const res = await basicAPI.get(`/api/posts/m?index=${index}`);
const images = res.data.images;
const end = images.length === 0;
this.setState({
images: this.state.images.concat(images),
index: index + 8,
isLoading: false,
isEnd: end,
});
}
makeSkeleton() {
for (let i = 0; i < this.state.length; i++) {
this.$target.insertAdjacentHTML(
'beforeend',
`
<div class="gallery--skelton"></div>`
);
}
}
removeSkeleton() {
const skeletons = this.$target.querySelectorAll('.gallery--skelton');
skeletons.forEach((skel) => {
this.$target.removeChild(skel);
});
}
appendImages(images: []) {
images.forEach(({ postId, imageUrl }: IImage) => {
if (this.$target.querySelector(`img[data-id="${postId}"]`) === null) {
this.$target.insertAdjacentHTML(
'beforeend',
` <img class="gallery--image" src="${imageUrl}" data-id="${postId}"></img>`
);
}
});
const nextImg = this.$target.querySelector('img:last-child');
if (nextImg !== null) {
if (this.lastImg !== null) {
this.observer.unobserve(this.lastImg);
}
this.lastImg = nextImg;
this.observer.observe(this.lastImg);
}
}
}
사실 fetch하는 로직은 최대한 컴포넌트를 사이드 이펙트에 영향받지 않게
컴포넌트 밖으로 빼고 싶었긴 했는데 일단은 기능에 집중하여 해당 형태로 구현하였다.
나중에 리팩토링 예정..