어느정도의 간단한 기능을 구현한 후 파일을 기능 및 목적에 맞게 분리하기 위해 다음과 같은 작업 진행
v1은 DOM에서 데이터와 뷰를 전부 관리하고 있었는데 이렇게 되면 나중에 서버에서 데이터를 주고 받을 때 필연적으로 해당 부분 수정이 필요함. 또한 프로젝트의 목적 중 하나가 데이터와 뷰를 분리 하는 것이 목적이었기 때문
addItem 내부에서 새로운 list가 추가 될 때 마다 DOM을 생성하고 속성들을 정의하며 이벤트를 바인딩 해주는데, 이를 각각의 함수로 분리하여 하나의 함수가 한가지의 일만 할 수 있도록 수정
version1에서 list만 JS에서 추가 생성 삭제를 구현하였는데 TodoList 전체를 사용하기 위해서 layout부분도 JS에서 만들어 사용할 수 있도록 수정
<!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>
// 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);
});
}
}
// 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);
});
}
}
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();
version1같은 경우에는 UI가 데이터와 뷰를 동시에 제어하고 있었기 때문에 어디서 문제가 발생했는지 정확하게 파악이 되질 않았고 로직을 수정하고 나면 수정한 로직과 관련있는 모든 로직을 확인 해야 했었는데, 데이터와 뷰를 나누고 나서 기능을 추가하거나 변경사항이 있을 때 데이터면 데이터, 뷰면 뷰등과 같이 해당되는 로직만 확인하면 되서 편해짐
이번 리팩토링을 통해 데이터와 뷰를 왜 나눠서 작업 해야하는지에 대한 이해도가 조금(?)은 생긴거 같습니다. v1은 변수가 변경되거나 UI가 변경되었을 때 관련있는 모든 로직을 봐야했고 수정도 빈번하게 일어나 관련없는 로직까지 영향이 갔었던 반면, 리팩토링 후에는 각각의 맡고있는 로직만 수정하면 됐었기에 기능을 수정하거나 변경할 때 편하게 할 수 있었던거 같습니다.
프로젝트의 크기가 작은데도 불구하고 이정도의 불편함을 느끼고 있는데 프로젝트의 크기가 커진다면 손을 쓸 수 없을 지경까지 갈 수 있다라고 생각되어 기능추가는 잠시 접어두고 리팩토링을 진행하였고, 개인적으로는 만족할만한 결과를 얻었다고 생각합니다.
하지만 여기서 멈추지 않고 하나의 파일에 있던 로직을 각각 역할에 맞는 파일로 분리하는 작업을 진행하고 있습니다.
각각의 로직을 data, view, todo라는 이름으로 분리하여 리팩토링을 진행하고 기능을 추가 하여 완성도를 높여나갈 예정입니다.