[Clone Project-4] Carousel 구현

young_pallete·2021년 7월 5일
0

시작하며 🌈

후... 제가 클론하려던 프로그래머스의 carousel은 꽤나 반응형으로 만들려던 흔적이 있어서인지,
모든 것들을 똑같이 구현하기 정말 힘들었습니다.
그러나! 성능 면에서는 어떨지 몰라도, 기능은 그대로 구현할 수 있게 됐습니다.
그 과정을 기록하며, 시작!

본론

일단 제가 구현하려던 기존 프로그래머스의 carousel은 말이죠, 다음과 같은 특징을 지녔습니다.

채용 프로그램 Carousel
Carousel 반응형

특징 1. 버튼

  1. 위의 화살표 버튼은 항상 왼쪽, 오른쪽 끝까지 가면 해당 방향 버튼이 비활성화됩니다.
  2. 밑에 버튼을 누르면 해당 번호의 페이지만큼 이동하고, 위의 버튼은 해당 방향으로 하나씩 이동합니다.
  3. 575px ~ 767px에서는 해당 프로그램 아이템들이 2개씩 이동합니다. 이때, 버튼의 수가 절반으로 줄어듭니다.
  4. 574px에서는 버튼 클릭 시 다시 1개씩 이동하는데, 여전히 버튼의 수는 최대 크기 대비 절반입니다.

특징 2. 이미지

  1. 991px 이하부터 이미지의 크기가 바뀝니다.
  2. 반응형으로 줄일 때마다 해당 이미지의 가운데가 계속 보여야 합니다.
  3. 이때, 웹사이트 특성상 검색 최적화를 위해 background-image를 쓰지 않고 있었습니다!

특징 3. 프로그램

여기는 breakpoint3곳이 있었습니다.

992px~ 개인적으로 기본 사이즈로 설정 (데스크탑 이용자가 많기 때문)
575px~767px 2개로 분할해서 보여줌
~574px 다시 하나의 프로그램 보여줌

그리고 데이터의 경우, 직접 서버로 받아오는 것까지 구현하기는 사족인 것 같아서, json 파일을 따로 만들어서 import하는 방식으로 구현했습니다.

대략 이정도의 고민 거리가 있었습니다.
여튼 이정도로 설명을 끝내고, 코드를 봅시다!

구현 - index.ts

