시작하기 앞서 . . .
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()
}