• [Vue/Vuex] 뷰 실습 - Todo 웹앱 만들기 (2)

    2020. 5. 28.

    by. 나나 (nykim)

    이 글은 Vue.js 중급 강좌 - 웹앱 제작으로 배워보는 Vue.js, ES6, Vuex 강의를 바탕으로 작성한 글입니다.
    멋진 강의 감사합니다 (__)

    시리즈: Todo 웹앱 만들기(1)

     

     

    5. 할 일 목록 구현하기

    지난 글에서 퍼블리싱까지 마쳤으니, 이제부터 기능을 개발할 차례입니다. 🚀

     

     

    할 일 추가하기

    TodoInput은사용자가 입력한 내용을 받아와 localStorage에 저장하는 기능을 담고 있습니다.

     

    Web Storage API는 직관적으로 key/value 데이터를 안전하게 저장할 수 있습니다.

    로컬 스토리지는 브라우저를 종료해도 데이터가 계속 남아 있지만, 세션 스토리지는 세션이 종료되면 데이터도 사라집니다.

    각각의 오리진에 대해 다른 스토리지 객체가 localStorage와 sessionStorage에 사용됩니다.

    (*오리진: 도메인, 서브도메인, 스킴, 포트 번호를 포함한 것)

     

     

    우선 newTodoItem이라는 data를 만든 뒤, v-model로 가져옵니다.

     

    <template>
      <div class="add">
        <input type="text" class="add__input" placeholder="Enter your task here" v-model="newTodoItem" />
        <button class="add__button">
          <span class="blind">Add</span>
        </button>
      </div>
    </template>
    
    <script>
    export default {
      data() {
        return {
          newTodoItem: ""
        };
      }
    };
    </script>

     

     

    크롬 익스텐션인 Vue 개발자도구를 설치하여 확인하니 data가 실시간으로 잘 들어가는 게 보입니다 :>

     

    엔터키를 누르거나 버튼을 클릭하면 이 data를 로컬 스토리지에 저장시켜 줍니다.

    setItem() 메서드를 사용하며, 2개의 인자는 key와 value를 뜻합니다.

     

    <!-- TodoInput.vue -->
    
    <template>
      <div class="add">
        <input
          type="text"
          class="add__input"
          placeholder="Enter your task here"
          v-model="newTodoItem"
          v-on:keyup.enter="addTodoItem"
        />
        <button class="add__button" v-on:click="addTodoItem">
          <span class="blind">Add</span>
        </button>
      </div>
    </template>
    
    <script>
    export default {
      data() {
        return {
          newTodoItem: ""
        };
      },
      methods: {
        addTodoItem() {
          localStorage.setItem(this.newTodoItem, this.newTodoItem);
        }
      }
    };
    </script>

     

     

    개발자 도구의 Application 탭에서 Key-Value를 확인할 수 있습니다.

     

    완성도 있는 UX를 위해 값이 있을 때만 addTodoItem()이 실행되도록 처리하고, 일단 입력 후에는 input란이 비워지도록 처리했습니다.

     

    <!-- TodoInput.vue -->
    
    <script>
    //...
      methods: {
        addTodoItem() {
          if (this.newTodoItem !== "") {
            var value = {
              item: this.newTodoItem,
              date: `${new Date().getMonth() + 1}/${new Date().getDate()}`,
              completed: false
            };
            localStorage.setItem(this.newTodoItem, JSON.stringify(value));
            this.clearInput();
          }
        },
        clearInput() {
          this.newTodoItem = "";
        }
      }
    </script>

     

     

     

    할 일 표시하기

    TodoList 컴포넌트에서 작업합니다. created() 시점에서 로컬 스토리지에 저장된 값들을 지정한 배열에 밀어넣고, 그 배열을 v-for로 렌더링합니다.

    v-for디렉티브는 item in items 형태로 배열을 기반으로 리스트를 렌더링 할 수 있습니다. v-for의 각 항목들에는 고유한 key 속성을 제공해야 합니다. (공식문서 링크)

     

    <!-- TodoList.vue -->
    
    <template>
      <ul class="list">
        <li class="list__item" v-for="todoItem in todoItems" v-bind:key="todoItem">
          <input type="checkbox" v-bind:id="todoItem.item" />
          <label v-bind:for="todoItem.item" class="list__label">
            <p class="list__text">{{ todoItem }}</p>
          </label>
        </li>
      </ul>
    </template>
    
    <script>
    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(localStorage.getItem(localStorage.key(i)));
            }
          }
        }
      }
    };
    </script>

     

    로컬 스토리지의 getItem() 메서드는 keyName을 인자로 keyValue를 리턴해 줍니다.

     

     

     

    할 일에 날짜 표시하기

    할 일에 텍스트 외에 날짜도 들어가야 합니다. 따라서 로컬 스토리지에 저장할 때 이 내용도 함께 저장하도록 수정합니다.

    원래는 시안대로 Month/Date 정보만 넘기려고 했는데, Date/Day 정보를 넘기는 게 좋을 것 같아 수정키로 했습니다.

     

    날짜 정보를 구하는 걸 어떻게 구현할까 고민을 하다가 common.js로 만든 뒤 호출하기로 했습니다.

    헤더에서도 날짜 정보를 보여주는 부분이 있어 공통으로 쓰는 게 낫겠다고 생각했습니다.

     

    <!-- getDate.js -->
    
    export default () => {
      const now = new Date();
      const month = now.getMonth() + 1;
      const date = now.getDate();
      const weekList = new Array(
        "Sun.",
        "Mon.",
        "Tue.",
        "Wed.",
        "Thu.",
        "Fri.",
        "Sat."
      );
      const week = weekList[now.getDay()];
      const time = now.getTime();
    
      const dateInfo = {
        month,
        date,
        week,
        time
      }
      return dateInfo
    }

     

    이렇게 날짜를 구하는 공통 함수 작성 후, import로 불러와 사용했습니다.

     

    <!-- TodoHeader.vue -->
    
    <script>
    import getDate from "../assets/commonJS/getDate.js";
    
    export default {
      data() {
        return {
          timestamp: ""
        };
      },
      created() {
        this.timestamp = `${getDate().month}/${getDate().date} ${getDate().week}`;
      }
    };
    </script>
    <!-- TodoInput.vue -->
    
    <script>
    import getDate from "../assets/commonJS/getDate.js";
    
    export default {
      //...
      methods: {
        addTodoItem() {
          if (this.newTodoItem !== "") {
            var value = {
              item: this.newTodoItem,
              date: `${getDate().date} ${getDate().week}`,
              time: getDate().time
            };
            localStorage.setItem(this.newTodoItem, value);
            this.clearInput();
          }
        },
        //...
      }
    };
    </script>
    

     

    time 정보를 넣어준 이유는 나중에 아이템 정렬 시에 사용할 예정이기 때문입니다.

     

     

    단, 이렇게 넣는 경우 Value값이 제대로 들어가지 않습니다. 왜냐면 스토리지에서 모든 key와 value는 항상 string으로 저장되기 때문입니다. 

    따라서 우리가 넣은 데이터를 일단 문자열로 바꿔 줄 필요가 있습니다. 이때는 JSON.stringify() 메서드를 사용합니다. 이 메서드는 JavaScript 값이나 객체를 JSON 문자열로 변환해 줍니다.

     

    //...
    localStorage.setItem(this.newTodoItem, JSON.stringify(value));
    //...

     

     

    이제 TodoList를 수정합니다. 이때 문자열로 바꾼 값을 JSON.parse() 해야 합니다. 이 메서드는 JSON 문자열의 구문을 분석해서 JavaScript 값이나 객체를 생성합니다.

     

    <!-- TodoList.vue -->
    
    <template>
      <ul class="list">
        <li class="list__item" v-for="todoItem in todoItems" v-bind:key="todoItem.item">
          <input type="checkbox" v-bind:id="todoItem.item" />
          <label v-bind:for="todoItem.item" class="list__label">
            <p class="list__text">{{ todoItem.item }}</p>
          </label>
          <p class="list__date">{{ todoItem.date }}</p>
        </li>
      </ul>
    </template>
    
    <script>
      //...
      this.todoItems.push(
        JSON.parse(localStorage.getItem(localStorage.key(i)))
      );
      //...
    </script>

     

     

    할 일의 완료여부 표시하기

    완료여부를 판단하기 위해 스토리지에 넣기 전 completed라는 속성을 추가하기로 합니다.

     

    <!-- TodoInput.vue -->
    
    <script>
    export default {
      data() {
        return {
          newTodoItem: ""
        };
      },
      methods: {
        addTodoItem() {
          if (this.newTodoItem !== "") {
            var value = {
              item: this.newTodoItem,
              date: `${getDate().date} ${getDate().week}`,
              time: getDate().time,
              completed: false //새 속성 추가
            };
            localStorage.setItem(this.newTodoItem, JSON.stringify(value));
            this.clearInput();
          }
        },
        //...
      }
    };
    </script>

     

    TodoList에 toggleComplete()라는 새 메서드를 추가합니다. completed의 불린값을 바꾸고, 로컬 스토리지의 내용을 대체하는 내용입니다.

    그리고 인풋에서 변화가 일어나면 해당 메서드가 실행되도록 처리합니다.

     

    <!-- TodoList.vue -->
    <template>
      <ul class="list">
        <li class="list__item" v-for="(todoItem, index) in propsdata" v-bind:key="todoItem.item">
          <input
            type="checkbox"
            v-bind:id="todoItem.item"
            v-bind:checked="todoItem.completed === true"
            v-on:change="toggleComplete(todoItem)"
          />
          <label v-bind:for="todoItem.item" class="list__label">
          <!-- ... -->
          </label>
        </li>
      </ul>
    </template>
    
    <script>
    export default {
      methods: {
        //...
        toggleComplete(todoItem) {
          todoItem.completed = !todoItem.completed;
          localStorage.setItem(todoItem.item, JSON.stringify(todoItem));
        }
      }
    };
    </script>

     

     

     

    할 일 삭제하기

    리스트 내 delete 버튼이 클릭된 경우 removeItem()으로 스토리지 아이템을 제거하고,splice()로 todoItems 배열 요소를 삭제합니다.

     

    어떤 리스트가 클릭됐는지를 알기 위해 리스트를 뿌려줄 때 index 값을 받아오고,

    removeTodo 메서드를 실행시킬 때 인자로 넘겨줍니다.

     

    <!-- TodoList.vue -->
    
    <template>
      <ul class="list">
        <li class="list__item" v-for="(todoItem, index) in todoItems" v-bind:key="todoItem.item">
            <button class="list__delete" v-on:click="removeTodo(todoItem, index)">
              <div class="blind">Delete</div>
            </button>
        </li>
      </ul>
    </template>

     

    removeItem()은 keyName을 인자로 전달해 아이템을 삭제할 수 있습니다.

    splice()는 배열의 변경을 시작할 인덱스, 제거할 요소의 수를 인자로 전달해 배열 내 요소를 제거할 수 있습니다.

     

    <!-- TodoList.vue -->
    
    <script>
      //...
      methods: {
        removeTodo(todoItem, index) {
          localStorage.removeItem(todoItem.item);
          this.todoItems.splice(index, 1);
        }
      },
      //...
    </script>

     

     

     

    모두 삭제하기

    localStorage를 비우려면 clear() 메서드를 사용합니다.

     

    <!-- TodoController.vue -->
    
    <template>
      <div class="controller">
        <button class="clear" v-on:click="clearTodo">Clear All</button>
      </div>
    </template>
    
    <script>
    export default {
      methods: {
        clearTodo() {
          localStorage.clear();
        }
      }
    };
    </script>

     

     

    여기까지 아주 기본적인 기능만 구현했습니다.

    하지만 컴포넌트간 통신이 없기 때문에 리스트가 바로 화면에 나타나지 않고, 리스트의 개수를 보여주거나 정렬하는 기능은 아직 없어요 🙅‍♀️

    이 부분은 리팩토링을 거치면서 수정하겠습니다 🙆‍♀️

     

     

     


     

     

    6. 리팩토링

    효율적인 데이터 관리를 위해, 루트 컴포넌트에 todoItems를 정의하고 하위 컴포넌트에서 이를 전달받아 사용케 합니다.

    즉 중앙 집권 체제로 변경해보자는 거죠 🤓

     

     

    목록 표시 기능 수정

    기존의 todoItems 데이터와 created() 함수를 App.vue로 가져왔습니다.

    그리고 데이터의 내용을 v-bind로 <TodoList/>에게 내려줍니다.

     

    <!-- App.vue -->
    
    <template>
      <div id="app">
        <TodoList v-bind:propsdata="todoItems" />
      </div>
    </template>
    
    <script>
    //...
    
    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: {
        //...
        TodoList
      }
    };
    </script>

     

    TodoList는 poprsdata를 받아오도록 설정합니다.

     

    <!-- TodoList.vue -->
    
    <template>
      <ul class="list">
        <li class="list__item" v-for="(todoItem, index) in propsdata" v-bind:key="todoItem.item">
          <!-- ... -->
        </li>
      </ul>
    </template>
    
    <script>
    export default {
      props: ["propsdata"],
      methods: {
        removeTodo(todoItem, index) {
          localStorage.removeItem(todoItem.item);
          this.todoItems.splice(index, 1);
        }
      }
    };
    </script>

     

     

     

    추가 기능 수정

    다음은 TodoInput입니다. 할 일을 추가하면 "추가했어요~!"라고 중앙에 있는 조정(?)에 알려야 합니다. 그래야 이 추가된 목록이 중앙에 있는 데이터에 들어갈 수 있어요.

    할 일이 추가되면 $emit을 통해 상위 컴포넌트에게 알려주도록 설정합니다.

     

    <!-- TodoInput.vue -->
    
    <script>
    export default {
      //...
      methods: {
        addTodoItem() {
          if (this.newTodoItem !== "") {
            this.$emit("addItem", this.newTodoItem);
            this.clearInput();
          }
        },
        //...
      }
    };
    </script>

     

    기존에 있던 스크립트는 지우고, App.vue에서 이뤄지도록 처리합니다.

     

    <!-- App.vue -->
    
    <script>
    import TodoInput from "./components/TodoInput";
    
    import getDate from "./assets/commonJS/getDate.js";
    
    export default {
      data() {
        return {
          todoItems: []
        };
      },
      methods: {
        addOneItem(todoItem) {
          var value = {
            item: todoItem,
            date: `${getDate().date} ${getDate().week}`,
            time: getDate().time,
            completed: false
          };
          localStorage.setItem(todoItem, JSON.stringify(value));
          this.todoItems.push(value);
        }
      },
      //...
    };
    </script>

     

    그리고 TodoList에서 addItem이라는 이벤트가 트리거되면 addOneItem이 실행되도록 합니다.

     

    <!-- App.vue -->
    
    <template>
      <div id="app">
        <TodoInput v-on:addItem="addOneItem" />
        <!-- ... -->
      </div>
    </template>

     

    이제 todoItems 데이터가 실시간으로 바뀌면서 화면에 바로바로 추가되는 걸 볼 수 있습니다.

     

     

     

     

     

    삭제 기능 / 완료 여부 기능 수정

    추가 기능과 거의 같습니다. TodoList(하위 컴포넌트)에서는 $emit으로 발생 여부를 알려주고, 실제 데이터를 다루는 일은 App(상위 컴포넌트)에서 진행합니다.

     

    <!-- TodoList.vue -->
    
    <script>
    export default {
      props: ["propsdata"],
      methods: {
        removeTodo(todoItem, index) {
          this.$emit("removeItem", todoItem, index);
        },
        toggleComplete(todoItem) {
          this.$emit("toggleItem", todoItem);
        }
      }
    };
    </script>
    <!-- App.vue -->
    
    <template>
      <div id="app">
        <!-- ... -->
        <TodoList
            v-bind:propsdata="todoItems"
            v-on:removeItem="removeOneItem"
            v-on:toggleItem="toggleOneItem"
        />
      </div>
    </template>
    
    <script>
    export default {
      data() {
        return {
          todoItems: []
        };
      },
      methods: {
        removeOneItem(todoItem, index) {
          localStorage.removeItem(todoItem.item);
          this.todoItems.splice(index, 1);
        },
        toggleOneItem(todoItem) {
          todoItem.completed = !todoItem.completed;
          localStorage.setItem(todoItem.item, JSON.stringify(todoItem));
        },
      },
      //...
    };
    </script>

     

     

    모두 삭제 기능 수정

    똑같이 $emit으로 상위 컴포넌트에게 알려줍니다. App.vue에서는 데이터를 관리하고 있으므로 모두 삭제를 클릭하면 빈 배열로 만들어버릴 수 있습니다. 🧙‍♀️

     

    <!-- TodoController.js -->
    
    <script>
    export default {
      methods: {
        clearTodo() {
          this.$emit("clearAll");
        }
      }
    };
    </script>
    <!-- App.vue -->
    
    <script>
      //...
      clearAllItem() {
          this.todoItems = [];
          localStorage.clear();
       }
       //...
    </script>

     

     

    할 일 개수 알려주기

    만들다보니 전체 개수 이외에 완료 처리한 개수도 보여줘야할 거 같아서 UI를 살짝 수정했습니다.

    남은 할 일 / 전체 할 일 이렇게 나눠서 보여주려고 합니다.

     

    이건 강의에 없는 내용이라 어떻게 구현하지 머리를 좀 싸매다가, 일단 computed로 처리하고 props로 내려줬습니다.

    더 좋은 방법 알려주실 천사님 찾습니다..... (っ ×﹏× ς)

     

    <!-- App.vue -->
    
    <template>
      <div id="app">
        <TodoTitle v-bind:propsdata="checkCount" />
        <!-- ... -->
      </div>
    </template>
    
    <script>
    import TodoTitle from "./components/TodoTitle";
    
    export default {
      data() {
        return {
          todoItems: []
        };
      },
      computed: {
        checkCount() {
          const checkLeftItems = () => {
            let leftCount = 0;
            for (let i = 0; i < this.todoItems.length; i++) {
              if (this.todoItems[i].completed === false) {
                leftCount++;
              }
            }
            return leftCount;
          };
    
          const count = {
            total: this.todoItems.length,
            left: checkLeftItems()
          };
          return count;
        }
      },
      //...
     </script>
    <!-- TodoTitle.vue -->
    
    <template>
      <div class="title">
        <p class="title__message">{{ message }}</p>
        <p class="title__task">
          <span class="title__task-top">You've got</span>
          <span class="title__task-count">
            <em class="title__task-left">{{ propsdata.left }}</em>
            <em v-if="propsdata.total" class="title__task-total">&nbsp;/ {{ propsdata.total }}</em>
          </span>
          <span class="title__task-bottom">tasks today !</span>
          <span class="title__task-info"></span>
        </p>
      </div>
    </template>
    
    <script>
    export default {
      props: ["propsdata"],
      //...
    };
    </script>

     

     

     

    목록 정렬하기

    sort() 메서드를 활용해 목록을 정렬해 봅니다. 이 메서드는 정렬 순서를 정의하는 함수를 인자로 받아 배열의 요소를 적절한 위치에 정렬한 후 그 배열을 반환합니다.

    정렬 순서를 정의하는 compareFunction이 제공되면 배열 요소는 compare 함수의 반환 값에 따라 정렬됩니다.

     

    compareFunction(a, b)를 통해 나온 값이,

    - 0보다 작다면, a가 먼저!

    - 0보다 크다면, b가 먼저!

    오게 됩니다.

     

    a=1, b=5라고 가정한 경우,

    a-b라면 1-5 = -4이기 때문에 ab 순서로 오게 됩니다 (=오름차)

    b-a라면 5-1 = 4이기 때문에 ba 순서로 오게 됩니다 (=내림차)

    더 간단히 말해 '리턴값이 음수면 오름차, / 리턴값이 양수면 내림차'이 됩니다.

     

    todoItem에는 time이라는 값이 들어 있고, 이는 나중에 추가될수록 값이 큽니다.

    오래된 순이라면 작은 값이 먼저 나오고(=오름차), 최신순이라면 큰 값이 먼저 나오게 됩니다(=내림차)

     

    예를 들어 '1번째 할일'의 time값은 10이고, '2번째 할 일'의 time값은 20입니다.

    오래된 순이라면 1번째 → 2번째가 되므로, 10 → 20의 순서가 되어야 하며, 리턴값이 음수여야 합니다. 따라서 10-20을 하면 됩니다.

    반대로 최신 순이라면 2번째 → 1번째가 되므로, 20 → 10의 순서가 되어야 하며, 리턴값이 양수여야 합니다. 따라서 20-10을 하면 됩니다.

     

    즉, 결론은
    오래된 순 정렬은 a.time - b.time을 하고, 최신순 정렬은 b.time - a.time을 하면 됩니다!

     

    //...
        sortTodoLatest() {
          this.todoItems.sort(function(a, b) {
            return b.time - a.time;
          });
        },
        sortTodoOldest() {
          this.todoItems.sort(function(a, b) {
            return a.time - b.time;
          });
        }
     //...

     

    셀렉트박스가 선택되면 그 값을 확인해 거기에 맞는 메서드를 실행시켜 줍니다.

     

    <!-- TodoController.vue -->
    
    <template>
      <div class="controller">
        <div class="select">
          <label class="blind" for="order">Order</label>
          <select name="order" id="order" class="selectbox" v-model="selected" v-on:change="sortTodo">
            <option value="date-asc">Oldest</option>
            <option value="date-desc">Latest</option>
          </select>
        </div>
        <button class="clear" v-on:click="clearTodo">Clear All</button>
      </div>
    </template>
    
    <script>
    export default {
      data() {
        return {
          selected: "date-asc"
        };
      },
      methods: {
        sortTodo() {
          this.$emit("sortItem", { value: this.selected });
        },
        clearTodo() {
          this.$emit("clearAll");
        }
      }
    };
    </script>
    <!-- App.vue -->
    
    <template>
      <div id="app">
        <!-- ... -->
        <TodoController v-on:sortItem="sortAllItem" v-on:clearAll="clearAllItem" />
      </div>
    </template>
    
    <script>
    //...
    
    export default {
      //...
      methods: {
        //...
        sortTodoLatest() {
          this.todoItems.sort(function(a, b) {
            return b.time - a.time;
          });
        },
        sortTodoOldest() {
          this.todoItems.sort(function(a, b) {
            return a.time - b.time;
          });
        },
        sortAllItem(selectedSort) {
          if (selectedSort.value === "date-desc") {
            this.sortTodoLatest();
          } else if (selectedSort.value === "date-asc") {
            this.sortTodoOldest();
          }
        }
      },
      mounted() {
        this.sortTodoOldest();
      },
      //...
    };
    </script>

     

    돔 로드 시에도 자동으로 오래된 순 정렬을 하고 싶었기에 mounted()에도 sort 메소드가 실행되도록 했습니다.

     

     

     

     

    여기까지 완료한 모습입니다 🤩

     

     

    이전 글: Todo 웹앱 만들기 (1)
    다음 글: Todo 웹앱 만들기 (3)

     

     

    댓글 1

    • 프로필사진
      goor 2020.08.10 13:51

      나나님 정말 잘보고있습니다! 따라하던 중 전체할일과 남은 할일 이 뽑아지지 않아서요ㅜㅜㅜ방법있을까요