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 AllTodoFooter 컴포넌트에 있습니다.
이 또한 TodoList 컴포넌트에 해당 사실을 알리지 않았습니다.
그렇기 때문에 화면의 리스트들은 아무런 반응없이 가만히 있는겁니다.

Note

이런 것들이 컴포넌트가 서로 분리가되면서 컴포넌트끼리 서로 데이터전달이 안되고 통신이 안돼서 발생하는 문제입니다.
그래서 지우고나서 새로고침을해야 적용이되는겁니다.

3.1.1 문제 해결을 위한 애플리케이션 구조 개선

TodoInput 컴포넌트에서 newTodoItem이라는 데이터값을 받아서 데이터를 추가하면 로컬 스토리지에 최신 todoItems가 생깁니다.
그런데 그게지금 TodoList에 반영이되고있지 않습니다.
마찬가지로 TodoFooter에서도 clear all 버튼으로 데이터를 삭제했을 때, 로컬 스토리지의 데이터가 모두 지워지지만 이것이 또 반영이 안됩니다.

구조를 위와 같이 바꿀 수 있습니다.
이게 이제부터 저희가 개선해야될 부분인데요, TodoInput, TodoList, TodoFooter 하위에 있는 컴포넌트들은 표현만할겁니다.

실질적인 데이터 조작에대한 것들은 위에 공통적으로 갖고있는 상위 컴포넌트인 App 컴포넌트에서 진행을 할거고,
그렇기 때문에 App 컴포넌트에서 TodoList 컴포넌트로 props라는 todoItems라는 속성을 내릴거고,
todoItemsTodoInput이라던지 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 [리팩토링] 할 일 목록 표시 기능

  1. TodoList에 있던 created life cycle hook을 그대로 App.vue로 들고옵니다.
  2. todoItems는 모든 컴포넌트(TodoInput, TodoList, TodoFooter)에서 동일하게 쓰는 데이터입니다.
    App.vuedatatotoItems라는 데이터를 만들어줍니다.
  3. App.vuetodoItems 데이터를 TodoList 컴포넌트로 내려줍니다.
  4. 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에 로컬스토리지에 있는 데이터를 배열형태로 넣어줬고, 이를 propsTodoList 컴포넌트에 내려주었습니다.



<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>


Note

이제 TodoInput 컴포넌트에서 하는일이란 eventemit시키는 일밖엔없다.
v-model로 연동시킨 newTodoItem 값을 event emit으로 태워보내는 것밖엔 없다.

App.vue에서 하는일은 그 이벤트를 받아서 저장해주고 배열에 반영해주는 일을한다.

지금 위의 App 같은 경우는 컨테이너라는 개념으로 보시면됩니다.
컴포넌트를 설계를 하실 때, 단순하게 화면에 표현하는 프레젠테이셔널 컴포넌트가 있고,
그 앱의 동작이라던지 데이터 조작, 비즈니스 로직이라고 보통 실무에서 표현을하는데,
그런 데이터 조작에 관련된거를 컨테이너라고 합니다.

App.vue컨테이너 컴포넌트, TodoInput은 프레젠테이셔널 컴포넌트라고 보시면됩니다.

3.4 [리팩토링] 할 일 삭제 기능

TodoList 컴포넌트의 propsdata.
아까 TodoList에있던 데이터 리스트를 App.vue 컨테이너 컴포넌트로 올렸었다.
App.vue에서 todoItems를 받아가지고 propsTodoList 컴포넌트로 내렸습니다.
그러다보니까 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>


Note

위와 같이 수정해도 기능은 정상 작동한다.
여기서 좀 더 수정을해 완성도 높은 로직을 짜보도록 하겠습니다.
위와 같이 toggleOneItem 함수에서 todoItem을 바로 직접 건드리는 것은 생각해보면..

현재 저희가 App.vue 컨테이너 컴포넌트에서 todoItems라는 데이터를 propsTodoList 프레젠데이셔널 컴포넌트로 내렸잖아요?
그래서 todoItemspropsTodoList 컴포넌트로 내려가서 TodoList 컴포넌트에서 propsdata로 받아서 리스트를 뿌립니다.
그런데 다시 TodoList 컴포넌트에서 toggleComplete를 통해 내려받은 propsdatatodoItem을 다시 상위 컨테이너 컴포넌트인 App.vue로 올렸습니다.

이런식으로 다시 올려서 바꾸는 것은 좋지 않은 패턴입니다.
보통 이런 것을을 안티패턴이라고하는데,

이런 패턴보다는 컨테이너 컴포넌트, App.vue라는 컴포넌트가 컨테이너 성격을 갖고있기 때문에, App.vuetodoItems로 접근해서 수정하는 것이 훨씬 더 좋습니다.
그렇게하려면 아래와 같이 수정을 할 수 있습니다.

수정 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>


Note

위와 같이 수정해주시면됩니다.
수정전과 수정후 모두 같은 기능을 하지만, 수정후가 중요한 것은 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>


Note

TodoFooter 컴포넌트도 표현만하는 프레젠테이셔널 컴포넌트화 작업이 끝났다.
App.vue 컨테이너 컴포넌트에서 실질적인 데이터 처리를 하고있다.

3.7 리팩토링이 완료된 애플리케이션 정리

App.vue 컨테이너 컴포넌트

App.vue 컴포넌트에 처음엔 datamethods 부분..
data 관련 조작 부분은 원랜 App.vue에 하나도 없었습니다.
그런데 지금은 많이 생겼습니다.
원래 처음엔 components 등록만 했었는데, 지금은 datamethods, 그리고 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 키를 누르면 아무것도 안뜬다.
이런 것들도 예외처리를 뷰에서 제공하는 모달 컴포넌트로 한번 진행을 해보겠습니다.