target 요소와 상위, 또는 document viewport 내의 변화를 비동기적으로 관찰할 수 있는 API이다.
무한 스크롤 또는 lazy loading을 구현하려고 할 때, 기존에는 scroll event를 이용하여 구현하였다.
window.addEventListener('scroll', callback);
그러나 scroll 이벤트가 발생할 때마다 콜백 함수가 실행되어, 많은 성능 저하가 일어났다. scroll을 한번 할 때, scroll이 완전히 멈출 때까지 이벤트가 발생하기 때문에, debouncing이나 requestAnimationFrame를 이용하여 따로 최적화 하는 방식을 사용했었다.
또한, 요소의 크기를 구하기 위하여 getBoundingClientRect()
메서드를 호출 시 reflow가 많이 발생하는 문제가 있다.
그러나 scroll을 이용하여 UI를 구성하는 경우가 적지 않기 때문에, 성능상에 이점을 취할 수 있으면서, 신뢰할 수 있는 api의 필요성이 대두되었는데 그것이 바로 Intersection Observer
이다.
IntersectionObserver는 루트 요소와 타겟 요소를 관찰한다. 다음과 같이 작성할 수 있다.
const io = new IntersectionObserver(callback, options)
const options = {
root: document.querySelector('.App'),
rootMargin: '1rem',
threshold: 0.5
}
일반적으로 다음의 형식으로 사용한다.
const io = new IntersectionObserver((entries, observer) => {}, options)
IntersectionObserver
는 IntersectionObserverCallback
type을 가지며, 해당 콜백함수에 entries, observer 두 인자를 넘겨준다. entries는 IntersectionObserverEntry[]
인데, 각 entry에는 관찰 대상이 화면과 교차되고 있는 상태인지 알려주는 boolean 값이므로, 가장 보편적으로 사용되는 값이다.
const io = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// do something
}
});
});
일반적으로 사진의 lazy-loading 처럼 렌더링 최적화나, 무한 스크롤을 구현하기 위해 사용된다.
일단 lazy-loading을 IntersectionObserver로 구현해보자.
다음과 같은 App 컴포넌트 안에, 박스 컴포넌트 50개가 있다고 생각해보자.
// Box.js
function Box({ $target }) {
this.element = document.createElement('div');
this.element.className = 'box';
$target.appendChild(this.element);
}
export default Box;
// App.js
import Box from './Box.js';
function App({ $target }) {
// box 100개 등록
const boxes = Array(100).fill(new Box({ $target }));
}
export default App;
* {
box-sizing: border-box;
}
.box {
width: 200px;
height: 200px;
margin: 1rem;
background-color: lime;
}
.lazy {
background-color: royalblue;
}
스크롤을 내릴 때 50%이상 보일 경우 색깔이 변하는 로직을 IntersectionObserver를 이용하여 구현해 보기로 한다. 박스 배열에 존재하는 모든 박스를 관찰하고, 교차할 때 동작을 정의해주면 된다.
import Box from './Box.js';
function App({ $target }) {
// box 100개 등록
for (let i = 0; i < 100; i += 1) {
new Box({ $target });
}
const io = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 이미 노출되었기 때문에 더이상 관찰하지 않는다.
// unobserve에 entry.target
observer.unobserve(entry.target);
entry.target.classList.add('lazy');
}
});
},
{ threshold: 0.5 },
);
document.querySelectorAll('.box').forEach((box) => {
io.observe(box);
});
}
export default App;
잘 동작함을 확인할 수 있다.
이와 같은 원리로 서버에서 내려오는 사진의 lazy loading을 구현할 수 있다.
lazy loading을 구현하기 위해서는 placeholder가 필요하다. 또한, 높이 값이 주어져 있어야 한다.
Step
IntersectionObserver로 모든 아이템들을 구독한다.
해당되는 아이템이 viewport와 교차한다면, img 태그에 src를 삽입한다.
삽입 후 placeholder는 보이지 않게 처리한다.
기본 html 마크업을 한다.
<li class="item">
<img data-src=""/>
<div class="placeholder"></div>
</li>
// intersection observer
const io = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// entry.target을 unobserve에 넣으면 not element error가 발생함
const { target } = entry;
// target 구독 해제
observer.unobserve(target);
const $img = target.querySelector('img');
const { src } = $img.dataset;
if (!src) return;
// img tag에 src 삽입
$img.setAttribute('src', src);
const $placeholder = target.querySelector('.placeholder');
if (!$placeholder) return;
// plarceholder 제거
$placeholder.classList.add('fade-out');
}
});
},
{
threshold: 0.5,
},
);
this.element.querySelectorAll('.item').forEach((item) => io.observe(item));
* {
box-sizing: border-box;
}
.search-result {
display: grid;
grid-template-columns: repeat(4, minmax(250px, 1fr));
gap: 1rem;
list-style: none;
}
.item {
position: relative;
height: 300px;
}
.item > img {
width: 100%;
height: 100%;
object-fit: cover;
}
.item .placeholder {
position: absolute;
width: 100%;
height: 100%;
background-color: #eee;
z-index: 2;
}
.fade-in {
display: block;
}
.fade-out {
display: none;
}
// App.js
import SearchResult from './SearchResult.js';
function App({ $target }) {
this.state = [];
this.element = $target;
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
this.element.innerHTML = ``;
new SearchResult({ $target, initialState: this.state });
};
this.setState(
Array(100).fill(
'https://www.sisa-news.com/data/photos/20200936/art_159912317533_32480a.jpg',
),
);
}
export default App;
// SearchResult.js
import Image from './Image.js';
// initialState: string[]
function SearchResult({ $target, initialState }) {
this.element = document.createElement('ul');
this.element.className = 'search-result';
$target.appendChild(this.element);
this.state = initialState;
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
// 초기화
this.element.innerHTML = ``;
this.state.map((imgSrc) => {
return new Image({ $target: this.element, initialState: imgSrc });
});
};
this.render();
// intersection observer
const io = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const { target } = entry;
observer.unobserve(target);
const $img = target.querySelector('img');
const { src } = $img.dataset;
if (!src) return;
$img.setAttribute('src', src);
const $placeholder = target.querySelector('.placeholder');
if (!$placeholder) return;
$placeholder.classList.add('fade-out');
}
});
},
{
threshold: 0.5,
},
);
this.element.querySelectorAll('.item').forEach((item) => io.observe(item));
}
export default SearchResult;
// Image.js
function Image({ $target, initialState }) {
this.element = document.createElement('li');
this.element.className = 'item';
$target.appendChild(this.element);
this.state = initialState;
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
this.element.innerHTML = `
<img data-src="${this.state}" />
<div class="placeholder"></div>
`;
};
this.render();
}
export default Image;
요약
무한 스크롤 역시 Intersection Observer로 구현할 수 있으나, 방법은 약간 다르다.
가장 마지막 요소 또는 바닥 요소를 관찰한 뒤 해당 요소가 viewport에 드러나게 될 경우, callback 함수를 실행시켜 주면 된다.
이때, IntersectionObserver로 요소들을 추가해줄 때 중요한 점은, 기존에는 this.setState
를 이용하여 데이터가 업데이트 될 때 렌더링을 하는 방식을 사용했는데, 그러다 보니 스크롤을 내리면 위쪽 요소들이 초기화되어 다시 loaded 되기 전으로 돌아가는 문제가 발생했다.
이 문제를 해결하기 위하여 데이터와 완전히 동기화 시키는 렌더링 방식을 포기하고, fetchNextData
라는 함수를 만들고, 해당 함수에서 데이터를 바탕으로 element 배열을 만든 후, 해당 배열을 this.element.append(...array)
방식을 이용하여 넣어주는 방식을 사용하였다.
그리고 이 때 lazy loading을 위해 observe를 어떻게 하면 되지 고민했는데, 일단 렌더링 하고 보니 굳이 lazy-loading을 하지 않아도 원하는대로 잘 떠서, 따로 observe 처리는 하지 않았다.
import Image from './Image.js';
// initialState: string[]
function SearchResult({ $target, initialState }) {
this.element = document.createElement('ul');
this.element.className = 'search-result';
$target.appendChild(this.element);
this.state = initialState;
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
// 초기화
this.element.innerHTML = ``;
this.state.map((imgSrc) => {
return new Image({ $target: this.element, initialState: imgSrc });
});
};
this.render();
const lazyLoadCallback = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const { target } = entry;
observer.unobserve(target);
const $img = target.querySelector('img');
const { src } = $img.dataset;
if (!src) return;
$img.setAttribute('src', src);
const $placeholder = target.querySelector('.placeholder');
if (!$placeholder) return;
$placeholder.classList.add('fade-out');
}
});
};
const fetchNextData = () => {
return Array(25)
.fill(
'https://www.sisa-news.com/data/photos/20200936/art_159912317533_32480a.jpg',
)
.map((url) => {
const item = document.createElement('li');
item.className = 'item';
item.innerHTML = `
<img src="${url}" />
<div class="placeholder" style="display: none;"></div>
`;
return item;
});
};
const infiniteScrollCallback = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const { target } = entry;
observer.unobserve(target);
const newElements = fetchNextData();
this.element.append(...newElements);
observer.observe(newElements[newElements.length - 1]);
}
});
};
// intersection observer
const lazyIo = new IntersectionObserver(lazyLoadCallback, {
threshold: 0.5,
});
const infiniteScrollIo = new IntersectionObserver(infiniteScrollCallback);
const $items = this.element.querySelectorAll('.item');
$items.forEach((item) => lazyIo.observe(item));
infiniteScrollIo.observe($items[$items.length - 1]);
}
export default SearchResult;
코드가 잘은 동작하지만, 추후 리팩토링의 여지가 많다. 바닐라 자바스크립트로 앱을 구성하면, 로직과 UI의 분리가 어려운게 참 아쉽다.