프로젝트 메인 페이지의 캐러셀을 구현해보았다. 라이브러리를 사용하지 않고 구현해보고 싶어서 구글링을 하며 관련 정보를 얻었고, 내 코드에 적용시켜 보았다. 내가 하고 싶은 기능은 크게 3가지이다.
- 좌우 버튼 클릭 시 이동
- 하단 페이지네이션
- 자동 슬라이드
먼저 전체 코드를 살펴보고 하나씩 쪼개서 파헤쳐보자!
const HomeCarousel = () => {
const TOTAL_SLIDES = 4;
const [slideIndex, setSlideIndex] = useState(0);
const [thumbnail, setThumbnail] = useState([]);
const navigate = useNavigate();
// 썸네일 리스트 API
useEffect(() => {
const getThumbnail = async () => {
// API 요청
// ... 생략
};
getThumbnail();
}, []);
// 캐러셀 이미지 상세 페이지 이동
const handleDetailPost = ({ item }) => {
navigate('/photodetail', {
state: {
...
},
});
};
// 캐러셀 자동 슬라이드
useInterval(() => {
if (slideIndex === 4) {
setSlideIndex(0);
} else {
setSlideIndex(slideIndex + 1);
}
}, 3500);
// 오른쪽 버튼 클릭 시 오른쪽으로 슬라이드 이동
const nextSlide = () => {
if (slideIndex !== TOTAL_SLIDES) {
setSlideIndex(slideIndex + 1);
}
};
// 왼쪽 버튼 클릭 시 왼쪽으로 슬라이드 이동
const prevSlide = () => {
if (slideIndex !== 0) {
setSlideIndex(slideIndex - 1);
}
};
// 하단 버튼
const movePage = (index) => {
setSlideIndex(index);
};
return (
<Carousel>
{thumbnail.map((item, index) => (
<ThumbnailWrap
key={index}
className={slideIndex === index ? 'active' : null}
style={
slideIndex === 5
? { transform: 'translateX(0px)' }
: { transform: `translateX(-${slideIndex}00%)` }
}
>
<Title>{item.itemName}</Title>
<Thumbnail
src={item.itemImage}
alt=""
onClick={() => handleDetailPost({ item })}
/>
</ThumbnailWrap>
))}
{slideIndex !== 0 && (
<HomeCarouselPagination moveSlide={prevSlide} direction="prev" />
)}
{slideIndex !== TOTAL_SLIDES && (
<HomeCarouselPagination moveSlide={nextSlide} direction="next" />
)}
<IconWrap>
{Array.from({ length: 5 }).map((item, index) => (
<PageIcon
key={index}
onClick={() => movePage(index)}
className={slideIndex === index ? 'icon active' : 'icon'}
/>
))}
</IconWrap>
</Carousel>
);
};
const TOTAL_SLIDES = 4;
const [slideIndex, setSlideIndex] = useState(0);
먼저, useState
를 통해 slideIndex
초기값을 0으로 설정하여 캐러셀을 관리한다.
TOTAL_SLIDES
는 마지막 페이지에서의 다음 페이지 이동을 방지하기 위해 설정한다.
{thumbnail.map((item, index) => (
<ThumbnailWrap
key={index}
className={slideIndex === index ? 'active' : null}
style={
slideIndex === 5
? { transform: 'translateX(0px)' }
: { transform: `translateX(-${slideIndex}00%)` }
}
>
...
</ThumbnailWrap>
))}
API로 캐러셀 이미지를 가져와 thumbnail
에 데이터가 저장된다. 이미지를 렌더링할 때, css의 transform: translateX()
속성을 slideIndex
에 따라 수정한다.
// 하단 버튼
const movePage = (index) => {
setSlideIndex(index);
};
return (
<Carousel>
...
<IconWrap>
{Array.from({ length: 5 }).map((item, index) => (
<PageIcon
key={index}
onClick={() => movePage(index)}
className={slideIndex === index ? 'icon active' : 'icon'}
/>
))}
</IconWrap>
</Carousel>
);
총 5페이지로 캐러셀을 구성할 계획이라 배열을 만들어 하단 페이지네이션 버튼을 만들어준다. Array.from()
메서드로 배열을 만들건데,
Array.from()
Array.from() 메서드는 유사 배열 객체(array-like object)나 반복 가능한 객체(iterable object)를 얕게 복사해 새로운 Array객체를 만든다.
Array.from() - JavaScript | MDN
코드에서 { length: 5 }
는 5 길이의 유사 배열 객체를 생성한다.
만약 유사 배열 객체에 length 값만 입력한다면 Array.from 메서드가 값이 undefined로 채워진 배열을 반환한다. 나는 인덱스 값으로 이루어진 배열이 필요하기 때문에 인덱스를 참조하여 새로운 배열의 값을 반환하도록 해야한다.
Array.from()
은 선택 매개변수인 mapFn를 가지는데, 배열(혹은 배열 서브클래스)의 각 요소를맵핑할 때 사용할 수 있습니다. 즉,Array.from(obj, mapFn, thisArg)
는 중간에 다른 배열을 생성하지 않는다는 점을 제외하면Array.from(obj).map(mapFn, thisArg)
와 같습니다. 이 특징은 typed arrays와 같은 특정 배열 서브클래스에서 중간 배열 값이 적절한 유형에 맞게 생략되기 때문에 특히 중요합니다.
라고 mdn 문서에서 설명하고 있다. map
메서드는 첫 번째 매개변수(currentValue)로 값을, 두 번째 매개변수(index)로 인덱스를 참조할 수 있다. 위의 설명과 같이 Array.from
메서드에서도 인덱스를 참조하려면 두 개의 매개변수가 필요하다.
즉, 내가 원하는 결과를 만들기 위해선 인덱스를 참조하여 새로운 배열의 값을 반환하기 위해 길이가 5인 배열을 만들고 맵핑해야 한다.
export const PageIcon = styled.div`
display: inline-block;
margin: 4px;
width: 8px;
height: 8px;
background-color: white;
box-shadow: 1px 1px 2px var(--black);
border-radius: 4px;
cursor: pointer;
transition: ease-in 0.4s;
&.icon {
opacity: 0.4;
}
&.icon.active {
opacity: 0.8;
}
`;
해당 페이지 index값과 아이콘의 index값이 같다면 icon active를, 다를 경우 icon 클래스명을 적용하여 opacity
속성으로 변화를 주고 transition
속성을 적용하여 자연스러운 효과를 준다.
// 오른쪽 버튼 클릭 시 오른쪽으로 슬라이드 이동
const nextSlide = () => {
if (slideIndex !== TOTAL_SLIDES) {
setSlideIndex(slideIndex + 1);
}
};
// 왼쪽 버튼 클릭 시 왼쪽으로 슬라이드 이동
const prevSlide = () => {
if (slideIndex !== 0) {
setSlideIndex(slideIndex - 1);
}
};
return (
<Carousel>
...
{slideIndex !== 0 && (
<HomeCarouselPagination moveSlide={prevSlide} direction="prev" />
)}
{slideIndex !== TOTAL_SLIDES && (
<HomeCarouselPagination moveSlide={nextSlide} direction="next" />
)}
...
</Carousel>
);
'>' 버튼 (nextSlide) 클릭 시
slideIndex + 1
이동
'<' 버튼 (prevSlide) 클릭 시slideIndex - 1
이동
HomeCarouselPagination
컴포넌트를 호출하여 moveSlide
(슬라이드 이동 함수)와 direction
을 전달한다.
const HomeCarouselPagination = ({ direction, moveSlide }) => {
return (
<button type="button" onClick={moveSlide}>
{direction === 'next' && <MoveBtn className="right" type="button" />}
{direction === 'prev' && <MoveBtn className="left" type="button" />}
</button>
);
};
moveSlide
와 direction
을 전달받아 조건에 맞는 button 요소를 반환한다.
// 캐러셀 자동 슬라이드
useInterval(() => {
if (slideIndex === 4) {
setSlideIndex(0);
} else {
setSlideIndex(slideIndex + 1);
}
}, 3500);
커스텀 훅을 적용하기 전에 먼저, 자동 슬라이드 기능을 구현하기 위해 setInterval()
메서드를 사용해야 한다고 생각했다.
setInterval()
지정된 시간 간격마다 지정된 기능을 반복하고자 할 때 사용한다. 이는 특정 코드나 주어진 함수를 지정된 간격으로 호출한다. 이 메소드는 윈도우가 닫히거나 clearInterval() 메소드가 호출될 때 까지 계속 실행되고 리턴값으로 0이 아닌 숫자인 타이머 id를 반환한다.
JavaScript setInterval() method
setInterval(() => {
if (slideIndex === 4) {
setSlideIndex(0);
} else {
setSlideIndex(slideIndex + 1);
}
}, 3500);
많이 이상하다. 3500ms 이후 그 타이밍의 상태 값으로 돌아갔다가 렌더링되기를 반복한다.
구글링 해보니 React에서는 state가 변하면 리렌더링되기 때문에 setInterval()
메서드는 무한히 실행되버린다. 그렇다면 렌더링 시에만 실행되도록 useEffect()
안에서 실행하면 어떨까?
useEffect(() => {
const timer = setInterval(() => {
if (slideIndex === 4) {
setSlideIndex(0);
} else {
setSlideIndex(slideIndex + 1);
}
}, 3500);
return () => clearInterval(timer);
}, [slideIndex]);
오잉? 이 코드를 적용했을 때 자동 슬라이드 기능이 잘된다! 그러나 구글링을 통해 안 사실은 setInterval
메서드는 함수를 실행하는 시간조차 delay에 포함시키기 때문에 우리가 원하는 delay 시간을 100% 보장하지 못하며 만약 함수를 실행하는 시간이 delay 시간보다 길다면 타이머가 제대로 작동하지 않는다.
예를 들어 1초 마다 한 번씩 함수가 호출되도록 했다. 그런데 함수 실행이 1초보다 길어져버리면 어떻게 될까? 함수가 실행이 끝난 후에 1초를 기다리지 않고 다음 함수를 바로 실행해버린다.
만약 위의 코드에 3500ms 마다 서버에 요청해 데이터를 불러오는 메서드를 실행하고 그 과정이 3500ms 이상 걸린다면 이러한 문제가 나타날 수 있을 것이다.
const useInterval = (callback, delay) => {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
};
위의 useInterval 커스텀 훅은 Dan Abramov 개발자에 의해 구현된 훅이다. 그는 React hooks 컴포넌트에서 setInterval 사용 시의 문제점을 설명하며 커스텀 훅을 제시했다.
const savedCallback = useRef(); // 최근에 들어온 callback을 저장할 ref 생성
useRef
useRef
는.current
프로퍼티로 전달된 인자(initialValue)로 초기화된 변경 가능한ref
객체를 반환합니다. 반환된 객체는 컴포넌트의 전 생애주기를 통해 유지될 것입니다. 본질적으로useRef
는.current
프로퍼티에 변경 가능한 값을 담고 있는 상자와 같습니다.
Hook API reference | React
React 공식 문서의 정의이다. 정의가 참 와닿지 않는다... 그래서 useRef
의 중요한 특징만 말하자면, 저장공간(변수 관리)과 DOM 요소 접근, 리렌더링 방지를 위해 사용한다.
useInterval
에서 useRef
를 사용한 이유는 바로 리렌더링을 방지하기 위해서다.
useRef
는 함수형 프로그래밍에서 사용하는 ref로 초기화된 ref 객체인 {current: null}
을 반환하며 반환된 객체는 컴포넌트의 전 생애주기 동안 유지되어 useRef
로 관리하는 값은 값이 변경되어도 컴포넌트가 리렌더링 되지 않는다.
만약, 값이 변경될 때마다 리렌더링되는
useState
로 데이터를 관리하게 된다면,useEffect()
내부에서 savedCallback 값이 변경될 때마다 리렌더링이 일어나게 된다. 그래서tick()
함수 안의 savedCallback 값을 확인하면 계속해서 초기값만 가져오게 될 것이다.
useEffect(() => {
savedCallback.current = callback; // callback이 바뀔 때마다 ref를 업데이트
}, [callback]);
callback 데이터가 바뀔 때마다 savedCallback의 current 값이 새로운 callback 데이터로 업데이트 된다.
useEffect(() => {
function tick() {
savedCallback.current(); // tick 함수가 실행되면 callback 함수를 실행
}
if (delay !== null) {
const id = setInterval(tick, delay); // delay에 맞추어 interval을 새로 실행
return () => clearInterval(id); // unmount될 때 clearInterval
}
}, [delay]);
두 번째 useEffect
의 setInterval
함수에 첫 번째 useEffect
를 통해 저장한 callback 함수를 전달해 실행되도록 한다. delay가 변경될 때마다 실행되며 delay가 null이 아닐 경우 setInterval
함수를 호출하여 callback 함수를 실행한다. 이 후 언마운트 될 때 clearInterval을 실행한다.
참고 :