프로젝트에서 프론트엔드 부분을 담당하면서 작업을 이어가다 보면, 어느 순간 무한 스크롤 기능을 구현해야 하는 순간이 오게 된다. 아무런 방법을 모른다고 가정 할 때, 머리속에서 방법에 대해 떠올려보면 아마도 가장 먼저 생겨나는 방법은 웹 브라우저 내 스크롤바의 위치에 따라 데이터를 fetch 해오는 과정이 떠오를 것 같다. 물론 아닐수도 있지만 나는 그랬다..
그야 아주 직관적인 방법이기 때문이다. 스크롤 바가 화면 맨 아래로 이동이 되었을 때, 그 순간 추가적으로 데이터를 가져오게 되면 그게 무한 스크롤이지 않을까. 브라우저에는 이렇게 스크롤 바의 위치를 표현해주는 API 를 제공한다. 이벤트에 구독하여 매 움직이는 순간마다 위치를 가져올 수 있는데, 이를 이용하면 충분히 직관적인 스크롤 이벤트를 구현할 수 있다.
// 리엑트에서의 예제
useEffect(() => {
function onScroll() {
if (window.scrollY > document.documentElement.scrollHeight - document.documentElement.clientHeight - 1200) {
// 지정 위치에 도달했을 때 처리해줄 로직
fetchItems(); // 데이터를 가져온다.
}
}
}
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
}, []);
위 코드는 리엑트에서 useEffect 를 활용해서 무한 스크롤 기능을 가능하게 해주는 코드 예시이다. 브라우저는 scroll 이벤트를 구독할 수 있는 API 를 제공하며, 스크롤이 움직일 때마다 넘겨준 콜백함수 onScroll 이 실행이 된다. 실제 콘솔로그를 통해 window.scrollY 값을 살펴보게 되면, 밑으로 내리면 내릴수록 수치가 상승하게 된다. 즉 맨위에서 부터 얼마만큼 내려갔는지를 수치로 나타낸다.
간략히 원리를 설명하자면, scrollHeight 와 clientHeight 만 무엇인지 알게 되면 해석할 수 있는데, scrollHeight 의 경우 스크롤까지 포함한 전체 높이를 의미한다. 즉 화면에 보이는 부분만이 아닌 전체 높이라고 생각하면 된다. 아래 그림을 참조하자.
clientHeight 는 현 사용자의 화면 viewport 만큼의 높이를 의미한다.
이제 조건문을 한번 해석해보자. 삽화가 없어서 바로 이해는 안될 수 있겠지만..
// 맨 상단에서 현재까지 내려온 길이가 (전체 높이 - 화면 높이 - 여유분) 보다 크다면 fetch 발동
window.scrollY > document.documentElement.scrollHeight - document.documentElement.clientHeight - 1200
화면 맨 위에서 scrollHeight 의 어느 부분까지 사용자는 스크롤을 내릴 것이고, scrollY 는 비례해서 증가할 것이다. 끝까지 내리게 된다면 맨 위에서 clientHeight 의 상단부분까지의 길이 scrollY 는 scrollHeight - clientHeight 의 높이와 같아지게 된다. 만일 끝까지 내려야만 fetch 가 시작된다면 사용자 경험에 별로 좋지 않을테니 좀 더 여유분 px 를 추가해주어서 다 내려오기전에 fetch 를 보내겠다는 의미다.
직관적인 방법이고, 실제로 사용시 별다른 문제를 유발하지 않는다. 하지만 효율성을 신경써야 하는 상황이라면 그다지 추천되는 방법은 아니다. 그냥 스크롤 이벤트인데 왜 효율성이 나오는 것인지, 그저 콜백함수를 여러번 호출하기 때문인것인가? 이 궁금증을 해소하기 위해서는 우선적으로 브라우저가 어떻게 렌더링 과정을 거치는지 알아야 한다.
따라서 이번 포스팅은 기본적으로 브라우저가 렌더링 될 때는 어떠한 과정을 거치고, 이 과정을 최소화 하는 방안과 어째서 scroll 이벤트 구독보다 Intersection Observer 를 사용하는 것이 더 효율적일 수 있는지 살펴보도록 하자. 그리고 이를 응용하여 리엑트 내 커스텀 훅으로서의 useOnScreen 을 실제로 구현하면서 코드를 살펴보겠다.
(반드시 Intersection Observer 가 효율적이라곤 할 수 없다. 만일 광고 효과를 측정하는 경우 스크롤 위치의 시간을 측정하는 것이 더 효율적일 수 있다)
브라우저 렌더링 과정과 Reflow & Repaint
브라우저가 서버로부터 HTML, CSS, Javascript 를 전달받았을 경우 어떤식으로 렌더링을 하는지 살펴보자.
브라우저는 크롬, 사파리, 파이어폭스 등 여러가지가 있지만, 기본적으로 웹킷을 사용하는 크롬을 기준으로 하겠다.
1. HTML
서버로 부터 브라우저는 각각 html 에 대한 원시 바이트를 가져온다. (1바이트는 8비트이며, 4비트를 16진수로 표현했다). 이 원시 데이터를 utf-8(기본설정값이라고 알고는 있는데, 어떤 경우는 사용자가 직접 설정하기도 한다) 에 따라 문자열로 변경시킨다.
이 다음 파싱 과정을 거치게 되는데, HTML 파싱은 다른 CSS, Javascript 의 파싱과는 차이점이 있다. HTML 은 기존에 존재하는 파서인 플렉스(flex, 어휘 생성), 바이슨(Bison, 파서 생성)을 사용하지 못하는데, 그 이유는 다음과 같다.
- 언어의 너그러운 속성.
- 잘 알려져 있는 유저의 실수에 대한 브라우저의 관용
- HTML 의 script 태그는 파싱 과정에서 토큰을 추가할 수 있기에 재파싱이 발생한다.
이러한 특징때문에 HTML 의 파싱은 별도의 알고리즘으로 돌아가는데, 위 그림에서 보이는 토큰화와 이를 통한 트리화이다. 인코딩 된 문자열을 기반으로 HTML 토큰화가 진행되면서, 순차적으로 노드 객체가 생성이 되어 트리를 구성해나간다. <html> 이 토큰화 후 노드 HTMLhtmlElement 가 생성되어 트리를 구성하기 시작하는 것이다. 사용자가 몇가지 태그를 생략했다고 해도 너그러운 HTML 은 이를 알아서 수정해서 트리에 추가시켜준다.
HTML의 파싱과정은 동기적으로 이뤄지는데, 그렇기 때문에 <script> 태그를 마주치게 되면, html 파싱을 멈춘 다음 javascript 파싱이 시작된다. 이후 다시 html 파싱으로 이어간다. 자바스크립트가 외부 라이브러리라면 이를 먼저 서버에서 가져와야 하는데, 이 역시 가져와서 처리할 때 까지 문서 파싱은 중단된다. 제작자는 스크립트를 '지연'(defer) 으로 표시할 수 있는데, 이렇게 되면 스크립트를 만나게 되도 문서 파싱은 계속 진행이 된다.(비동기)
여하튼 이러한 과정을 통해 서버로 부터 전달받은 HTML 문서는 DOM(Document Object Model) 트리를 구성하게 된다.
2. CSS
CSS 역시 html 과 동일한 과정으로 CSSOM 트리를 형성한다. 다만 파싱과정은 HTML 과 달리 기존의 파서를 사용할 수 있다. 보통 CSS 파일은 html 내 inline style 로 적용하지않고, 외부에서 관리하기 때문에 이 파일역시 브라우저가 이해할 수 있는 형식으로 변환해야 하고 그 과정이 CSSOM 트리를 형성하는 과정이다.
3. Render Tree
DOM 트리가 형성되면서 렌더 트리도 형성이 되기 시작한다. 렌더트리는 DOM 트리의 노드 중 실제로 브라우저에 보여지게 되는 노드로 구성이 되어진다. html 이나 header 와 같이 실제 브라우저 내 위치를 차지하지 않는 노드는 렌더 트리에 포함되지 않는다.
화면에 보여지는 노드로 구성되는 트리라는 특징은 화면에 보여지지 않으면 포함시키지 않는다는 것이며 우리가 흔히 사용했던 display: none 의 경우 역시 렌더 트리에 포함되지 않는다는 의미다. (visibility 의 hidden 의 경우는 포함된다)
또한 DOM 트리와 매치가 될 지라도, 동일한 트리 위치에 형성되지 않을 수 있는데, position: absolute 와 같은 속성이 있다면 렌더 트리는 이에 맞는 위치에 요소를 위치시킨다. 그리고 cascade 라는 명칭을 지닌 것 처럼, 실제 속성의 우선순위 역시 중요한데 미리 지정해놓은 알고리즘에 따라 속성의 우선순위를 적용시킨다.
4. Layout
렌더 트리가 형성이 될 때 각각의 요소들에 크기와 위치 정보는 없는데, 이러한 값을 계산하여 화면 내 배치를 조정하는 과정을 Layout 혹은 Reflow 라고 한다. 문서의 가장 최상위인 <html> 부터 시작하여, 순차적으로 배치가 이루어진다. 각 요소마다 box model 이 형성되어진다.
이러한 레이아웃 단계에서는 속성값이 퍼센트로 되어있는 상대적인 값이라도 모두 절대치(픽셀)로 변경이 된다. 맨 최상위부터 순차적으로 자신의 위치가 결정이 되고, 이후 자식요소의 위치를 점검하게 된다. 이렇기 때문에 하위 요소는 상위 요소의 크기에 영향을 끼치지 못한다. <body> 의 width 가 100% 라면 이는 상위 html 의 폭을 그대로 가져오는 것이며, 이후 body 바로 밑 div 의 폭 역시 부모인 body 에 따라 결정이 된다.
5. Paint
렌더 트리의 'paint' 메서드가 실행 되면서, 실제 우리가 보는 웹 페이지의 화면이 그려지는 단계다. 레이아웃 단계는 실제 픽셀로 '계산'을 하는 단계라고 생각하면 되고, 실제로 화면에 픽셀을 그려주는 작업은 paint 단계에서 이루어진다.
6. Reflow & Repaint
단어의 뜻에서 알 수 있듯이 다시 레이아웃을 계산하고 다시 그려준다는 의미이다. 만약 처음 그려진 웹 페이지가 아무런 변화가 없다면 이 단계들은 일어나지 않겠지만, 이벤트에 의해 새로운 요소들이 생성이 된다던지, 어떤 속성이 변화하던지 등의 상황이 발생하게 되면, 기본적으로 브라우저는 다시 DOM 과 CSSOM 을 형성하여 렌더트리를 구현하고, 다시 레이아웃을 계산한 다음 페인트 하게 된다. 변화가 있을 때마다 반복된다고 생각하면 된다.
여기서 렌더 트리까지 구현한 후 레이아웃을 계산하는 단계까지를 리플로우(Reflow) 라고 한다. 리플로우는 계산을 다시 하는것이지 화면에 다시 그리는 것은 아니다. 화면에 다시 그리는 작업은 리페인트(Repaint) 에서 이루어진다.
기본적으로는 리페인트까지의 작업이 순차적으로 발생하지만, 브라우저는 변경에 대해 최소한의 동작으로 반응하려고 노력하게 된다. 그래서 실제 계산된 레이아웃에 영향을 끼치지 않는 CSS 속성을 변경시키게 되면, 리플로우가 생략이 되고 다시 그리기만 하게 된다. 반면 DOM 노드가 추가되면 여지없이 모든 과정이 진행이 된다. 즉 레이아웃에 변화가 생긴다면 리플로우가 발생하고, 리플로우가 발생하면 반드시 리페인트가 발동한다.
과정은 간단할 수록 좋다. 리플로우의 경우 기본적으로 요소의 위치가 변화되거나 포함하는 컨텐츠가 변화되는 경우라면 발생하는데 이러한 리플로우를 발동시키지 않고 리페인트만을 유발하는 속성값들은 다음과 같다.
background | background-image | border-radius | border-style | box-shadow | color |
line-style | outline | outline-color | outline-style | visiblity | .... |
리플로우가 생략된다는 장점이 있지만 그럼에도 리페인트라는 작업이 한번 더 일어나야 하는 것은 맞다. 특정 속성은 이러한 리페인트가 일어나는 것도 막아주는데, 대표적으로는 transform, opacity 속성이 그러하다. 이 부분에 대해서는 추가적으로 설명할 개념인 paint layout, graphic layout 등이 있어서 추후 포스팅에서 설명하겠다.
기존 스크롤 이벤트방식
지금까지 기본적으로 서버에서 받은 html, css, javascript 파일이 실제 브라우저에 렌더되는 과정까지를 살펴보았다. 그리고 유저의 이벤트에 의해(dropmenu 클릭 등) 리플로우와 리페인트 과정이 일어난다는 사실도 알게 되었다. 이러한 렌더과정이 기존 스크롤 이벤트를 통한 무한 스크롤 구현 방식과 어떠한 연관이 있는 것일까? 또한 그 외 다른 문제점은 무엇이 있길래 Intersection Observer 가 등장하게 되었을지에 대해 살펴보도록 하자
기본적으로 스크롤 이벤트를 구독하여, 무한 스크롤을 구현하는 방법에는 너무 과다한 핸들러 호출이 생기게 된다. 호출이 여러번 되는것도 그렇지만, 핸들러 내부에 Document.clientHeight() 와 같은 리플로우를 발생시키는 메서드가 포함되어 있을시 계속해서 리플로우가 발생하게 될테니 좋은 상황은 아니다.
간단하게 useEffect 를 통해서 스크롤 핸들러에 콘솔로 함수가 어떻게 호출되는지 살펴보면
export default function App() {
useEffect(() => {
const handleScroll = () => {
console.log("발동이 됩니다", window.scrollY);
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
});
return (
<div className="App">
<h1>Scroll 실험하기</h1>
<h2>scroll 이벤트</h2>
<div className="box">실험중</div>
</div>
);
}
핸들러가 실행되면 콘솔이 찍히는 상황이며, 이벤트가 발생할 때 마다 핸들러가 실행이 된다
이전에 실시간 검색창 input 값을 query 를 통해 서버에 요청한 적이 있었고, 과도한 요청을 해결하기 위해 Debounce 를 응용했었는데, Debounce 의 경우 일정 시간안에 이벤트가 다시 일어난다면 시간을 초기화 해버리기 때문에, 계속해서 이벤트가 지연될 수 있는 문제가 있다. 반면 Throttle 방식은 일정 주기마다 이벤트를 모아서 한번에 처리하는 방식이기에, 이벤트 발동이 보장되어서 어찌되었던 데이터를 서버에 요청할 수 있을 테니 이 방식으로 하는게 좋겠다.
친절하게도 Throttle 를 적용한 스크롤 이벤트에 대한 코드를 포스팅해주신 분이 계셔서 한번 살펴보도록 하자
https://tech.kakaoenterprise.com/149
// handler 를 인자로 감싸는 throttle 함수이다.
// 기본 timeout 은 300
const throttle = (handler, timeout = 300) => {
let invokedTime;
let timer;
return function (...args) {
if (!invokedTime) {
// hander 를 호출한다. apply 를 통해 this를 바인딩하고 인자를 단일로 전달한다
handler.apply(this, args);
// 클로저의 성질으로 invokedTime 에 접근할 수 있다.
// 현재 날짜로 설정한다
invokedTime = Date.now();
} else {
// setTimeout 을 취소한다.
clearTimeout(timer);
timer = window.setTimeout(() => {
// 지금의 시간과 이전 설정 날짜의 차이가 300 이상이라면
if (Date.now() - invokedTime >= timeout) {
// 핸들러를 호출한다
handler.apply(this, args);
// invokedTime 을 갱신한다
invokedTime = Date.now();
}
}, Math.max(timeout - (Date.now() - invokedTime), 0)); // 핸들러가 실행될 쯤 0초에 도달한다
}
};
};
timeout 은 개발자가 선택할 수 있으며, 코드에 대한 설명은 위 주석과 같다. throttle 은 함수를 return 하는 클로저를 응용한 방식이며, 그렇기에 내부 invokedTime 을 전역적으로 (return 함수 기준) 저장할 수 있게 된다.
처음 이벤트가 실행될때는 invokedTime 이 undefined 이기에 handler 가 실행이 되고, invokedTime 이 실행시점으로 설정이 된다. 이후 계속해서 handler 가 실행이 되면, invokedTime 이 true 이기에 timer 를 생성하게 된다. timer 는 실행될 때마다 지워지고 새로 생성되는 setTimeout 이다. 계속 timer 가 생성될 수록 실행될 시점도 줄어드는데, 현재 시점과 과거 핸들러가 실행됬던 시점의 차이가 timeout 보다 같아지거나 커지게 되면, 다시 handler 를 실행시킨다. 동시에 invokedTime 역시 실행 시점으로 갱신해준다.
이렇게 되면 매 timeout 시간마다 호출이 되게 된다.
호출이 훨씬 더 줄었지만, 그럼에도 스크롤에 따라 리플로우가 발생할 수 있다는 점은 아쉬운 부분이다. 유저들이 반드시 스크롤을 다른 데이터를 더 보기 위해서 밑으로만 내릴 이유는 없으니 말이다. 위 핸들러는 스크롤 이벤트가 발생하면 발동하는것이니 원천적으로 리플로우가 발생하지 않도록 하는 것이 좋을것 같다.
Intersection Observer
구글 크롬 51버전 부터 DOM 의 엘리먼트를 비동기적으로 관측할 수 있는 Intersection Observer 가 추가되었다. 많은 개발자가 이제 무한 스크롤이나 Lazy Loading, 광고 수익성 등을 판단할 때 Intersection Observer API 를 활용하고 있다.
기존 스크롤 이벤트와 대조적인 부분은 리플로우(Reflow) 가 발생하지 않으면서도 손쉽게 원하는 기능을 구현할 수 있다는 점에 있다. (다만 아직 낮은 버전의 브라우저는 이 API 를 지원하지 않으니, polyfill 을 같이 설정해주자)
Intersection Observer API 의 작동 과정은, viewport 와 element 의 교차수준에 따라 핸들러를 작동시킬지 말지를 결정하게 된다. 화면에 미리 지정해둔 element 가 보이게 되면 핸들러가 작동하는 것이다. 무한 스크롤이라고 한다면 핸들러가 fetch 함수가 될 것이다. 즉, 기존방식과 접근 방향성이 다른 것인데, 기존에는 스크롤의 위치에 따라서 핸들러를 발동시켰다면, Intersection Observer 의 경우 스크롤을 내리면서 만일 viewport 내 지정 element 가 확인될 때 핸들러가 발동이 된다. 즉 매 순간 계산을 할 필요가 없어진 것이다.
Intersection Observer 를 설정하는 예시는 다음과 같다.
const io = new IntersectionObserver((entries, observer) => {
// 타겟들을 받게 된다.
entries.forEach(entry => {
if (entry.isIntersecting) {
// 하고싶은 코드를 작성한다.
// target element:
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.target
// entry.time
}
});
});
io.observe(element);
생성자 IntersectionObserver를 통해 인스턴스 io 를 생성하고, 이후 io.observe(관측요소) 를 실행하여 관측을 시작한다.
관측되는 요소는 객체 배열로서(entries === IntersectionObserverEntry), 관측 요소와의 관계 상태값을 알 수 있다. 코드 내 조건문 속 entry.isIntersecting 의 경우 실제 viewport 와 관측 element 가 교차하였는지에 대한 boolean 값이다. 각각의 속성들 중 대표적인 것들을 살펴보면,
- boundingClientRect: 이전 리플로우를 유발하는 getBoundingClientRect 의 결과값과 동일. 관찰 대상의 사각형 정보
- intersectionRect: 관찰 대상의 교차한 영역 정보
- intersectionRatio: 관찰 대상의 교차한 영역 백분율(숫자)
- isIntersectiong: 관찰 대상과의 교차 상태
- rootBounds: 지정한 루트 요소의 사각형 정보
- target: 관찰 대상 요소
- time: 변경이 발생한 시간 정보
위와 같이 살펴볼 수 있다.
생성자 IntersectionObservser 에는 option 을 설정해줄 수 있다. 옵션은 관측할 viewport 인 root 을 어떻게 설정할 것인지, root 에 margin 을 어떻게 부여하고 싶은지(즉 전체화면이 아닌 일부 화면에서만 교차성을 확인하고 싶을 때), 관측 요소의 어느정도가 확인되어야 핸들러를 실행시킬 지 등등을 설정할 수 있다.
- root : 뷰포트 대신 사용할 요소를 지정하는데, null 로 설정할 시 기본 브라우저가 뷰포트가 설정이 된다.
- rootMargin : 바깥 여백을 설정하여 root 범위를 조정할 수 있다. px 이나 % 로 나타낼 수 있다. 단위를 꼭 입력해야 한다.
- threshold : 가장 많이 사용되는 옵션인데, 핸들러가 실행되기 전 얼마나 요소가 보여야 하는지에 대한 비율. 0~1
메서드로는 대상 요소를 관측하는 observer 가 있으며, 관찰을 중지하는 unobserver 가 있다. 또한 모든 관측을 중지하는 disconect 도 존재한다.
실제로 활용을 해보자
실제로 리엑트에서 Intersection Observer 를 활용해서 무한 스크롤을 구현하는 과정에 대해서 살펴보도록 하자.
참고로 실제 리엑트에서 사용하기 편하도록 라이브러리 react-intersection-observer 를 제공하기도 하니, 급하면 사용하도록 하자. 이번 포스팅에서는 직접 커스텀훅으로 구현해보도록 할 것이다.
무한 스크롤의 동작 과정은 스크롤을 내리면서 특정 요소가 화면에 관측되면, 서버에 일정 갯수의 데이터를 요청하여 이를 추가로 렌더링한다. 그 다음 다시 스크롤을 내릴 때 마다 요소가 확인되고, 계속해서 데이터를 받게 된다. 데이터가 있을 때 까지 말이다. 이렇게 작동하려면 우선 관측할 요소가 필요하고, 이 요소를 Intersection Observer 에 연결시켜주어야 한다. 이 관측 요소는 함수 컴포넌트가 실행되어 랜더링 된 다음 연결해주는 것이 확실하기 때문에(요소가 있어야지 연결이 되니깐), useEffect 를 활용해 줄 것이다.
요소가 관측이 될 때 핸들러의 경우 서버에 데이터 전송 요청을 보내게 하면 된다. 즉 관측이 되었다는 조건인 isIntersection 을 활용할 것이다.
실제 무한 스크롤링이 활용되는 컴포넌트가 unmount 되면 더이상 관측할 필요가 없으니 관측 연결을 끊어주는 작업까지 더해서 구현을 마무리 해보도록 하자.
아래 코드는 실제 진행중인 Closet 프로젝트에서 구현된 코드이다.
// Store.tsx
const store = ({ device }: StoreProps) => {
// 타겟 요소의 ref 를 지정해준다.
const observerTargetElement: any = useRef<HTMLDivElement>(null);
// 데이터는 useSWRInfinite 를 통해 가져온다.
const { items, paginationPosts, setSize, isReachedEnd, isItemsLoading, infinitiMutate } = usePagination<ItemsArray>(categoriName, windowWidth);
// IntersectionObserver 의 option 객체를 useMemo 를 통해 전달해준다.
const option = useMemo(() => {
return { root: null, threshold: 0.3 };
}, []);
// 뒤에 살펴볼 Intersection Observer 가 적용된 커스텀 훅인 useOnScreen 이 호출된 모습이다.
let isIntersecting = useOnScreen<HTMLDivElement>(observerTargetElement, option);
// useEffect 를 통해 만일 isIntersecting 이 true 라면 size 를 1 증가시켜 데이터를 추가로 가져온다.
useEffect(() => {
if (isIntersecting) {
setSize(prev => prev + 1);
}
}, [isIntersecting]);
return (
// 생략
<ItemsStoreSection>
<CardBoard itemData={paginationPosts} onSubmit={deleteItemAtTable} isLoading={isLoading} isItemsLoading={isItemsLoading} />
</ItemsStoreSection>
//생략
<div ref={observerTargetElement}>store</div>
)
}
많은 부분을 생략한 무한 스크롤이 적용될 페이지이다.
Intersection Observer 를 활용한 커스텀 훅을 다루기 전에, 먼저 페이지에 대해 간략히 설명하자면, 무한스크롤의 데이터는 SWR 의 infiniteScroll 기능을 탑제한 useSWRInfinite 을 통해서 데이터 paginationPost 를 가져오게 된다. setSize 를 통해 1씩 증가할 때마다 데이터를 추가하는 방식이다.
import React, { useState, useEffect, MutableRefObject } from 'react';
type Options = {
root?: null | Element;
rootMargin?: string;
threshold?: number;
};
const useOnScreen = <T extends Element>(ref: MutableRefObject<T>, options: Options) => {
const [intersecting, setIntersecting] = useState<boolean>(false);
useEffect(() => {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
setIntersecting(entry.isIntersecting);
});
}, options);
if (ref.current) {
observer.observe(ref.current);
}
return () => {
if (ref.current) {
observer.disconnect();
}
};
}, [ref, options]);
return intersecting;
};
export default useOnScreen;
Intersection Observer 가 적용된 커스텀 훅 useOnScreen 의 구현 코드이다. 인자로 타겟 요소에 접근할 수 있는 ref 를 전달해주고, option 을 넣을 수 있도록 했다.
entries 를 순회하면서 상태값 isIntersecting 에 entry.isIntersecting 의 boolean 값을 적용해준다. 즉 true 가 되면 상태값 isIntersecting 역시 true 가 된다. 이 값을 통해 실제 데이터를 더 가져올지 말지를 결정하게 된다.
실제 코드는 인자가 조금 더 추가되긴 하지만, 기본 코드는 위와 동일하며, 이제 실제 프로젝트 내에서 무한스크롤이 작동했을 때의 모습은 다음과 같다.
생각처럼 서버로부터 데이터를 잘 받아오고 있으며
서버로부터 데이터를 가져올때만 Recalculate Style(Reflow) 가 작동하고 있는 모습 또한 확인되었다. 스크롤을 할때마다 발생하진 않았다.
참고문헌
https://d2.naver.com/helloworld/59361
https://tech.lezhin.com/2017/07/13/intersectionobserver-overview
'Programing > React' 카테고리의 다른 글
신선하지 않은 캐시(Stale)와 SWR(Stale-while-Revalidate) (0) | 2023.05.03 |
---|---|
useEffect을 사용하기 전 생각해봐야 할 상황들 (1) | 2023.03.12 |
Redux의 비동기 처리 (feat. middleware chaining) (1) | 2023.02.25 |
[번역] useState 에 await 을 적용시킨 커스텀훅을 구현하기 (0) | 2023.02.07 |
Redux를 간략하게 구현해보자 (0) | 2023.02.01 |