이번 프로젝트는 유튜브 api를 사용하여 추천 영상을 등록하고, 검색하는 프로젝트를 진행하였다.
한 페이지에 이미지가 많이 배치될 예정이라 이미지 렌더링을 한 번에 하지 않는 방법을 찾던 중 IntersectionObserver
를 알게 되었다.
먼저 useState와 useRef를 추가한다.
렌더링 될 이미지가 여러 개이기 때문에 배열로 useRef에 담는다.
const [visibleIndices, setVisibleIndices] = useState([]);
const imageRefs = useRef([]);
useEffect로 data가 변경될 때마다 실행시킨다.
data : supabase에서 검색해 온 데이터
IntersectionObserver(callback[, options])
: 대상요소와 최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우를 비동기적으로 감지한다.
매개변수
- callback(entries, observer) : 교차 영역에 대한 최소한의 조건에 도달했을 때 호출 할 함수.
- entries : 교차 영역에 대한 조건에 대해 만족하게 된 요소를 나타내는 IntersectionObserverEntry 객체의 배열 (스크롤했을 때 보이거나 가려진 요소)
- observer : 콜백을 호출한 IntersectionObserver
entries에 다양한 속성이 있는데 그중 isIntersecting 은 해당 요소가 화면에 보이는지에 대한 속성이다.
만약 entry.isIntersecting 이 true 면 화면에 보인다는 뜻이다.
이 부분은 console.log를 찍으면서 확인해 보았다.
스크롤이 아래 지점에 도달하면 화면에 보이는 요소를 console.log로 찍었다.
그리고 네트워크 탭에서도 하나씩 이미지를 불러오는 걸 볼 수 있다.
코드의 일부분
const observer = new IntersectionObserver((entries) => {
// console.log('entries', entries);
entries.forEach((entry) => {
console.log('entry', entry);
if (entry.isIntersecting) {
console.log('화면에 보이는 entry', entry);
const index = imageRefs.current.indexOf(entry.target);
setVisibleIndices((prevVisibleIndices) => [...prevVisibleIndices, index]);
}
});
});
observe(targetElement)
: (교차 지점이 변경될 때 주시할 대상을) 주시 대상 목록에 추가한다. (조건에 맞는지 감지할 요소를 목록에 추가한다)
disconnect()
: 모든 변화 주시 대상을 해제한다.
entry.isIntersecting true 인 요소의 index를 구해서 렌더링 할 요소를 state에 업데이트한다.
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const index = imageRefs.current.indexOf(entry.target); //화면에 보이는 요소의 index 값을 구한다.
setVisibleIndices((prevVisibleIndices) => [...prevVisibleIndices, index]); //state 업데이트
}
});
});
imageRefs.current.forEach((ref) => {
if (ref) {
observer.observe(ref); //주시 대상 목록에 추가
}
});
return () => {
observer.disconnect();
};
}, [data]);
visibleIndices state에 있는 요소만 이미지가 로딩되도록 조건부 렌더링 처리를 한다.
return (
<SearchWrap>
{data && data.length > 0 ? (
<>
<PageTitle>"{searchKeyword}" 검색 결과 입니다.</PageTitle>
<SearchListArea>
{data?.map((el, index) => {
const src = el.videoId ? `https://img.youtube.com/vi/${el.videoId}/maxresdefault.jpg` : '';
return (
<Item key={el.id}>
//imageRefs.current의 배열 index에 해당 요소를 할당
<Thumbnail ref={(e) => (imageRefs.current[index] = e)}>
//state 에 포함된 요소만 렌더링 되도록 조건부 렌더링
{visibleIndices.includes(index) && (
<Link to={`/detail/${el.id}`} state={{ id: el.id }}>
<LazyLoadedImage src={src} alt={'썸네일'} />
</Link>
)}
</Thumbnail>
<ItemTitle>
<Link to={`/detail/${el.id}`} state={{ id: el.id }}>
{el.title}
</Link>
</ItemTitle>
<Time>{formatAgo(el.timeString, 'ko')}</Time>
</Item>
);
})}
</SearchListArea>
</>
) : (
<NoDataArea>"{searchKeyword}" 검색 결과가 없습니다.</NoDataArea>
)}
</SearchWrap>
);
최종 결과 화면 및 코드
SearchList.jsx 전체 코드
import React, { useEffect, useRef, useState } from 'react';
import { Item, ItemTitle, NoDataArea, PageTitle, SearchListArea, SearchWrap, Thumbnail } from 'styles/SearchStyle';
import client from 'api/supabase';
import { Link, useSearchParams } from 'react-router-dom';
import { LazyLoadedImage } from './LazyLoadedImage';
const SearchList = () => {
const [data, setData] = useState();
const [searchParams] = useSearchParams();
const searchKeyword = searchParams.get('keyword');
const [visibleIndices, setVisibleIndices] = useState([]);
const imageRefs = useRef([]);
//데이터 조회
useEffect(() => {
const fetchData = async () => {
try {
const { data, error } = await client
.from('content')
.select('*')
.like('title', `%${searchKeyword}%`)
.order('timeString', { ascending: false });
if (error) {
throw error;
}
setData(data);
} catch (error) {
console.error('Error fetching data:', error.message);
alert('검색 중 에러가 발생하였습니다. 잠시 후 다시 시도해주세요.');
}
};
fetchData();
}, [searchKeyword]);
//IntersectionObserver : 대상요소와 최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우를 비동기적으로 감지
//supabase에서 가져온 data 의 값이 변경될 때 실행
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const index = imageRefs.current.indexOf(entry.target);
setVisibleIndices((prevVisibleIndices) => [...prevVisibleIndices, index]);
}
});
});
imageRefs.current.forEach((ref) => {
if (ref) {
observer.observe(ref);
}
});
return () => {
observer.disconnect();
};
}, [data]);
return (
<SearchWrap>
{data && data.length > 0 ? (
<>
<PageTitle>"{searchKeyword}" 검색 결과 입니다.</PageTitle>
<SearchListArea>
{data?.map((el, index) => {
const src = el.videoId ? `https://img.youtube.com/vi/${el.videoId}/maxresdefault.jpg` : '';
return (
<Item key={el.id}>
<Thumbnail ref={(e) => (imageRefs.current[index] = e)}>
{visibleIndices.includes(index) && (
<Link to={`/detail/${el.id}`} state={{ id: el.id }}>
<LazyLoadedImage src={src} alt={'썸네일'} />
</Link>
)}
</Thumbnail>
<ItemTitle>
<Link to={`/detail/${el.id}`} state={{ id: el.id }}>
{el.title}
</Link>
</ItemTitle>
</Item>
);
})}
</SearchListArea>
</>
) : (
<NoDataArea>"{searchKeyword}" 검색 결과가 없습니다.</NoDataArea>
)}
</SearchWrap>
);
};
export default SearchList;
LazyLoadedImage.jsx
import React from 'react';
export const LazyLoadedImage = ({ src, alt }) => {
return <img src={src} alt={alt} />;
};
느낀 점
이미지 렌더링 최적화라는 걸 처음 해봐서 정말 막막했는데, 이렇게 처리할 수 있게 돼서 기쁘다.
메인화면, 자체 검색 화면, 유튜브 api를 활용한 검색화면 총 3개의 화면에 적용했다.
사실 지금은 데이터가 많지 않아서 느리거나 하는 이슈는 없지만 이번 경험을 토대로 다음에 데이터가 많은 경우에 사용할 수 있을 것 같다.
'TIL' 카테고리의 다른 글
[프로그래머스] 문자열 다루기 기본 / 오타 때문에... 오래걸린 코딩 테스트 (2) | 2024.03.13 |
---|---|
[typescript] 빌드에 대하여 (0) | 2024.03.07 |
supabase database 정책 설정 (0) | 2024.02.23 |
[Glitch / vercel] json-server 배포하기 (0) | 2024.02.22 |
리팩토링 진행 (0) | 2024.02.06 |