Vanilla JS Sticker Project V2

DW J·2022년 10월 5일
0

project_sticker

목록 보기
2/2

스터디 과제 - 요구하는 기능을 구현하는 목적으로 만든 프로젝트입니다

Sticker 프로젝트 요구사항2

localStorage 저장

  • 현재의 스티커 상태를 localStorage에 저장합니다
    - 스티커의 상태가 변경되는 모든 상황마다 저장해야 합니다. (ex. 이동, 추가, 삭제 등)
    - 스티커의 상태를 관리하기 위해서 객체 지향 프로그래밍으로 스티커와 항목을 클래스로 만들면 좋습니다. 다른 방법을 사용해도 상관없습니다
    - 현재 스티커의 상태를 객체형태의 데이터로 만들어야 합니다. 그래야 문자열로 만들어 localStorage에 저장할 수 있습니다.
  • 최초 화면 로드시 localStorage에 저장되어 있는 스티커 정보를 가져와 그대로 다시 복원합니다

첫 번째 요구사항에서 로컬 스토리지를 이용하여 데이터를 추가/삭제하는 기능 요구
프로젝트를 시작할 때 Class를 사용하여 만들어 두었기 데이터를 쉽게 추가/삭제 할 수 있을거라 생각했지만 예상하지 못했 던 곳에서 다양한 문제점이 발생하였습니다

※ HTML과 CSS는 V1과 비교해 봤을 때 크게 변화된 게 없어 생략

프로젝트 구조

app > StickerLayout > Sticker > StickerList

1. App

  • Entry Point
  • 버튼의 이벤트 연결 및 스티커 레이아웃 생성
import StickerLayout from "./stickerLayout.js";

window.addEventListener("DOMContentLoaded", (e) => {
    const stickerAdd = document.querySelector("#sticker-add");
    const stickerRemoveAll = document.querySelector("#sticker-remove-all");
    const stickerLayout = new StickerLayout();
    stickerLayout.render(document.body);

    // 스티커 추가
    stickerAdd.addEventListener("click", e => {
        stickerLayout.createSticker();
    });

    // 스티커 전부 삭제
    stickerRemoveAll.addEventListener("click", e => {
        stickerLayout.removeStickerAll();
    });
});

2. StickerLayout

  • Sticker 인스턴스 생성 및 삭제/수정 로직이 존재
  • Sticker / StickerList에서 dispatch된 이벤트 로직이 정의 되어 있음
import Sticker from "./sticker.js";

export default class StickerLayout {
    constructor() {
        this.el = null;
        this.count = 0;
        this.zIdx = 0;
        this.stickerList = [];
        this.initialPositionX = 5;
        this.initialPositionY = 5;

        this.fetchDatas();
        this.initElement();
    }

    initElement() {
        const element = document.createElement("div");

        element.classList.add("sticker-wrapper");

        // 다 없어져야 할 로직
        element.addEventListener("removeSticker", this.handleClickRemoveSticker.bind(this));
        element.addEventListener("changeZindex", this.handleMousedownChangeZindex.bind(this));
        element.addEventListener("changeSticker", this.handleClickUpdate.bind(this));
        element.addEventListener("updateSticker", this.setStorage.bind(this));

        this.el = element;
    }

    fetchDatas() {
        const storage = localStorage;
        const datas = JSON.parse(storage.getItem("stickers"));

        if (datas) {
            this.stickerList = datas;
        }
    }

    setStorage() {
        const storage = localStorage;
        storage.setItem("stickers", JSON.stringify(this.stickerList));
    }

    render(parent) {
        this.renderSticker();
        parent.appendChild(this.el);
    }

    renderSticker() {
        this.stickerList = this.stickerList?.map(data => {
            const sticker = new Sticker(data);

            sticker.render(this.el);
            return sticker;
        });
    }

    createSticker() {
        const data = {
            id: `sticker_${crypto.randomUUID()}`,
            stickerCount: ++this.count,
            zIdx: ++this.zIdx,
            position: {
                currentX: this.initialPositionX,
                currentY: this.initialPositionY
            },
            parentClientRect: this.el.getBoundingClientRect(),
        };
        const sticker = new Sticker(data);

        // 필요한 데이터만 저장한다
        this.stickerList.push(sticker);
        sticker.render(this.el);

        this.setStorage();

        // 아이템 추가 후 초기값에 10을 더해준다
        this.initialPositionX = this.initialPositionX + 10;
        this.initialPositionY = this.initialPositionY + 10;
    }

    removeSticker(id) {
        this.stickerList = this.stickerList.filter(sticker => sticker.id !== id);

        this.count--;
        this.initialPositionX = this.initialPositionX - 10;
        this.initialPositionY = this.initialPositionY - 10;
    }

