3 Todo App - 애플리케이션 구조 개선하기
source: categories/study/vue-beginner-lv2/vue-beginner-lv2_3.md
3.1 현재 앱 구조의 문제점 진단 및 개선된 앱 구조 소개
첫번째 문제
현재 새로 추가하는 데이터들은 로컬 스토리지에 바로바로 저장이 되고있는 상태입니다.
하지만 화면 리스트에 갱신은 안됩니다.
이 이유는 너무나도 당연한게 TodoInput
컴포넌트에서 입력을 받아서 로컬 스토리지에 저장을했지만, TodoList
컴포넌트에 해당 사실을 알리질 않았습니다.
이 부분에서 저희가 이 두개의 컴포넌트만놓고 수정을 한다고하면 event
를 올려서 수정할 수 있을 겁니다.
여튼 현재 상태는 TodoInput
컴포넌트를 통해 로컬 스토리지에 저장을하고 새로고침을해서 created life cycle hook
이 발생해서 거기서 data
를 다 불러와서 갱신을 해주는 상태입니다.
두번째 문제
두번째 문제는 Clear All
버튼을 통해 로컬 스토리지에 저장된 데이터를 전부 지우잖아요?
그런데 이것도 화면에 바로 렌더링이 안됩니다.
그런데 Clear All
은 TodoFooter
컴포넌트에 있습니다.
이 또한 TodoList
컴포넌트에 해당 사실을 알리지 않았습니다.
그렇기 때문에 화면의 리스트들은 아무런 반응없이 가만히 있는겁니다.
이런 것들이 컴포넌트가 서로 분리가되면서 컴포넌트끼리 서로 데이터전달이 안되고 통신이 안돼서 발생하는 문제입니다.
그래서 지우고나서 새로고침을해야 적용이되는겁니다.
3.1.1 문제 해결을 위한 애플리케이션 구조 개선
TodoInput
컴포넌트에서 newTodoItem
이라는 데이터값을 받아서 데이터를 추가하면 로컬 스토리지에 최신 todoItems
가 생깁니다.
그런데 그게지금 TodoList
에 반영이되고있지 않습니다.
마찬가지로 TodoFooter
에서도 clear all
버튼으로 데이터를 삭제했을 때, 로컬 스토리지의 데이터가 모두 지워지지만 이것이 또 반영이 안됩니다.
구조를 위와 같이 바꿀 수 있습니다.
이게 이제부터 저희가 개선해야될 부분인데요, TodoInput
, TodoList
, TodoFooter
하위에 있는 컴포넌트들은 표현만할겁니다.
실질적인 데이터 조작에대한 것들은 위에 공통적으로 갖고있는 상위 컴포넌트인 App
컴포넌트에서 진행을 할거고,
그렇기 때문에 App
컴포넌트에서 TodoList
컴포넌트로 props
라는 todoItems
라는 속성을 내릴거고,
이 todoItems
는 TodoInput
이라던지 TodoFooter
에서 신호를 받았을 때, 제어를하도록 구조를 변경할겁니다.
전체 컴포넌트(TodoInput
, TodoFooter
, TodoList
)가 상위 컴포넌트인 App
컴포넌트에서 관리하는 하나의 데이터 todoItems
만 바라보게하는겁니다.
그렇게 구조 변경을 할 것이고,
이렇게 이곳저곳에서 로컬스토리지의 todoItems
를 건드리게하는 것이 아닌,
이렇게 한 곳에서(App
) 데이터를(todoItems
) 관리를하게끔 하는 것이 관리하기 편합니다.
그리고 그 해당 데이터를 props
로 내려주기만하면 전체적인 컴포넌트의 동작을 좀 더 매끄럽게 이어줄 수 있습니다.
이러한 부분이 저희가 개선할 어플리케이션 구조입니다.
책에서는 다루지 않았던 것을 하나 더 말씀드리면 지금 위의 App
같은 경우는 컨테이너라는 개념으로 보시면됩니다.
컴포넌트를 설계를 하실 때, 단순하게 화면에 표현하는 프레젠테이셔널 컴포넌트가 있고,
그 앱의 동작이라던지 데이터 조작, 비즈니스 로직이라고 보통 실무에서 표현을하는데,
그런 데이터 조작에 관련된거를 컨테이너라고 합니다.
따라서 위에선 App
이 컨테이너가되겠고, 나머지 TodoInput
, TodoList
, TodoFooter
는 표현단인 프레젠테이셔널 컴포넌트라고 보시면됩니다.
이거를 이렇게 컴포넌트 설계관점이아니라 vuex
관점에서봐도..
vuex
를 저희가 이번 강의에서 다룰거기 때문에 한번 알려드리면,
전체적인 컴포넌트(TodoInput
, TodoList
, TodoFooter
)에서 사용할 데이터(todoItems
)를 한군데(App
)로 다 몰았습니다.
그리고 그 한군데서 데이터조작이 일어나는겁니다.
그래서 약간 작은 버전의 vuex
버전이라고 보시면 될 거 같고, 이러한 구조로 저희가 리팩토링을 시작해보겠습니다.
3.2 [리팩토링] 할 일 목록 표시 기능
TodoList
에 있던created life cycle hook
을 그대로App.vue
로 들고옵니다.todoItems
는 모든 컴포넌트(TodoInput
,TodoList
,TodoFooter
)에서 동일하게 쓰는 데이터입니다.
App.vue
의data
에totoItems
라는 데이터를 만들어줍니다.App.vue
의todoItems
데이터를TodoList
컴포넌트로 내려줍니다.TodoList.vue
에서props: ['propsdata']
이런식으로App
에서 내려준 data를 받으면됩니다.
3.2.1 src/App.vue
<template>
<div id="app">
<TodoHeader></TodoHeader>
<TodoInput></TodoInput>
<TodoList v-bind:propsdata="todoItems"></TodoList>
<TodoFooter></TodoFooter>
</div>
</template>
<script>
import TodoFooter from "@/components/TodoFooter";
import TodoHeader from "@/components/TodoHeader";
import TodoInput from "@/components/TodoInput";
import TodoList from "@/components/TodoList";
export default {
data() {
return {
todoItems: [],
}
},
created() {
if (localStorage.length > 0) {
for (let i=0; i < localStorage.length; i++) {
if (localStorage.key(i) !== 'loglevel:webpack-dev-server') {
this.todoItems.push(JSON.parse(localStorage.getItem(localStorage.key(i))));
}
}
}
},
components: {
TodoHeader,
TodoInput,
TodoList,
TodoFooter
}
}
</script>
<style>
body {
text-align: center;
background-color: #f6f6f6;
}
input {
border-style: groove;
width: 200px;
}
button {
border-style: groove;
}
.shadow {
box-shadow: 5px 10px 10px rgba(0, 0, 0, 0.03);
}
</style>
3.2.2 src/components/TodoList.vue
<template>
<div>
<ul>
<li v-for="(todoItem, index) in propsdata" v-bind:key="todoItem.item" class="shadow">
<i class="checkBtn fas fa-check" v-bind:class="{checkBtnCompleted: todoItem.completed}" v-on:click="toggleComplete(todoItem)"></i>
<span v-bind:class="{textCompleted: todoItem.completed}">{{todoItem.item}}</span>
<span class="removeBtn" v-on:click="removeTodo(todoItem, index)">
<i class="fas fa-trash-alt"></i>
</span>
</li>
</ul>
</div>
</template>
<script>
export default {
name: "TodoList",
props: ['propsdata'],
methods: {
removeTodo(todoItem, index) {
localStorage.removeItem(todoItem.item);
this.todoItems.splice(index, 1);
},
toggleComplete(todoItem) {
todoItem.completed = !todoItem.completed;
localStorage.removeItem(todoItem.item); // 해당 키로 삭제
localStorage.setItem(todoItem.item, JSON.stringify(todoItem)); // 키와 값 다시 재등록
}
},
}
</script>
<style scoped>
ul {
list-style-type: none;
padding-left: 0;
margin-top: 0;
text-align: left;
}
li {
display: flex;
min-height: 50px;
height: 50px;
line-height: 50px;
margin: 0.5rem 0;
padding: 0 0.9rem;
background-color: #ffffff;
border-radius: 5px;
}
.checkBtn {
line-height: 45px;
color: #62acde;
margin-right: 5px;
}
.checkBtnCompleted {
color: #b3adad;
}
.textCompleted {
text-decoration: line-through;
color: #b3adad;
}
.removeBtn {
margin-left: auto;
color: #de4343;
}
</style>
3.3 [리팩토링] 할 일 추가 기능
3.3.1 App.vue
앞시간에 TodoList
에서 표시하던 리스트를 App.vue
로 옮겨왔습니다.
todoItems
라는 data
에 로컬스토리지에 있는 데이터를 배열형태로 넣어줬고, 이를 props
로 TodoList
컴포넌트에 내려주었습니다.
<template>
<div id="app">
<TodoHeader></TodoHeader>
<TodoInput></TodoInput>
<TodoList v-bind:propsdata="todoItems"></TodoList>
<TodoFooter></TodoFooter>
</div>
</template>
<script>
import TodoFooter from "@/components/TodoFooter";
import TodoHeader from "@/components/TodoHeader";
import TodoInput from "@/components/TodoInput";
import TodoList from "@/components/TodoList";
export default {
data() {
return {
todoItems: [],
}
},
created() {
if (localStorage.length > 0) {
for (let i=0; i < localStorage.length; i++) {
if (localStorage.key(i) !== 'loglevel:webpack-dev-server') {
this.todoItems.push(JSON.parse(localStorage.getItem(localStorage.key(i))));
}
}
}
},
components: {
TodoHeader,
TodoInput,
TodoList,
TodoFooter
}
}
</script>
<style>
body {
text-align: center;
background-color: #f6f6f6;
}
input {
border-style: groove;
width: 200px;
}
button {
border-style: groove;
}
.shadow {
box-shadow: 5px 10px 10px rgba(0, 0, 0, 0.03);
}
</style>
그럼이제 TodoInput
에서 add
하는 부분에대해서 방금 저희가 App.vue
에 작업했던 것과 연관지어보겠습니다.
3.3.1 TodoInput.vue
TodoInput
을 보시면 어떤 데이터를 add
할 때 newTodoItem
라는 TodoInput
만의 데이터를 쓰고있습니다.
그런데 생각해보면, 결국 todoItems
라는 할일목록 배열 안에다가만 추가하면되는 거거든요?
그 부분을 바꿔보도록 하겠습니다.
<template>
<div class="inputBox shadow">
<input type="text" v-model="newTodoItem" v-on:keyup.enter="addTodo">
<span class="addContainer" v-on:click="addTodo">
<i class="fas fa-plus addBtn"></i>
</span>
</div>
</template>
<script>
export default {
name: "TodoInput",
data: function () {
return {
newTodoItem: ""
}
},
methods: {
addTodo: function () {
if (this.newTodoItem !== '') {
var obj = {completed: false, item: this.newTodoItem};
localStorage.setItem(this.newTodoItem, JSON.stringify(obj));
this.clearInput();
}
},
clearInput: function () {
this.newTodoItem = '';
}
}
}
</script>
<style scoped>
.input:focus {
outline: none;
}
.inputBox {
background-color: #fff;
height: 50px;
line-height: 50px;
border-radius: 5px;
}
.inputBox input {
border-style: none;
font-size: 0.9rem;
}
.addContainer {
float: right;
display: block;
background: linear-gradient(to right, #6478fb, #8763fb);
width: 3rem;
border-radius: 0 5px 5px 0;
}
.addBtn {
color: #ffffff;
vertical-align: middle;
}
</style>
3.3.3 수정 App.vue, TodoInput.vue
다시 App.vue
로 가보겠습니다.
TodoInput
컴포넌트에서 버튼을 클릭하면 그 이벤트를 상위 컴포넌트로 올려주고 그 데이터를 처리하는 부분이 App.vue
에 있으면됩니다.
App.vue
<template>
<div id="app">
<TodoHeader></TodoHeader>
<TodoInput v-on:addTodoItem="addOneItem"></TodoInput>
<TodoList v-bind:propsdata="todoItems"></TodoList>
<TodoFooter></TodoFooter>
</div>
</template>
<script>
import TodoFooter from "@/components/TodoFooter";
import TodoHeader from "@/components/TodoHeader";
import TodoInput from "@/components/TodoInput";
import TodoList from "@/components/TodoList";
export default {
data() {
return {
todoItems: [],
}
},
methods: {
addOneItem(todoItem) {
var obj = {completed: false, item: todoItem};
localStorage.setItem(todoItem, JSON.stringify(obj));
this.todoItems.push(obj);
}
},
created() {
if (localStorage.length > 0) {
for (let i=0; i < localStorage.length; i++) {
if (localStorage.key(i) !== 'loglevel:webpack-dev-server') {
this.todoItems.push(JSON.parse(localStorage.getItem(localStorage.key(i))));
}
}
}
},
components: {
TodoHeader,
TodoInput,
TodoList,
TodoFooter
}
}
</script>
<style>
body {
text-align: center;
background-color: #f6f6f6;
}
input {
border-style: groove;
width: 200px;
}
button {
border-style: groove;
}
.shadow {
box-shadow: 5px 10px 10px rgba(0, 0, 0, 0.03);
}
</style>
TodoInput.vue
<template>
<div class="inputBox shadow">
<input type="text" v-model="newTodoItem" v-on:keyup.enter="addTodo">
<span class="addContainer" v-on:click="addTodo">
<i class="fas fa-plus addBtn"></i>
</span>
</div>
</template>
<script>
export default {
name: "TodoInput",
data: function () {
return {
newTodoItem: ""
}
},
methods: {
addTodo: function () {
if (this.newTodoItem !== '') {
this.$emit('addTodoItem', this.newTodoItem);
this.clearInput();
}
},
clearInput: function () {
this.newTodoItem = '';
}
}
}
</script>
<style scoped>
.input:focus {
outline: none;
}
.inputBox {
background-color: #fff;
height: 50px;
line-height: 50px;
border-radius: 5px;
}
.inputBox input {
border-style: none;
font-size: 0.9rem;
}
.addContainer {
float: right;
display: block;
background: linear-gradient(to right, #6478fb, #8763fb);
width: 3rem;
border-radius: 0 5px 5px 0;
}
.addBtn {
color: #ffffff;
vertical-align: middle;
}
</style>
이제 TodoInput
컴포넌트에서 하는일이란 event
를 emit
시키는 일밖엔없다.v-model
로 연동시킨 newTodoItem
값을 event emit
으로 태워보내는 것밖엔 없다.
App.vue
에서 하는일은 그 이벤트를 받아서 저장해주고 배열에 반영해주는 일을한다.
지금 위의 App
같은 경우는 컨테이너라는 개념으로 보시면됩니다.
컴포넌트를 설계를 하실 때, 단순하게 화면에 표현하는 프레젠테이셔널 컴포넌트가 있고,
그 앱의 동작이라던지 데이터 조작, 비즈니스 로직이라고 보통 실무에서 표현을하는데,
그런 데이터 조작에 관련된거를 컨테이너라고 합니다.
App.vue
는 컨테이너 컴포넌트, TodoInput
은 프레젠테이셔널 컴포넌트라고 보시면됩니다.
3.4 [리팩토링] 할 일 삭제 기능
TodoList
컴포넌트의 propsdata
.
아까 TodoList
에있던 데이터 리스트를 App.vue
컨테이너 컴포넌트로 올렸었다.
App.vue
에서 todoItems
를 받아가지고 props
로 TodoList
컴포넌트로 내렸습니다.
그러다보니까 TodoList
에서 기존에 갖고있던 메소드들이 제대로 동작하지 않습니다.
그래서 아까 말씀드린 것처럼 TodoList
같은 프레젠테이셔널 컴포넌트에선 최대한 표현만하고,
event
같은 걸 던져가지고 App.vue
같은 컨테이너 컴포넌트에서 처리할 수 있도록 로직을 다시 설계해보겠습니다.
App.vue
<template>
<div id="app">
<TodoHeader></TodoHeader>
<TodoInput v-on:addTodoItem="addOneItem"></TodoInput>
<TodoList v-bind:propsdata="todoItems" v-on:removeItem="removeOneItem"></TodoList>
<TodoFooter></TodoFooter>
</div>
</template>
<script>
import TodoFooter from "@/components/TodoFooter";
import TodoHeader from "@/components/TodoHeader";
import TodoInput from "@/components/TodoInput";
import TodoList from "@/components/TodoList";
export default {
data() {
return {
todoItems: [],
}
},
methods: {
addOneItem(todoItem) {
var obj = {completed: false, item: todoItem};
localStorage.setItem(todoItem, JSON.stringify(obj));
this.todoItems.push(obj);
},
removeOneItem(todoItem, index) {
localStorage.removeItem(todoItem.item);
this.todoItems.splice(index, 1);
},
},
created() {
if (localStorage.length > 0) {
for (let i=0; i < localStorage.length; i++) {
if (localStorage.key(i) !== 'loglevel:webpack-dev-server') {
this.todoItems.push(JSON.parse(localStorage.getItem(localStorage.key(i))));
}
}
}
},
components: {
TodoHeader,
TodoInput,
TodoList,
TodoFooter
}
}
</script>
<style>
body {
text-align: center;
background-color: #f6f6f6;
}
input {
border-style: groove;
width: 200px;
}
button {
border-style: groove;
}
.shadow {
box-shadow: 5px 10px 10px rgba(0, 0, 0, 0.03);
}
</style>
TodoList.vue
<template>
<div>
<ul>
<li v-for="(todoItem, index) in propsdata" v-bind:key="todoItem.item" class="shadow">
<i class="checkBtn fas fa-check" v-bind:class="{checkBtnCompleted: todoItem.completed}" v-on:click="toggleComplete(todoItem)"></i>
<span v-bind:class="{textCompleted: todoItem.completed}">{{todoItem.item}}</span>
<span class="removeBtn" v-on:click="removeTodo(todoItem, index)">
<i class="fas fa-trash-alt"></i>
</span>
</li>
</ul>
</div>
</template>
<script>
export default {
name: "TodoList",
props: ['propsdata'],
methods: {
removeTodo(todoItem, index) {
this.$emit('removeItem', todoItem, index);
},
toggleComplete(todoItem) {
todoItem.completed = !todoItem.completed;
localStorage.removeItem(todoItem.item); // 해당 키로 삭제
localStorage.setItem(todoItem.item, JSON.stringify(todoItem)); // 키와 값 다시 재등록
}
},
}
</script>
<style scoped>
ul {
list-style-type: none;
padding-left: 0;
margin-top: 0;
text-align: left;
}
li {
display: flex;
min-height: 50px;
height: 50px;
line-height: 50px;
margin: 0.5rem 0;
padding: 0 0.9rem;
background-color: #ffffff;
border-radius: 5px;
}
.checkBtn {
line-height: 45px;
color: #62acde;
margin-right: 5px;
}
.checkBtnCompleted {
color: #b3adad;
}
.textCompleted {
text-decoration: line-through;
color: #b3adad;
}
.removeBtn {
margin-left: auto;
color: #de4343;
}
</style>
3.5 [리팩토링] 할 일 완료 기능
App.vue
<template>
<div id="app">
<TodoHeader></TodoHeader>
<TodoInput v-on:addTodoItem="addOneItem"></TodoInput>
<TodoList v-bind:propsdata="todoItems" v-on:removeItem="removeOneItem" v-on:toggleItem="toggleOneItem"></TodoList>
<TodoFooter></TodoFooter>
</div>
</template>
<script>
import TodoFooter from "@/components/TodoFooter";
import TodoHeader from "@/components/TodoHeader";
import TodoInput from "@/components/TodoInput";
import TodoList from "@/components/TodoList";
export default {
data() {
return {
todoItems: [],
}
},
methods: {
addOneItem(todoItem) {
var obj = {completed: false, item: todoItem};
localStorage.setItem(todoItem, JSON.stringify(obj));
this.todoItems.push(obj);
},
removeOneItem(todoItem, index) {
localStorage.removeItem(todoItem.item);
this.todoItems.splice(index, 1);
},
toggleOneItem(todoItem) {
todoItem.completed = !todoItem.completed; // deep copy를 해서 값을 변형시킨게 아니라 참조값을 변형시킨것이므로 todoItems 값에 변형이 가해진다.
// 때문에 todoItems 값을 따로 업데이트 안해도된다. 그렇기 때문에 바로바로 화면 렌더링이 일어난다.
localStorage.removeItem(todoItem.item); // 해당 키로 삭제
localStorage.setItem(todoItem.item, JSON.stringify(todoItem)); // 키와 값 다시 재등록
}
},
created() {
if (localStorage.length > 0) {
for (let i=0; i < localStorage.length; i++) {
if (localStorage.key(i) !== 'loglevel:webpack-dev-server') {
this.todoItems.push(JSON.parse(localStorage.getItem(localStorage.key(i))));
}
}
}
},
components: {
TodoHeader,
TodoInput,
TodoList,
TodoFooter
}
}
</script>
<style>
body {
text-align: center;
background-color: #f6f6f6;
}
input {
border-style: groove;
width: 200px;
}
button {
border-style: groove;
}
.shadow {
box-shadow: 5px 10px 10px rgba(0, 0, 0, 0.03);
}
</style>
TodoList.vue
<template>
<div>
<ul>
<li v-for="(todoItem, index) in propsdata" v-bind:key="todoItem.item" class="shadow">
<i class="checkBtn fas fa-check" v-bind:class="{checkBtnCompleted: todoItem.completed}" v-on:click="toggleComplete(todoItem)"></i>
<span v-bind:class="{textCompleted: todoItem.completed}">{{todoItem.item}}</span>
<span class="removeBtn" v-on:click="removeTodo(todoItem, index)">
<i class="fas fa-trash-alt"></i>
</span>
</li>
</ul>
</div>
</template>
<script>
export default {
name: "TodoList",
props: ['propsdata'],
methods: {
removeTodo(todoItem, index) {
this.$emit('removeItem', todoItem, index);
},
toggleComplete(todoItem) {
this.$emit('toggleItem', todoItem);
}
},
}
</script>
<style scoped>
ul {
list-style-type: none;
padding-left: 0;
margin-top: 0;
text-align: left;
}
li {
display: flex;
min-height: 50px;
height: 50px;
line-height: 50px;
margin: 0.5rem 0;
padding: 0 0.9rem;
background-color: #ffffff;
border-radius: 5px;
}
.checkBtn {
line-height: 45px;
color: #62acde;
margin-right: 5px;
}
.checkBtnCompleted {
color: #b3adad;
}
.textCompleted {
text-decoration: line-through;
color: #b3adad;
}
.removeBtn {
margin-left: auto;
color: #de4343;
}
</style>
위와 같이 수정해도 기능은 정상 작동한다.
여기서 좀 더 수정을해 완성도 높은 로직을 짜보도록 하겠습니다.
위와 같이 toggleOneItem
함수에서 todoItem
을 바로 직접 건드리는 것은 생각해보면..
현재 저희가 App.vue
컨테이너 컴포넌트에서 todoItems
라는 데이터를 props
로 TodoList
프레젠데이셔널 컴포넌트로 내렸잖아요?
그래서 todoItems
가 props
로 TodoList
컴포넌트로 내려가서 TodoList
컴포넌트에서 propsdata
로 받아서 리스트를 뿌립니다.
그런데 다시 TodoList
컴포넌트에서 toggleComplete
를 통해 내려받은 propsdata
의 todoItem
을 다시 상위 컨테이너 컴포넌트인 App.vue
로 올렸습니다.
이런식으로 다시 올려서 바꾸는 것은 좋지 않은 패턴입니다.
보통 이런 것을을 안티패턴이라고하는데,
이런 패턴보다는 컨테이너 컴포넌트, App.vue
라는 컴포넌트가 컨테이너 성격을 갖고있기 때문에, App.vue
의 todoItems
로 접근해서 수정하는 것이 훨씬 더 좋습니다.
그렇게하려면 아래와 같이 수정을 할 수 있습니다.
수정 TodoList.vue
<template>
<div>
<ul>
<li v-for="(todoItem, index) in propsdata" v-bind:key="todoItem.item" class="shadow">
<i class="checkBtn fas fa-check" v-bind:class="{checkBtnCompleted: todoItem.completed}" v-on:click="toggleComplete(todoItem, index)"></i>
<span v-bind:class="{textCompleted: todoItem.completed}">{{todoItem.item}}</span>
<span class="removeBtn" v-on:click="removeTodo(todoItem, index)">
<i class="fas fa-trash-alt"></i>
</span>
</li>
</ul>
</div>
</template>
<script>
export default {
name: "TodoList",
props: ['propsdata'],
methods: {
removeTodo(todoItem, index) {
this.$emit('removeItem', todoItem, index);
},
toggleComplete(todoItem, index) {
this.$emit('toggleItem', todoItem);
}
},
}
</script>
<style scoped>
ul {
list-style-type: none;
padding-left: 0;
margin-top: 0;
text-align: left;
}
li {
display: flex;
min-height: 50px;
height: 50px;
line-height: 50px;
margin: 0.5rem 0;
padding: 0 0.9rem;
background-color: #ffffff;
border-radius: 5px;
}
.checkBtn {
line-height: 45px;
color: #62acde;
margin-right: 5px;
}
.checkBtnCompleted {
color: #b3adad;
}
.textCompleted {
text-decoration: line-through;
color: #b3adad;
}
.removeBtn {
margin-left: auto;
color: #de4343;
}
</style>
수정 App.vue
<template>
<div id="app">
<TodoHeader></TodoHeader>
<TodoInput v-on:addTodoItem="addOneItem"></TodoInput>
<TodoList v-bind:propsdata="todoItems" v-on:removeItem="removeOneItem" v-on:toggleItem="toggleOneItem"></TodoList>
<TodoFooter></TodoFooter>
</div>
</template>
<script>
import TodoFooter from "@/components/TodoFooter";
import TodoHeader from "@/components/TodoHeader";
import TodoInput from "@/components/TodoInput";
import TodoList from "@/components/TodoList";
export default {
data() {
return {
todoItems: [],
}
},
methods: {
addOneItem(todoItem) {
var obj = {completed: false, item: todoItem};
localStorage.setItem(todoItem, JSON.stringify(obj));
this.todoItems.push(obj);
},
removeOneItem(todoItem, index) {
localStorage.removeItem(todoItem.item);
this.todoItems.splice(index, 1);
},
toggleOneItem(todoItem, index) {
this.todoItems[index].completed = !this.todoItems[index].completed;
// todoItem.completed = !todoItem.completed; // deep copy를 해서 값을 변형시킨게 아니라 참조값을 변형시킨것이므로 todoItems 값에 변형이 가해진다.
// 때문에 todoItems 값을 따로 업데이트 안해도된다. 그렇기 때문에 바로바로 화면 렌더링이 일어난다.
localStorage.removeItem(todoItem.item); // 해당 키로 삭제
localStorage.setItem(todoItem.item, JSON.stringify(todoItem)); // 키와 값 다시 재등록
}
},
created() {
if (localStorage.length > 0) {
for (let i=0; i < localStorage.length; i++) {
if (localStorage.key(i) !== 'loglevel:webpack-dev-server') {
this.todoItems.push(JSON.parse(localStorage.getItem(localStorage.key(i))));
}
}
}
},
components: {
TodoHeader,
TodoInput,
TodoList,
TodoFooter
}
}
</script>
<style>
body {
text-align: center;
background-color: #f6f6f6;
}
input {
border-style: groove;
width: 200px;
}
button {
border-style: groove;
}
.shadow {
box-shadow: 5px 10px 10px rgba(0, 0, 0, 0.03);
}
</style>
위와 같이 수정해주시면됩니다.
수정전과 수정후 모두 같은 기능을 하지만, 수정후가 중요한 것은 event emit
으로 넘기고 props
로 데이터를 내리는,
컴포넌트간 경계를 좀 더 명확히한다는 장점이 있습니다.
3.6 [리팩토링] 할 일 모두 삭제 기능
마지막으로 다룰 내용 TodoFooter
컴포넌트입니다.
App.vue
<template>
<div id="app">
<TodoHeader></TodoHeader>
<TodoInput v-on:addTodoItem="addOneItem"></TodoInput>
<TodoList v-bind:propsdata="todoItems" v-on:removeItem="removeOneItem" v-on:toggleItem="toggleOneItem"></TodoList>
<TodoFooter v-on:clearAll="clearAllItems"></TodoFooter>
</div>
</template>
<script>
import TodoFooter from "@/components/TodoFooter";
import TodoHeader from "@/components/TodoHeader";
import TodoInput from "@/components/TodoInput";
import TodoList from "@/components/TodoList";
export default {
data() {
return {
todoItems: [],
}
},
methods: {
addOneItem(todoItem) {
var obj = {completed: false, item: todoItem};
localStorage.setItem(todoItem, JSON.stringify(obj));
this.todoItems.push(obj);
},
removeOneItem(todoItem, index) {
localStorage.removeItem(todoItem.item);
this.todoItems.splice(index, 1);
},
toggleOneItem(todoItem, index) {
this.todoItems[index].completed = !this.todoItems[index].completed;
// todoItem.completed = !todoItem.completed; // deep copy를 해서 값을 변형시킨게 아니라 참조값을 변형시킨것이므로 todoItems 값에 변형이 가해진다.
// 때문에 todoItems 값을 따로 업데이트 안해도된다. 그렇기 때문에 바로바로 화면 렌더링이 일어난다.
localStorage.removeItem(todoItem.item); // 해당 키로 삭제
localStorage.setItem(todoItem.item, JSON.stringify(todoItem)); // 키와 값 다시 재등록
},
clearAllItems() {
localStorage.clear();
this.todoItems = [];
}
},
created() {
if (localStorage.length > 0) {
for (let i=0; i < localStorage.length; i++) {
if (localStorage.key(i) !== 'loglevel:webpack-dev-server') {
this.todoItems.push(JSON.parse(localStorage.getItem(localStorage.key(i))));
}
}
}
},
components: {
TodoHeader,
TodoInput,
TodoList,
TodoFooter
}
}
</script>
<style>
body {
text-align: center;
background-color: #f6f6f6;
}
input {
border-style: groove;
width: 200px;
}
button {
border-style: groove;
}
.shadow {
box-shadow: 5px 10px 10px rgba(0, 0, 0, 0.03);
}
</style>
TodoFooter.vue
<template>
<div class="clearAllContainer">
<span class="clearAllBtn" v-on:click="clearTodo">
Clear All
</span>
</div>
</template>
<script>
export default {
name: "TodoFooter",
methods: {
clearTodo() {
this.$emit('clearAll');
}
}
}
</script>
<style scoped>
.clearAllContainer {
width: 8.5rem;
height: 50px;
line-height: 50px;
background-color: #ffffff;
border-radius: 5px;
margin: 0 auto;
}
.clearAllBtn {
color: #e20303;
display: block;
}
</style>
TodoFooter
컴포넌트도 표현만하는 프레젠테이셔널 컴포넌트화 작업이 끝났다.App.vue
컨테이너 컴포넌트에서 실질적인 데이터 처리를 하고있다.
3.7 리팩토링이 완료된 애플리케이션 정리
App.vue 컨테이너 컴포넌트
이 App.vue
컴포넌트에 처음엔 data
와 methods
부분..
즉 data
관련 조작 부분은 원랜 App.vue
에 하나도 없었습니다.
그런데 지금은 많이 생겼습니다.
원래 처음엔 components
등록만 했었는데, 지금은 data
와 methods
, 그리고 life cycle hook
까지 들어왔는데,
이렇게 App.vue
로 들어온 이유는..
원래는 TodoList
, TodoInput
, TodoFooter
이렇게 각 컴포넌트에서 각 컴포넌트의 데이터들을 관리하고 있었습니다.
그것이 저희가 판단하기론, 각 컴포넌트의 데이터들이 모두 동일한 성격을 지니고있기 때문에
상위 컴포넌트로 올려서..
상위 컴포넌트로 올린다는 표현보단 한개의 컴포넌트로 모아서 그 한개의 컴포넌트에서 데이터조작이 모두 다 일어나고..
그 부분이 바로 App.vue
에 있는 addOneItem
, removeOneItem
, toggleOneItem
, clearAllItems
.. 이런 것들입니다.
이것이 바로 App.vue
가 컨테이너 컴포넌트로써 역할을 하는 것입니다.
App.vue
컴포넌트에서 모든 데이터 조작이 일어나고 - 컨테이너 컴포넌트.
TodoList
, TodoInput
, TodoHeader
, TodoFooter
- 프레젠테이셔널 컴포넌트.
이 4개의 하위 컴포넌트는 마크업적으로, UI적으로 표현만하고,
어떤 특정 동작을 통해 데이터를 변경하려면, 그 모든 이벤트들을 컨테이너 컴포넌트로 올립니다.
그래서 그렇게 올라온 이벤트를 상위 컴포넌트인 App.vue
에서 받아서 처리를합니다.
컴포넌트 설계기법은 많다.
컨테이너 컴포넌트, 프레젠테이셔널 컴포넌트 <- 리액트에서 생긴 컴포넌트 설계기법.
최대한 하위 컴포넌트, 리스트라던지 인풋이라던지.. 재사용하려면.. 컴포넌트 각각에서 데이터를 조작하던 것들을 좀 분리를 시켰어야했다.
그런 것들을 보통 관심사의 분리라고하는데,
인풋과 리스트같은 끝단에 있는 컴포넌트들은 최대한 UI적으로 표현만하고
App 컴포넌트 같은 컨테이너 컴포넌트에서 데이터 조작을 했을 때
좀 더 로직도 깔끔해지면서 vue의 반응성이 극대화되었다고 생각한다.
그리고 이렇게 리팩토링하지 않더라도,.. 지금 저희가 리팩토링을 App.vue
쪽에 데이터로직을 많이 넣어놨는데,
각 컴포넌트에서 data를 관리하면서 이벤트로도 충분히 조작이 가능할겁니다.
그렇게하면서 반응성을 살릴수도 있겠지만,
그렇게하면 좀 더 나중에 복잡한 앱을 구현하셨을 때,..
리액트에선 state
라고 표현을하고,
뷰에선 data
라고 표현을 합니다.
여튼 그렇게되면 data
의 추적이 어려워지고 data
가 꼬이는 상황이 발생할 수 있습니다.
그래서 최대한 위와 같은식으로 구현하는걸 권장드리고..
지금 위와 같이 구현해놓은게 어떻게보면 중앙관리식 데이터조작이죠?
한 컴포넌트에서 데이터를 다 갖고있고,
그리고 나머지 컴포넌트에선 그 데이터 값을 바꿔달라고 요청만하는겁니다.
이것이 이번 강좌 중후반에나오는 vuex
의 축소판이라고 보시면됩니다.
vuex
가 이런식으로 한곳에서 데이터를 모두 관리합니다.
다음시간부터 할거.
이 애플리케이션의 전반적인 사용자경험을 개선할 것이다.
사용자경험이란?
예를들어, 할일리스트 삭제 -> 어떤게 삭제되었는지 기억이 잘 안남.
할일 추가 -> 어떤게 추가되었는지 잘 기억이 안남.
이런 것들을 육안으로 구분할 수 있게 해주면 좋을거같음.
이런 부분들을 vue에서 제공하는.. vue가 react와 비교했을 때 좀 더 가지는 장점이 transition 이라던지 애니메이션 효과를 라이브러리단에서 제공을 해준다는 것입니다.
그래서 그런 transition과 애니메이션을 이용해서 위와 같은 문제점들을 해결해보도록 하겠다.
또, 인풋창에 아무것도 값이 안들어가있을 때 add 버튼을 누르거나 enter 키를 누르면 아무것도 안뜬다.
이런 것들도 예외처리를 뷰에서 제공하는 모달 컴포넌트로 한번 진행을 해보겠습니다.