To do App 만들기
요구사항
- Users API를 통해 사용자 목록을 불러오고, 클릭하면 해당 사용자의 todo 목록을 가져옴
- 할일을 추가하면 화면에 추가되고, API 호출을 통해 서버에도 추가
- TODO를 추가하고 삭제하는 동안 낙관적 업데이트를 사용
- 서버와 통신하는 동안 서버와 통신중임을 알리는 UI 처리
더보기
<!DOCTYPE html>
<html lang="en">
<head>
<title>Todo App</title>
</head>
<body>
<main id="app" style="display: flex; flex-direction: row"></main>
<script src="/src/main.js" type="module"></script>
</body>
</html>
더보기
import App from "./App.js";
const $target = document.querySelector("#app");
new App({
$target,
});
더보기
const storage = window.localStorage;
export const setItem = (key, value) => {
try {
storage.setItem(key, JSON.stringify(value));
console.log(key, value);
} catch (e) {
console.log(e);
}
};
export const getItem = (key, defaultValue) => {
try {
const storedValue = storage.getItem(key);
if (!storedValue) {
return defaultValue;
}
const pasedValue = JSON.parse(storedValue);
return pasedValue;
} catch (e) {
return defaultValue;
}
};
export const removeItem = (key) => {
storage.removeItem(key);
};
더보기
export const API_END_POINT = "...";
export const request = async (url, options = {}) => {
try {
const res = await fetch(`${API_END_POINT}${url}`, {
...options,
headers: {
"Content-Type": "application/json",
},
});
if (res.ok) {
const json = await res.json();
return json;
}
throw new Error("API 호출 오류");
} catch (e) {
alert(e.message);
}
};
더보기
export const parse = (querystring) =>
querystring.split("&").reduce((acc, keyAndValue) => {
const [key, value] = keyAndValue.split("=");
if (key && value) {
acc[key] = value;
}
return acc;
}, {});
더보기
import { request } from "./api.js";
import UserList from "./UserList.js";
import Header from "./Header.js";
import TodoForm from "./TodoForm.js";
import TodoList from "./TodoList.js";
import { parse } from "./querystring.js";
export default function App({ $target }) {
const $userListContainer = document.createElement("div");
const $todoListContainer = document.createElement("div");
$target.appendChild($userListContainer);
$target.appendChild($todoListContainer);
this.state = {
userList: [],
selectedUsername: null,
todos: [],
isTodoLoading: false,
};
const userList = new UserList({
$target: $userListContainer,
initialState: this.state.userList,
onSelect: async (username) => {
history.pushState(null, null, `/?selectedUsername=${username}`);
this.setState({
...this.state,
selectedUsername: username,
});
await await fetchTodos();
},
});
const header = new Header({
$target: $todoListContainer,
initialState: {
isLoading: this.state.isTodoLoading,
selectedUsername: this.state.selectedUsername,
},
});
new TodoForm({
$target: $todoListContainer,
onSubmit: async (content) => {
const isFirstTodoAdd = this.state.todos.length === 0;
const todo = {
content,
isCompleted: false,
};
this.setState({
...this.state,
todos: [...this.state.todos, todo],
});
await request(`/${this.state.selectedUsername}`, {
method: "POST",
body: JSON.stringify(todo),
});
await fetchTodos();
if (isFirstTodoAdd) {
await fetchUserList();
}
},
});
this.setState = (nextState) => {
this.state = nextState;
header.setState({
isLoading: this.state.isTodoLoading,
selectedUsername: this.state.selectedUsername,
});
todoList.setState({
isLoading: this.state.isTodoLoading,
todos: this.state.todos,
selectedUsername: this.state.selectedUsername,
});
userList.setState(this.state.userList);
this.render();
};
this.render = () => {
const { selectedUsername } = this.state;
$todoListContainer.style.display = selectedUsername ? "block" : "none";
};
const todoList = new TodoList({
$target: $todoListContainer,
initialState: {
isLoading: this.state.isTodoLoading,
todos: this.state.todos,
selectedUsername: this.state.selectedUsername,
},
onToggle: async (id) => {
const todoIndex = this.state.todos.findIndex((todo) => todo._id === id);
const nextTodos = [...this.state.todos];
nextTodos[todoIndex].isCompleted = !nextTodos[todoIndex].isCompleted;
this.setState({
...this.state,
todos: nextTodos,
});
await request(`/${this.state.selectedUsername}/${id}/toggle`, {
method: "PUT",
});
await fetchTodos();
},
onRemove: async (id) => {
const todoIndex = this.state.todos.findIndex((todo) => todo._id === id);
const nextTodos = [...this.state.todos];
nextTodos.splice(todoIndex, 1);
this.setState({
...this.state,
todos: nextTodos,
});
await request(`/${this.state.selectedUsername}/${id}?`, {
method: "DELETE",
});
await fetchTodos();
},
});
const fetchUserList = async () => {
const userList = await request("/users");
this.setState({
...this.state,
userList,
});
};
const fetchTodos = async () => {
const { selectedUsername } = this.state;
if (selectedUsername) {
this.setState({
...this.state,
isTodoLoading: true,
});
const todos = await request(`/${selectedUsername}`);
this.setState({
...this.state,
todos,
isTodoLoading: false,
});
}
};
const init = async () => {
await fetchUserList();
// url에 특정 사용자를 나타내는 값이 있을 경우
const { search } = location;
if (search.length > 0) {
const { selectedUsername } = parse(search.substring(1));
if (selectedUsername) {
this.setState({
...this.state,
selectedUsername,
});
await fetchTodos();
}
}
};
this.render();
init();
window.addEventListener("popstate", () => {
init();
});
}
더보기
export default function Header({ $target, initialState }) {
const $h2 = document.createElement("h2");
$target.appendChild($h2);
this.state = initialState;
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
const { selectedUsername, isLoading } = this.state;
if (!selectedUsername) {
$h2.innerHTML = "";
return;
}
$h2.innerHTML = `
${selectedUsername} 님의 할 일 목록 ${isLoading ? "로딩 중..." : ""}
`;
};
this.render();
}
더보기
export default function TodoList({
$target,
initialState,
onToggle,
onRemove,
}) {
const $todo = document.createElement("div");
$target.appendChild($todo);
this.state = initialState;
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
const { isLoading, todos } = this.state;
if (!isLoading && todos.length === 0) {
$todo.innerHTML = "Todo가 없습니다!";
return;
}
$todo.innerHTML = `
<ul>${todos
.map(
({ _id, content, isCompleted }) => `
<li data-id=${_id} class="todo-item">
${isCompleted ? `<s>${content}</s>` : content}
<button class="remove">x</button>
</li>
`
)
.join("")}</ul>
`;
};
$todo.addEventListener("click", (e) => {
const $li = e.target.closest(".todo-item");
if ($li) {
const { id } = $li.dataset;
const { className } = e.target;
if (className === "remove") {
onRemove(id);
} else {
onToggle(id);
}
}
});
this.render();
}
더보기
import { setItem, getItem, removeItem } from "./storage.js";
const TODO_TEMP_SAVE_KEY = "TODO_TEMP_SAVE_KEY";
export default function TodoForm({ $target, onSubmit }) {
const $form = document.createElement("form");
$target.appendChild($form);
this.render = () => {
$form.innerHTML = `
<input type="text" placeholder="할 일을 입력하세요.">
<button>추가하기</button>
`;
};
$form.addEventListener("submit", (e) => {
e.preventDefault();
const $input = $form.querySelector("input");
const content = $input.value;
onSubmit(content);
$input.value = "";
removeItem(TODO_TEMP_SAVE_KEY);
});
this.render();
const $input = $form.querySelector("input");
$form.querySelector("input").value = getItem(TODO_TEMP_SAVE_KEY, "");
$input.addEventListener("keyup", (e) => {
setItem(TODO_TEMP_SAVE_KEY, e.target.value);
});
}
더보기
export default function UserList({ $target, initialState, onSelect }) {
const $userList = document.createElement("div");
$target.appendChild($userList);
this.state = initialState;
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
$userList.innerHTML = `
<h1>Users</h1>
<ul>
${this.state.map((username) =>
`<li data-username=${username}>${username}</li>`
).join("")}
<li>
<form>
<input class="new-user" type="text" placeholder="add username">
</form>
</li>
</ul>
`;
};
this.render();
$userList.addEventListener("click", (e) => {
const $li = e.target.closest("li[data-username]");
if ($li) {
const { username } = $li.dataset;
onSelect(username);
}
});
$userList.addEventListener("submit", (e) => {
const $newUser = $userList.querySelector(".new-user");
const newUserValue = $newUser.value;
if (newUserValue.length > 0) {
onSelect($newUser.value);
$newUser.value = "";
}
});
}
이벤트 위임(delegation)
이벤트 위임을 사용하면 요소마다 핸들러를 할당하지 않고, 요소의 공통 조상에 이벤트 핸들러를 단 하나만 할당하여 여러 요소를 한번에 다룰 수 있다.
공통 조상에 할당한 핸들러에서 e.target을 이용하면 실제 이벤트가 어디에서 발생했는지 알 수 있다.
// TodoList.js
...
$todo.innerHTML = `
<ul>${todos.map(({ _id, content, isCompleted }) =>
`<li data-id=${_id} class="todo-item">
${isCompleted ? `<s>${content}</s>` : content}
<button class="remove">x</button>
</li>`
).join("")}</ul>
`;
};
$todo.addEventListener("click", (e) => {
const $li = e.target.closest(".todo-item");
if ($li) {
const { id } = $li.dataset;
const { className } = e.target;
if (className === "remove") {
onRemove(id);
} else {
onToggle(id);
}
}
});
...
낙관적 업데이트
낙관적 업데이트는 요청이 성공할 것이라고 낙관적으로 보고 화면을 먼저 갱신한 다음 서버에 요청을 보내는 것이다.
낙관적 업데이트: 사용자 요청 → 화면 갱신 → 서버에 수정 요청 (실패 시 이전 상태로 되돌리기)
비관적 업데이트: 사용자 수정 요청 → 서버에 수정 요청 → 성공하면 화면 갱신
낙관적 업데이트는 사용자의 경험을 존중하기 위해 실시간 업데이트가 필요하거나, 요청이 실패되어도 위험도가 낮은 좋아요 버튼과 같은 것들에 주로 사용된다.
// App.js
...
new TodoForm({
...
onSubmit: async (content) => {
...
// 낙관적 업데이트
const todo = {
content,
isCompleted: false,
};
// 서버 요청 전 상태를 먼저 갱신
this.setState({
...this.state,
todos: [...this.state.todos, todo],
});
// 서버 요청
await request(`/${this.state.username}`, {
method: "POST",
body: JSON.stringify(todo),
});
await fetchTodos();
},
});
...
출처: 프로그래머스 프론트엔드 데브코스
[Day 15] VanillaJS를 통한 자바스크립트 기본 역량 강화 I (7)
'데브코스' 카테고리의 다른 글
[Day 24] 프로젝트 배포 (0) | 2022.11.17 |
---|---|
[Day 16] 디바운싱, 커스텀 이벤트 (0) | 2022.11.09 |
[Day 14] history API (0) | 2022.11.06 |
[Day 13] fetch API (0) | 2022.11.05 |
[Day 12] Module, callback, Promise, async/await (0) | 2022.11.05 |