    removeStickerAll() {
        this.stickerList.forEach(sticker => {
            this.removeSticker(sticker.id);
            sticker.el.remove();
        });

        this.setStorage();
    }

    updateZindex(id) {
        const updateSticker = this.getSticker(id);

        if (updateSticker[0].zIdx !== this.zIdx) {
            updateSticker[0].zIdx = ++this.zIdx;
        }
    }

    getSticker(id) {
        return this.stickerList.filter(sticker => sticker.id === id);
    }

    handleClickRemoveSticker(e) {
        const { id } = e.detail;

        this.removeSticker(id);
        this.setStorage();
    }

    handleMousedownChangeZindex(e) {
        const { id } = e.detail;

        this.updateZindex(id);
        this.setStorage();
    }

    handleClickUpdate(e) {
        const { id, startParentID, endParentID } = e.detail;
        const startSticker = this.getSticker(startParentID)[0];
        const endSticker = this.getSticker(endParentID)[0];

        const removeList = startSticker.removeList(id);
        endSticker.updateList(removeList[0]);
        this.setStorage();
    }
}

3. Sticker

  • StickerList 인스턴스 생성 및 삭제/수정 로직이 존재
  • StickerList에서 dispatch된 이벤트 로직이 정의 되어 있음
import StickerList from "./stickerList.js";

export default class Sticker {
    constructor(options) {
        this.el = null;
        this.listEl = null;
        this.itemList = [];
        this.listCount = 0;
        this.isDraggable = false;

        Object.assign(this, options);

        this.titleColor = options.titleColor || "";
        this.bgColor = options.bgColor || this.makeBgColor();
        this.position.shiftX = 0;
        this.position.shiftY = 0;

        this.initElement();
        this.initBindEvent();
    }

    initElement() {
        const sticker = document.createElement("div");
        sticker.classList.add("sticker");
        sticker.id = this.id;

        this.setStyle(sticker);

        const header = this.createHeader();
        const contents = this.createContents();

        sticker.appendChild(header);
        sticker.appendChild(contents);

        this.el = sticker;
    }

    initBindEvent() {
        const header = this.el.querySelector(".sticker-header");

        // DRAG 이벤트 바인딩
        header.addEventListener("mousedown", this.dragStart.bind(this));
        header.addEventListener("mouseup", this.dragEnd.bind(this));

        // customEvent
        this.el.addEventListener("removeList", this.handleClickRemoveList.bind(this));
        this.el.addEventListener("chageList", this.handleClickUpdate.bind(this));
    }

    createHeader() {
        const header = document.createElement("div");
        header.classList.add("sticker-header", "cursor-move");

        const headingTitle = document.createElement("h3");
        headingTitle.textContent = `STICKER ${this.stickerCount}`;
        headingTitle.style.color = this.titleColor;

        const btnSettings = document.createElement("div");
        btnSettings.classList.add("sticker-setting");

        const addListBtn = document.createElement("button");
        addListBtn.classList.add("btn");
        addListBtn.id = "sticker-list-add";
        addListBtn.type = "button";
        addListBtn.textContent = "항목추가";
        addListBtn.addEventListener("click", this.handleClickAdddList.bind(this));

        const removeSticker = document.createElement("button");
        removeSticker.classList.add("btn");
        removeSticker.id = "sticker-remove";
        removeSticker.type = "button";
        removeSticker.textContent = "스티커삭제";
        removeSticker.addEventListener("click", this.handleClickRemoveSticker.bind(this, this.id));

        btnSettings.appendChild(addListBtn);
        btnSettings.appendChild(removeSticker);

        header.appendChild(headingTitle);
        header.appendChild(btnSettings);

        return header;
    }

    createContents() {
        const contents = document.createElement("div");
        contents.classList.add("sticker-contents");

        const list = document.createElement("ul");
        list.classList.add("sticker-list");

        contents.appendChild(list);

        this.listEl = list;

        return contents;
    }

    render(parent) {
        this.renderList();
        parent.append(this.el);
    }

    renderList() {
        this.itemList = this.itemList?.map(data => {
            const list = new StickerList(data);

            list.render(this.listEl);
            return list;
        });
    }


    // CRUD
    createList() {
        const data = {
            id: `list_${crypto.randomUUID()}`,
            stickerCount: this.stickerCount,
            listCount: ++this.listCount,
            parentEl: this.listEl,
        }

        const list = new StickerList(data);
        list.render(this.listEl);

        this.itemList.push(list);
    }

