과제가 시작되었다. 어려운 기능은 아니었고 에러처리와 투두리스트의 기본 기능인 삭제와 count를 추가하면 됐는데 막상 만들어진 형식에서 의존성을 최대한 줄이면서 컴포넌트로 상태 관리에 신경쓰다보니 어려웠다.
new에 대한 에러처리컴포넌트에 new 키워드를 붙이지 않을 경우에 대해서 예외처리를 해야 하는데 어떻게 하면 좋을까? 마침 최근에 스터디에서 나온 내용이 있어서 쉽게 구현할 수 있었다. 하지만 고민이 있었다. new.target을 사용하느냐 아니면instanceof를 이용하느냐 인데 우선 둘의 특징을 알아보자.
new.target
constructor인 모든 함수 내부에서 암묵적인 지역 변수와 같이 사용되며 new 연산자와 함께 생성자 함수로서 호출되면 함수 자신을 가리킨다. 일반 함수로서 호출될 경우는 undefined 이다.
ES6부터 지원한다.
if (!new.target) {
throw new Error("컴포넌트 앞에 new를 붙여서 생성해주세요");
}
instanceof
생성자 함수가 new 와 함께 호출되지 않았을 경우 this는 전역을 가리키고, 함께 호출된 경우 해당 함수를 가리키는 점을 이용한다.
if (!(this instanceof TodoList)) {
throw new Error("컴포넌트 앞에 new를 붙여서 생성해주세요");
}
기존에는 new.target문법이 IE에서는 지원이 되지 않아 instanceof를 더 이용했을 것 같은데 IE가 없어진 지금 시점에서는 이 과제를 할 때 new.target을 할때 instanceof와 다르게 함수 이름을 따로 지정해 주지 않아도 되기 때문에 더 동적이라고 생각되어 new.target을 이용하여 구현하였다.
한가지 궁금한점은 이렇게 에러가 발생했다는 것 외에도 생성자 함수를 다시 호출하여 생성된 인스턴스를 반환해야 하는 것이다.
if (!new.target) {
// new 연산자와 함께 생성자 함수를 재귀 호출하여 생성된 인스턴스를 반환한다.
return new TodoList($target, initialState);
}
App.js
function App({ $target, initialState }) {
if (!new.target) {
throw new Error("컴포넌트 앞에 new를 붙여서 생성해주세요");
}
new Header({
$target,
text: "Simple Todo List",
});
new TodoForm({
$target,
onSubmit: (text, count) => {
const nextState = [
...todoList.state,
{
text,
id: count + 1,
},
];
todoList.setState(nextState);
storage.setItem("todos", JSON.stringify(nextState));
},
});
const todoList = new TodoList({
$target,
initialState,
// 삭제(리스트마다 있으므로 Form이 아닌 App에서 관리한다.)
deleteTodo: (btnId) => {
const nextState = [
...todoList.state.filter((todo) => {
return todo.id !== Number(btnId);
}),
];
todoList.setState(nextState);
storage.setItem("todos", JSON.stringify(nextState));
},
});
}
우선 삭제를 하려면 그 리스트는 고유해야 한다. 인덱스를 통해 지운다면 추가/삭제할때 엉키는 문제가, text를 통해 지운다면 같은 text가 동시에 삭제된다는 문제가 있기 때문에 이렇게 꼭 고유한 id 값이 리스트마다 있어야 하기 때문에 id 값을 state안에 추가해 주었다.
TodoList.js
function TodoList({ $target, initialState, deleteTodo }) {
if (!new.target) {
throw new Error("컴포넌트 앞에 new를 붙여서 생성해주세요");
}
const $todoList = document.createElement("div");
$target.appendChild($todoList);
this.state = initialState;
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
$todoList.innerHTML = `
<ul>
${this.state
.map(
(todo) =>
`<li>${todo.text} <button id=${todo.id}>삭제</button></li>`
)
.join("")}
</ul>
`;
$todoList.querySelectorAll("button").forEach((btn) => {
btn.addEventListener("click", (e) => {
deleteTodo(e.target.id);
});
});
};
this.render();
}
}
컴포넌트 안에서 모든 button 태그들을 찾아 삭제 이벤트 핸들러를 등록하였고, 인자로 클릭한 리스트의 id를 넘긴다. 매번 렌더링 시 이벤트를 등록한다는 점이 비효율적으로 보이는데 예전에 배운 이벤트 위임을 사용한다면 문제를 해결할 수 있어 보인다.
TodoForm.js
function TodoForm({ $target, onSubmit }) {
let count = 0;
if (!new.target) {
throw new Error("컴포넌트 앞에 new를 붙여서 생성해주세요");
}
const $form = document.createElement("form");
$target.appendChild($form);
let isInit = false;
this.render = () => {
$form.innerHTML = `
<input type="text" name="todo" />
<button>Add</button>
`;
$form.addEventListener("submit", (e) => {
e.preventDefault();
const $todo = $form.querySelector("input[name=todo]");
const text = $todo.value;
if (text.length > 1) {
$todo.value = "";
onSubmit(text, count);
count++;
}
});
isInit = false;
};
this.render();
}
리스트를 생성할 때 마다 id값을 여기서 정해줘야 하는데 count변수를 통해 처음에는 0으로 시작하여, 순차적으로 각 리스트는 2의 id를 갖게 된다. 하지만, 새로고침 하였을때도 count 값은 0이므로 새로고침 후 추가하였을때 리스트 끼리 id가 겹친다는 문제가 발생한다. 이 문제는 아래에 기능을 추가하면서 해결하였다.
state에 isCompleted 값을 추가하여 클릭 시 해당 todo의 text를 삭선처리하는 토글 기능이다.
TodoList.js
`<li id=${todo.id}><span style= "${
todo.isCompleted
? "text-decoration:line-through;"
: "text-decoration:none;"
}" id=${todo.id}>${todo.text}</span><button id=${
todo.id
}>삭제</button></li>`
$todoList.querySelectorAll("span").forEach((todoItem) => {
todoItem.addEventListener("click", (e) => {
clickTodo(e.target.id);
});
});
따로 css 파일이 없이 구현하는 중이라 js로 동적인 스타일 태그를 만들어야 하므로 ? : 연산자를 이용하였다. 그리고 삭제 기능과 같이 리스트의 span 태그에 이벤트 핸들러를 등록하였다.
App.js
const nextState = [
...todoList.state,
{
text,
id: count + 1,
isCompleted: false,
},
];
clickTodo: (todoId) => {
const nextState = [
...todoList.state.map((todo) => {
if (todo.id === Number(todoId)) {
todo.isCompleted = !todo.isCompleted;
}
return todo;
}),
];
todoList.setState(nextState);
storage.setItem("todos", JSON.stringify(nextState));
},
리스트를 추가할때 기본적으로 isCompleted를 false로 초기화하여 넣었고, clickTodo 함수의 기능은 삭제 기능과 유사한 방식이다. 주의할 점은 인자로 받는 todoId는 String 타입이므로 Number로 형 변환을 꼭 해야 한다는 점이다.
TodoCount 컴포넌트를 만들어 현재 완료된(클릭된) 리스트의 개수와 등록된 리스트의 개수를 화면에 출력해야 한다.
TodoCount.js
function TodoCount({ $target, initialState }) {
if (!new.target) {
throw new Error("컴포넌트 앞에 new를 붙여서 생성해주세요");
}
const $div = document.createElement("div");
$target.appendChild($div);
this.state = initialState;
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
const completedCount = this.state.filter((todo) => todo.isCompleted).length;
const totalCount = this.state.length;
$div.innerHTML = `
<p>완료된 Todo의 개수 : ${completedCount}</p>
<p>전체 Todo의 개수 : ${totalCount}</p>
`;
};
this.render();
}
처음에는 로컬 스토리지에서 값을 받아와서 사용할까 했지만 TodoList 컴포넌트와 일관성을 유지하기 initialState를 넘겨서 상태를 변화시키는 방향으로 구현하였다. App.js에서 TodoList 컴포넌트를 관리하는 방식과 매우 유사하다.
이외에도 여러가지 수정 사항이 있었는데 글이 너무 길어질 것 같아 생략하였다.
state를 가지는 컴포넌트마다 에러처리를 하라고 되어있는데 App.js에서 따로 함수를 구현하여 한꺼번에 해도 되는건지( ex) update함수)count 값은 로컬 스토리지의 가장 마지막 리스트의 id값을 받아오도록 되어있는데 이렇게 사용하였을때의 문제점이 있는지 아니면 더 좋은 방법이 있는지new 생성자 에러처리에서 에러가 발생했다는 것 외에도 생성자 함수를 다시 호출하여 생성된 인스턴스를 반환해야 하는지TodoList, TodoForm, TodoCount끼리의 의존성이 덜한지 또는 없는지