Vanilla JS TodoList Project Version2

DW J·2022년 8월 29일
0

project-todolist

목록 보기
3/4

Version1 진행 후..

어느정도의 간단한 기능을 구현한 후 파일을 기능 및 목적에 맞게 분리하기 위해 다음과 같은 작업 진행

1. 데이터가 아닌 UI를 통하여 관리

v1은 DOM에서 데이터와 뷰를 전부 관리하고 있었는데 이렇게 되면 나중에 서버에서 데이터를 주고 받을 때 필연적으로 해당 부분 수정이 필요함. 또한 프로젝트의 목적 중 하나가 데이터와 뷰를 분리 하는 것이 목적이었기 때문

2. 하나의 함수 내부에 모든 로직이 있어 가독성이 떨어짐

addItem 내부에서 새로운 list가 추가 될 때 마다 DOM을 생성하고 속성들을 정의하며 이벤트를 바인딩 해주는데, 이를 각각의 함수로 분리하여 하나의 함수가 한가지의 일만 할 수 있도록 수정

3. DOM Layout JS파일에서 생성

version1에서 list만 JS에서 추가 생성 삭제를 구현하였는데 TodoList 전체를 사용하기 위해서 layout부분도 JS에서 만들어 사용할 수 있도록 수정

4. TodoList Version2 소스

1) HTML

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <link rel="stylesheet" href="css/common.css">
        <title>TODO</title>
    </head>
    <body>
        <!-- todo_v2 로직분리 -->
        <script src="./js/todo_v2.js"></script>
    </body>
</html>

2) JS

2-1) element 생성 관련

// TODO Element 생성 객체
const element = {
    elementCreate: function (makeData) {
        const { tagName, attrs, events, children } = makeData;
        // element 생성
        const el = document.createElement(tagName);

        // element 속성 설정
        this.setElementAttribute(el, attrs);

        // element 이벤트 바인딩
        this.setElementEventBind(el, events);

        // 자식 엘리먼트 설정
        if (children) {
            children.forEach((child) => {
                const childEl = this.elementCreate(child);
                el.appendChild(childEl);
            });
        }

        return el;
    },

    setElementAttribute: function (target, attrs) {
        target = target || null;

        keys = Object.keys(attrs);

        keys?.forEach(key => {
            const value = attrs[key];

            if (!value) return;

            switch (key) {
                case "className":
                    target.classList.add(value);
                    break;
                case "id":
                case "type":
                    target.setAttribute(key, value);
                    break;
                default:
                    target[key] = value;
                    break;
            }
        });

        return target;
    },

    setElementEventBind: function (target, events) {
        events?.forEach((event) => {
            const { type, callback } = event;

            target.addEventListener(type, callback);
        });
    }
}

2-2) Util 관련

// TODO Utility 객체 - 프로젝트 종속되어 있는 부분 제거할 수 있는 방법 고민
const utility = {
    getId: function (target, type) { 
        const element = target.closest("li");
        const id = element.getAttribute("id");

        if (id) {
            return id;
        }

        return null;
    },

    getElement: function (id) { 
        const element = document.querySelector("#" + id);

        if (element) {
            return element;
        }

        return null;
    },

    emptyValueCheck: function (value, message) { 
        let result = false;

        if (!value) {
            console.log(message);
            result = true;
        }

        return result;
    },

    uuid: function () { 
        return 'todo-yxxx'.replace(/[xy]/g, (c) => {
            var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
            return v.toString(16);
        });
    }
}

2-3) TODO 관련

