ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 클래스101 클론 후기
    리액트 2022. 1. 16. 09:50

    클래스101 클론

    # 0 세팅 및 라이브러리

    세팅은 cra(create-react-app)을 사용했습니다. class 101 디자인 시스템 라이브러리를 사용할까 생각했지만 결국 사용 안했습니다...

    # 1 아토믹 디자인

     폴더는 크게 atoms, molecules, oranganism으로 나누었습니다. atoms에는 Badge, Button, Icon, IconButton, Image, TextButton 등 재활용할 수 있는 가장 작은 단위로 나누었습니다.

     

    Icon의 경우 svg파일을 직접 사용하기 보다는 컴포넌트로 사용할 수 있도록 했습니다. cra에서 svg를 컴포넌트로 사용할 수 있도록 지원하지만 좀 더 재활용성을 높이기 위해 아래와 같이 사용했습니다.

    type iconName =
      | "ChevronLeft"
      | "ChevronRight"
    
    type icon = {
      path: string;
      viewBox: string;
    };
    
    const IconSet: Record<iconName, icon> = {
      ChevronLeft: {
        path: "M16.67 0l2.83 2.829-9.339 9.175 9.339 9.167-2.83 2.829-12.17-11.996z",
        viewBox: "0 0 24 24",
      },
      ChevronRight: {
        path: "M5 3l3.057-3 11.943 12-11.943 12-3.057-3 9-9z",
        viewBox: "0 0 24 24",
      },
    };
    
    interface IconProp {
      iconName: iconName;
      fillColor?: string;
      size: number;
      disabled?: boolean;
    }
    
    const Icon = ({
      fillColor = "#000000",
      size = 10,
      iconName,
      disabled = false,
    }: IconProp) => {
      if (disabled) {
        fillColor = "#d3d3d3";
      }
      return (
        <svg
          height={size}
          viewBox={IconSet[iconName].viewBox}
          fill={fillColor}
          xmlns="http://www.w3.org/2000/svg"
        >
          <path d={IconSet[iconName].path} />
        </svg>
      );
    };

    코드를 보시면 <Icon size={size} iconName={iconName} /> 과 같이 icon을 쉽게 재활용할 수 있도록 했습니다.

     

    Badge 및 다른 atom들의 경우 styled-components를 활용하여 size를 xs, sm , md로 강제화하고 이에 따른 css를 꾸밀 수 있도록 했습니다. 만약에 커스텀이 필요하다면 className을 통해 그때마다 css값을 새롭게 주었습니다.  

    interface StyledBadgeProps {
      size: "xs" | "sm" | "md";
      color?: string;
      backgroundColor?: string;
    }
    
    interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
      size: "xs" | "sm" | "md";
      color?: string;
      backgroundColor?: string;
      children: React.ReactNode;
    }
    
    const getBadgeSize = (size: string) => {
      switch (size) {
        case "xs":
          return css`
            font-size: 9px;
            min-width: 16px;
            padding: 4;
          `;
        case "sm":
          return css`
            font-size: 9px;
            min-width: 20px;
            padding: 6;
          `;
        case "md":
          return css`
            font-size: 11px;
            min-width: 24px;
            padding: 6;
          `;
      }
    };
    
    const TagBox = styled.div<StyledBadgeProps>`
      font-weight: 600;
      background-color: ${(props) => props.backgroundColor || palette.black};
      color: ${(props) => props.color || palette.white};
      ${(props) => getBadgeSize(props.size)};
    `;
    
    const Badge: React.FC<BadgeProps> = ({
      children,
      size,
      backgroundColor,
      color,
      className,
    }) => {
      return (
        <TagBox
          size={size}
          backgroundColor={backgroundColor}
          color={color}
          className={className}
        >
          {children}
        </TagBox>
      );
    };

    # 2 Carousel

    # 2-1 SlidesPerView

    캐러셀의 재활용성을 높이기 위해 캐러셀 컴포넌트에 캐러셀에 보이고자 하는 슬라이드 수를 작성하면 이에 맞게 각 슬라이드의 너비를 자동적으로 맞추도록 구현했습니다. 

     

    const SlidesViewWidth = (slidesPerView: number, count: number) => {
      if (count < slidesView) {
        return css`
          width: calc((100% - 20px * ${slidesPerView - 1}) / ${slidesView});
        `;
      } else {
        return css`
          min-width: calc((100% - 20px * ${slidesPerView - 1}) / ${slidesView});
        `;
      }
    };
    // 계산식의 20px는 슬라이드의 좌우 margin값임
    // slidesPerView는 캐러셀에 현재 보이는 slide 수를 말함

     

     

    # 2-2 좌우 움직임 구현

    슬라이드가 좌우로 움직이게 하기 위해서 useRef()를 통해 캐러셀의 offsetWidth+ margin에서 slidesPerView를 나누어 각 슬라이드가 얼마만큼 움직여야지를 구했습니다. 또한 useState(0)을 통해 오른쪽 버튼 클릭 시 해당 state가 +1을 하여 얼마만큼 움직이는지를 파악 할 수 있도록 했습니다.

     

    // swipe effect
      const [active, setActive] = useState(0);
      const slideRef = useRef<HTMLDivElement>(null);
    
      useEffect(() => {
        const { current } = slideRef;
        if (current != null) {
          let margin = 20;
          const width = ((current.offsetWidth + margin) / slidesView) * active;
          current.style.transform = `translateX(calc(-${width}px))`;
        }
      }, [active, slidesView, innerWidth]);

    innerWidth는 window.innerWidth로 window가 resize 시에 이벤트 리스너로 이를 감지하고 변한 offsetWidth값에 맞게 다시 조절할 수 있도록 했습니다.

     

    window resize 관련 함수

    function getWindowDimensions() {
      const { innerWidth, innerHeight } = window;
      return {
        innerWidth,
        innerHeight,
      };
    }
    
    export default function useWindowDimensions() {
      const [windowDimensions, setWindowDimensions] = useState(
        getWindowDimensions()
      );
    
      useEffect(() => {
        function handleResize() {
          setWindowDimensions(getWindowDimensions());
        }
    
        window.addEventListener("resize", handleResize);
        return () => window.removeEventListener("resize", handleResize);
      }, []);
    
      return windowDimensions;
    }

    # 2-3 autoplay

     

    캐러셀 컴포넌트에  autoplay 기능을 추가하여 원할시에 자동적으로 움직일 수 있도록 구현했습니다. setInterval()을 통해 일정한 시간마다 페이지를 넘기도록 했습니다.

     

     // autoplay
      useEffect(() => {
        const id = setInterval(() => {
          if (autoplay) {
            setActive(active + 1);
            if (active > count - slidesView - 1) {
              setActive(0);
            }
          }
        }, 5 * 1000);
        return () => clearInterval(id);
      }, [active, autoplay, count, slidesView]);

     

    setActive()를 통해 페이지를 담당하는 상태값을 5초마다 1씩 더하도록 구현했으며, 마지막 페이지 수를 넘어가면 다시 처음 페이지로 돌아가도록 했습니다.

     

    # 2-4 아쉬운 점

     

    Class101 Design System과 같이 버튼을 어디에 (캐러셀 양쪽 혹은 캐러셀 오른쪽 아래) 배치할지 지정할 수 있도록 구현했지만 지금 생각해보면 오히려 이것이 atomic 디자인과 맞지 않은 좋지 않은 방법이었던 것 같습니다. 구현한 캐러셀을 활용해보며 버튼을 어디에 둘지 강제한 점이 코드를 점점 복잡해지게 만들었습니다. 다양한 환경에서 재활용하기 위해서는 추상적이어야 하는데, 불필요한 부분도 구현해서 문제지 않았나 싶습니다. 디자인적인 면을 강제하기 보다는 버튼 컴포넌트를 따로 export하도록 했거나, 위의 useWindowDimensions 함수와 같이 필요한 state를 자유롭게 조절할 수 있도록 구현했어야 됐던 것 같습니다.

     

    # 2-5 아직 구현 못한 기능

     

    캐러셀을 마우스로 drag하여 다음 슬라이드로 넘어가는 기능을 구현 못했습니다. 아직 로직을 생각 못했기 때문에 현재 구현을 못했지만 (그리고 시간 부족으로...) 추후에 구현하도록 하겠습니다.

     

    # 3 Card

     

    Card를 활용하여  json 데이터를 위와 같이 다양한 종류의 카드들로 구현할 수 있도록 했습니다.

     

    # 3-1 type에 따른 다양한 디자인의 카드

     

    Card는 type (오늘의 특가, 인기있는 신규 클래스, 오픈예정 클래스) 등에 맞추어 다르게 디자인을 구현하도록 했습니다. 예를 들어 가장 위의 오늘의 특가 섹션은 제목 위에 시간 제한, 제목 옆의 폭탄 이모티콘 등이 다릅니다. 오픈 예정 클래스는 아래에 버튼이 있으며 응원마감까지 몇일 남았는지 써있습니다.

     

    # 3-2 시간 계산

     

    특가 마감까지 몇 시간 몇 분 남았는지, 응원마감까지 몇 일 남았는지 등 시간 관련 데이터들이 많았는데 이는 함수를 직접 구현하여 사용했습니다. 

    const getDay = (yearMonthDate: string) => {
      const days = ["일", "월", "화", "수", "목", "금", "토"];
      let [year, month, date] = yearMonthDate.split(/\.|-/);
    
      const target = new Date(`20${year}-${month}-${date}`);
      const day = target.getDay();
    
      return `${month}.${date} (${days[day]})`;
    };

    인기 있는 신규 클래스 섹션의 경우 요일을 작성해야하는데 위와 같이 함수를 통해 계산할 수 있도록 했습니다.

     

    # 4 Header 및 Navigation Bar

    # 4-1 전체카테고리 및 아쉬운 점

     

    네비게이션바의 전체 카테고리를 호버할 때 첫번째 카테고리가 먼저 뜨고, 각 카테고리를 호버 할때  그 하위범주들이 나타나도록 구현했습니다. 첫번째 카테고리가 나타나도록은 css를 통해 구현했으며 카테고리별 하위범주는 마우스 이벤트를 통해 구현했습니다.

    // mouse event
      const firstUL = useRef<HTMLUListElement>(null);
      const onMouseEnter = () => {
        const { current } = firstUL;
        if (current != null) {
          current.style.width = `391px`;
        }
      };
      const onMouseLeave = () => {
        const { current } = firstUL;
        if (current != null) {
          current.style.width = `180px`;
        }
      };

    위와 같이 구현한 이유는 처음에는 css hover을 사용하여 구현하도록 했었는데 li를 hover하여 하위범주를 떠도 li의 영역을 조금만 벗어나면 두번째 카테고리가 사라져 못 넘어가는 문제에 직면했었습니다. 이는 위의 코드와 같이 첫번째 카테고리를 늘려 두번째 카테고리로 넘어갈 수 있도록 하여 해결했습니다. 그러나 이 방법은 최선의 방법이 아니라고 생각되어 많이 아쉽습니다.

     

    # 4-2 Search Bar

     

    Search Bar 클릭 시 createPortal을 활용하여 모달을 띄우도록 했습니다. 그리고 추천 검색어 클릭 시에 해당 텍스트를 localStorage에 저장하고 최근 검색어에 나타나도록 구현했습니다. 

    여기서 구현하기 좀 어려웠던 점은 ref를 map()과 같이 사용했을 때 어떻게 각 엘리먼트에 ref를 줄 수 있는지에 대한 방법이었습니다. 

    <RecommendSearchBox>
    	{recommendSearch.recommend_search.map((value, index) => (
          <button
            className={"recommendButton"}
            key={value}
            ref={refs.current[index]}
            onClick={() => saveData(index)}
          >
            {value}
          </button>
    </RecommendSearchBox>

    검색해보니 ref로 구성된 배열을 활용하는 방법이 가장 일반적으로 사용하는 방법인 것 같습니다. 

    // localstorage
      const recommendSearchLength = recommendSearch.recommend_search.length;
      let refs: React.MutableRefObject<React.RefObject<HTMLButtonElement>[]> =
        useRef([...new Array(recommendSearchLength)].map(() => React.createRef()));
    
      const [searchedWords, setSearchedWords] = useState<string[]>([]);
    
      useEffect(() => {
        localStorage.setItem("searchedWords", JSON.stringify(searchedWords));
      }, [searchedWords]);
    
      const saveData = (index: number) => {
        const newSearchedWord = refs.current[index].current?.textContent;
        if (newSearchedWord) {
          if (!searchedWords.includes(newSearchedWord))
            setSearchedWords([newSearchedWord, ...searchedWords]);
        }
      };
    
      const removeData = (filterword: string) => {
        const filterWords = searchedWords.filter((word) => {
          return word !== filterword;
        });
        setSearchedWords(filterWords);
      };
    
      const clearAllData = () => {
        setSearchedWords([]);
      };

    추천검색어 클릭 시 searchedWords 배열에 추가 하도록 했으며, useEffect()를 통해 searchedWords 배열 값에 변화가 생기면 localStorage에 저장하는 방식으로 구현했습니다. 삭제도 이와 비슷한 방법으로 구현했습니다.

     

    # 4 Top Banner

    # 4-1 아쉬운 점

     

    TopBanner의 구현할 때 제가 캐러셀의 설계를 잘못했다는 생각이 들었습니다. 기존에 보여줬던 케러셀들은 슬라이드 하나가 전체적으로 넘어갔던 반면, TopBanner은 위를 보시다시피 캐러셀은 이미지에만 적용되고 해당 이미지와 관련된 title이나 subtitle은 슬라이드가 넘어가면서 이에 맞게 데이터가 바뀌는 구조입니다. 

     

    저는 카드와 아래배너를 먼저 구현을 했는데 이들 모두 아래 코드와 같이 위에서 아래로 데이터를 받는 방식이었습니다.

    <Section
      title={"오픈 예정 클래스"}
      subTitle={"오픈 예정인 클래스를 응원하면 얼리버드 오픈 시 알려드려요!"}
      button={true}
      >
      <Carousel slidesView={4} navPosition="eachSide" iconColor="black">
        {openSoon.open_soon.map(({ id, title, creator, img, cheer }) => (
          <Card
            type="openSoon"
          	key={id}
          	title={title}
          	creator={creator}
          	img={img}
          	cheer={cheer}
          />
        ))}
      </Carousel>
    </Section>

    그런데 TopBanner을 구현하기 위해서는 위의 Carousel과 Card가 같이 공존하는 형태여야 하는데 Carousel에 먼저 구현한 방법으로는 버튼들이 Card에 사용할 수 없는 형태였습니다. 그래서 어쩔 수 없이 기존에 구현한 Carousel과 Banner을 재사용을 못하고 처음부터 다시 다 만들었습니다. 지금 시간이 부족하여 못 고쳤지만 추후에 고치도록 하겠습니다.

     

    # 4-2 Pagination

     

    pagination은 두가지 타입을 제공할 수 있도록 했습니다. 하나는 위와 같이 숫자로 된 pagination이며, 다른 하나는 둥근 모형으로 구성된 타입입니다.

    pagination에는 현재 페이지, 전체 슬라이드 수, slidesPerView, 그리고 슬라이드 관련 state를 제어할 수 있는 함수를 받도록 했습니다. 받은 데이터들을 조합하여 원하는 pagination이 나타나도록 했습니다. onClickPaginationHandler는 위의 동그라미를 클릭 시 해당 페이지로 넘어가도록 하기 위해서 사용한 것입니다. 

     const onClickPaginationHandler = (index: number) => {
        setActive(index);
      };

    위는 캐러셀에서 구현한 onClickPaginationHandler입니다. 

    // pagination 주요 코드
    const Pagination: React.FC<PaginationProps> = ({
      paginationType,
      active,
      slidesPerView,
      childrenCount,
      onClickPaginationHandler = () => {},
      className,
    }) => {
      const pages = childrenCount - slidesPerView + 1;
      const currentPage = active + 1;
      const currentPageString = pageNumberToString(currentPage);
      const lastPageString = pageNumberToString(pages);
    
      const array = Array.from(Array(pages).keys());
      const circleOrNot = (index: number) => {
        if (active === index) return "longCircle";
        else return "circle";
      };
    
      if (pages > 1)
        return (
          <PaginationContainer className={className}>
            {paginationType === "number" && (
              <PaginationNumber>
                <span className="currentPage">{currentPageString}</span>
                <span className="bar"> | </span>
                <span className="lastPage">{lastPageString}</span>
              </PaginationNumber>
            )}
            {paginationType === "circle" && (
              <PaginationCircle>
                {array.map((val, index) => {
                  return (
                    <div
                      key={val}
                      className={circleOrNot(index)}
                      onClick={() => onClickPaginationHandler(index)}
                    ></div>
                  );
                })}
              </PaginationCircle>
            )}
          </PaginationContainer>
        );
      else return <div></div>;
    };

     

     

    # 4-3 Progress Bar

     

    Progress Bar는 재활용성을 높이기 위해 Progress Bar과 애니메이션을 reset할 수 있는 함수를 따로 export 했습니다.

    const useProgressBar = () => {
      const [toggle, setToggle] = useState("");
    
      const resetAnimation = () => {
        setToggle("");
        setTimeout(() => {
          setToggle("animation");
        }, 100);
      };
    
      const ProgressBar: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
        className,
      }) => {
        return (
          <BarContainer className={className}>
            <div className={toggle} />
          </BarContainer>
        );
      };
    
      return { resetAnimation, ProgressBar };
    };

    애니메이션 재시작은 애니메이션 관련 css에 className을 toggle하는 방법으로 구현했습니다. 바로 toggle하면 애니메이션이 리셋되지 않아 setTimeout()을 활용하여 100ms 후에 토글하도록하여 구현했습니다. 

     

    # 4-3 TopBanner 특징

    <CarouselTopBanner array={topEvent.top_event} />

    TopBanner을 구현하기 위해서는 주어진 데이터를 있는 그대로 활용을 못하고 조금 가공해야하기에 데이터를 통째로 받도록 했습니다.

    // imagesrcTitleBadge Array
    const imgsrcTitle = array.map((val) => {
      let badge: string;
      if (!val.badge) badge = "";
      else badge = val.badge;
    
      return [val.img, val.title, badge];
    });

    위와 같이 필요한 데이터들만 골라 사용할 수 있도록 했습니다. 위는 이미지만으로 구성된 캐러셀에 넣어야할 데이터들을 가공한 것입니다. 

     

    # 5 반응형 디자인

     

    # 5-1 아쉬운 점

     

    위의 사진에서 잘 안나타나지만 topBanner의 경우 사진이 찌그러지는 느낌이 들어서 아쉬었습니다. width와 cacl()를 활용하여 알맞는 비율을 찾아야한다고 생각했는데 잘 안되었습니다...

     

    # 5-2 아쉬운 점

     

    Carousel 사용자들에게 옆에 슬라이드가 더 있다는 것을 인지시키기 위해서는 옆에 슬라이드를 빼꼼 나오게 구현해야했지만 어떻게 구현해야할지 생각을 못해 아직 못했습니다... 이것도 좀 더 방법을 생각해보고 추후에 하도록 하겠습니다....

     

    # 6 전체적 후기

     

    중요한 기능은 drag를 제외하고 어느정도 다 구현한 것 같습니다. 그런데 급한 마음에 하다보니 코드들이 중구난방이어서 정돈되지 않은 코드들이 많다고 생각합니다. 특히 css의 media-query가 여기저기 복잡하게 얽혀 있고, 불필요한 코드들도 많다고 느껴져서 전반적으로 아쉽다는 생각이 듭니다. 추후에 drag 기능을 구현하고 코드들을 검토하여 좀 더 가독성 좋은 코드로 만들도록 하겠습니다.

    '리액트' 카테고리의 다른 글

    [넘블 챌린지] 색깔 찾기 게임 만들기  (0) 2022.02.13
    react-router-dom  (0) 2021.09.15
    ref & useRef() & forwardRef()  (0) 2021.09.11
    Portals  (0) 2021.09.08
    Context  (0) 2021.09.05
Designed by Tistory.