🔹 지금까지 배운 Vanilla JS 지식들로 Todo list를 새로 진화시켰다. programmers에서 제공해준 api를 이용한다.
🔹 업그레이드 TodoApp
1) users api를 통해 사용자 목록을 그리고, 클릭하면 해당 사용자의 todo 목록을 가져온다.
2) 할 일을 추가하면 화면에 추가되고, API 호출을 통해 서버에도 추가된다.
3) TODO를 추가하고 삭제하는 동안 낙관적 업데이트를 사용한다.
💡 낙관적 업데이트
- 서버 요청 성공을 낙관적으로 가정하고 서버의 api를 호출함과 동시에 화면에 변화한 요소를 출력, 출력 이후에 api 호출이 완료되면 다시 업데이트하는 형식을 의미한다.
new TodoForm({ $target, 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 fetchTodo() } })
4) 서버와 통신하는 동안 서버와 통신중임을 알리는 UI적 처리를 한다.
🔹 핵심인 TodoList
1) 특정 유저의 todo list 불러오기
2) 추가 및 삭제하기
3) 클릭 시, 완료처리하거나 완료처리 취소하기
🔹 구조는 다음과 같다.
⌨ index.html
<!DOCTYPE html>
<html lang="ko">
<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">
<title>Todo List</title>
</head>
<body>
<main id="app" style="display:flex; flex-direction: row;"></main>
<script src="./src/main.js" type="module"></script>
</body>
</html>
⌨ main.js
import App from "./App.js"
const $target = document.querySelector('#app')
new App({$target})
// App.js 생성
⌨ api.js
export const API_END_POINT = 'https://cnu1.todo.edu-api.programmers.co.kr'
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)
}
}
⌨ Header.js
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()
}
// 선택된 유저의 이름을 표시
⌨ querystring.js
export const parse = (querystring) =>
// '?name=gue&position=bassist'
// &로 쪼갠다.
// key=value의 조합을 object의 형태로 만든다.
// 만들어진 것을 리턴
querystring.split('&').reduce((acc, keyAndValue) => {
const [key, value] = keyAndValue.split('=')
if(key && value) {
acc[key] = value
console.log(acc, key, value)
}
return acc
}, {})
// querystring parsing 함수
⌨ TodoList.js
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>
`
}
// li가 몇 개든 클릭 한번으로 처리가 가능함
$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()
}
⌨ TodoForm.js
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')
$input.value = getItem(TODO_TEMP_SAVE_KEY, '')
$input.addEventListener('keyup', (e) => {
setItem(TODO_TEMP_SAVE_KEY, e.target.value)
})
}
// 로컬 스토리지를 이용해서 저장하기 전의 값을 저장하고 있다가 불러오기
// 임시로 넣었던 값을 삭제
⌨ userList.js
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= ''
}
onSelect($newUser.value)
})
}
⌨ storage.js
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 parsedValue = JSON.parse(storedValue)
return parsedValue
} catch(e) {
return defaultValue
}
}
export const removeItem = (key) => {
storage.removeItem(key)
}
⌨ 🌟App.js
import Header from "./Header.js"
import UserList from "./userList.js"
import TodoForm from "./TodoForm.js"
import TodoList from "./TodoList.js"
import { request } from "./api.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: [],
isLoading: false
}
// querylist 안에 있는 유저를 선택처리, 그게 아니면 클릭할 때 유저가 처리되는 형식
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 fetchTodo()
}
})
const header = new Header({
$target: $todoListContainer,
initialState:{
selectedUsername: this.state.selectedUsername,
isLoading: this.state.isLoading
}
})
new TodoForm({
$target: $userListContainer,
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 fetchTodo()
if(isFirstTodoAdd) {
await fetchUserList()
}
}
})
this.setState = nextState => {
this.state = nextState
header.setState({
isLoading: this.state.isLoading,
selectedUsername: this.state.selectedUsername
})
todoList.setState({
isLoading: this.state.isLoading,
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: $userListContainer,
initialState: {
isLoading: this.state.isLoading,
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?delay=1000`, {
method: 'PUT'
})
await fetchTodo()
},
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 fetchTodo()
}
})
const fetchUserList = async () => {
const userList = await request('/users')
this.setState({
...this.state,
userList
})
}
const fetchTodo = async () => {
const {selectedUsername} = this.state
if(selectedUsername) {
this.setState({
...this.state,
isLoading: true
})
const todos = await request(`/${selectedUsername}`)
this.setState({
...this.state,
todos,
isLoading: 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 fetchTodo()
}
}
}
this.render()
init()
window.addEventListener('popstate', () => {
init()
})
}
🖨 결과물
TodoList가 할일 목록 아래에 붙지 않는거 외엔 정상 작동한다.
저게 왜 저러는지는... 주말을 이용해 천천히 디버깅 해보아야겠다.
다양한 비동기 제어방식들과 api, 로컬 스토리지, history api까지 이용하여 만들었다.
다양한 기능이 추가된 만큼 이전보다 더 나은 구성이 되었다.