    removeList(id) {
        const removeListIdx = this.itemList.findIndex(item => item.id === id);
        const removeList = this.itemList.splice(removeListIdx, 1);

        this.updateSticker();

        return removeList;
    }

    updateList(list) {
        this.itemList.push(list);
    }

    // DRAG
    dragStart(e) {
        if (e.target.tagName === "BUTTON" || e.target.tagName === "LI") return false;
        e.preventDefault();

        const pageX = e.pageX;
        const pageY = e.pageY;
        const clientX = e.clientX;
        const clientY = e.clientY;
        const currentElX = this.el.getBoundingClientRect().x;
        const currentElY = this.el.getBoundingClientRect().y;
        const parentX = this.parentClientRect.x;
        const parentY = this.parentClientRect.y;

        // 현재 선택 된 스티커가 가장 상단에 올 수 있도록 zindex값을 변경한다
        const event = new CustomEvent("changeZindex", { bubbles: true, detail: { id: this.id } });
        this.el.dispatchEvent(event);
        this.el.style.zIndex = this.zIdx;

        this.isDraggable = true;

        this.position.shiftX = clientX - (currentElX - parentX);
        this.position.shiftY = clientY - (currentElY - parentY);

        this.setPosition(pageX, pageY);

        document.addEventListener("mousemove", this.dragMove.bind(this));
    }

    dragMove(e) {
        if (this.isDraggable) {
            this.setPosition(e.pageX, e.pageY);
        }
    }

    dragEnd(e) {
        this.updateSticker();
        this.isDraggable = false;
        document.removeEventListener("mousemove", this.dragMove);
    }


    // SET, GET
    setPosition(pageX, pageY) {
        const currentX = pageX - this.position.shiftX;
        const currentY = pageY - this.position.shiftY;

        this.el.style.left = `${currentX}px`;
        this.el.style.top = `${currentY}px`;

        this.position.currentX = currentX;
        this.position.currentY = currentY;
    }

    setStyle(target) {
        const { currentX, currentY } = this.position;

        target.style.top = `${currentY}px`;
        target.style.left = `${currentX}px`;
        target.style.zIndex = this.zIdx;
        target.style.backgroundColor = `rgba(${this.bgColor}, 0.8)`;
    }

    makeBgColor() {
        const result = [];
        let sum = 0;

        for (let i = 0; i < 3; i++) {
            const num = Math.floor(Math.random() * 255);
            sum += num;
            result.push(num);
        }

        this.titleColor = (sum > 500) ? "#333" : "#fff";
        return result.join();
    }

    updateSticker() {
        const event = new CustomEvent("updateSticker", { bubbles: true, detail: { id: this.id } });
        this.el.dispatchEvent(event);
    }


    // HANDLER
    handleClickAdddList(e) {
        this.createList();
    }

    handleClickRemoveList(e) {
        const { id } = e.detail;

        this.removeList(id);
    }

    handleClickRemoveSticker(id) {
        const event = new CustomEvent("removeSticker", { bubbles: true, detail: { id } });

        this.el.dispatchEvent(event);
        this.el.remove();
        this.el = null;
    }

    handleClickUpdate(e) {
        const event = new CustomEvent("changeSticker", { bubbles: true, detail: e.detail });

        this.el.dispatchEvent(event);
    }
}

4. StickerList

  • 프로젝트에서 가장 하위에 위치하는 파일
  • 리스트의 상태가 변경되면 Sticker를 관리하는 SickerLayout까지 이벤트를 dispatch함
export default class StickerList {
    constructor(options) {
        this.el = null;
        this.helper = null;
        this.isDraggable = false;
        this.startParentID = null;
        this.endParentID = null;
        this.position = {};

        Object.assign(this, options);

        this.content = options.content || `목록${this.stickerCount}-${this.listCount}`;

        this.initElement();
        this.initBindEvent();
    }

    initElement() {
        const li = document.createElement("li");
        li.classList.add("cursor-move");
        li.id = this.id;

        const div = document.createElement("div");
        div.textContent = this.content;

        const remove = document.createElement("button");
        remove.classList.add("btn");
        remove.type = "button";
        remove.textContent = "삭제";
        remove.addEventListener("click", this.handleClickRemove.bind(this, this.id));

        li.appendChild(div);
        li.appendChild(remove);

        this.el = li;
    }

    initBindEvent() {
        this.el.addEventListener("mousedown", this.dragStart.bind(this));
    }

    render(parent) {
        parent.appendChild(this.el);
    }