const todos = () => {
    // TODO 전역 변수
    let topWrapper = document.body;
    let todoListElements = null;
    let todoItems = [];

    // TODO 초기화
    const init = () => {
        initElement();
        fetchTodos(); // TODO API로 변경해야함
        render();
    }

    // TODO 초기 ELEMENT 생성 및 설정
    const initElement = () => {
        initElementCreate(); // 생성
        initElementSetting(); // 설정
    }

    // TODO 초기 ELEMENT 생성
    const initElementCreate = () => {
        const todoWrapper = initElementWrapperCreate();
        const todoHeader = initElementHeaderCreate();
        const todoContents = initElementContentsCreate();

        todoWrapper.appendChild(todoHeader);
        todoWrapper.appendChild(todoContents);

        topWrapper.appendChild(todoWrapper);
    }

    // TODO 레이아웃 별로 다시 나눈 이유 - 동적으로 데이터를 셋팅하기 위해
    const initElementWrapperCreate = () => {
        const initElementWrapperData = {
            tagName: "div",
            attrs: { className: "wrapper-todo" }
        };

        return element.elementCreate(initElementWrapperData);
    }

    const initElementHeaderCreate = () => {
        const initElementHeaderData = {
            tagName: "div",
            attrs: { className: "todo-header" },
            children: [
                {
                    tagName: "div",
                    attrs: { className: "todo-title" },
                    children: [{ tagName: "h2", attrs: { textContent: "TODO TITLE" } }]
                },
                {
                    tagName: "div",
                    attrs: { className: "todo-date-contents" },
                    children: [
                        { tagName: "span", attrs: { className: "todo-year", textContent: "====" } },
                        { tagName: "span", attrs: { className: "todo-month", textContent: "==" } },
                        { tagName: "span", attrs: { className: "todo-date", textContent: "==" } },
                        { tagName: "span", attrs: { className: "todo-day" } }
                    ]
                }
            ]
        };

        return element.elementCreate(initElementHeaderData);
    }

    const initElementContentsCreate = () => {
        const initElementContentsData = {
            tagName: "div",
            attrs: { className: "todo-contents" },
            children: [
                {
                    tagName: "div",
                    attrs: { className: "todo-add-contents" },
                    children: [
                        {
                            tagName: "input",
                            attrs: { className: "add-input", type: "text" }
                        },
                        {
                            tagName: "button",
                            attrs: { className: "add-button", type: "button", textContent: "+" }
                        }
                    ]
                },
                {
                    tagName: "ul",
                    attrs: { className: "todo-list" }
                }
            ]
        }
        
        return element.elementCreate(initElementContentsData);
    }

    // TODO 초기 ELEMENT 설정
    const initElementSetting = () => {
        todoListElements = document.querySelector(".todo-list");
        initAddElementSetting();
        initDateElementSetting();
    }

    const initAddElementSetting = () => {
        const addInput = document.querySelector(".add-input");
        const addButton = document.querySelector(".add-button");

        addInput.addEventListener("keyup", handleInputAddKeyup);
        addButton.addEventListener("click", handleButtonAddClick);
    }

    const initDateElementSetting = () => {
        // TODO - 접속 국가별로 나올 수 있도록 수정 ( day.js 날짜 포맷팅 방법 참고 )
        const todoYear = document.querySelector(".todo-year");
        const todoMonth = document.querySelector(".todo-month");
        const todoDate = document.querySelector(".todo-date");
        const getTodayDate = () => {
            const dateInstance = new Date();
            let yaer = dateInstance.getFullYear().toString();
            let month = dateInstance.getMonth() + 1;
            let date = dateInstance.getDate();

            return {
                yaer: yaer,
                month: (month >= 10) ? month : "0" + month,
                date: (date >= 10) ? date : "0" + date,
                // day: day(dateInstance)
            };
        };

        const currentDate = getTodayDate();
        // DOM 조작하는 로직 제거
        todoYear.innerText = currentDate.yaer + "년";
        todoMonth.innerText = currentDate.month + "월";
        todoDate.innerText = currentDate.date + "일";
        // todoDay.innerText = currentDate.day
    }

    // TODO DATA 초기화
    const fetchTodoItems = () => {
        const items = [];

        return items;
    }

    const fetchTodos = () => {
        todoItems = fetchTodoItems();
    }

    // TODO RENADER
    const render = () => {
        todoItems?.forEach(todo => {
            const element = createTodoElement(todo);
            todoListElements.appendChild(element);
        });
    }

    // TODO ITEM ELEMENT 생성
    const createTodoElement = ({ id, todo, complete }) => {
        const createTodoElementData = {
            tagName: "li",
            attrs: { id: id, className: (complete) ? "disabled" : "" },
            children: [
                {
                    tagName: "input",
                    events: [{ type: "click", callback: handleCompleteClick }],
                    attrs: { className: "todo-checkbox", type: "checkbox", checked: complete }
                },
                {
                    tagName: "div",
                    events: [
                        { type: "dblclick", callback: handleContentDbclick },
                    ],
                    attrs: { className: "todo-content", textContent: todo },
                    children: [
                        {
                            tagName: "input",
                            events: [{ type: "keyup", callback: handleUpdateContentKeyup }],
                            attrs: { type: "text", className: "hide" }
                        }
                    ]
                },
                {
                    tagName: "span",
                    events: [
                        { type: "click", callback: handleRemoveClick }
                    ],
                    attrs: { className: "todo-remove", textContent: "X" }
                }
            ]
        }

        return element.elementCreate(createTodoElementData);
    }    

    // TODO DATA
    const addTodo = (value) => {
        const todo = { id: utility.uuid(), todo: value, complete: false };
        const completeItemIdx = todoItems.findIndex(item => item.complete);

        if (completeItemIdx > -1) {
            // 목록 중 완료된 목록이 존재하면 해당 목록 앞에 새로 추가한다
            todoItems.splice(completeItemIdx, 0, todo);
        }
        else {
            todoItems.push(todo);
        }

        return todo;
    }

    const removeTodo = (id) => {
        const removeTodoIndex = todoItems.findIndex(item => item.id === id);
        todoItems.splice(removeTodoIndex, 1);
    }

    const updateTodo = (id, value) => {
        // 해당 데이터를 찾아서 value를 업데이트 해준다
        for (let i = 0, len = todoItems.length; i < len; i++) {
            const item = todoItems[i];
            
            if (item.id === id) {
                item.todo = value;
                break;
            }
        }
    }

    const completeTodo = (id, isComplete) => {
        const updateTodoIndex = todoItems.findIndex(item => item.id === id);
        const updateTodo = todoItems.splice(updateTodoIndex, 1);
        const todoItemLenth = todoItems.length;

        updateTodo[0].complete = isComplete;

        if (isComplete) {
            todoItems.splice(todoItemLenth, 0, updateTodo[0]);
        }
        else {
            todoItems.splice(0, 0, updateTodo[0]);
        }
    }

    // TODO DOM조작
    const addTodoView = (viewData) => {
        const todoElement = createTodoElement(viewData);
        const completeElement = todoListElements.querySelector(".disabled");

        if (completeElement) {
            todoListElements.insertBefore(todoElement, completeElement);
        }
        else {
            todoListElements.appendChild(todoElement);
        }
    }

    const completeTodoView = (id, isComplete) => {
        const completeTarget = utility.getElement(id);

        if (isComplete) {
            completeTarget.classList.add("disabled");
            todoListElements.appendChild(completeTarget);
        }
        else {
            completeTarget.classList.remove("disabled");
            todoListElements.prepend(completeTarget);
        }
    }

    const modifyTodoView = (id) => {
        const parentTarget = utility.getElement(id);
        const modifyTarget = Array.prototype.slice.apply(parentTarget.childNodes)
        	.filter(node => node.classList.value === "todo-content")[0];

        if (modifyTarget.tagName !== "INPUT" && parentTarget.classList.value !== "disabled") {
            const value = modifyTarget.textContent;
            const input = modifyTarget.querySelector("input");
            modifyTarget.textContent = "";

            input.value = value;
            input.classList.add("show");
            input.classList.remove("hide");
            modifyTarget.appendChild(input);
            input.focus();
        }
    }

    const updateTodoView = (target, value) => {
        const parentElement = target.parentElement;
        const textNode = document.createTextNode(value);

        target.classList.add("hide");
        target.classList.remove("show");
        parentElement.appendChild(textNode);
    }

    const removeTodoView = (id) => {
        const removeTarget = utility.getElement(id);
        todoListElements.removeChild(removeTarget);
    }    

    // TODO HANDLER
    const handleInputAddKeyup = (e) => {
        if (e.key === "Enter") {
            const { target } = e;
            if (utility.emptyValueCheck(target.value, "내용을 입력해 주세요")) return;
            const viewData = addTodo(target.value);

            addTodoView(viewData);
            target.value = "";
        }
    }

    const handleButtonAddClick = (e) => {
        const { target } = e;
        const input = target.previousElementSibling;
        if (utility.emptyValueCheck(input.value, "내용을 입력해 주세요")) return;
        const viewData = addTodo(input.value);

        addTodoView(viewData);
        input.value = "";
    }

    const handleCompleteClick = (e) => {
        const { target } = e;
        const id = utility.getId(e.target);
        const checked = target.checked;

        completeTodoView(id, checked);
        completeTodo(id, checked);
    }

    const handleContentDbclick = (e) => {
        const id = utility.getId(e.target);
        modifyTodoView(id);
    }

    const handleUpdateContentKeyup = (e) => {
        if (e.key === "Enter") {
            const { target } = e;
            const id = utility.getId(e.target);
            const value = target.value;

            if (utility.emptyValueCheck(value, "내용을 입력해주세요.")) return;

            updateTodoView(target, value);
            updateTodo(id, value);
        }
    }

    const handleRemoveClick = (e) => {
        const id = utility.getId(e.target);

        removeTodoView(id);
        removeTodo(id);
    }   

    // 실행
    init();
}

