Next.js 13과 redux/toolkit, styled-components 등으로 북마크 기능이 들어있는 쇼핑몰 앱을 구현한 프로젝트이며, 설명에 필요한 코드만 최소한으로 서술되었습니다. :
(1) gitHub - README 및 전체 코드 보러 가기
(2) 프로젝트 요구 명세서 보기 (해당 프로젝트 첫 포스팅)
헤더는 금방 완성이 되었다.
중요한 것은 우측 햄버거 버튼을 누르면 드롭다운 메뉴가 펼쳐지는데 이것을 어떻게 구현할까? 였다.
구현 결과
아래와 같이 사용자 편의를 위한 구현에 신경을 많이 쓰느라 시간이 꽤 소요가 된 것 같다.
- 햄버거 버튼을 hover 했을 때 햄버거 버튼이 살짝 확대가 된다.
- 말풍선 모양의 드롭다운 메뉴가 부드럽게 나타난다.
- 메뉴를 클릭하거나, 바깥쪽을 클릭했을 때만 드롭다운 메뉴가 사라진다.
어떻게 보면 정말 간단한 기능인데도.. 신경써야 할 부분이 많구나, 라는 것을 느끼게 되었다.
구현 과정
1. 삼각형 모양 만들기
- 말풍선 컴포넌트는 삼각형 + 사각형 의 조합으로 만들 수 있었다..! (충격과 공포)
- 물론 UI 라이브러리가 있겠지...?
- 블로그 작성 시점에서 찾아보니 말풍선을 편하게 만들어주는 사이트가 있다.
내가 작성한 코드 중 발췌 (더 자세한 코드는 2번 챕터에 있다)
border: 12px solid transparent; /* border의 굵기를 12로 지정하고 투명하게 설정 */
border-top-width: 0; /* 위 border의 너비를 0으로 조정 (삼각형의 방향 설정) */
border-bottom-color: white; /* 바닥 border의 색만 원하는 색으로 조정 */
2. 드롭다운 컴포넌트 생성하기
드롭다운 컴포넌트를 만들어서 Header 컴포넌트 안에 추가해주었다. (중간 중간에 tailwindCSS가 들어있다.)
export default function DropdownMenu() {
const [DropDownIsOpen, ImgRef, DropDownHandler] = useDetectClose();
return (
<div>
<DropdownContainer ref={ImgRef}>
<!-- 햄버거 아이콘 (...은 생략의 의미) -->
<Image ... />
<Menu className={DropDownIsOpen ? "isdropped" : null}>
<div className="hover:cursor-default mt-4">
{`Doyu`} 님, 안녕하세요!
</div>
<Ul>
<!--Next.js 방식으로 패스를 지정해주었다.-->
<Link href="/product/list" onClick={DropDownHandler}>
<li className="flex mr-2 hover:text-myBlue hover:font-bold transition-all ease-in-out">
<Image ... />
상품리스트 페이지
</li>
</Link>
<Link href="/bookmark" onClick={DropDownHandler}>
<li className="flex mr-2 hover:text-myBlue hover:font-bold transition-all ease-in-out">
<Image ... />
북마크 페이지
</li>
</Link>
</Ul>
</Menu>
</DropdownContainer>
</div>
);
}
CSS는 다음과 같이 해줬다. (중요한 부분만 발췌)
const DropdownContainer = styled.div`
position: relative;
`;
const Menu = styled.div.attrs(() => ({ role: "dropdown-menu" }))`
position: absolute;
.
.
.
opacity: 0; /* isDropped 클래스가 추가되지 않았을 때는 숨겨둔다 */
visibility: hidden;
transition: all 0.4s ease; /* isDropped CSS 로의 부드러운 전환효과 */
z-index: 999;
&:after { /* 말풍선의 삼각형 만들어준 CSS */
content: "";
position: absolute;
top: -4.5%;
left: 73%;
border: 12px solid transparent;
border-top-width: 0;
border-bottom-color: white;
}
&.isDropped { /* isDropped 클래스가 동적으로 추가될 때, 컴포넌트가 나타난다. */
opacity: 1;
visibility: visible;
}
`;
동적인 값을 props로 넣어줘도 된다.
아래의 코드는 isdropped라는 prop 값에 따라 스타일을 동적으로 설정하는 기능을 구현한 것이다.
- CSS-in-JS 라이브러리인 styled-components의 템플릿 리터럴 문법을 사용한 것이다.
- ${({ isdropped }) => ...} 부분은 ES6의 디스트럭처링 문법을 활용하여 isdropped라는 prop 값을 가져온다.
- isdropped 값이 true일 경우, 지정한 스타일들이 적용된다.
// {...} 구문은 JavaScript에서 객체 전개 연산자(Object Spread Operator)를 나타낸다.
// 이 연산자를 사용하면 객체의 속성을 복사하거나 새로운 속성을 추가할 수 있다.
// 아래의 {...(DropDownIsOpen ? { isdropped: "true" } : {})}는 조건부로 객체를 병합하는 역할을 한다.
// DropDownIsOpen이 참인 경우, { isdropped: "true" } 객체를 복사하여 isdropped 속성을 추가한다.
// DropDownIsOpen이 거짓인 경우, 빈 객체 {}를 사용하여 아무 속성도 추가하지 않는다.
<Menu {...(DropDownIsOpen ? { isdropped: "true" } : {})} >
.
.
.
${( {isDropped} ) =>
isDropped &&
css`
opacity: 1;
visibility: visible;
transform: translate(-70%, -20px);
`};
3. Custom Hook 만들기
- 바깥쪽과 메뉴를 눌렀을 때 드롭다운 컴포넌트가 닫히고,
- 그외의 곳을 눌렀을 때는 컴포넌트가 닫히지 않도록 custom hook을 만들어주었다.
- 해당 블로그 글 참고
useDetectClose.js
import { useEffect, useState, useRef } from "react";
export default function useDetectClose() {
const [isOpen, setIsOpen] = useState(false);
const ref = useRef(null);
const toggleHandler = () => setIsOpen(!isOpen);
const handleClickOutside = (e) => { // 🟣 설명(1)
if (!ref.current.contains(e.target)) {
setIsOpen(false);
}
};
useEffect(() => { // 🟣 설명(2)
if(isOpen) { window.addEventListener("click", handleClickOutside);
return () => window.removeEventListener("click", handleClickOutside);
}}, [isOpen]);
return [isOpen, ref, toggleHandler];
}
// 드롭다운 컴포넌트 -> const [DropDownIsOpen, Ref, DropDownHandler] = useDetectClose();
// DropDownIsOpen :: 드롭다운 컨테이너에 넣어준다. ( isDropped={DropDownIsOpen} )
// Ref :: 드롭다운 컨테이너 + 햄버거 아이콘까지 다 감싸주는 바깥 컨테이너에 넣어준다. ref={ref}
// DropDownHandler :: 햄버거 버튼, 드롭다운 속 메뉴에 넣어준다.
🟣 설명 (1) handleClickOutside
말 그대로 드롭다운 박스 바깥의 공간을 클릭했을 때를 처리해주는 함수이다.
DropDown.js
드롭다운 컴포넌트의 가장 바깥 컨테이너에 넣어주었다.
return (
<div>
<DropdownContainer ref={Ref}> <!-- 여기!! -->
<Image
onClick={DropDownHandler}
src="/햄버거 아이콘.svg"
width={34}
height={24}
alt="햄버거 메뉴 드롭다운 버튼"
className="transition-all duration-300 hover:scale-110 transform hover:cursor-pointer"
/>
<Menu isDropped={DropDownIsOpen} >
<div className="hover:cursor-default mt-4">
{`Doyu`} 님, 안녕하세요!
</div>
const handleClickOutside = (e) => { /
// console.log(e.target) // ref.current - 예) 이미지 태그
// ref.current는 객체형태 - 모든 객체는 조건문에서 true로 계산된다.
if (!ref.current.contains(e.target)) {
setIsOpen(false); // 닫는다.
}
};
여기서 console.log(e.target); 을 하면 아래와 같이 내가 클릭한 요소가 출력이 된다.
- 예를 들어 드롭다운 버튼을 열기 위해 햄버거 버튼을 클릭하면, <Img alt="햄버거 메뉴 ... 가 출력이 된다.
console.log(ref); 하고 햄버거 버튼을 클릭했을 때
console.log(ref.current); 하고 햄버거 버튼을 클릭했을 때
- 속성으로 Ref를 넣어준 엘리먼트가 뜬다.
즉, 해당 함수는 클릭 이벤트가 ref.current를 포함하지 않을 때, setIsOpen(false)를 호출하여 드롭다운 메뉴를 닫는 역할을 합니다.
- ref.current는 드롭다운 메뉴가 마운트되면 할당되는 DOM 요소를 참조한다.
- ref.current는 햄버거 아이콘과 드롭다운 메뉴박스를 다 포함한 드롭다운 컨테이너이다.
- 클릭 이벤트의 target을 확인하여, 해당 target이 ref.current(드롭다운 컨테이너)에 포함(contains)되는 지의 여부를 확인한다.
- 이는 클릭 이벤트가 드롭다운 메뉴 외부의 요소에서 발생했는지를 확인하는 것이다.
- 만약 드롭다운 메뉴 외부의 요소에서 발생했다면 IsOpen 상태를 false로 바꿔준다.
if (!ref.current.contains(e.target)) {
setIsOpen(false); // 닫는다.
}
// 즉 e.target (내가 클릭한 요소가)
// ref.current (드롭다운 컨테이너에)
// contains (들어있는 아이가)
// ! (아니라면)
// setIsOpen(false) ( 드롭다운 박스를 닫아줘!)
여기서 contains의 쓰임새를 알아보면 더욱 이해가 잘 갈 것이다.
- contains는 JavaScript에서 DOM 요소를 포함하는지 확인하는 메서드이다. 이 메서드는 DOM 요소의 자식 요소 또는 자손 요소 중에서 특정 요소를 포함하는지 여부를 확인한다.
const parentRef = useRef(null);
const childRef = useRef(null);
useEffect(() => {
console.log(parentRef.current.contains(childRef.current)); // true
}, []);
return (
<div>
<div ref={parentRef} id="parent">
<div ref={childRef} id="child">Child element</div>
</div>
</div>
);
위의 코드는 parentRef가 childRef를 포함하는지 여부를 확인한다. 결과로서 true가 출력된다.
- parentElement가 childElement를 직접 포함하고 있는 경우 true를 반환한다.
- parentElement의 자식 요소 중 하나가 childElement를 포함하고 있는 경우에도 true를 반환한다.
- 그 외의 경우, 즉 parentElement가 childElement를 포함하지 않는 경우 false를 반환한다.
🟣 설명 (2) useEffect를 쓰는 이유
useEffect를 사용하여 window 객체에 클릭 이벤트 리스너를 등록하고, 컴포넌트가 언마운트될 때 해당 리스너를 제거하는 작업을 수행한다.
- useEffect를 사용하면 컴포넌트가 렌더링될 때나 특정 값이 변경될 때 특정 동작을 수행할 수 있다.
- 주어진 코드에서 useEffect를 사용하는 이유는 isOpen 값이 변경될 때마다(즉, 열고 닫을 때마다) 이벤트 핸들러를 추가하고 삭제하기 위함이다.
- 과정은 다음과 같다.
- DropdownMenu는 초기 렌더링 때 useDetectClose 함수를 호출하며, 해당 함수 안의 useEffect도 실행이 된다.
- 하지만, isOpen의 초기값은 false이기 때문에 useEffect 속 if(isOpen) 이하는 실행되지 않는다.
- 햄버거 버튼을 클릭하여 isOpen이 true가(메뉴가 열림) 되는 순간, useEffect는 작동하며 if문을 통과하여 window에 이벤트핸들러를 추가한다.
- 그 뒤에 전역에서 클릭 이벤트가 발생했을 때 isOpen 상태를 false로 만드는 handleClickOutside 함수가 실행된다.
- 그 뒤, isOpen이 false가(메뉴가 닫힘) 되는 순간 3번의 useEffect 함수의 return문이 일단 실행되어 여기서 이벤트 핸들러가 삭제된다.
- useEffect의 반환 함수는 컴포넌트가 언마운트되거나 isOpen 값이 변경될 때마다 호출된다. (clean up 단계)
- 이를 통해 이벤트 리스너를 제거하여 메모리 누수를 방지하고, 이전에 등록된 이벤트 리스너를 정리한다.
- 즉, useEffect를 사용하여 컴포넌트의 마운트와 언마운트 시점에서 이벤트 리스너를 추가하고 제거함으로써 클릭 이벤트 처리를 관리하는 것이다.
- 그 후 새로운 useEffect 함수는 if문을 통과하지 못하므로 아무 동작도 하지 않게 된다.
콘솔로 찍어 보았다.
useEffect(() => {
if(isOpen) {
window.addEventListener("click", handleClickOutside);
console.log('add!') // useEffect 호출 후, isOpen이 true일 때, 이벤트리스터 추가
return () => {
console.log('close') // isOpen이 false가 될 때 새로운 useEffect함수 전에 리턴 함수가 먼저 호출됨
window.removeEventListener("click", handleClickOutside);
}}}, [isOpen]);
콘솔창
'📌 TOY-PROJECT > 2305 쇼핑몰 웹앱 (북마크 기능)' 카테고리의 다른 글
redux/toolkit(RTK) / 토스트 애니메이션 자연스럽게 구현하기 (0) | 2023.05.16 |
---|---|
RTK Query (0) | 2023.05.15 |
Next.js 13 - StackoverFlow에 질문글을 올리다. (1) | 2023.05.12 |
git 전략 : 작업 브랜치 생성 (0) | 2023.05.12 |
솔로 프로젝트 시작 : 스크럼을 통한 프로젝트 계획 (0) | 2023.05.12 |