    // DRAG
    dragStart(e) {
        if (e.target.tagName === "BUTTON") return false;
        e.preventDefault();

        this.setHelper();

        const pageX = e.pageX;
        const pageY = e.pageY;
        const clientX = e.clientX;
        const clientY = e.clientY;
        const currentElX = this.el.getBoundingClientRect().x;
        const currentElY = this.el.getBoundingClientRect().y;

        this.isDraggable = true;
        this.startParentID = this.el.closest(".sticker").id;

        this.position.shiftX = clientX - currentElX;
        this.position.shiftY = clientY - currentElY;

        this.setPosition(pageX, pageY);

        this.el.classList.add("transper");

        document.addEventListener("mousemove", this.dragMove.bind(this));
    }

    dragMove(e) {
        if (this.isDraggable) {
            this.helper.style.display = "none"; // 마우스에 있는 녀석 잠깐 숨겼다가
            // const targetElement = document.elementFromPoint(e.clientX, e.clientY).closest("li"); // 마우스 아래 있는 요녀석만 찾아내고
            const cursorElement = document.elementFromPoint(e.clientX, e.clientY);
            const listElement = cursorElement.closest("ul");
            const listItemElement = cursorElement.closest("li");
            this.helper.style.display = "flex"; // 마우스에 있는 녀석 다시 보여준다

            if (listItemElement) { // li
                const y = listItemElement.getBoundingClientRect().y + (listItemElement.getBoundingClientRect().height / 2);

                if (e.pageY < y) {
                    listItemElement.before(this.el);
                }
                else {
                    listItemElement.after(this.el);
                }
            }
            else if (listElement && listElement.children.length < 1) { // ul
                listElement.appendChild(this.el);
            }

            this.setPosition(e.pageX, e.pageY);
        }
    }

    dragEnd(e) {
        this.isDraggable = false;
        this.helper.remove();
        this.helper = null;
        this.el.classList.remove("transper");
        this.endParentID = this.el.closest(".sticker").id;
        this.checkOfChangeParent();

        document.removeEventListener("mousemove", this.dragMove);
    }

    // SET, GET
    setPosition(pageX, pageY) {
        const currentX = pageX - this.position.shiftX;
        const currentY = pageY - this.position.shiftY;

        this.helper.style.left = `${currentX}px`;
        this.helper.style.top = `${currentY}px`;
    }

    setHelper() {
        this.helper = this.el.cloneNode(true);
        this.helper.classList.add("helper");
        this.helper.style.position = "absolute";
        this.helper.style.zIndex = "1000";

        this.helper.addEventListener("mouseup", this.dragEnd.bind(this));

        document.body.append(this.helper);
    }


    checkOfChangeParent() {
        const eventOptions = {
            bubbles: true,
            detail: {
                id: this.id,
                startParentID: this.startParentID,
                endParentID: this.endParentID,
            }
        };
        const event = new CustomEvent("chageList", eventOptions);
        this.el.dispatchEvent(event);
    }

    // HANDLER 
    handleClickRemove(id) {
        const event = new CustomEvent("removeList", { bubbles: true, detail: { id: id } });

        this.el.dispatchEvent(event);
        this.el.remove();
        this.el = null;
    }
}

프로젝트 문제점

많은 기능을 하는 어플리케이션이 아님에도 불구하고 요구하는 기능을 추가하다보니 읽기도 어려워지고 로직끼리 연결되어 있는 부분이 많아 사이드이펙트도 많이 발생하였습니다 이 이상 진행하기 어려워 리팩토링을 진행하려고 합니다

문제점

  • Sticker, StickerList에서 상태가 변경 되면 모든 파일에 관련 된 로직 추가
  • 예를 들어 스티커내부에 있는 목록이 변경되면 수직적인 구조로 인해 Sticker, StickerLayout까지 관련 로직이 추가 되야함
  • 모든 클래스에 로직이 추가 되면서 관리해야하는 포인트가 늘어나고 유지보수가 어려움
  • 단순 이벤트 버블링을 하기위해 의미없는 로직들이 추가됨
  • drag, element생성 등 반복적인 로직 추가
  • 데이터에 의해 DOM이 변경되야 하는데, DOM에 의해 데이터가 변경되고 있음

수정해야할 범위를 정확하게 예측할 수 없고 어떤방식으로 리팩토링을 진행해야할 것인가에 대해서 정해진 바가 없지만, 리팩토링을 진행 하면서 개선해야할 내용 및 추가해야될 내용은 업데이트예정임

2022.10.15
DOM을 직접 변경하는 방식이 아닌 상태로 인해 DOM이 변경될 수 있도록 관련 내용 학습중
DOM을 직접 변경하는 것이 어떠한 문제점이 있는지, 상태로 DOM을 관리하게 되면 어떤 장점이 있는지도 같이 파악

profile
잘하는것보다 꾸준히하는게 더 중요하다

0개의 댓글