import programData from "./datas/program-data.json";
import throttle from "./Throttle.ts";
window.addEventListener('DOMContentLoaded', () => {
    function openBar(e: Event): void {
        const $navBar: HTMLElement = document.querySelector('.nav__navbar');
        $navBar.classList.toggle('active');
    }
    function openSignUpPage(e:MouseEvent):void {
        window.location.href = 'https://programmers.co.kr/users/signup';
    }

    document.querySelector('.nav__btn').addEventListener('click', openBar);
    document.querySelector('.header__sign-up-btn').addEventListener('click',  openSignUpPage);

    interface DataFormat {
        name: string;
        url: string;
        image: string;
        receipt: string;
        test: string;
        language: string[];
    }
    interface Names {
        [name: string]: string;
    }
    class Program {
        private readonly names: Names;
        private num: number;
        private dataLength: number;
        private nowWidth: number;
        private moveWidth: number;
        private windowWidth: number;
        private cardWidth: number;
        // 받아들여온 데이터는 수정 불가하도록 readonly로 작성
        constructor(private readonly datas: Array<DataFormat>) {
            this.names = {
                programLink: "programs__link",
                programImage: "programs__image",
                programInfo: "programs__program-info",
                programTitle: "programs__program-title",
                programTimes: "programs__program-times",
                programPeriodReceipt: "programs__period-receipt",
                programPeriodTest: "programs__period-test",
                programLanguage: "programs__program-language",
                languageItem: "programs__language-item",
                moreLanguage: "programs__more-language",
                pageBtn: "programs__page-btn",
                pageBtns: "programs__page-btns",
                programCard: "programs__program-card",
                programCards: "programs__program-cards",
                moveBtn: "programs__move-btn",
                leftBtn: "programs__move-left",
                rightBtn: "programs__move-right",
                cardItems: "programs__card-items"
            }; // 클래스명을 관리함.
            this.num = 0; // 현재 페이지 number
            this.windowWidth; // 초기 윈도우 너비 -> HandleResize에서 받아옴
            this.nowWidth; // 카드 너비 -> initialize
            this.moveWidth; // 한 번 움직일 때마다의 값 -> HandleResize에서 받아옴
            this.dataLength; // 데이터 길이 -> HandleResize에서 조정도 할 예정.
            this.initialize()

            this.render(); // 카드와 pagination 버튼 생성
            this.HandleEvents(); // 이벤트 함수를 모아놓음
            this.HandleResize(); // 초기 card width 세팅 및 리사이즈 이벤트 시 실행될 함수
            this.checkDisable(); // 버튼의 disable 속성 부여 여부 체크
        }

        private initialize(): void {
            this.windowWidth = window.innerWidth;
            this.num = this.setNum(this.windowWidth);
            this.nowWidth = this.setWidth(this.windowWidth);
            this.moveWidth = this.setMoveWidth(this.windowWidth);
            this.dataLength = this.setDataLength(this.windowWidth);
            this.reRenderPageBtn();
        }
        private renderCard(): void {
            function makeLinkElement(parent:HTMLElement, data: DataFormat): void {
                // 링크주소를 포함함
                const $programLink:HTMLElement = document.createElement('a');
                $programLink.className = this.names.programLink;
                $programLink.setAttribute("href", data.url);
                
                // 이미지를 포함함
                const $programImage: HTMLElement = document.createElement('img');
                $programImage.className = this.names.programImage;
                $programImage.setAttribute('src', data.image);
                $programImage.setAttribute('alt', "채용 프로그램 이미지");
                
                // programLink에 image를 넣어줌
                $programLink.appendChild($programImage);

                //부모노드에게 programLink를 넣어줌
                parent.appendChild($programLink);
            }

            function makeInfoElement(parent: HTMLElement, data: DataFormat): void {
                const $programInfo:HTMLElement = document.createElement("section");
                $programInfo.className = this.names.programInfo;

                const $programTitle:HTMLElement = document.createElement("h3");
                $programTitle.className = this.names.programTitle;
                $programTitle.textContent = data.name;

                const $programTimes:HTMLElement = document.createElement("h4");
                $programTimes.className = this.names.programTimes;

                // programInfo에 title, times를 넣어줌. 
                $programInfo.appendChild($programTitle);
                $programInfo.appendChild($programTimes);

                const $programPeriodReceipt:HTMLElement = document.createElement("span");
                $programPeriodReceipt.className = this.names.programPeriodReceipt;
                $programPeriodReceipt.textContent = `접수: ${data.receipt}`;
                const $programPeriodTest:HTMLElement = document.createElement("span");
                $programPeriodTest.className = this.names.programPeriodTest;
                $programPeriodTest.textContent = `테스트: ${data.test}`;
                
                const $programLanguage:HTMLElement = document.createElement('ul');
                $programLanguage.className = this.names.programLanguage;
                if (data.language.length > 8) {
                    const $moreLanguage: HTMLElement = document.createElement('li');
                    const moreLanguageArr: string[] = [];
                    $moreLanguage.className = this.names.moreLanguage;
                    data.language.forEach((each:string, idx: number) => {
                        if (idx <= 7) {
                            const $languageItem:HTMLElement = document.createElement('li');
                            $languageItem.textContent = each;
                            $languageItem.className = this.names.languageItem;
                            $programLanguage.appendChild($languageItem);
                        } else {
                            moreLanguageArr.push(each);
                        }
                    })
                    $moreLanguage.textContent = `+${moreLanguageArr.length}`
                    $moreLanguage.dataset.language = moreLanguageArr.join(', ')
                    $programLanguage.appendChild($moreLanguage);
                } else {
                    data.language.forEach((each: string) => {
                        const $languageItem:HTMLElement = document.createElement('li');
                        $languageItem.textContent = each;
                        $languageItem.className = this.names.languageItem;
                        $programLanguage.appendChild($languageItem);
                    })
                }
                $programInfo.appendChild($programLanguage);

                // programTimes에 Receipt, Test를 넣어줌.
                $programTimes.appendChild($programPeriodReceipt);
                $programTimes.appendChild($programPeriodTest);

                parent.appendChild($programInfo);
            };

            function makeLabelElement(parent:HTMLElement, data:DataFormat): void {
                const label:HTMLElement = document.createElement('div');
                label.className = "programs__label";
                parent.appendChild(label);
            };
            
            // forEach가 map보다 메모리를 저장하지 않기에 빠르므로 forEach로 처리.
            this.datas.forEach((data: DataFormat, idx: number): void => {
                const $programCard:HTMLElement = document.createElement('li'); 
                $programCard.className = this.names.programCard;
                
                const $cardItem:HTMLElement = document.createElement('div')
                $cardItem.classList.add(this.names.cardItems);

                makeLabelElement.bind(this)($programCard, data);
                makeLinkElement.bind(this)($cardItem, data);
                makeInfoElement.bind(this)($cardItem, data);
                
                // 첫번째라면 클래스명 추가
                if(!idx) $programCard.classList.add(`${this.names.programCard}--active`);
                $programCard.appendChild($cardItem);
                document.querySelector(`.${this.names.programCards}`).appendChild($programCard)
            })
        }

        // 버튼을 필요한만큼 다시 조정
        private reRenderPageBtn(): void {
            const $pageBtns = document.querySelector(`.${this.names.pageBtns}`);
            const differenceCount = $pageBtns.children.length - this.dataLength;
            if (!differenceCount) return;
            // 만약 0보다 작다면 버튼을 더 넣어줘야 함.
            else if (differenceCount < 0) {
                this.renderPageBtn(-differenceCount);
            } else {
                for (let i = 0; i < differenceCount; i++) {
                    const $pageBtn = document.querySelector(`.${this.names.pageBtn}`);
                    $pageBtns.removeChild($pageBtn)
                }
            }
            this.checkActive(this.names.pageBtn);
        }
        // 버튼 생성
        private renderPageBtn(count: number): void {
            const $pageBtns: HTMLElement = document.querySelector(`.${this.names.pageBtns}`);
            for (let i:number = 0; i < count; i++) {
                const $pageBtn:HTMLElement = document.createElement('li');
                $pageBtn.className = this.names.pageBtn;
                $pageBtns.appendChild($pageBtn);
            };
            this.checkActive(this.names.pageBtn);
            // const $pageBtn = document.querySelectorAll(`.${this.names.pageBtn}`);
            // $pageBtn[0].classList.add(`${this.names.pageBtn}--active`);
        };
        private render(): void {
            this.renderCard(); // DOMContentLoad될 시 카드를 렌더링함
            this.renderPageBtn(this.dataLength); // DOMContentLoad시 pagination할 밑의 버튼을 렌더링함.
        }

        // 너비를 구한다
        private setWidth(windowWidth: number): number {
            /**
             * mobile-and-tablet에서만 조정수치가 달라짐.
             */
            if (991 < windowWidth) {
                return 1160;
            }
            else if (991 < windowWidth && windowWidth <= 1200) {
                return (windowWidth - 56);
            }
            else if (767 <= windowWidth && windowWidth <= 991) {
                return (windowWidth - 124);
            }
            else if (574 < windowWidth && windowWidth < 767) {
                return (windowWidth) / 2 - 48
            } else {
                return windowWidth - 64;
            }
        }

        // moveCardWidth를 구한다
        private setMoveWidth(windowWidth: number): number {
            /**
             * mobile-and-tablet에서만 조정수치가 달라짐.
             */
            if (991 <windowWidth) {
                return (this.nowWidth + 16);
            }
            else if (574 < windowWidth && windowWidth < 767) {
                return (this.nowWidth * 2 + 64);
            } else {
                return (this.nowWidth + 32);
            }
        }

        // 버튼 개수를 정한다.
        private setDataLength(windowWidth: number): number {
            // mobile-and-tablet부터는 버튼이 1/2개로 줄어듦. (만약 9개라면, 올림을 해서 5개로 계산)
            if (windowWidth < 767) return Math.ceil(this.datas.length / 2);
            else return this.datas.length;
        }

        // num 숫자를 정한다. (버튼 active를 위해)
        private setNum(windowWidth: number): number {
            let res:number = 0;
            document.querySelectorAll(`.${this.names.programCard}`).forEach((card: HTMLElement, idx: number) => {
                if (card.classList.contains(`${this.names.programCard}--active`)) {
                    res = idx;  
                }
            })
            if (windowWidth < 767) return Math.floor(res / 2);
            else return res;
        }

        private checkActive(name: string) {
            const nodeList = document.querySelectorAll(`.${name}`);
            if (this.nowWidth < 767 && name !== this.names.pageBtn) {
                nodeList.forEach((node: HTMLElement) => {
                    node.classList.toggle(`${name}--active`, node === nodeList[this.num * 2])
                })
            } else {
                nodeList.forEach((node: HTMLElement) => {
                    node.classList.toggle(`${name}--active`, node === nodeList[this.num])
                })
            }
        }
        

        private HandleResize(e?:MouseEvent): void {
            this.initialize()
            const $cardItems: NodeListOf<HTMLElement> = document.querySelectorAll(`.${this.names.cardItems}`);
            $cardItems.forEach((cardItem: HTMLElement) => {
                cardItem.style.width = `${this.nowWidth}px`;
            })
            const $programCards:HTMLElement = document.querySelector(`.${this.names.programCards}`);
            $programCards.style.transform = `translate(${-this.moveWidth * this.num}px, 0)`;
        };

        private HandleEvents(): void {
            function HandlePageBtn(e:MouseEvent): void {

                const target = e.target as HTMLElement;
                const pageBtns:NodeListOf<HTMLElement> = document.querySelectorAll(`.${this.names.pageBtn}`);
                const $programCards:HTMLElement = document.querySelector(`.${this.names.programCards}`);

                if (!target.classList.contains(this.names.pageBtn)) return;
                pageBtns.forEach((btn, idx) => {
                    btn.classList.toggle(`${this.names.pageBtn}--active`, btn === target)
                    if (btn === target) this.num = idx;
                })
                // this.checkActive(this.names.pageBtn);

                $programCards.style.transform = `translate(${-this.moveWidth * this.num}px, 0)`;
                this.checkDisable()
            }
            function HandleMoveBtn(e:MouseEvent): void {
                const $programCards:HTMLElement = document.querySelector(`.${this.names.programCards}`);

                const target = e.target as HTMLElement;
                if (this.num && target.classList.contains(this.names.leftBtn)) {
                    this.num--;
                }  else if (this.num !==  this.dataLength - 1 && target.classList.contains(this.names.rightBtn)) {
                    this.num++
                } 
                else return;

                $programCards.style.transform = `translate(${-this.moveWidth * this.num}px, 0)`;
                this.checkActive(this.names.pageBtn);
                this.checkActive(this.names.programCard);
                this.checkDisable()
            }
            document.querySelector(`.${this.names.moveBtn}`).addEventListener('click', HandleMoveBtn.bind(this));
            document.querySelector(`.${this.names.pageBtns}`).addEventListener('click', HandlePageBtn.bind(this));
            window.addEventListener('resize', throttle(this.HandleResize.bind(this)));
        }

        private checkDisable(): void {
            document.querySelector(`.${ this.names.leftBtn}`).classList.toggle(`${ this.names.leftBtn}--disable`, !this.num);
            document.querySelector(`.${ this.names.rightBtn}`).classList.toggle(`${ this.names.rightBtn}--disable`, this.num === this.dataLength - 1);
        }
    }

    new Program(programData);
})

