시작하기 앞서 . . .
JSON ( JavaScript Object Notation )은 Javascript 객체 문법으로 구조화된, 데이터를 표현하기 위한 문자 기반의 표준 포맷이다. 웹 어플리케이션에서 데이터를 전송할 때 일반적으로 사용한다. 요즘, 대부분 요청에 대한 Content-Type은 application/json 타입인 것이 많다.

| method | 기능 | 용도 |
|---|---|---|
JSON.parse( ) | string 객체를 → json 객체로 변환 | 서버에서 받은 json을 해석할 때 사용 |
JSON.stringify( ) | json 객체를 → string 객체로 변환 | 서버로 json을 보낼 때 사용 |
response.json( ) | string 객체를 → json 객체로 변환 | 응답의 body 안의 json을 해석할 때 사용 |
🧐
JSON.parse(response),response.json()차이
response.json()는 json안에 header와 body 전부를 넣어도 body만 해석
JSON.parse(response)는 header와 body를 전부 넣어주면 해석 불가능
그동안 배운 지식을 활용해 simple Todo list를 Todo App으로 발전시켜보자.
이전에 배운 history API, fetch를 이용할 것이다.
구현기능
요구사항
낙관적 업데이트
서버 요청 성공을 낙관적으로 가정하여 서버 API를 호출함과 동시에 화면에 변화한 요소를 출력한다. 만약 출력 이후에 API 호출이 완료되면 다시 업데이트를 한다.new TodoForm({ $target, onSubmit: async(content) => { // 1. 서버에 post하기 전에 todo를 todoList에 추가 (낙관적 업데이트) const todo = { content, isCompleted: false } this.setState({ ...this.state, todos: [ ...this.state.todos, todo ] }) // 2. 서버에 추가하기 await request(`/:${this.state.username}`, { method: 'POST', body: JSON.stringify(todo) }) }})
파일 구조

- 데이터 형태
{ "_id": 할 일의 고유값. 숫자와 문자가 섞여있는 문자로 되어있음, "content": 할 일 text, "isCompleted": 할 일의 완료 여부 }API_END_POINT= https://cnu1.todo.edu-api.programmers.co.kr
- 유저 목록 불러오기
- API URL : https://cnu1.todo.edu-api.programmers.co.kr/users
URL/users["programmers","null","test","dns-query","query","resolve", "HNAP1","최효재",".env","index.htm","FD873AC4-CF86-4FED-84EC-4BD59C6F17A7", "dddf","이게 정말 되나","users","sdk","posts",".env.prod",".env.old",".env.development", ".env.production",".env.project","live_env",".env.dist",".env.save","json-rpc"]- method :
GET// ex) 유저 목록 불러오기 fetch('https://cnu1.todo.edu-api.programmers.co.kr/users').then()...
- 할 일 목록 불러오기
- API URL : https://cnu1.todo.edu-api.programmers.co.kr/:username
URL/:username[ {"content":"","isCompleted":false,"_id":"63eee8e498dd4702950c5224"}, {"content":"","isCompleted":false,"_id":"63fdf1ea98dd4702950c5277"}, {"content":"","isCompleted":false,"_id":"63fe11af98dd4702950c5278"}, {"content":"","isCompleted":false,"_id":"63ff361298dd4702950c527e"}, {"content":"","isCompleted":false,"_id":"63ff475e98dd4702950c5280"}, {"content":"","isCompleted":false,"_id":"6400150e98dd4702950c5284"} ]
- method :
GET// ex) 할 일과 관련된 모든 API에는 username이 들어가게 되어있다. // 본인의 username을 적당히 넣으면 된다. fetch('https://cnu1.todo.edu-api.programmers.co.kr/sdk').then()...
- 할 일 추가하기
- API URL : https://cnu1.todo.edu-api.programmers.co.kr/:username
URL/:username- method :
POST// ex) ":username"에 지정한 값이 할 일과 동시에 유저 목록에 추가 fetch('https://cnu1.todo.edu-api.programmers.co.kr/sdk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: 'Javascript 실습' }) }).then(function(){ .... })
- 할 일 삭제하기
- API URL : https://cnu1.todo.edu-api.programmers.co.kr/:username/:todo_id
URL/:username/:todo_id- method:
Delete/* ex) 서버에서 불러온 todo 데이터는 _id 라는 이름으로 해당 todo의 id가 있다. 이것을 url의 <todo_id> 부분에 넣으면 된다. */ fetch('https://cnu1.todo.edu-api.programmers.co.kr/sdk/5d11cf671e050d3f7c583166', { method: 'DELETE' }).then(function(){ .... })
- 할 일 완료 여부 토글하기
- API URL : https://cnu1.todo.edu-api.programmers.co.kr/:username/:todo_id/toggle
URL/:username/:todo_id/toggle- method :
PUT/* ex) todo_id에 해당하는 todo가 완료 상태인 경우 미완료 처리, 미완료 상태인 경우 완료 처리를 한다. */ fetch('https://cnu1.todo.edu-api.programmers.co.kr/sdk/63d875575ad9f10afacbab57/toggle', { method: 'PUT' }).then(function(){
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>upgraded TodoList</title>
</head>
<body>
<main id="app"></main>
<script src="/src/main.js" type="module"></script>
</body>
</html>
main.js
import TodoForm from "./TodoForm.js"
import TodoList from "./ToDoList.js"
const DummyData = [
// 유저별 할일 목록 데이터 형태
{
_id : 1,
content: "javascript 학습하기",
isCompleted: true
},
{
_id : 2,
content: "javascript 복습하기",
isCompleted: false
}
]
const $App = document.querySelector('#app')
new TodoForm({
$target: $App,
onSubmit: (content) => {
alert(`${content} 추가 예정`)
}})
new TodoList({
$target: $App,
initialState: DummyData,
onToggle: (id) => {alert(`${id} 토글 예정`)},
onRemove: (id) => {alert(`${id} 삭제 예정`)}
})
TodoList.js
export default function TodoList({$target, initialState, onToggle, onRemove})
{
const $todo = document.createElement('div')
$target.appendChild($todo)
this.state = initialState
/* [
// 유저별 할일 목록 데이터 형태
{
_id : 1,
content: "javascript 학습하기",
isCompleted: true
},
{
_id : 2,
content: "javascript 복습하기",
isCompleted: false
}
]
*/
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>
${this.state.map(({_id, content, isCompleted}) => `
<li data-id="${_id}" class="todo-item">
${isCompleted ? `<s>${content}</s>` : content}
<!-- <s></s> 취소선 처리 -->
<button class="remove"> X </button>
</li>
`).join('')}
</ul>
`
}
// li가 몇 개든 클릭 한번으로 처리가 가능함
$todo.addEventListener('click', (e) => {
const $li = e.target.closest('.todo-item')
// => <li data-id="${_id}" class="todo-item">
if($li) {
const {id} = $li.dataset
// 실제 이벤트를 발생시킨 곳이 어디인지 찾는 법
const { className } = e.target
if(className === 'remove') {
onRemove(id)
} else{
onToggle(id)
}
}
})
this.render()
}
TodoForm.js
export default function TodoForm({$target,onSubmit}){
const $form = document.createElement('form')
$target.appendChild($form)
this.render = () => {
$form.innerHTML = `
<input type='text' placeholder='할 일을 입력하세요.'></input>
<button type='submit'> 추가하기 </button> <!-- 버튼 타입의 기본값은 submit -->
`
$form.addEventListener('submit', (e) => {
e.preventDefault()
const $input = $form.querySelector('input')
const content = $input.value
onSubmit(content)
$input.value = ''
})
}
this.render()
}

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 $h1 = document.createElement('h1')
$target.appendChild($h1)
this.state = initialState // username, isLoading
this.setState = (nextState) => {
this.state = nextState
this.render()
}
this.render = () => {
const {username, isLoading} = this.state
$h1.innerHTML = `${username}님의 할 일 목록 ${isLoading ? '로딩 중...':''}`
}
this.render()
}
TodoList.js
export default function TodoList({$target, initialState, onToggle, onRemove}) {
const $todo = document.createElement('div')
$target.appendChild($todo)
this.state = initialState // {isLoading, todos: []}
/* [
// 유저별 할일 목록 데이터 형태
{
_id : 1,
content: "javascript 학습하기",
isCompleted: true
},
{
_id : 2,
content: "javascript 복습하기",
isCompleted: false
}
]
*/
this.setState = (nextState) => {
this.state = nextState
this.render()
}
this.render = () => {
const { isLoading, todos } = this.state
if (!isLoading && todos.length.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')
// => <li data-id="${_id}" class="todo-item">
if($li) {
const {id} = $li.dataset
// 실제 이벤트를 발생시킨 곳이 어디인지 찾는 법
const { className } = e.target
if(className === 'remove') {
onRemove(id)
} else{
onToggle(id)
}
}
})
this.render()
}
TodoForm.js
export default function TodoForm({$target,onSubmit}){
const $form = document.createElement('form')
$target.appendChild($form)
this.render = () => {
$form.innerHTML = `
<input type='text' placeholder='할 일을 입력하세요.'></input>
<button type='submit'> 추가하기 </button>
`
$form.addEventListener('submit', (e) => {
e.preventDefault()
const $input = $form.querySelector('input')
const content = $input.value
onSubmit(content)
$input.value = ''
})
}
this.render()
}
App.js
import TodoForm from "./TodoForm.js"
import TodoList from "./ToDoList.js"
import Header from "./Header.js"
import { request } from "./api.js"
export default function App({$target}){
this.state = {
username: 'programmers',
todos: [],
isTodoLoading: false
/*
[ // todos 형태
{
_id : 1,
content: "javascript 학습하기",
isCompleted: true
},
{
_id : 2,
content: "javascript 복습하기",
isCompleted: false
}
]
*/
}
const header = new Header({
$target,
initialState: {
username:this.state.username,
isLoading: this.state.isTodoLoading
}
})
new TodoForm({
$target,
onSubmit: async(content) => {
// todoList에 반영
// 낙관적 업데이트 (사용성 굿)
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)
})
// todoList에 반영 (낙관적 업데이트가 아닐 때)
// await fetchTodos()
}})
this.setState = (nextState) =>{
this.state = nextState
todoList.setState({
isLoading: this.state.isTodoLoading,
todos: this.state.todos})
header.setState({
username:this.state.username,
isLoading: this.state.isTodoLoading
})
}
const todoList = new TodoList({
$target,
initialState: {
isLoading: this.state.isTodoLoading,
todos: this.state.todos},
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.username}/${id}`,{
method: 'DELETE'
})
},
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.username}/${id}/toggle`,{
method: 'PUT',
})
}
})
// username별 todo 목록 불러오기
const fetchTodos = async () => {
const {username} = this.state
// 현재 username이 있을 때만 todo 목록을 불러온다.
if(username){
// 데이터를 불러오는 로딩중일 때
this.setState({
... this.state,
isTodoLoading: true
})
// request
const todos = await request(`/${username}`)
// 로딩중 확인할 때 `/${username}?delay=1000`
this.setState({
...this.state,
todos,
isTodoLoading: false // 로딩이 끝나면 false 처리
})
}
}
fetchTodos()
}
main.js
import App from "./App.js"
const $target = document.querySelector('#app')
new App({$target})

만약 input 작성 중 저장 or 추가하기 버튼을 누르지 않은 경우, 새로고침 시 이전 값을 채워주는 작업을 local storage를 이용해 구현할 것이다. 값을 추가하기 전 새로고침을 하면 input에 작성 중인 값이 그대로 남아있고, 값을 추가 후 새로고침을 하면 input이 비워져 있다.

storage.js
const storage = window.localStorage
export const setItem = (key,value) => {
try{
storage.setItem(key,JSON.stringify(value))
} catch(e){
console.log(e)
}
/* 브라우저마다 setItem 용량 제한이 있다.
용량을 초과해서 데이터를 저장하려고 하면 에러가 발생하므로
try catch 처리를 해주는 것이 안전하다. */
}
export const getItem = (key, defaultValue) => {
// 꺼내오는데 실패 시를 대비해 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)
}
TodoForm.js
import { setItem, getItem, removeItem } from "./storage.js"
const TODO_TEXT_SAVE_KEY = 'TODO_TEXT_SAVE_KEY'
export default function TodoForm({$target,onSubmit}){
const $form = document.createElement('form')
$target.appendChild($form)
this.render = () => {
$form.innerHTML = `
<input type='text' placeholder='할 일을 입력하세요.'></input>
<button type='submit'> 추가하기 </button>
`
$form.addEventListener('submit', (e) => {
e.preventDefault()
const $input = $form.querySelector('input')
const content = $input.value
onSubmit(content)
$input.value = ''
// 데이터가 추가되면 로컬 스토리지 삭제
removeItem(TODO_TEXT_SAVE_KEY)
})
}
this.render()
// 렌더링 된 이후에 .. 로컬스토리지 처리
const $input = $form.querySelector('input')
$input.value = getItem(TODO_TEXT_SAVE_KEY,'')
$input.addEventListener('keyup', (e)=>{
setItem(TODO_TEXT_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='newUser' type='text' placeholder='add new User'>
</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('.newUser')
if ($newUser.value.length > 0){
onSelect($newUser.value)
$newUser.value = ''
}
})
}
App.js
import TodoForm from "./TodoForm.js"
import TodoList from "./ToDoList.js"
import Header from "./Header.js"
import { request } from "./api.js"
import UserList from "./userList.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
/*
[ // todos 형태
{
_id : 1,
content: "javascript 학습하기",
isCompleted: true
},
{
_id : 2,
content: "javascript 복습하기",
isCompleted: false
}
]
*/
}
const userList = new UserList({
$target: $userListContainer,
initialState: this.state.userList,
onSelect: async (username) => {
this.setState({
...this.state,
selectedUsername: username
})
await fetchTodos()
$todoListContainer.style.display = 'block'
}
})
const header = new Header({
$target: $todoListContainer,
initialState: {
username:this.state.selectedUsername,
isLoading: this.state.isTodoLoading
}
})
new TodoForm({
$target: $todoListContainer,
onSubmit: async(content) => {
const isFirstTodoAdd = this.state.todos.length === 0
// todoList에 반영
// 낙관적 업데이트 (사용성 굿)
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)
})
// 새 유저에 처음 todo 추가 시 유저리스트에 새 유저 바로 보이게 하기
if(isFirstTodoAdd){
await fetchUserList()
}
// todoList에 반영 (낙관적 업데이트가 아닐 때)
// await fetchTodos()
}})
this.setState = (nextState) =>{
this.state = nextState
userList.setState(this.state.userList)
todoList.setState({
isLoading: this.state.isTodoLoading,
todos: this.state.todos})
header.setState({
username:this.state.selectedUsername,
isLoading: this.state.isTodoLoading
})
}
// selectedUsername가 없을 땐 $todoListContainer 그리지 않는 처리
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,
},
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'
})
},
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',
})
}
})
// username 불러오기
const fetchUserList = async () => {
const userList = await request('/users')
this.setState({
...this.state,
userList: userList
})
}
// username별 todo 목록 불러오기
const fetchTodos = async () => {
const {selectedUsername} = this.state
// 현재 username이 있을 때만 todo 목록을 불러온다.
if(selectedUsername){
// 데이터를 불러오는 로딩중일 때
this.setState({
... this.state,
isTodoLoading: true
})
// request
const todos = await request(`/${selectedUsername}`)
// 로딩중 확인할 때 `/${selectedUsername}?delay=1000`
this.setState({
...this.state,
todos,
isTodoLoading: false // 로딩이 끝나면 false 처리
})
}
}
const init = async() => {
await fetchUserList()
}
init()
this.render()
}

history API를 이용해서 유저를 선택하면 URL을 업데이트 해주고, 처음 URL에 따라 특정 유저의 TodoList가 보이도록 수정한다.
querystring.js
export const Parse = (querystring) =>
// '?name=HyeSoo&position=student'
// 1. &로 쪼갠다.
// 2. key=value의 조합을 object의 형태로 만든다.
// 3. 만들어진 것을 리턴
querystring.split('&').reduce((acc, keyAndValue) => {
const [key, value] = keyAndValue.split('=')
if (key && value) {
acc[key] = value
}
return acc
}, {})
App.js
import TodoForm from "./TodoForm.js"
import TodoList from "./ToDoList.js"
import Header from "./Header.js"
import { request } from "./api.js"
import UserList from "./userList.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
/*
[ // todos 형태
{
_id : 1,
content: "javascript 학습하기",
isCompleted: true
},
{
_id : 2,
content: "javascript 복습하기",
isCompleted: 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 fetchTodos()
}
})
const header = new Header({
$target: $todoListContainer,
initialState: {
username:this.state.selectedUsername,
isLoading: this.state.isTodoLoading
}
})
new TodoForm({
$target: $todoListContainer,
onSubmit: async(content) => {
const isFirstTodoAdd = this.state.todos.length === 0
// todoList에 반영
// 낙관적 업데이트 (사용성 굿)
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)
})
// 새 유저에 처음 todo 추가 시 유저리스트에 새 유저 바로 보이게 하기
if(isFirstTodoAdd){
await fetchUserList()
}
// todoList에 반영 (낙관적 업데이트가 아닐 때)
// await fetchTodos()
}})
this.setState = (nextState) =>{
this.state = nextState
userList.setState(this.state.userList)
todoList.setState({
isLoading: this.state.isTodoLoading,
todos: this.state.todos})
header.setState({
username:this.state.selectedUsername,
isLoading: this.state.isTodoLoading
})
this.render()
}
// selectedUsername가 없을 땐 $todoListContainer 그리지 않는 처리
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,
},
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'
})
},
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',
})
}
})
// username 불러오기
const fetchUserList = async () => {
const userList = await request('/users')
this.setState({
...this.state,
userList: userList
})
}
// username별 todo 목록 불러오기
const fetchTodos = async () => {
const {selectedUsername} = this.state
// 현재 username이 있을 때만 todo 목록을 불러온다.
if(selectedUsername){
// 데이터를 불러오는 로딩중일 때
this.setState({
... this.state,
isTodoLoading: true
})
// request
const todos = await request(`/${selectedUsername}`)
// 로딩중 확인할 때 `/${selectedUsername}?delay=1000`
this.setState({
...this.state,
todos,
isTodoLoading: false // 로딩이 끝나면 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()
}
}
}
window.addEventListener('popstate',(e)=>{
init()
})
this.render()
init()
}
<!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>upgraded TodoList</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') // <main> 태그 선택
new App({$target}) // App 함수 선언은 App.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)}
}
export default function Header({$target,initialState}){
const $h2 = document.createElement('h2')
$target.appendChild($h2)
this.state = initialState // username, isLoading
this.setState = (nextState) => {
this.state = nextState
this.render()
}
this.render = () => {
const {username, isLoading} = this.state
$h2.innerHTML = `${username}님의 할 일 목록 ${isLoading ? '로딩 중...':''}`
}
this.render()
}
export const Parse = (querystring) =>
// '?name=HyeSoo&position=student'
// 1. &로 쪼갠다.
// 2. key=value의 조합을 object의 형태로 만든다.
// 3. 만들어진 것을 리턴
querystring.split('&').reduce((acc, keyAndValue) => {
const [key, value] = keyAndValue.split('=')
if (key && value) {
acc[key] = value
}
return acc
}, {})
export default function TodoList({$target, initialState, onToggle, onRemove}) {
const $todo = document.createElement('div')
$target.appendChild($todo)
this.state = initialState // {isLoading, todos: []}
/* [
// 유저별 할일 목록 데이터 형태
{
_id : 1,
content: "javascript 학습하기",
isCompleted: true
},
{
_id : 2,
content: "javascript 복습하기",
isCompleted: false
}
]
*/
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') // => <li data-id="${_id}" class="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_TEXT_SAVE_KEY = 'TODO_TEXT_SAVE_KEY'
export default function TodoForm({$target,onSubmit}){
const $form = document.createElement('form')
$target.appendChild($form)
this.render = () => {
$form.innerHTML = `
<input type='text' placeholder='할 일을 입력하세요.'></input>
<button type='submit'> 추가하기 </button>
`
$form.addEventListener('submit', (e) => {
e.preventDefault()
const $input = $form.querySelector('input')
const content = $input.value
onSubmit(content)
$input.value = ''
// 데이터가 추가되면 로컬 스토리지 삭제
removeItem(TODO_TEXT_SAVE_KEY)
})
}
this.render()
// 렌더링 된 이후에 .. 로컬스토리지 처리
const $input = $form.querySelector('input')
$input.value = getItem(TODO_TEXT_SAVE_KEY,'')
$input.addEventListener('keyup', (e)=>{
setItem(TODO_TEXT_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='newUser' type='text' placeholder='add new User'>
</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('.newUser')
if ($newUser.value.length > 0){
onSelect($newUser.value)
$newUser.value = ''
}
})
}
const storage = window.localStorage
export const setItem = (key,value) => {
try{
storage.setItem(key,JSON.stringify(value))
} catch(e){
console.log(e)
}
/* 브라우저마다 setItem 용량 제한이 있다.
용량을 초과해서 데이터를 저장하려고 하면 에러가 발생하므로
try catch 처리를 해주는 것이 안전하다. */
}
export const getItem = (key, defaultValue) => {
// 꺼내오는데 실패 시를 대비해 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)
}
import TodoForm from "./TodoForm.js"
import TodoList from "./ToDoList.js"
import Header from "./Header.js"
import { request } from "./api.js"
import UserList from "./userList.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
/*
[ // todos 형태
{
_id : 1,
content: "javascript 학습하기",
isCompleted: true
},
{
_id : 2,
content: "javascript 복습하기",
isCompleted: 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 fetchTodos()
}
})
const header = new Header({
$target: $todoListContainer,
initialState: {
username:this.state.selectedUsername,
isLoading: this.state.isTodoLoading
}
})
new TodoForm({
$target: $todoListContainer,
onSubmit: async(content) => {
const isFirstTodoAdd = this.state.todos.length === 0
// todoList에 반영
// 낙관적 업데이트 (사용성 굿)
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)
})
// 새 유저에 처음 todo 추가 시 유저리스트에 새 유저 바로 보이게 하기
if(isFirstTodoAdd){
await fetchUserList()
}
// todoList에 반영 (낙관적 업데이트가 아닐 때)
// await fetchTodos()
}})
this.setState = (nextState) =>{
this.state = nextState
userList.setState(this.state.userList)
todoList.setState({
isLoading: this.state.isTodoLoading,
todos: this.state.todos})
header.setState({
username:this.state.selectedUsername,
isLoading: this.state.isTodoLoading
})
this.render()
}
// selectedUsername가 없을 땐 $todoListContainer 그리지 않는 처리
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,
},
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'
})
},
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',
})
}
})
// username 불러오기
const fetchUserList = async () => {
const userList = await request('/users')
this.setState({
...this.state,
userList: userList
})
}
// username별 todo 목록 불러오기
const fetchTodos = async () => {
const {selectedUsername} = this.state
// 현재 username이 있을 때만 todo 목록을 불러온다.
if(selectedUsername){
// 데이터를 불러오는 로딩중일 때
this.setState({
... this.state,
isTodoLoading: true
})
// request
const todos = await request(`/${selectedUsername}`)
// 로딩중 확인할 때 `/${selectedUsername}?delay=1000`
this.setState({
...this.state,
todos,
isTodoLoading: false // 로딩이 끝나면 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()
}
}
}
window.addEventListener('popstate',(e)=>{
init()
})
this.render()
init()
}
