한 달간 프로그래머스에서 진행한 roto의 스터디에 참여했다.
React, Next.js와 같은 라이브러리와 프레임워크를 위주로 개발을 진행하다보니 JavaScript의 동작 원리를 글로만 파악하고 실제로 마주해보지 못한 것에 대한 아쉬움의 해소를 위해 시작했다.
다음 글은 vanilla JavaScript를 이용해서 Todo App을 만드는 작업을 진행한 일련의 과정에 대한 글이다.
초기 구현했던 Todo App의 구조는 아래와 같다.
App.js
TodoCounter.js
TodoList.js
TodoInput.js
main.js
index.html
vanilla JavaScript로 컴포넌트 개발을 진행하면서 각 컴포넌트에서 신경쓰고자 했던 점은 아래와 같다.
<html>
<head>
<title>Mission 2</title>
<meta charset="utf-8" />
</head>
<body>
<form id="todo-form">
<input type="text" name="todo-input" />
<button type="submit">+</button>
</form>
<div id="todo-list"></div>
<div id="todo-counter"></div>
<script src="./TodoList.js" type="module"></script>
<script src="./App.js" type="module"></script>
</body>
</html>
import { TodoList } from './TodoList.js';
import { TodoInput } from './TodoInput.js';
import { TodoCounter } from './TodoCounter.js';
const todoList = document.querySelector('#todo-list');
function App() {
this.state = JSON.parse(localStorage.getItem('todoData')) || '[]';
this.setState = (nextState) => {
this.state = JSON.parse(JSON.stringify(nextState));
localStorage.setItem('todoData', JSON.stringify(this.state));
};
}
try {
const app = new App();
const todoCounter = new TodoCounter(app);
const todo = new TodoList(todoList, app, todoCounter);
const todoInput = new TodoInput(todo, app, todoCounter);
} catch (e) {
alert(e);
}
export function TodoCounter(app) {
const todoCounter = document.querySelector('#todo-counter');
this.render = () => {
todoCounter.innerHTML = `<div>${app.state.length}</div>`;
};
this.render();
}
export function TodoList(element, app, todoCounter) {
// new State과 같은 값을 참조하지 않도록 깊은 복사를 통해 이전 state값 저장
this.state = JSON.parse(JSON.stringify(app.state));
// 이벤트 위임
element.addEventListener('click', (event) => this.delete(event));
element.addEventListener('click', (event) => this.changeState(event));
this.validate = () => {
// null and undefined 처리
if (this.state == null) {
throw new Error('data가 존재하지 않습니다!');
}
if (!new.target) {
throw new Error('new 연산자를 사용하지 않았습니다!');
}
if (!Array.isArray(this.state)) {
throw new Error('배열이 아닙니다!');
}
if (
!this.state.every(
(el) =>
el.text &&
el.isCompleted !== undefined &&
typeof el.text === 'string' &&
typeof el.isCompleted === 'boolean'
)
) {
throw new Error('data 형식이 올바르지 않습니다!');
}
};
this.setState = (nextState) => {
let isSame = false;
// data length가 다르면 다른 data이므로
if (this.state.length === nextState.length) {
let flag = true;
for (let i = 0; i < this.state.length; i++) {
if (
this.state[i].text !== nextState[i].text ||
this.state[i].isCompleted !== nextState[i].isCompleted
) {
flag = false;
}
}
isSame = flag;
}
if (!isSame) {
this.state = JSON.parse(JSON.stringify(nextState));
app.setState(this.state);
this.render();
todoCounter.render();
} else {
throw new Error('data is same');
}
};
this.render = () => {
let index = -1;
element.innerHTML = this.state
.map((todo) => {
const { text, isCompleted } = todo;
// console.log(isCompleted);
index++;
return `<li id=${index}>
<span id=${index} class='todo-text'>${
isCompleted ? `<s>${text}</s>` : text
}</span>
<button class='delete-button'>-</button>
</li>`;
})
.join('');
};
this.delete = (event) => {
if (event.target.closest('button')) {
const deletedData = this.state.filter(
(el, idx) => idx !== Number(event.target.parentElement.id)
);
this.setState(deletedData);
}
};
this.changeState = (event) => {
const targetIndex = Number(event.target.parentElement.id);
if (event.target.closest('span')) {
const newState = app.state.map((el, idx) => {
if (idx === targetIndex) {
app.state[targetIndex].isCompleted =
!app.state[targetIndex].isCompleted;
return app.state[targetIndex];
} else {
return app.state[idx];
}
});
this.setState(newState);
}
};
this.addEvent = (elements, event, func) => {
elements.forEach((el, idx) => el.addEventListener(event, () => func(idx)));
};
this.validate();
this.render();
}
event bubbling
의 특성을 이용한 것으로, 이벤트 위임을 사용하게 되는 경우 element마다 event handler를 할당하지 않고 부모 요소에만 event handler를 할당하면 된다는 편리함이 있다.const todoList = document.querySelector('#todo-list');
element를 넘겨 받아 해당 element에 event를 위임하여 한 번의 event 할당으로 자식 요소들의 이벤트를 처리하였다.export function TodoInput(todo, app, todoCounter) {
const todoForm = document.querySelector('#todo-form');
todoForm.addEventListener('submit', (event) => this.addTodo(event));
this.addTodo = (event) => {
// 새로고침 방지
event.preventDefault();
const newTodo = event.target['todo-input'];
if (newTodo.value.trim() !== '') {
// 데이터를 카피하지 않으면 TodoList도 같은 데이터를 참조하고 있기 때문에 데이터 변경을 감지하지 못함.
app.state.push({
text: newTodo.value,
isCompleted: false,
});
// submit이후 기존 데이터 날림
newTodo.value = '';
// 기존 todoData와 다른 data로 상태를 변경해주고 todoData를 업데이트 해주어야 set함수에서 데이터가 같다는 오류가 나지 않음.
app.setState(app.state);
todo.setState(app.state);
todoCounter.render();
}
};
}
roto님과 리뷰를 주고 받으며 기존 작성했던 컴포넌트들 같은 경우는 컴포넌트 간의 의존도가 매우 높은 구조라는 사실을 알게 되었다. 컴포넌트가 컴포넌트를 넘겨받아 데이터를 처리하는 구조로 어떤 한 컴포넌트가 삭제되면 다른 컴포넌트의 사용이 불가능한 효율성과 확장성이 떨어지는 코드였다. 따라서 이를 개선해보고자 코드를 대폭 수정했다 ㅎ..
따라서 App이 대부분의 상태를 가지고 있는 점을 활용하여 App 내부에도 setState 함수를 구현해 App의 state이 변경이 될 때 하위 컴포넌트의 setState함수를 같이 실행시켜 App 컴포넌트와 관련있는 컴포넌트의 state을 변경할 수 있도록 구조를 변경했다.
App.js
import { TodoList } from './TodoList.js';
import { TodoInput } from './Todoinput.js';
import { TodoCounter } from './TodoCounter.js';
import { setItem } from './localStorage.js';
import { localStorageKey } from './localStorage.js';
const todo = document.querySelector('#todo');
export function App(initialState) {
this.state = initialState;
this.setState = (nextState) => {
this.state = nextState;
todoList.setState(nextState);
todoCounter.setState(nextState);
setItem(localStorageKey, nextState);
};
const todoInput = new TodoInput({
target: todo,
onAddTodo: (text) => {
this.setState([
...this.state,
{
text: text,
isCompleted: false,
},
]);
},
});
const todoList = new TodoList({
target: todo,
initialState: this.state,
onDelete: (index) => {
const nextState = [...this.state];
const deletedData = nextState.filter((el, idx) => idx !== Number(index));
this.setState(deletedData);
},
onToggle: (index) => {
const nextState = [...this.state];
nextState[index].isCompleted = !nextState[index].isCompleted;
this.setState(nextState);
},
});
const todoCounter = new TodoCounter({
target: todo,
todoCount: this.state.length,
});
window.addEventListener('removeAll', () => {
this.setState([]);
});
}
TodoList.js
export function TodoList({ target, initialState, onDelete, onToggle }) {
this.state = initialState;
this.lastState = JSON.parse(JSON.stringify(initialState));
this.element = document.createElement('ul');
target.appendChild(this.element);
// 이벤트 위임
// ul에 이벤트를 달아서 자식 요소에서 클릭이 되었을 때 상위 요소로 전파되어서 상위요소에서 조건에 따라 처리할 수 있도록 함.
this.element.addEventListener('click', (event) => {
event.stopPropagation();
const index = event.target.closest('li').dataset.index;
if (event.target.closest('button')) {
onDelete(index);
} else if (event.target.closest('s') || event.target.closest('span'))
onToggle(index);
});
this.validate = () => {
// null and undefined 처리
if (this.state == null) {
throw new Error('data가 존재하지 않습니다!');
}
if (!new.target) {
throw new Error('new 연산자를 사용하지 않았습니다!');
}
if (!Array.isArray(this.state)) {
throw new Error('배열이 아닙니다!');
}
if (
!this.state.every(
(el) =>
el.text &&
el.isCompleted !== undefined &&
typeof el.text === 'string' &&
typeof el.isCompleted === 'boolean'
)
) {
throw new Error('data 형식이 올바르지 않습니다!');
}
};
this.setState = (nextState) => {
let isSame = false;
// data length가 다르면 다른 data이므로
if (this.lastState.length === nextState.length) {
let flag = true;
for (let i = 0; i < this.state.length; i++) {
if (
this.lastState[i].text !== nextState[i].text ||
this.lastState[i].isCompleted !== nextState[i].isCompleted
) {
flag = false;
}
}
isSame = flag;
}
if (!isSame) {
this.state = nextState;
this.lastState = JSON.parse(JSON.stringify(nextState));
this.render();
} else {
console.log(nextState);
throw new Error('data is same');
}
};
this.render = () => {
let index = -1;
this.element.innerHTML = this.state
.map((todo) => {
const { text, isCompleted } = todo;
// console.log(isCompleted);
index++;
return `<li data-index=${index}>
<span class='todo-text'>${isCompleted ? `<s>${text}</s>` : text}</span>
<button class='delete-button'>-</button>
</li>`;
})
.join('');
};
this.validate();
this.render();
}
TodoInput.js
export function TodoInput({ target, onAddTodo }) {
this.element = document.createElement('form');
this.input = document.createElement('input');
this.deleteButton = document.createElement('button');
this.removeAllButton = document.createElement('button');
target.appendChild(this.element);
this.element.appendChild(this.input);
this.element.appendChild(this.deleteButton);
this.element.appendChild(this.removeAllButton);
this.render = () => {
this.deleteButton.innerHTML = '추가';
this.input.placeholder = '할 일을 입력하세요.';
this.removeAllButton.innerHTML = '전체 삭제';
};
const todoForm = document.querySelector('#todo-form');
// todoForm.addEventListener('submit', (event) => this.addTodo(event));
this.element.addEventListener('submit', (event) => {
event.preventDefault();
if (this.input.value.trim() !== '') {
onAddTodo(this.input.value);
}
this.input.value = '';
this.input.focus();
});
this.removeAllButton.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('removeAll'));
});
this.render();
}
TodoCounter.js
export function TodoCounter({ target, todoCount }) {
this.element = document.createElement('div');
target.appendChild(this.element);
this.state = todoCount;
this.setState = (nextState) => {
this.state = nextState.length;
this.render();
};
this.render = () => {
this.element.innerHTML = `<span>Count: ${this.state}</span>`;
};
const todoCounter = document.querySelector('#todo-counter');
this.render();
}
localStorage.js
export const localStorageKey = 'todos';
export const getItem = (key, defaultValue) => {
try {
const todo = window.localStorage.getItem(key);
return todo ? JSON.parse(todo) : defaultValue;
} catch (e) {
return defaultValue;
}
};
export const setItem = (key, value) => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.log(e);
}
};
항상 Javascript에 대한 갈증이 있었는데 React의 구조에 대해 어느정도 익숙해지고 나니 JS로 React스럽게 코드를 짜는 것이 재미있게 느껴졌다. 먼저 구조에 대해 충분히 고민하고 리뷰를 주고 받으며 수정하는 과정을 통해 JS로 어떻게 코드를 짜는 것이 재사용성을 높이고 의존성을 낮추는 것인지 조금은 감을 잡은 것 같다.
앞으로도 종종 JS로만 미니 프로젝트를 진행해봐야겠다고 다짐 😊