todos();
  • 데이터, 뷰, 이벤트, 속성 등 각각의 로직을 역할에 맞게 분리
  • Todo Layout도 JS파일 내부에서 생성 할 수 있도록 수정
  • DOM을 생성하여 설정하는 객체 추가로 생성
  • TODO에 종속되어 있지만 유틸객체도 생성

version1같은 경우에는 UI가 데이터와 뷰를 동시에 제어하고 있었기 때문에 어디서 문제가 발생했는지 정확하게 파악이 되질 않았고 로직을 수정하고 나면 수정한 로직과 관련있는 모든 로직을 확인 해야 했었는데, 데이터와 뷰를 나누고 나서 기능을 추가하거나 변경사항이 있을 때 데이터면 데이터, 뷰면 뷰등과 같이 해당되는 로직만 확인하면 되서 편해짐

Version2을 마치며..

이번 리팩토링을 통해 데이터와 뷰를 왜 나눠서 작업 해야하는지에 대한 이해도가 조금(?)은 생긴거 같습니다. v1은 변수가 변경되거나 UI가 변경되었을 때 관련있는 모든 로직을 봐야했고 수정도 빈번하게 일어나 관련없는 로직까지 영향이 갔었던 반면, 리팩토링 후에는 각각의 맡고있는 로직만 수정하면 됐었기에 기능을 수정하거나 변경할 때 편하게 할 수 있었던거 같습니다.

프로젝트의 크기가 작은데도 불구하고 이정도의 불편함을 느끼고 있는데 프로젝트의 크기가 커진다면 손을 쓸 수 없을 지경까지 갈 수 있다라고 생각되어 기능추가는 잠시 접어두고 리팩토링을 진행하였고, 개인적으로는 만족할만한 결과를 얻었다고 생각합니다.

하지만 여기서 멈추지 않고 하나의 파일에 있던 로직을 각각 역할에 맞는 파일로 분리하는 작업을 진행하고 있습니다.

각각의 로직을 data, view, todo라는 이름으로 분리하여 리팩토링을 진행하고 기능을 추가 하여 완성도를 높여나갈 예정입니다.

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

0개의 댓글