구현 2- Throttle.ts

let timer: number;
let baewi: string = '베위~';
const throttle:Function = function (cb: any) {
    return function () {
        if (!timer) {
            // setTimeout을 이용하여 설정한 주기마다 콜백이 실행될 수 있도록 하였고,
            // 실행이 끝난 후에는 다시 throttleCheck를 false로 만들어 주어, 설정한 주기마다 이벤트가 한 번씩만 호출되도록 하였습니다.
            timer = setTimeout(() => {
                cb(...arguments);
                timer = null;
            }, 300);
        }
    }
}
export default throttle;

정상적으로 작동하는 데 성공합니다!

미해결 과제

  1. 라벨에 진행 중, 종료 중 이라는 content가 데이터에 따라 동적으로 구성되어야 했지만 아직 이를 해결하지 못했습니다.
  2. program__language의 경우, 아직 반응형 최적화가 되지 않았습니다. overflowhidden되어 잘리네요.
  3. 일단 쓰로틀까지 해서 최대한 이벤트로 인한 과부하를 막으려했지만, 여전히 기존 사이트보다는 좀 더 과부하가 걸립니다. 이 역시 최적화 방안을 고민해야겠습니다.
  4. 가장 중요한 건데, 클래스를 너무 하나로 크게 만든 듯 싶습니다. 꽤나 코드가 지저분하군요...
    추후 클래스들로 분할시켜서 합치는 게 더 깔끔할 듯 합니다.

마치며 🎉

여튼 이러한 미해결 과제는 지금 당장 다른 것들도 많이 남아 있기에, 추후에 바꾸려 합니다.

그래도 뭔가 직접 3일간 자투리 시간 써가며 바꿨는데, 결과물이 나오니 뿌듯함이 듭니다 😘

나머지도 빠르게 마무리해서, 미해결 과제들도 구현해봐야겠습니다. 그럼!

profile
People are scared of falling to the bottom but born from there. What they've lost is nth. 😉

0개의 댓글