FE - Vue.js (CLI, To Do List)

수현·2023년 9월 27일
0

Ucamp

목록 보기
9/19

📒 Todo App

📕 Vue Project

1. 프로젝트 생성

1) 프로젝트 구조

2) App 작성 순서

2. 프로젝트 초기 설정

1) index.html 수정

  • 반응형 웹 태그 설정
    • viewport meta tag 추가
  • awesome 아이콘 CSS 설정
  • favicon 설정
    • Vue에서 제공하는 기본 로고 사용
  • google ubuntu 폰트 사용

📋 public/index.html 📋

<link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.1/css/all.css"
integrity="sha384-fnmOCqbTlWIlj8LyTjo7mOUStjsKC4pOpQbqyi7RrhN7udi9RwhKkMHpvLbHG9Sr" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/css?family=Ubuntu" rel="stylesheet">

2) App.vue 수정

  • 루트 컴포넌트

📋 src/App.vue 📋

<template>
  <div id="app"></div>
</template>

<script></script>

<style></style>

3. 컴포넌트 생성

  • src/compenents 폴더에 생성
    • TodoHeader.vue (어플리케이션 제목 표시)
    • TodoInput.vue (할 일 입력 및 추가)
    • TodoList.vue (할 일 목록 표시 및 삭제)
    • TodoFooter.vue (할 일 모두 삭제)

📋 src/components/Todo*.vue 📋

<template>
    <div>Header/Input/List/Footer</div>
</template>

<script>
export default {

}
</script>

<style>

</style>

4. App.vue에 컴포넌트 등록

  • src/App.vue에 생성한 컴포넌트들 등록
    • template 추가
    • script에 import, export 추가

📋 src/App.vue 📋

<template>
  <div id="app">
    <TodoHeader></TodoHeader>
    <TodoInput></TodoInput>
    <TodoList></TodoList>
    <TodoFooter></TodoFooter>
  </div>
</template>

<script>
import TodoHeader from './components/TodoHeader.vue'
import TodoInput from './components/TodoInput.vue'
import TodoList from './components/TodoList.vue'
import TodoFooter from './components/TodoFooter.vue'

export default {
  components : {
    TodoHeader, // 'TodoHheader' : TodoHeader와 동일
    TodoInput,
    TodoList,
    TodoFooter
  }
}
</script>

<style></style>

5. 컴포넌트 구현

1) TodoHeader

  • todo 제목 추가
  • TodoHeader style 설정
    • <style> 태그의 scoped : 스타일 정의를 컴포넌트에만 적용
  • App style 설정
    • css 외부 파일 설정

📋 src/components/TodoHeader.vue 📋

<template>
    <div>
        <h1>TODO it!</h1>
    </div>
</template>

<script>
export default {

}
</script>

<style scoped>
h1 {
    color:#2F3852;
    font-weight: 900;
    margin: 2.5rem 0 1.5rem;
}
</style>

📋 src/App.vue 📋

<template>
  <div id="app">
    <TodoHeader></TodoHeader>
    <TodoInput></TodoInput>
    <TodoList></TodoList>
    <TodoFooter></TodoFooter>
  </div>
</template>

<script>
import TodoHeader from "@/components/TodoHeader.vue";
import TodoInput from "@/components/TodoInput.vue";
import TodoList from "@/components/TodoList.vue";
import TodoFooter from "@/components/TodoFooter.vue";

export default {
  name: "App",
  components: {
    TodoHeader, // 'TodoHheader' : TodoHeader와 동일
    TodoInput,
    TodoList,
    TodoFooter,
  },
  data() {
    return {}
  }
};
</script>

<style src="@/assets/styles/styles.css">

</style>

📋 src/assets/styles/styles.css 📋

#app {
    font-family: Avenir, Arial, Helvetica, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-align: center;
    color: #2c3e50;
    margin-top: 60px;
}
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);
}

2) TodoInput

  • inputbox 추가
    • v-model (two-way binding) directive 사용
    • 사용자가 입력한 값을 Model에 반영시키고, Model이 변경되면 입력 요소의 값도 변경됨
    • input 박스에서 enter 입력했을 때도 todo 추가되도록 v-on:keyup.enter 이벤트 처리
  • button 추가
    • <span>, <i> 태그 추가
    • v-on:click에 버튼 이벤트 핸들러 addTodo 지정
  • addTodo() 구현
    • 입력 받은 텍스트를 localStorage에 저장
    • localStorage의 setItem() API를 이용하여 저장
    • newTodoItem 변수 초기화
    • 크롬 개발자 도구의 Application ➡️ Storage ➡️ Lacal Storage ➡️ http://localhost:8080
    • addTodo() 안에 예외처리 코드 추가
  • clearInput() 구현
    • clearInput() 함수 추가
  • 스타일 설정 (awesome 아이콘 이용해 직관적인 버튼 생성)

📋 src/components/TodoInput.vue 📋

<template>
    <div class="inputBox shadow">
        <!--input 박스에서 enter 입력했을 때도 todo 추가되도록 v-on:keyup.enter 이벤트 처리 -->
        <!-- v-on:keyup.enter과 @keyup.enter 동일 -->
        <input type="text" v-model="newTodoItem" ref="todoItem" @keyup.enter="addTodo">
      <!--awesome 아이콘 이용해 직관적인 버튼 생성-->
      <span class="addContainer" v-on:click="addTodo">
          <i class="fas fa-plus addBtn"></i>
      </span>
    </div>
  </template>
  
  <script>
  export default {
    /* $refs는 document.getElementById(id) 함수처럼 html DOM에 직접 접근할 때 사용하는 객체
        ref = "todoItem"의 ref 속성은 기존의 id 속성과 동일한 속성 */
    mounted() {
        this.$refs.todoItem.focus();
    },
    data() {
        return {
            newTodoItem: ""
        }
    },
    methods: {
      addTodo: function () {
        if (this.newTodoItem !== "") {
            /* toggleComplete() 메서드를 위한 객체 추가 */
            var obj = {completed: false, item: this.newTodoItem};
            /* 입력 받은 텍스트를 localStorage의 setItem() API를 이용하여 저장 
            JSON.stringify()로 object를 json string으로 변환*/
          localStorage.setItem(this.newTodoItem, JSON.stringify(obj)); // setItem(key, value)
          this.clearInput();
        }
      },
      clearInput: function () {
        this.newTodoItem = ""; // newTodoitem 변수 초기화
      },
    },
  };
  </script>
  
  <style scoped>
  input:focus {
    outline: none !important; 
    box-shadow: 0 0 0.4px #d6a8e9;
  }
  .inputBox {
    background: white;
    height: 50px;
    line-height: 50px;
    border-radius: 5px;
  }
  .inputBox input {
    border-style: none;
    font-size: 0.9rem;
    width: 75%;
    height: 50%;
  }
  .addContainer {
    float: right;
    background: linear-gradient(to right, #6478fb, #8763fb);
    display: block;
    width: 3rem;
    border-radius: 0 5px 5px 0;
  }
  .addBtn {
    color: white;
    vertical-align: middle;
  }
</style>

3) TodoList

  • 로컬 스토리지 데이터를 뷰에 출력
    • created() 라이프 사이클 메서드에 for 반복문과 push()로 로컬 스토리지의 모든 데이터를 todoitems에 저장하는 로직 구현
    • v-for directive를 사용하여 목록 렌더링
  • 스타일 설정
  • todo 삭제
    • 할 일 목록 & 삭제 버튼 마크업 작업
    • removeTodo() 메서드 구현
      • localStorage의 removeItem()으로 데이터를 삭제
      • 배열의 특정 인덱스를 삭제하는 splice() 함수로 todo 삭제
  • todo 완료
    • 완료 버튼 마크업 작업
    • TodoInput.vue의 addTodo() 메서드 수정
      • completed 속성 추가한 객체 생성
      • JSON.stringify()로 object를 json string으로 변환
      • {"completed" : false, "item" : "Vue.js 완성"}
      // addTodo 수정 메서드
       addTodo: function () {
          if (this.newTodoItem !== "") {
              /* toggleComplete() 메서드를 위한 객체 추가 */
              var obj = {completed: false, item: this.newTodoItem};
              /* 입력 받은 텍스트를 localStorage의 setItem() API를 이용하여 저장 
              JSON.stringify()로 object를 json string으로 변환*/
            localStorage.setItem(this.newTodoItem, JSON.stringify(obj)); // setItem(key, value)
            this.clearInput();
          }
        },
    • created() 메서드 수정
      • JSON.parse()로 json string을 object로 변환
      • 리스트에 출력하는 부분도 수정
    • v-bind directive 사용
      • todoItem.completed 값(true/false) 따라서 true면 textCompleted/checkBtnCompleted css class 적용
    • toggleComplete() 메서드 구현
      • todoItem.completed 값(ture/false) 토글링에 따라서 local

📋 src/components/TodoList.vue 📋

<template>
  <div>
    <ul>
        <!-- v-for directive를 사용하여 목록 렌더링 -->
        <!-- v-bind 생략 가능 -->
      <li v-for="(todoItem, idx) in todoItems" v-bind:key="idx" class="shadow">
        <!-- 완료 버튼 마크업 작업 -->
        <i class="fas fa-check checkBtn" 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 {
  data() {
    return {
      todoItems: [],
    };
  },
  /* life cycle method */
  /* push()로 로컬 스토리지의 모든 데이터를 todoitems에 저장 */
  created: function () {
    if (localStorage.length > 0) {
      for (var i = 0; i < localStorage.length; i++) {
            var itemJson = localStorage.getItem(localStorage.key(i));
            /* JSON.parse()로 json string을 object로 변환 */
            this.todoItems.push(JSON.parse(itemJson));
        }
    }
  },
  /* localStorage의 데이터를 삭제하는 removeItem()
  배열의 특정 인덱스를 삭제하는 splice() 함수로 todo 삭제 */
  methods: {
    removeTodo: function(todoItem, index) {
        localStorage.removeItem(todoItem.item);
        this.todoItems.splice(index, 1);
    },
    toggleComplete: function(todoItem) {
        const { item, completed } = todoItem;
        todoItem.completed = !completed;
        localStorage.removeItem(item);
        localStorage.setItem(item, JSON.stringify(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: 0px;
  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: white;
  border-radius: 5px;
}
.removeBtn {
  margin-left: auto;
  color: #de4343;
}
.checkBtn {
  line-height: 45px;
  color: #62acde;
  margin-right: 5px;
}
.checkBtnCompleted {
  color: #b3adad;
}
.textCompleted {
  text-decoration: line-through;
  color: #b3adad;
}
</style>

4) TodoFooter

  • 할 일 모두 삭제 기능
  • clearTodo 메서드 구현
    • 스타일 설정
    • 삭제 버튼 추가

📋 src/components/TodoFooter.vue 📋

<template>
  <div class="clearAllContainer">
    <span class="clearAllBtn" @click="clearTodo"> 
        <!-- v-on:click과 @click 동일 -->
        Clear All
    </span>
  </div>
</template>

<script>
export default {
    methods: {
        clearTodo() {
            localStorage.clear();
        }
    }
};
</script>

<style scoped>
.clearAllContainer {
  width: 8.5rem;
  height: 50px;
  line-height: 50px;
  background-color: white;
  border-radius: 5px;
  margin: 0 auto;
}
.clearAllBtn {
  color: #e20303;
  display: block;
}
</style>

📕 Refactoring

1. 현재 구조 파악

1) 현재 어플리케이션 구조의 문제점

  • 할 일을 입력/삭제 했을 때 할 일 목록에 바로 반영X
  • 각각의 컴포넌트에서 각자 뷰 데이터 속성(newTodoItem, todoItems) 가지고 있지만, localStorage의 데이터는 공유되고 있는 상황

2) 해결 방법

  • 데이터 속성을 최상위(루트) 컴포넌트(App)에 todoItems를 정의하고, 하위 컴포넌트(TodoList)에 props로 전달
  • 하위 컴포넌트에서 발생한 이벤트를 $.emit을 이용하여 상위 컴포넌트로 전달

2. 컴포넌트 구현 리팩토링

1) 할 일 목록 표시 기능

  • TodoList 수정
    • todoItems 데이터 변수와 created life cycle hook method를 App으로 옮김
  • App 수정
    • TodoList에서 props로 전달

📋 src/components/TodoList.vue 📋

<script>
export default {
  props: ["propsdata"],
  /* App 으로 이동 
  data() {
      return {
          todoItems: []
      }
  },
  // life cycle method 
  created() {
      if (localStorage.length > 0) {
          for (var i = 0; i < localStorage.length; i++) {
              var itemJson = localStorage.getItem(localStorage.key(i));
              this.todoItems.push(JSON.parse(itemJson));
          }
      }
  },
  */
  methods: {
      removeTodo(todoItem, index) {
          localStorage.removeItem(todoItem.item);
          this.todoItems.splice(index, 1);
      },
      toggleComplete(todoItem) {
          const { item, completed } = todoItem;
          todoItem.completed = !completed;
          localStorage.removeItem(item);
          localStorage.setItem(item, JSON.stringify(todoItem));
      }
  },
}
</script>

📋 src/App.vue 📋

<template>
  <div id="app">
    <TodoHeader></TodoHeader>
    <TodoInput></TodoInput>
    <TodoList v-bind:propsdata="todoItems"></TodoList>
    <TodoFooter></TodoFooter>
  </div>
</template>

<script>
import TodoHeader from '@/components/TodoHeader.vue'
import TodoInput from '@/components/TodoInput.vue'
import TodoList from '@/components/TodoList.vue'
import TodoFooter from '@/components/TodoFooter.vue'

export default {
  name: 'App',
  components: {
    TodoHeader,
    TodoInput,
    TodoList,
    TodoFooter
  },
  // TodoList에서 이동
  data() {
      return {
          todoItems: []
      }
  },
  created() {
      if (localStorage.length > 0) {
          for (var i = 0; i < localStorage.length; i++) {
              var itemJson = localStorage.getItem(localStorage.key(i));
              this.todoItems.push(JSON.parse(itemJson));
          }
      }
  },
}
</script>

2) 할 일 추가 기능

  • TodoInput 수정
    • addTodo 메서드에 있던 코드 옮김
  • App 수정
    • addOneItem 메서드 추가
  • TodoInput에서 발생한 Event를 App에 전달할 때 this.$emit("이벤트 이름", 인자) 사용
    • <TodoInput v-on: 하위 컴포넌트에서 발생시킨 이벤트 이름="현재 컴포넌트의 메서드명"></TodoInput>

📋 src/components/TodoInput.vue 📋

<script>
export default {
  //LifeCycle Hook method
  mounted() {
      this.$refs.todoItem.focus();
  },
  data() {
      return {
          newTodoItem: ""
      }
  }, //data
  methods: {
      addTodo() {
          if (this.newTodoItem !== '') {
            this.$emit("addItemEvent", this.newTodoItem);
            /* app으로 이동 
              var todoObj = { completed: false, item: this.newTodoItem };
              localStorage.setItem(this.newTodoItem, JSON.stringify(todoObj)); */
              this.clearInput();
          }
      }, //addTodo
      clearInput() {
          this.newTodoItem = '';
      }, //clearTodo


  }, //methods
}
</script>

📋 src/App.vue 📋

<template>
  <div id="app">
    <TodoHeader></TodoHeader>
    <TodoInput v-on:addItemEvent="addOneItem"></TodoInput>
    <TodoList v-bind:propsdata="todoItems"></TodoList>
    <TodoFooter></TodoFooter>
  </div>
</template>

<script>
import TodoHeader from '@/components/TodoHeader.vue'
import TodoInput from '@/components/TodoInput.vue'
import TodoList from '@/components/TodoList.vue'
import TodoFooter from '@/components/TodoFooter.vue'

export default {
  name: 'App',
  components: {
    TodoHeader,
    TodoInput,
    TodoList,
    TodoFooter
  },
  data() {
      return {
          todoItems: []
      }
  },
  created() {
      if (localStorage.length > 0) {
          for (var i = 0; i < localStorage.length; i++) {
              var itemJson = localStorage.getItem(localStorage.key(i));
              this.todoItems.push(JSON.parse(itemJson));
          }
      }
  },
  // TodoInput에서 이동
  methods: {
    addOneItem: function(todoItem) {
      var obj = { completed: false, item: todoItem };
      localStorage.setItem(todoItem, JSON.stringify(obj)); 
      // localStorage와 화면을 동기화 시키기 위해서
      this.todoItems.push(obj);
    }
  }
}
</script>

3) 할 일 삭제 기능

  • TodoList 수정
    • removeTodo 메서드 코드를 옮김
  • App 수정
    • removeOneItem 메서드 추가
  • TodoList에서 발생한 클릭 Event를 App에 전달할 때 this.$emit("이벤트 이름", 인자) 사용
    • <TodoList v-on:하위컴포넌트에서 발생시킨 이벤트이름="현재 컴포넌트의 메서드명"></TodoList>

📋 src/components/TodoList.vue 📋

<script>
export default {
  props: ["propsdata"],

  methods: {
      removeTodo(todoItem, index) {
        this.$emit('removeItemEvent', todoItem, index);
        /* App 으로 이동
        localStorage.removeItem(todoItem.item);
        this.todoItems.splice(index, 1);
        */
      },
      toggleComplete(todoItem) {
          const { item, completed } = todoItem;
          todoItem.completed = !completed;
          localStorage.removeItem(item);
          localStorage.setItem(item, JSON.stringify(todoItem));
      }
  },
}
</script>

📋 src/App.vue 📋

<template>
  <div id="app">
    <TodoHeader></TodoHeader>
    <TodoInput v-on:addItemEvent="addOneItem"></TodoInput>
    <TodoList v-on:removeItemEvent="removeOneItem" v-bind:propsdata="todoItems"></TodoList>
    <TodoFooter></TodoFooter>
  </div>
</template>

<script>
import TodoHeader from '@/components/TodoHeader.vue'
import TodoInput from '@/components/TodoInput.vue'
import TodoList from '@/components/TodoList.vue'
import TodoFooter from '@/components/TodoFooter.vue'

export default {
  name: 'App',
  components: {
    TodoHeader,
    TodoInput,
    TodoList,
    TodoFooter
  },
  data() {
      return {
          todoItems: []
      }
  },
  created() {
      if (localStorage.length > 0) {
          for (var i = 0; i < localStorage.length; i++) {
              var itemJson = localStorage.getItem(localStorage.key(i));
              this.todoItems.push(JSON.parse(itemJson));
          }
      }
  },
  methods: {
    addOneItem: function(todoItem) {
      var obj = { completed: false, item: todoItem };
      localStorage.setItem(todoItem, JSON.stringify(obj)); 
      this.todoItems.push(obj);
    },
    // TodoList에서 이동
    removeOneItem: function(todoItem, index) {
      localStorage.removeItem(todoItem.item);
      this.todoItems.splice(index, 1);
    }
  }
}
</script>

4) 할 일 완료 기능

  • TodoList 수정
    • toggleComplete 메서드에 있던 코드를 옮김
  • App 수정
    • toggleOneItem 메서드를 추가
  • TodoList에서 발생한 클릭 Event를 App에 전달할 때 this.$emit("이벤트이름", 인자) 사용
    • <TodoList v-on:하위컴포넌트에서 발생시킨 이벤트 이름="현재 컴포넌트의 메서드명"><TodoList>
  • completed 값 변경
    • TodoList에서 인자로 전달 받은 todoItem의 completed 값 변경하는 것
      ➡️ todoItems 배열 중의 1개의 todoItem의 completed 값을 변경하는 것이 더 좋은 방법

📋 src/components/TodoList.vue 📋

<script>
export default {
  props: ["propsdata"],

  methods: {
      removeTodo(todoItem, index) {
        this.$emit('removeItemEvent', todoItem, index);
      },
      toggleComplete(todoItem) {
        this.$emit('toggleItemEvent', todoItem);
        /* App으로 이동
          const { item, completed } = todoItem;
          todoItem.completed = !completed;
          localStorage.removeItem(item);
          localStorage.setItem(item, JSON.stringify(todoItem));
        */
      }
  },
}
</script>

📋 src/App.vue 📋

<template>
  <div id="app">
    <TodoHeader></TodoHeader>
    <TodoInput v-on:addItemEvent="addOneItem"></TodoInput>
    <TodoList v-on:toggleItemEvent="toggleOneItem" v-on:removeItemEvent="removeOneItem" v-bind:propsdata="todoItems"></TodoList>
    <TodoFooter></TodoFooter>
  </div>
</template>

<script>
import TodoHeader from '@/components/TodoHeader.vue'
import TodoInput from '@/components/TodoInput.vue'
import TodoList from '@/components/TodoList.vue'
import TodoFooter from '@/components/TodoFooter.vue'

export default {
  name: 'App',
  components: {
    TodoHeader,
    TodoInput,
    TodoList,
    TodoFooter
  },
  data() {
      return {
          todoItems: []
      }
  },
  created() {
      if (localStorage.length > 0) {
          for (var i = 0; i < localStorage.length; i++) {
              var itemJson = localStorage.getItem(localStorage.key(i));
              this.todoItems.push(JSON.parse(itemJson));
          }
      }
  },
  methods: {
    addOneItem: function(todoItem) {
      var obj = { completed: false, item: todoItem };
      localStorage.setItem(todoItem, JSON.stringify(obj)); 
      this.todoItems.push(obj);
    },
    removeOneItem: function(todoItem, index) {
      localStorage.removeItem(todoItem.item);
      this.todoItems.splice(index, 1);
    },
    // TodoList에서 이동
    toggleOneItem: function(todoItem) {
      this.todoItems[index].completed = !this.todoItems[index].completed;
      localStorage.removeItem(todoItem.item);
      localStorage.setItem(todoItem.item, JSON.stringify(this.todoItems[index]));
      /* completed을 배열로 값 변경
      const { item, completed } = todoItem;
      todoItem.completed = !completed;
      localStorage.removeItem(item);
      localStorage.setItem(item,JSON.stringify(todoItem)); 
      */
    }
  }
}
</script>

5) 할 일 모두 삭제 기능

  • TodoFooter 수정
    • clearTodo 메서드를 코드를 옮김
  • App 수정
    • removeAllItems 메서드 추가
  • TodoFooter에서 발생한 클릭 Event를 App에 전달할 때 this.$emit("이벤트 이름", 인자) 사용
    • <TodoFooter v-on:하위컴포넌트에서 발생시킨 이벤트 이름="현재 컴포넌트의 메서드명"></TodoFooter>

📋 src/components/TodoFooter.vue 📋

<script>
export default {
  methods: {
      clearTodo() {
          this.$emit('removeAllItemEvent');
          /* App 으로 이동
          localStorage.clear();
          */
      }
  }
}
</script>

📋 src/App.vue 📋

<template>
  <div id="app">
    <TodoHeader></TodoHeader>
    <TodoInput v-on:addItemEvent="addOneItem"></TodoInput>
    <TodoList v-on:toggleItemEvent="toggleOneItem" v-on:removeItemEvent="removeOneItem" v-bind:propsdata="todoItems"></TodoList>
    <TodoFooter v-on:removeAllItemEvent="removeAllItems"></TodoFooter>
  </div>
</template>

<script>
import TodoHeader from '@/components/TodoHeader.vue'
import TodoInput from '@/components/TodoInput.vue'
import TodoList from '@/components/TodoList.vue'
import TodoFooter from '@/components/TodoFooter.vue'

export default {
  name: 'App',
  components: {
    TodoHeader,
    TodoInput,
    TodoList,
    TodoFooter
  },
  data() {
      return {
          todoItems: []
      }
  },
  created() {
      if (localStorage.length > 0) {
          for (var i = 0; i < localStorage.length; i++) {
              var itemJson = localStorage.getItem(localStorage.key(i));
              this.todoItems.push(JSON.parse(itemJson));
          }
      }
  },
  methods: {
    addOneItem: function(todoItem) {
      var obj = { completed: false, item: todoItem };
      localStorage.setItem(todoItem, JSON.stringify(obj)); 
      this.todoItems.push(obj);
    },
    removeOneItem: function(todoItem, index) {
      localStorage.removeItem(todoItem.item);
      this.todoItems.splice(index, 1);
    },
    toggleOneItem: function(todoItem, index) {
      this.todoItems[index].completed = !this.todoItems[index].completed;
      localStorage.removeItem(todoItem.item);
      localStorage.setItem(todoItem.item, JSON.stringify(this.todoItems[index]));
    },
    // TodoFooter에서 이동
    removeAllItems: function() {
      localStorage.clear();
      this.todoItems = [];
    }
  }
}
</script>

📕 Modal

1. 사용자 경험 개선

1) 개선할 기능

  • 할 일을 입력할 때 값을 입력하지 않고, + 버튼 클릭하는 경우
  • 할 일을 추가하거나, 삭제할 때 좀 더 자연스럽게 화면이 보이게 하는 경우

2) 해결 방법

2. 컴포넌트 구현

1) 뷰 Modal

  • Modal 추가
    • components/common/Modal.vue 컴포넌트 생성
  • TodoInput 수정
    • Modal 컴포넌트를 TodoInput의 하위 컴포넌트로 등록
    • Modal에 있는 slot의 header와 body 부분만 재정의
    • Modal.vue 안에 선언된 footer slot 부분 제거

📋 src/components/TodoInput.vue 📋

<template>
  <div class="inputBox shadow">
      <input type="text" v-model="newTodoItem" ref="todoItem" @keyup.enter="addTodo">
      <span class="addContainer" v-on:click="addTodo">
          <i class="fas fa-plus addBtn"></i>
      </span>
      <!-- Modal에 있는 slot의 header와 body 부분만 재정의 -->
      <MyModal v-if="showModal" @close="showModal = false">
          <h3 slot="header">
              경고!
              <i class="closeModalBtn fas fa-times" @click="showModal = false"></i>
          </h3>
          <div slot="body">
              아무것도 입력하지 않으셨습니다.
          </div>
      </MyModal>
  </div>
</template>

<script>
/* Modal을 import */
import MyModal from '@/components/common/MyModal.vue';

export default {
  mounted() {
      this.$refs.todoItem.focus();
  },
  data() {
      return {
          newTodoItem: "",
          showModal: false
      }
  }, 
  /* Modal 컴포넌트를 TodoInput의 하위 컴포넌트로 등록 */
  components: {
      MyModal
  }, 
  methods: {
      addTodo() {
          if (this.newTodoItem !== '') {
              //this.$store.commit('addTodo', this.newTodoItem);
              const itemObj = { item: this.newTodoItem, completed: false  };
              this.$store.dispatch('addTodoItem', itemObj);
              this.clearInput();
          } else {
              this.showModal = !this.showModal;
          }
      }, 
      clearInput() {
          this.newTodoItem = '';
      }, 

    }, 
  }
</script>

📋 src/components/common/Modal.vue 📋

<template>
    <transition name="modal">
        <div class="modal-mask">
            <div class="modal-wrapper">
                <div class="modal-container">
                    <div class="modal-header">
                        <slot name="header">
                            default header
                        </slot>
                    </div>

                    <div class="modal-body">
                        <slot name="body">
                            default body
                        </slot>
                    </div>

                    <div class="modal-footer">
                        <slot name="footer">
                            default footer
                            <button class="modal-default-button" @click="$emit('close')">
                                OK
                            </button>
                        </slot>
                    </div>
                </div>
            </div>
        </div>
    </transition>
</template>
  
<style>
.modal-mask {
    position: fixed;
    z-index: 9998;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, .5);
    display: table;
    transition: opacity .3s ease;
}

.modal-wrapper {
    display: table-cell;
    vertical-align: middle;
}

.modal-container {
    width: 300px;
    margin: 0px auto;
    padding: 20px 30px;
    background-color: #fff;
    border-radius: 2px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, .33);
    transition: all .3s ease;
    font-family: Helvetica, Arial, sans-serif;
}

.modal-header h3 {
    margin-top: 0;
    color: #42b983;
}

.modal-body {
    margin: 20px 0;
}

.modal-default-button {
    float: right;
}

/*
   * The following styles are auto-applied to elements with
   * transition="modal" when their visibility is toggled
   * by Vue.js.
   *
   * You can easily play with the modal transition by editing
   * these styles.
   */
.modal-enter {
    opacity: 0;
}

.modal-leave-active {
    opacity: 0;
}

.modal-enter .modal-container,
.modal-leave-active .modal-container {
    -webkit-transform: scale(1.1);
    transform: scale(1.1);
}
</style>

2) 뷰 애니메이션

  • TodoList 수정
    • 리스트 아이템의 트랜지션 효과 스타일 설정
    • <ul> 엘리먼트를 <transition-group> 엘리먼트로 변경

📋 src/components/TodoList.vue 📋

<template>
  <div>
  	<!-- <ul> 엘리먼트를 <transition-group> 엘리먼트로 변경  -->
    <transition-group name="list" tag="ul">
      <li v-for="(todoItem, idx) in propsdata" :key="idx" class="shadow">
        <i class="fas fa-check checkBtn" :class="{ checkBtnCompleted: todoItem.completed }"
          @click="toggleComplete(todoItem)"></i>
        <span :class="{ textCompleted: todoItem.completed }">{{ todoItem.item }}</span>
        <span class="removeBtn" @click="removeTodo(todoItem, idx)">
          <i class="fas fa-trash-alt"></i>
        </span>
      </li>
    </transition-group>
  </div>
</template>

📕 Vuex

1. MVC 문제점

1) MVC 패턴 단점

  • 양방향 데이터 흐름
  • Model이 업데이트 되어 View가 따라서 업데이트 되고, 업데이트된 View가 다시 다른 Model을 업데이트 하면, 또 다른 View가 업데이트됨
  • 어플리케이션이 복잡해지면, 양방향 데이터 흐름은 새로운 기능이 추가될 때에 시스템의 복잡도를 증가시키고, 예측 불가능한 코드 생성

2) Flux로 해결

  • 단방향 데이터 흐름
    • 데이터 흐름은 항상 Dispatcher에서 Store로, Store에서 View로, View는 Action을 통해 다시 Dispatcher로 데이터가 흐름
    • 단방향 데이터 흐름으로 데이터 변화를 훨씬 예측하기 쉬움
  • Flux
    • Dispatcher : Action이 발생되면 Dispatcher로 전달되는데, Dispatcher는 전달된 Action을 보고, 등록된 콜백 함수를 실행하여 Store에 데이터를 전달
    • Model(Store) : 어플리케이션의 모든 상태 변경은 Store에 의해 결정됨
    • View : 사용자에게 비춰지는 화면
    • Action : Dispatcher에서 콜백 함수가 실행 되면 Store가 업데이트되게 되는데, 이 콜백 함수를 실행할 때 데이터가 담겨있는 객체(Action)가 인수로 전달되어야 함

3) Vuex

  • Vue.js 애플리케이션에 대한 상태 관리 패턴 + 라이브러리
    • 많은 컴포넌트의 데이터를 효율적으로 관리하는 상태 관리 라이브러리
    • 애플리케이션의 모든 컴포넌트에 대한 중앙 집중식 저장소 역할을 하며 예측 가능한 방식으로 상태 변경할 수 있음
    • Vuex는 Flux, Redux, The Elm Architecture에서 발전
  • Vuex 필요성
    • 복잡한 어플리케이션에서 컴포넌트의 개수가 많아지면 컴포넌트 간에 데이터 전달이 어려워짐
    • 중앙 집중화된 상태 정보 관리가 필요하고, 상태 정보가 변경되는 상황과 시간 추적이 필요함
    • 컴포넌트에서 상태 정보를 안전하게 접근함
  • Vuex 해결
    • MVC 패천에서 발생하는 구조적 오류
    • 컴포넌트 간 데이터 전달 명시
    • 여러 개의 컴포넌트에서 같은 데이터를 업데이트 할 때 동기화 문제
  • Vuex 개념
    • State : 컴포넌트 간에 공유하는 데이터 (data())
    • View : 데이터를 표시하는 화면 (template)
    • Action : 사용자의 입력에 따라 데이터를 변경하는 (methods)
  • Vuex 구조
    • 컴포넌트 ➡️ Actions (비동기 로직) ➡️ Mutations (동기 로직) ➡️ State (상태)
    • 컴포넌트가 Actions을 일으킬 경우 (🗒️ 예시 : 버튼 클릭)
      ➡️ Action 에서는 외부 API를 호출한 뒤 그 결과를 이용해 Mutations를 일으킴 (만일 외부 API가 없으면 생략)
      ➡️ Mutations에서는 Action의 결과를 받아 State를 변경함 (DevTools 같은 도구를 이용해 상태 변경 내역 확인 가능)
      ➡️ Mutations에 의해 변경된 State는 다시 컴포넌트에 바인딩되어 UI를 갱신함
  • Vuex 구성 요소
    • state : 여러 컴포넌트에 공유되는 데이터 (data)
    • getters : 연산된 state 값을 접근하는 속성 (computed)
    • mutations : state 값을 변경하는 이벤트 로직과 메서드 (methods 동기 메서드)
    • actions : 비동기 처리 로직을 선언하는 메서드 (async method 비동기 메서드)

2. Vuex 구현

1) Store 생성

  • src/store 디렉토리와 store.js 파일 생성
  • 루트 컴포넌트에 store 옵션을 제공함으로써, store는 루트의 모든 하위 컴포넌트에 주입됨

📋 src/store/store.js 📋

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export const store = new Vuex.Store({

});

📋 src/main.js 📋

import Vue from 'vue'
import App from './App.vue'
// store.js를 import
import { store } from './store/store';

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
  // store 추가
  store,
}).$mount('#app')

2) state 속성 적용

  • 상태 : 여러 컴포넌트 간에 공유할 데이터

  • Todoheader 수정

  • store 수정

    • state 속성 적용
    • state에 todoItems 속성 정의
    • created() 메서드를 fetch로 변경
  • App 수정

    • created() 메서드를 store.js로 이동
    • <TodoList v-bind:propsdata="todoItems“의 v-bind 속성을 제거
  • TodoList 수정

    • v-for 구문에서 store에 바로 접근하기 위해 propsdata ➡️ this.$store.state.todoItems로 변경
    • props: ['propsdata'] 제거

📋 src/store/store.js 📋

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

// App에서 이동
const storage = {
    fetch() {
        const arr = [];
        if (localStorage.length > 0) {
            for (let i = 0; i < localStorage.length; i++) {
                if (localStorage.key(i) !== 'loglevel:webpack-dev-server') {
                    arr.push(JSON.parse(localStorage.getItem(localStorage.key(i))));
                }
            }
        }
        return arr;
    },
};

export const store = new Vuex.Store({
    // state 속성 적용
    state: {
        todoItems: storage.fetch(),
        headerText: "TODO it"
    }
});

📋 src/components/TodoHeader.vue 📋

<template>
    <header>
        <h1>{{this.$store.state.headerText}}</h1>
    </header>
</template>

📋 src/App.vue 📋

<template>
  <div id="app">
    <TodoHeader></TodoHeader>
    <TodoInput v-on:addItemEvent="addOneItem"></TodoInput>
    <!--v-bind 속성 삭제
        <TodoList v-on:toggleItemEvent="toggleOneItem" v-on:removeItemEvent="removeOneItem" v-bind:propsdata="todoItems"></TodoList> -->
    <TodoList v-on:toggleItemEvent="toggleOneItem" v-on:removeItemEvent="removeOneItem"></TodoList>
    <TodoFooter v-on:removeAllItemEvent="removeAllItems"></TodoFooter>
  </div>
</template>

<script>
import TodoHeader from '@/components/TodoHeader.vue'
import TodoInput from '@/components/TodoInput.vue'
import TodoList from '@/components/TodoList.vue'
import TodoFooter from '@/components/TodoFooter.vue'

export default {
  name: 'App',
  components: {
    TodoHeader,
    TodoInput,
    TodoList,
    TodoFooter
  },
  data() {
      return {
          todoItems: []
      }
  },
  /* store로 이동
  created() {
      if (localStorage.length > 0) {
          for (var i = 0; i < localStorage.length; i++) {
              var itemJson = localStorage.getItem(localStorage.key(i));
              this.todoItems.push(JSON.parse(itemJson));
          }
      }
  },
  */
  methods: {
    addOneItem: function(todoItem) {
      var obj = { completed: false, item: todoItem };
      localStorage.setItem(todoItem, JSON.stringify(obj)); 
      this.todoItems.push(obj);
    },
    removeOneItem: function(todoItem, index) {
      localStorage.removeItem(todoItem.item);
      this.todoItems.splice(index, 1);
    },
    toggleOneItem: function(todoItem, index) {
      this.todoItems[index].completed = !this.todoItems[index].completed;
      localStorage.removeItem(todoItem.item);
      localStorage.setItem(todoItem.item, JSON.stringify(this.todoItems[index]));
    },
    removeAllItems: function() {
      localStorage.clear();
      this.todoItems = [];
    }
  }
}
</script>

📋 src/components/TodoList.vue 📋

<template>
  <div>
    <transition-group name="list" tag="ul">
      <!-- propsdata에서 store 직접 접근으로 변경 -->
      <li v-for="(todoItem, idx) in this.$store.state.todoItems" :key="idx" class="shadow">
        <i class="fas fa-check checkBtn" :class="{ checkBtnCompleted: todoItem.completed }"
          @click="toggleComplete(todoItem)"></i>
        <span :class="{ textCompleted: todoItem.completed }">{{ todoItem.item }}</span>
        <span class="removeBtn" @click="removeTodo(todoItem, idx)">
          <i class="fas fa-trash-alt"></i>
        </span>
      </li>
    </transition-group>
  </div>
</template>

<script>
export default {
  /* props 삭제
  props: ["propsdata"],
  */
  methods: {
      removeTodo(todoItem, index) {
        this.$emit('removeItemEvent', todoItem, index);
      },
      toggleComplete(todoItem) {
        this.$emit('toggleItemEvent', todoItem);
      }
  },
}
</script>

3) getters

  • state 값을 접근하는 속성
  • computed() 처럼 미리 연산된 값을 접근하는 속성

4) mutations 적용

  • state의 값을 변경할 수 있는 메서드

  • Mutations는 commit()으로 동작

    • state를 변경하기 위해 mutations를 동작시킬 때 인자(payload, 객체)를 전달할 수 있음

  • store 수정

    • mutations 속성 정의
    • mutations 속성 내에 App.vue에 있는 메서드 이동
      • addOneItem()
      • removeOneItem()
      • toggleOneItem()
      • removeAllItems()
  • App 수정

    • <TodoInput v-on:addItemEvent="addOneItem"></TodoInput> ➡️ <TodoInput></TodoInput>로 변경
    • <TodoList v-on:removeItemEvent="removeOneItem"></TodoList> ➡️ <TodoList></TodoList>로 변경
    • <TodoList v-on:toggleItemEvent="toggleOneItem"></TodoList> ➡️ <TodoList></TodoList>로 변경
    • <TodoFooter v-on:removeAllItemEvent="removeAllItems"></TodoFooter> ➡️ <TodoFooter></TodoFooter>로 변경
  • TodoInput 수정

    • TodoInput에서 할 일 추가 이벤트가 발생했을 경우
    • App.vue에게 Event 보내는 대신에 store에 저장된 addOneItem() 메서드 직접 호출
    • this.$emit('addItemEvent', this.newTodoItem); ➡️ this.$store.commit('addOneItem', this.newTodoItem);
  • TodoList 수정

    • TodoList에서 할 일 삭제 이벤트가 발생했을 경우
    • App.vue에게 Event 보내는 대신에 store에 저장된 removeOneItem() 메서드 직접 호출
    • this.$emit('removeItemEvent',todoItem,index); ➡️ this.$store.commit('removeOneItem', {todoItem, index});
    • 원래 {todoItem: todoItem, index:index}이지만 전달하는 객체의 key와 value 값이 동일하므로 {todoItem, index}로 전달
    • TodoList에서 할 일 완료 이벤트가 발생했을 경우
    • App.vue에게 Event 보내는 대신에 store에 저장된 toggleOneItem() 메서드 직접 호출
      -₩this.$emit('toggleItemEvent',todoItem, index); ➡️ this.$store.commit('toggleOneItem', {todoItem, index});
  • TodoFooter 수정

    • TodoFooter에서 할 일 모두 삭제 이벤트가 발생했을 경우
    • App.vue에게 Event 보내는 대신에 store에 저장된 removeAllItems() 메서드 직접 호출
    • this.$emit('removeAllItemEvent'); ➡️ this.$store.commit('removeAllItems');

📋 src/store/store.js 📋

export const store = new Vuex.Store({
    state: {
        todoItems: storage.fetch(),
    },
    /* mutations 속성 추가 */
    mutations: {
         /* 할 일 추가 */
        addOneItem(state, todoItem) {
            const obj = { completed: false, item: todoItem };
            localStorage.setItem(todoItem, JSON.stringify(obj));
            state.todoItems.push(obj);
        },
         /* 할 일 삭제 */
        removeOneItem(state, payload) {
            const { todoItem: { item }, index } = payload;
            localStorage.removeItem(item);
            state.todoItems.splice(index, 1);
            /*
            localStorage.removeItem(payload.todoItem.item);
            state.todoItems.splice(payload.index, 1);
            */
        },
        /* 할 일 완료 */
        toggleOneItem(state, payload) {
            const { todoItem: { item, completed }, index } = payload;

            state.todoItems[index].completed = !completed;
            localStorage.removeItem(item);
            localStorage.setItem(item, JSON.stringify(state.todoItems[index]));
            /*
            state.todoItems[payload.index].completed = !state.todoItems[payload.index].completed;
            localStorage.removeItem(payload.todoItem.item);
            localStorage.setItem(payload.todoItem.item, JSON.stringify(payload.todoItem));
            */
        },
        /* 할 일 모두 삭제 */
        removeAllItems(state) {
            localStorage.clear();
            state.todoItems = [];
        },
    }
});

📋 src/App.vue 📋

<template>
  <div id="app">
    <TodoHeader></TodoHeader>
    <!-- TodoInput v-on 속성 삭제 
    <TodoInput v-on:addItemEvent="addOneItem"></TodoInput> -->
    <TodoInput></TodoInput>
    <!-- TodoList v-on 속성 삭제 
    <TodoList v-on:toggleItemEvent="toggleOneItem" v-on:removeItemEvent="removeOneItem"></TodoList>-->
    <TodoList></TodoList>
    <!-- TodoFooter v-on 속성 삭제 
    <TodoFooter v-on:removeAllItemEvent="removeAllItems"></TodoFooter>-->
    <TodoFooter></TodoFooter>
  </div>
</template>

<script>
import TodoHeader from '@/components/TodoHeader.vue'
import TodoInput from '@/components/TodoInput.vue'
import TodoList from '@/components/TodoList.vue'
import TodoFooter from '@/components/TodoFooter.vue'

export default {
  name: 'App',
  components: {
    TodoHeader,
    TodoInput,
    TodoList,
    TodoFooter
  },
  data() {
      return {
          todoItems: []
      }
  },
  methods: {
    /* 할 일 추가 메서드 store로 이동
    addOneItem: function(todoItem) {
      var obj = { completed: false, item: todoItem };
      localStorage.setItem(todoItem, JSON.stringify(obj)); 
      this.todoItems.push(obj);
    },
    */
   /* 할 일 삭제 메서드 store로 이동
    removeOneItem: function(todoItem, index) {
      localStorage.removeItem(todoItem.item);
      this.todoItems.splice(index, 1);
    },
    */
   /* 할 일 완료 메서드 store로 이동
    toggleOneItem: function(todoItem, index) {
      this.todoItems[index].completed = !this.todoItems[index].completed;
      localStorage.removeItem(todoItem.item);
      localStorage.setItem(todoItem.item, JSON.stringify(this.todoItems[index]));
    },
    */
   /* 할 일 모두 삭제 메서드 store로 이동
    removeAllItems: function() {
      localStorage.clear();
      this.todoItems = [];
    }
    */
  }
}
</script>

📋 src/components/TodoInput.vue 📋

<script>
import MyModal from '@/components/common/MyModal.vue';

export default {
  mounted() {
      this.$refs.todoItem.focus();
  },
  data() {
      return {
          newTodoItem: "",
          showModal: false
      }
  }, //data
  components: {
      MyModal
  }, 
  methods: {
      addTodo() {
          if (this.newTodoItem !== '') {
            /* App에 event 보내는 대신 store에 저장된 메서드 직접 호출
             this.$emit('addTodoEvent', this.newTodoItem);
             */
              this.$store.commit('addOneItem', this.newTodoItem);
              this.clearInput();
          } else {
              this.showModal = !this.showModal;
          }
      }, 
      clearInput() {
          this.newTodoItem = '';
      }, 
  }, 
}
</script>

📋 src/components/TodoList.vue 📋

<script>
export default {
  methods: {
      removeTodo(todoItem, index) {
        /* App에 event 보내는 대신 store에 저장된 메서드 직접 호출
        this.$emit('removeItemEvent', todoItem, index);
        */
        this.$store.commit('removeOneItem', {todoItem, index});
      },
      toggleComplete(todoItem, index) {
        /* App에 event 보내는 대신 store에 저장된 메서드 직접 호출
        this.$emit('toggleItemEvent', todoItem);
        */
        this.$store.commit('toggleOneItem', {todoItem, index});
      }
  },
}
</script>

📋 src/components/TodoFooter.vue 📋

<script>
export default {
  methods: {
      clearTodo() {
         /* App에 event 보내는 대신 store에 저장된 메서드 직접 호출
          this.$emit('removeAllItemEvent');
        */
        this.$store.commit('removeAllItems');
      }
  }
}
</script>

📖 참고 📖 state를 직접 변경하지 않고, mutations 변경 이유

  • 여러 개의 컴포넌트에서 state 값 변경하는 경우, 어느 컴포넌트에서 해당 state를 변경했는지 추적하기가 어려움
    • 🗒️ 예시
    methods: {
    	increaseCounter() { this.$store.state.counter++ }
    }
  • 특정 시점에 어떤 컴포넌트가 state를 접근하여 변경한 것인지 확인하기 어려움
    ➡️ 뷰의 반응성을 거스르지 않게 명시적으로 상태 변화 수행

📕 Helper

1. Vuex Helper

1) Helper 사용법

  • Helper를 사용하고자 하는 vue 파일에서 아래와 같이 해당 Helper 로딩
  • ... : Object Spread Operator
//App.vue
import { mapState } from 'vuex'
import { mapGetters } from 'vuex'
import { mapMutations } from 'vuex'
import { mapActions } from 'vuex'
export default {
  computed: { ...mapState(['num']), ...mapGetters(['countedNum']) },
methods:
  { ...mapMutations(['clickBtn']), ...mapActions(['asyncClickBtn']) }
}

2) mapState

  • Vuex에 선언한 state 속성을 뷰 컴포넌트에 더 쉽게 연결해주는 헬퍼
  • <p>{{this.$store.state.num}}</p> ➡️ <p>{{num}}</p>
//App.vue
import { mapState } from 'vuex'
export default {
  computed: { ...mapState(['num']) }
  //num() { return this.$store.state.num }
}
//store.js
state: {
  num: 10
}

3) mapGetters

  • Vuex에 선언한 getters 속성을 뷰 컴포넌트에 더 쉽게 연결해주는 헬퍼
  • <p>{{this.$store.getters.reverseMessage}}</p> ➡️ <p>{{reverseMessage}}</p>
  • computed : 템플릿의 데이터 표현을 더 직관적이고 간결하게 도와주는 속성
//App.vue
import { mapGetters } from 'vuex'
export default {
  computed : { ...mapGetters(['reverseMessage']) }
}
//store.js
getters: {
  reverseMessage(state) {
    return state.msg.split('').reverse().join('');
  }
}

4) mapMutations

  • Vuex에 선언한 mutations 속성을 뷰 컴포넌트에 더 쉽게 연결해주는 헬퍼
  • <button v-on:click="setValue">변경</button>
//App.vue
import { mapMutations } from 'vuex'
export default {
  methods : {
    ...mapMutations(['setValue']),
    authLogin() {},
    displayTable() {}
  }
}
//store.js
mutations: {
  setValue(state, value) {
    this.values += value
  }
}

5) mapActions

  • Vuex에 선언한 actions 속성을 뷰 컴포넌트에 더 쉽게 연결해주는 헬퍼
  • <button @click="delayClickBtn">delay popup message</button>
//App.vue
import { mapActions } from 'vuex'
export default {
  methods : {
    ...mapActions(['delayClickBtn']),
  }
}
//store.js
actions: {
  delayClickBtn(context) {
    setTimeout(() => context.commit('clickBtn'), 2000);
  }
}

6) Helper의 유연한 문법

  • Vuex에 선언한 속성을 그대로 컴포넌트에 연결하는 문법
//배열 리터럴
export default {
  methods : {
  ...mapMutations(['clickBtn', 'addNumber']),
  }
}
  • Vuex에 선언한 속성을 컴포넌트의 특정 메서드에 연결하는 문법
//객체 리터럴
export default {
  methods : {
  ...mapMutations({ popupMsg : 'clickBtn' }),
  }
}

2. Helper 함수 적용

1) getters 적용

  • store 수정
    • getters 추가
  • TodoList 수정
    • this.store.state.todoItems ➡️ this.store.getters.getTodoItems로 변경
    • computed 속성으로 todoItems 변경

📋 src/store/store.js 📋

export const store = new Vuex.Store({
    /* getters 추가 */
    getters: {
        getTodoItems(state) {
            return state.todoItems;
        }
    },
  ...

📋 src/components/TodoList.vue 📋

<template>
  <div>
    <transition-group name="list" tag="ul">
      <!-- this.$store.state.todoItems를 this.$store.getters.getTodoItems로 변경 -->
      <!-- this.$store.getters.getTodoItems에서 todoItems로 변경 -->
      <li v-for="(todoItem, idx) in todoItems" :key="idx" class="shadow">
        <i class="fas fa-check checkBtn" :class="{ checkBtnCompleted: todoItem.completed }"
          @click="toggleComplete(todoItem, idx)"></i>
        <span :class="{ textCompleted: todoItem.completed }">{{ todoItem.item }}</span>
        <span class="removeBtn" @click="removeTodo(todoItem, idx)">
          <i class="fas fa-trash-alt"></i>
        </span>
      </li>
    </transition-group>
  </div>
</template>

<script>
export default {
  methods: {
      removeTodo(todoItem, index) {
        this.$store.commit('removeOneItem', {todoItem, index});
      },
      toggleComplete(todoItem, index) {
        this.$store.commit('toggleOneItem', {todoItem, index});
      }
  },
  /* computed 속성 추가 */
  computed: {
    todoItems() {
      return this.$store.getters.getTodoItems;
    }
  },
}
</script>

2) mapGetters 적용

  • TodoList 수정
    • mapGetters를 import
    • computed 속성 추가
    • computed 속성 내에 전개 연산자(spread operator)를 사용하여 mapGetters 선언

📋 src/components/TodoList.vue 📋

<template>
  <div>
    <transition-group name="list" tag="ul">
      <!-- todoItems를 computed 속성의 getTodoItems로 변경 -->
      <li v-for="(todoItem, idx) in getTodoItems" :key="idx" class="shadow">
        <i class="fas fa-check checkBtn" :class="{ checkBtnCompleted: todoItem.completed }"
          @click="toggleComplete(todoItem, idx)"></i>
        <span :class="{ textCompleted: todoItem.completed }">{{ todoItem.item }}</span>
        <span class="removeBtn" @click="removeTodo(todoItem, idx)">
          <i class="fas fa-trash-alt"></i>
        </span>
      </li>
    </transition-group>
  </div>
</template>

<script>
/* mapGetters를 import */
import { mapGetters } from 'vuex'

export default {
  methods: {
      removeTodo(todoItem, index) {
        this.$store.commit('removeOneItem', {todoItem, index});
      },
      toggleComplete(todoItem, index) {
        this.$store.commit('toggleOneItem', {todoItem, index});
      }
  },
  computed: {
    ...mapGetters(['getTodoItems'])
    /* 전개 연산자(spread operator)를 사용하여 mapGetters 선언
    todoItems() {
      return this.$store.getters.getTodoItems;
    }*/
  },
}
</script>

3) mapMutations 적용

  • TodoList 수정
    • mapMutations를 import
    • methods 속성에 정의된 removeTodo()/toggleComplete() 메서드 제거
    • methods 속성에 spread operator 사용하여 mapMutations 선언
    • v-on:click="removeTodo({todoItem, index})"의 인자의 타입을 객체로 수정
    • v-on:click=“toggleComplete({todoItem, index})"의 인자의 타입을 객체로 수정
  • ToFooter 수정
    • mapMutations를 import
    • methods 속성에 정의된 clearTodo() 메서드 제거
    • methods 속성에 spread operator 사용하여 mapMutations 선언

📋 src/components/TodoList.vue 📋

<template>
  <div>
    <transition-group name="list" tag="ul">
      <!-- todoItems를 computed 속성의 getTodoItems로 변경 -->
      <li v-for="(todoItem, idx) in getTodoItems" :key="idx" class="shadow">
        <i class="fas fa-check checkBtn" :class="{ checkBtnCompleted: todoItem.completed }"
          @click="toggleComplete({todoItem, idx})"></i>
          <!-- toggleComplete(todoItem, idx)의 인자 타입을 toggleComplete({ todoItem, idx }) 객체로 수정 -->
        <span :class="{ textCompleted: todoItem.completed }">{{ todoItem.item }}</span>
        <!-- removeTodo(todoItem, idx)의 인자 타입을 removeTodo({ todoItem, idx }) 객체로 수정 -->
        <span class="removeBtn" @click="removeTodo({todoItem, idx})">
          <i class="fas fa-trash-alt"></i>
        </span>
      </li>
    </transition-group>
  </div>
</template>

<script>
/* mapMutations를 import */
import { mapGetters, mapMutations } from 'vuex'

export default {
  methods: {
    ...mapMutations({
      removeTodo: 'removeOneItem',
      toggleComplete: 'toggleOneItem',
    }),
      /* spread operator 사용하여 mapMutations 선언
      removeTodo(todoItem, index) {
        this.$store.commit('removeOneItem', {todoItem, index});
      },
      */
     /* spread operator 사용하여 mapMutations 선언
      toggleComplete(todoItem, index) {
        this.$store.commit('toggleOneItem', {todoItem, index});
      }
      */
  },
  computed: {
    ...mapGetters(['getTodoItems'])
    /* 전개 연산자(spread operator)를 사용하여 mapGetters 선언
    todoItems() {
      return this.$store.getters.getTodoItems;
    }*/
  },
}
</script>

📋 src/components/TodoFooter.vue 📋

<template>
  <div class="clearAllContainer">
      <span class="clearAllBtn" @click="clearTodo">Clear All</span>
  </div>
</template>

<script>
/* mapMutations를 import */
import { mapMutations } from 'vuex'
export default {
  methods: {
    ...mapMutations({
      clearTodo: 'removeAllItems'
      }),
      /* pread operator 사용하여 mapMutations 선언
      clearTodo() {
        this.$store.commit('removeAllItems');
      }
      */
  }
}
</script>

📕 Axios

1. Axios

1) Axios 개념

  • HTTP 클라이언트 라이브러리
  • 특징
    • Make XMLHttpRequests from the browser
    • Make http requests from node.js
    • Supports the Promise API
    • Intercept request and response
    • Transform request and response data
    • Cancel requests
    • Automatic transforms for JSON data
    • Client side support for protecting against XSRF

2) Express

  • Node.js를 위한 빠르고 개방적이고 간결한 웹 프레임워크
  • Express는 프레임워크를 사용한 Node 웹서버에서 간단한 REST API를 구현하고, Client에서는 axios 라이브러리를 사용하여 비동기적으로 통신
//app.js
var express = require('express');
var app = express();
app.get('/', function (req, res) {
  res.send('Hello World!');
});
app.listen(3000, function () {
  console.log('Example app listening on port 3000!');
});

3) CORS

  • Ajax에서 보안 상의 이슈 때문에 동일 출처(Single Origin Policy)를 기본적으로 웹에서 준수
    • Single Origin Policy를 우회하기 위한 기법
    • 서로 다른 Origin 간에 resource를 share하기 위한 방법
  • SOP (Single Origin Policy, 동일 출처 원칙)
    • 같은 Origin에만 요청을 보낼 수 있음
  • Origin
    • URI 스키마 (http, https) + hostname (localhost) + 포트 (8080, 1 8080)

2. axios와 vue-axios

1) 할 일 목록

  • store 수정
    • axios 와 VueAxios 를 import
    • store.js의 state의 todoItems 변수를 초기화
    • getters, mutations, actions 프로퍼티 추가
    • actions 프로퍼티의 loadTodoItems()에서 axios.get() 호출
  • TodoList 수정
    • 화면 load 할 때 store의 actions에 정의된 loadTodoItem() 호출
    • template 영역에서 사용할 수 있도록 mapGetters 헬퍼 함수를 사용하여 getTodoItems() 정의

📋 src/store/store.js 📋

import Vue from 'vue';
import Vuex from 'vuex';
// axios와 vue-axios import
import axios from 'axios';
import VueAxios from 'vue-axios';

Vue.use(Vuex);
Vue.use(VueAxios, axios); // 순서 중요

/*
const storage = {
    fetch() {
        const arr = [];
        if (localStorage.length > 0) {
            for (let i = 0; i < localStorage.length; i++) {
                if (localStorage.key(i) !== 'loglevel:webpack-dev-server') {
                    arr.push(JSON.parse(localStorage.getItem(localStorage.key(i))));
                }
            }
        }
        return arr;
    },
};
*/

const todo_url = 'http://localhost:4500/api/todos';

export const store = new Vuex.Store({
    state: {
        todoItems: []
        /* state의 todoItems 변수를 초기화
        todoItems: storage.fetch(),
        */
    },
    getters: {
        getTodoItems(state) {
            return state.todoItems;
        }
    },
    /* actions 속성의 loadTodoItems()에서 axios.get() 호출 */
    actions: {
        loadTodoItems(context) {
            axios
                .get(`${todo_url}`)  //Promise
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
      	addTodoItem(context, payload) {
            axios
                .post(`${todo_url}`, payload)
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
    },
    mutations: {
        /* setTodoItems 추가 */
        setTodoItems(state, items) {
            state.todoItems = items;
        },
        addOneItem(state, todoItem) {
            const obj = { completed: false, item: todoItem };
            localStorage.setItem(todoItem, JSON.stringify(obj));
            state.todoItems.push(obj);
        },
        removeOneItem(state, payload) {
            const { todoItem: { item }, index } = payload;
            localStorage.removeItem(item);
            state.todoItems.splice(index, 1);
        },
        toggleOneItem(state, payload) {
            const { todoItem: { item, completed }, index } = payload;
            state.todoItems[index].completed = !completed;
            localStorage.removeItem(item);
            localStorage.setItem(item, JSON.stringify(state.todoItems[index]));
        },
        removeAllItems(state) {
            localStorage.clear();
            state.todoItems = [];
        },
    },
});

📋 src/components/TodoList.vue 📋

<script>
import { mapGetters, mapMutations } from 'vuex'

export default {
  mounted () {
    this.$store.dispatch('loadTodoItems');
  },
  methods: {
    ...mapMutations({
      removeTodo: 'removeOneItem',
      toggleComplete: 'toggleOneItem',
    }),
  },
  computed: {
    ...mapGetters(['getTodoItems'])
  },
}
</script>

2) 할 일 삭제

  • store 수정
    • actions 프로퍼티의 removeTodoItem()에서 axios.delete() 호출
  • TodoList 수정
    • mapActions를 import
    • methods에 mapActions 추가

📋 src/store/store.js 📋

export const store = new Vuex.Store({
    state: {
        todoItems: []
    },
    getters: {
        getTodoItems(state) {
            return state.todoItems;
        }
    },
    actions: {
        loadTodoItems(context) {
            axios
            .get(`${todo_url}`)  //Promise
            .then(r => r.data)
            .then(items => {
                context.commit('setTodoItems', items)
            })
        },
        /* actions 속성의 removeTodoItem()에서 axios.delete() 호출 */
        removeTodoItem(context, payload) {
            axios
                .delete(`${todo_url}/${payload.id}`)
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
    },
  ...

📋 src/components/TodoList.vue 📋

<template>
  <div>
    <transition-group name="list" tag="ul">
      <li v-for="(todoItem, idx) in getTodoItems" :key="idx" class="shadow">
        <i class="fas fa-check checkBtn" :class="{ checkBtnCompleted: todoItem.completed }"
          @click="toggleComplete({todoItem, idx})"></i>
        <span :class="{ textCompleted: todoItem.completed }">{{ todoItem.item }}</span>
        <!-- removeTodo({todoItem, idx})객체를 removeTodoItem(todoItem)로 변경 -->
        <!-- <span class="removeBtn" @click="removeTodo({todoItem, idx})"> -->
          <span class="removeBtn" @click="removeTodoItem(todoItem)">
          <i class="fas fa-trash-alt"></i>
        </span>
      </li>
    </transition-group>
  </div>
</template>

<script>
/* mapAcitions를 import */
import { mapGetters, mapMutations, mapActions } from 'vuex'

export default {
  mounted () {
    this.$store.dispatch('loadTodoItems');
  },
  methods: {
    ...mapMutations({
      removeTodo: 'removeOneItem',
      toggleComplete: 'toggleOneItem',
    }),
    /* methods에 mapActions 추가 */
    ...mapActions(['removeTodoItem']),
  },
  computed: {
    ...mapGetters(['getTodoItems'])
  },
}
</script>

3) 할 일 추가

  • store 수정
    • actions 프로퍼티의 addTodoItem() 에서 axios.post() 을 호출
  • TodoInput 수정
    • commit에서 dispatch로 수정

📋 src/store/store.js 📋

export const store = new Vuex.Store({
    state: {
        todoItems: []
    },
    getters: {
        getTodoItems(state) {
            return state.todoItems;
        }
    },
    actions: {
        loadTodoItems(context) {
            axios
            .get(`${todo_url}`)  //Promise
            .then(r => r.data)
            .then(items => {
                context.commit('setTodoItems', items)
            })
        },
      /* actions 프로퍼티의 addTodoItem() 에서 axios.post() 을 호출 */
        addTodoItem(context, payload) {
            axios
            .post(`${todo_url}`, payload)
            .then(r => r.data)
            .then(items => {
                context.commit('setTodoItems', items)
            })
        },
        removeTodoItem(context, payload) {
            axios
            .delete(`${todo_url}/${payload.id}`)
            .then(r => r.data)
            .then(items => {
                context.commit('setTodoItems', items)
            })
        },
    },
  ...

📋 src/components/TodoInput.vue 📋

<script>
import MyModal from '@/components/common/MyModal.vue';

export default {
  mounted() {
      this.$refs.todoItem.focus();
  },
  data() {
      return {
          newTodoItem: "",
          showModal: false
      }
  }, //data
  components: {
      MyModal
  }, 
  methods: {
      addTodo() {
          if (this.newTodoItem !== '') {
              const itemObj = { completed: false, item: this.newTodoItem };
              this.$store.dispatch('addTodoItem', itemObj);
               /* commit에서 dispatch로 수정
              this.$store.commit('addOneItem', this.newTodoItem);
              */
              this.clearInput();
          } else {
              this.showModal = !this.showModal;
          }
      }, 
      clearInput() {
          this.newTodoItem = '';
      }, 
  }, 
}
</script>

4) 할 일 모두 삭제

  • store 수정
    • actions 프로퍼티의 removeAllTodoItems() 에서 axios.delete() 을 호출
  • TodoFooter 수정
    • clearTodo를 removeAllTodoItems로 변경
    • mapActions를 import

📋 src/store/store.js 📋

export const store = new Vuex.Store({
    state: {
        todoItems: []
    },
    getters: {
        getTodoItems(state) {
            return state.todoItems;
        }
    },
    actions: {
        loadTodoItems(context) {
            axios
                .get(`${todo_url}`)  //Promise
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
        addTodoItem(context, payload) {
            axios
                .post(`${todo_url}`, payload)
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
        removeTodoItem(context, payload) {
            axios
                .delete(`${todo_url}/${payload.id}`)
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
        /* actions 프로퍼티의 removeAllTodoItems() 에서 axios.delete() 을 호출 */
        removeAllTodoItems(context) {
            axios
                .delete(`${todo_url}`)
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
    },
  ...

📋 src/components/TodoFooter.vue 📋

<template>
  <div class="clearAllContainer">
    <!-- clearTodo를 removeAllTodoItems로 변경 -->
      <span class="clearAllBtn" @click="clearTodo">Clear All</span>
  </div>
</template>

<script>
/* mapActions를 import*/
import { mapMutations, mapActions } from 'vuex'
export default {
  methods: {
    ...mapMutations({
      clearTodo: 'removeAllItems'
    }),
    /* mapActions 추가 */
    ...mapActions(['removeAllTodoItems']), 
  }
}
</script>

5) 할 일 완료

  • store 수정
    • actions 프로퍼티의 toggleTodoItem() 에서 axios.put() 을 호출
  • TodoList 수정

📋 src/store/store.js 📋

export const store = new Vuex.Store({
    state: {
        todoItems: []
    },
    getters: {
        getTodoItems(state) {
            return state.todoItems;
        }
    },
    actions: {
        loadTodoItems(context) {
            axios
                .get(`${todo_url}`)  //Promise
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
        addTodoItem(context, payload) {
            axios
                .post(`${todo_url}`, payload)
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
        removeTodoItem(context, payload) {
            axios
                .delete(`${todo_url}/${payload.id}`)
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
        removeAllTodoItems(context) {
            axios
                .delete(`${todo_url}`)
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
        /* actions 프로퍼티의 toggleTodoItem() 에서 axios.put() 을 호출 */
        toggleTodoItem(context, payload) {
            axios
                .patch(`${todo_url}/${payload.id}`, payload)
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
    },
  ...

📋 src/components/TodoList.vue 📋

<template>
  <div>
    <transition-group name="list" tag="ul">
      <li v-for="(todoItem, idx) in getTodoItems" :key="idx" class="shadow">
        <i
          class="fas fa-check checkBtn"
          :class="{ checkBtnCompleted: todoItem.completed }"
          @click="toggleTodo(todoItem)"
        ></i>
        <!-- toggleComplete({todoItem, idx}객체를 toggleTodo(todoItem)로 변경 -->
        <span :class="{ textCompleted: todoItem.completed }">{{
          todoItem.item
        }}</span>
        <span class="removeBtn" @click="removeTodoItem(todoItem)">
          <i class="fas fa-trash-alt"></i>
        </span>
      </li>
    </transition-group>
  </div>
</template>

<script>
/* mapAcitions를 import */
import { mapGetters, mapMutations, mapActions } from "vuex";

export default {
  mounted() {
    this.$store.dispatch("loadTodoItems");
  },
  methods: {
    ...mapMutations({
      removeTodo: "removeOneItem",
      toggleComplete: "toggleOneItem",
    }),
    ...mapActions(["removeTodoItem"]),
    /* methods에 toggleTodo 추가 */
    toggleTodo(todoItem) {
      todoItem.completed = !todoItem.completed;
      this.$store.dispatch("toggleTodoItem", todoItem);
    },
  },
  computed: {
    ...mapGetters(["getTodoItems"]),
  },
};
</script>

📕 모듈화

1. Mode

1) Vue-cli 모드

  • 1️⃣ : development
    • development is used by vue-cli-service serve
    • npm run serve

  • 2️⃣ : production
    • production is used by vue-cli-service build
    • npm run build

  • 3️⃣ : test
    • test is used by vue-cli-service test:unit
    • npm run test

//package.json
"scripts": {
  "serve": "vue-cli-service serve",
  "build": "vue-cli-service build",
  "lint": "vue-cli-service lint",
  // local 로컬 모드 추가
  "local": "vue-cli-service serve --mode local",
  // 사용자 정의 모드 생성
  "mymode": "vue-cli-service serve --mode mymode",
},

2) Env Variable

  • 모드명에 맞춰 환경 변수 파일 생성
    • package.json과 같은 위치 (root)에 두어야함
  • 기본모드 이외에 사용자가 정의한 모드 추가 가능
    • 각 모드별로 생성한 파일 안에 필요한 환경변수 추가
  • 환경 변수는 process.env 객체로 접근 가능
    • 기본 변수가 아닌 사용자가 정의한 변수는 VUEAPP prefix 키워드를 추가해야 인식 가능
    • VUEAPP[사용자 지정]
    • Vue_app_secret_code 확인 : console.log(process.env.VUE_APP_SECRET_CODE)

3) Modes와 Environment Variables

📋 .env.development 📋

// npm run serve
VUE_APP_TITLE=개발 모드
VUE_APP_APIURL=http://localhost:4500/api

📋 src/store/store.js 📋

/* api_url 추가 */
const api_url = process.env.VUE_APP_APIURL;
const todo_url = `${api_url}/todos`
// const todo_url = 'http://localhost:4500/api/todos';

📋 .env.production 📋

// npm run build
VUE_APP_TITLE=운영 모드
VUE_APP_APIURL=http://localhost:4500/api

📋 src/components/Todo.vue 📋

<template>
    <header>
        <h1>TODO it {{ mode }}</h1>
    </header>
</template>
<script>
export default {
    setup() {
        const mode =  process.env.VUE_APP_TITLE
        return {mode}
    }
}
</script>

2. Store 모듈화

1) 프로젝트 구조화와 모듈화 방법

  • Vuex.Store({})의 속성을 모듈로 연결
//store.js
import Vue from 'vue'
import Vuex from 'vuex'
export const store = new Vuex.Store({
  state: {}
  getters: {},
  mutations: {},
  actions: {}
});
  • App의 규모가 커져서 1개의 store로 관리가 힘들 때 modules 속성 사용
  • module : state, mutations, actions, getters를 갖는 store의 하위 객체를 의미
//store.js
import Vue from 'vue'
import Vuex from 'vuex'
import todo from 'modules/todo.js'
export const store = new Vuex.Store({
  modules: {
  //모듈명칭 : 모듈파일명 todo:todo
  todo
  }
});
//todo.js
const state = {}
const getters = {}
const mutations = {}
const actions = {}

2) Store 모듈화 1 (getters, mutations)

  • getters/mutations 생성
    • store 디렉터리 아래에 getters.js와 mutations.js 생성
    • const로 선언하여 상수로 정의
    • Arrow Functions 형태로 수정
  • store 수정
    • store.js 내의 getters/mutations 속성의 내용을 getter.js로 이동
    • getters.js와 mutations.js를 import
    • mutations 속성 안에 getters: getters로 선언

📋 src/store/getters.js 📋

export const storedTodoItems = (state) => {
    return state.todoItems;
}
/* 기존 코드
getTodoItems(state) {
    return state.todoItems;
}
*/

📋 src/store/mutations.js 📋

const addOneItem = (state, todoItem) => {
    const obj = { completed: false, item: todoItem };
    localStorage.setItem(todoItem, JSON.stringify(obj));
    state.todoItems.push(obj);
}
const removeOneItem = (state, payload) => {
    const { todoItem: { item }, index } = payload;
    localStorage.removeItem(item);
    state.todoItems.splice(index, 1);
}
const toggleOneItem = (state, payload) => {
    const { todoItem: { item, completed }, index } = payload;
    state.todoItems[index].completed = !completed;
    localStorage.removeItem(item);
    localStorage.setItem(item, JSON.stringify(state.todoItems[index]));
}
const removeAllItems = (state) => {
    localStorage.clear();
    state.todoItems = [];
}
export { addOneItem, removeOneItem, toggleOneItem, removeAllItems }

/* 기존 코드
setTodoItems(state, items) {
            state.todoItems = items;
        },
        addOneItem(state, todoItem) {
            const obj = { completed: false, item: todoItem };
            localStorage.setItem(todoItem, JSON.stringify(obj));
            state.todoItems.push(obj);
        },
        removeOneItem(state, payload) {
            const { todoItem: { item }, index } = payload;
            localStorage.removeItem(item);
            state.todoItems.splice(index, 1);
        },
        toggleOneItem(state, payload) {
            const { todoItem: { item, completed }, index } = payload;
            state.todoItems[index].completed = !completed;
            localStorage.removeItem(item);
            localStorage.setItem(item, JSON.stringify(state.todoItems[index]));
        },
        removeAllItems(state) {
            localStorage.clear();
            state.todoItems = [];
        },
*/

📋 src/store/store.js 📋

/* getters.js와 mutations.js를 import */
import * as getters from './getters';
import * as mutations from './mutations';

export const store = new Vuex.Store({
    state: {
        todoItems: Storage.fetch()
        /* 
        todoItems: []
        */
    },
    getters: {
        getters
        /* getters로 이동
        getTodoItems(state) {
            return state.todoItems;
        }
        */
    },
    mutations: {
        mutations
        /* mutations로 이동
        setTodoItems(state, items) {
            state.todoItems = items;
        },
        addOneItem(state, todoItem) {
            const obj = { completed: false, item: todoItem };
            localStorage.setItem(todoItem, JSON.stringify(obj));
            state.todoItems.push(obj);
        },
        removeOneItem(state, payload) {
            const { todoItem: { item }, index } = payload;
            localStorage.removeItem(item);
            state.todoItems.splice(index, 1);
        },
        toggleOneItem(state, payload) {
            const { todoItem: { item, completed }, index } = payload;
            state.todoItems[index].completed = !completed;
            localStorage.removeItem(item);
            localStorage.setItem(item, JSON.stringify(state.todoItems[index]));
        },
        removeAllItems(state) {
            localStorage.clear();
            state.todoItems = [];
        },
        */
    },

3) Store 모듈화 2 (module)

  • todo.js 생성
    • store 디렉토리 아래에 modules 디렉터리를 생성하고 todo.js 생성
  • store 수정
    • store.js 내의 state, getters, mutations 속성의 내용을 todo.js로 이동
    • todo 내에 state, getters, mutations를 const로 정의
    • todo를 export

📋 src/store/store.js 📋

import Vue from 'vue';
import Vuex from 'vuex';
import axios from 'axios';
import VueAxios from 'vue-axios';
/* todo를 import */
import todo from './modules/todo';

Vue.use(Vuex);
Vue.use(VueAxios, axios);

// const api_url = process.env.VUE_APP_APIURL;
// const todo_url = `${api_url}/todos`

// import * as getters from './getters';
// import * as mutations from './mutations';

export const store = new Vuex.Store({
    modules: {
        todo // todo: todo
    }
    /* todo.js로 이동
    state: {
        todoItems: Storage.fetch()
    },
    getters: {
        getters
    },
    mutations: {
        mutations
    },
    actions: {
        loadTodoItems(context) {
            axios
                .get(`${todo_url}`)  //Promise
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
        addTodoItem(context, payload) {
            axios
                .post(`${todo_url}`, payload)
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
        removeTodoItem(context, payload) {
            axios
                .delete(`${todo_url}/${payload.id}`)
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
        removeAllTodoItems(context) {
            axios
                .delete(`${todo_url}`)
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
        toggleTodoItem(context, payload) {
            axios
                .patch(`${todo_url}/${payload.id}`, payload)
                .then(r => r.data)
                .then(items => {
                    context.commit('setTodoItems', items)
                })
        },
    },
    */
});

📋 src/store/modules/todo.js 📋

import axios from 'axios';

const api_url = process.env.VUE_APP_APIURL;
const todo_url = `${api_url}/todos`
//'http://localhost:4500/api/todos';

const state =  {
    todoItems: []
};

const getters = {
    getTodoItems(state) {
        return state.todoItems;
    }
}; //getters

const actions = {
    loadTodoItems(context) {
        axios
            .get(`${todo_url}`)  //Promise
            .then(res => res.data)
            .then(items => {
                context.commit('setTodoItems', items)
            })
    }, //loadTodoItems
    addTodoItem(context, payload) {
        axios
            .post(`${todo_url}`, payload)
            .then(r => r.data)
            .then(items => {
                context.commit('setTodoItems', items)
            })
    }, //addTodoItem
    removeTodoItem(context, payload) {
        axios
            .delete(`${todo_url}/${payload.id}`)
            .then(r => r.data)
            .then(items => {
                context.commit('setTodoItems', items)
            })
    }, //removeTodoItem
    toggleTodoItem(context, payload) {
        axios
            .patch(`${todo_url}/${payload.id}`, payload)
            .then(r => r.data)
            .then(items => {
                context.commit('setTodoItems', items)
            })
    },
    removeAllTodoItems(context) {
        axios
            .delete(`${todo_url}`)
            .then(r => r.data)
            .then(items => {
                context.commit('setTodoItems', items)
            })
    },

}; //actions

const mutations = {
    setTodoItems(state, items) {
        state.todoItems = items;
    },
    addTodo(state, todo_text) {
        const obj = { completed: false, item: todo_text };
        localStorage.setItem(todo_text, JSON.stringify(obj));
        state.todoItems.push(obj);
    },
    removeTodo(state, payload) {
        const { todoItem: { item }, index } = payload;
        localStorage.removeItem(item);
        state.todoItems.splice(index, 1);
    },
    toggleTodo(state, payload) {
        const { todoItem: { item, completed }, index } = payload;

        state.todoItems[index].completed = !completed;
        localStorage.removeItem(item);
        localStorage.setItem(item, JSON.stringify(state.todoItems[index]));
    },
    clearTodo(state) {
        localStorage.clear();
        state.todoItems = [];
    },
}; //mutations

export default {
    state, getters, actions, mutations
}

📕 router

1. Vue Router

1) Routing

  • 웹페이지 간의 이동 방법
    • 현대 웹 앱 형태 중 하나인 SPA (Single Page Application, 싱글 페이지 어플리케이션)에서 주로 사용
  • 브라우저에서 웹 페이지를 요청하면 서버에서 응답을 받아 웹 페이지를 다시 사용자에게 돌려주는 시간 동안 화면 상에 깜빡거림 현상 발생
    • 해당 부분을 라우팅으로 처리하면 화면을 매끄럽게 전환 가능
    • 더 빠르게 화면을 조작할 수 있어 사용자 경험 향상됨

2) Vue Router

  • 뷰 라우터는 뷰에서 라우팅 기능을 구현할 수 있도록 지원하는 공식 라이브러리
  • Vue Router 제공 기능
    • 중첩된 라우트/뷰 매핑
    • 모듈화된 컴포넌트 기반의 라우터 설정
    • 라우터 파라미터, 쿼리, 와일드카드
    • 세밀한 네비게이션 컨트롤
    • active css 클래스를 자동으로 추가해주는 링크
    • 사용자정의 가능한 스크롤 동작
  • 뷰 라우터를 이용하여 뷰로 만든 페이지 간에 자유롭게 이동 가능
  • 뷰 라우터 구현시 필요한 특수 태그 기능
    • <router-link to="URL 값"> : 페이지 이동 태그, 화면에서는 <a>로 표시되며 클릭하며 to에 지정한 URL로 이동
    • <router-view> : 페이지 표시 태그, 변경되는 URL에 따라 해당 컴포넌트를 뿌려주는 영역

2. Vue Router 구현

1) 라우터 객체 생성

  • main.js
    • 뷰 인스턴스 생성 객체에는 router 속성이 있음
    • 뷰 라우터를 사용하려면 이 속성으로 Router 객체를 전달해야함
  • router/index.js
    • 뷰 라우터는 플러그인 형태로 Vue.use() 함수를 이용해서 등록
    • VueRouter 클래스로 라우터 객체를 생성

📋 src/main.js 📋

import Vue from 'vue'
import App from './App.vue'
import { store } from './store/store';
// router를 import
import router from './router'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
  store,
  // router 추가
  router
}).$mount('#app')

📋 src/router/index.js 📋

import Vue from 'vue'
import VueRouter from 'vue-router'
import HomeView from '../views/HomeView.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/about',
    name: 'about',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})
export default router

2) 라우터 뷰

  • App.vue
    • App.vue 루트 컴포넌트에 라우트 뷰 추가
    • 라우팅이 경로를 따라 컴포넌트를 바꿔가면서 렌더링하는데 렌더링 부분에 <router-view> 태그 사용

3) 라우터 링크

  • 라우터에 등록된 링크는 <a> 태그 보다는 <router-link> 태그 사용을 권장
    • History 모드에서는 주소 체계가 달라서 <a> 태그 사용할 경우 모드 변경시 주소 값을 일일히 변경해줘야 함
    • <a> 태그를 클릭하면 화면을 갱신하는데 <router-link>는 이를 차단 (갱신 없이 화면을 이동)

📋 src/App.vue 📋

<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </nav>
    <router-view/>
  </div>
</template>

4) 중첩된 라우팅 (children 속성)

  • routes 속성에 정의하는 컬렉션에 children 속성이 있음
    • 특정 라우팅의 하위 경로가 변경됨에 따라 하위 컴포넌트를 변경할 수 있음
  • View 컴포넌트 작성
    • views 디렉터리에 PostList/PostNew/PostDetail 작성
    • 부모 라우터(PostList)에서는 자식 라우터들(PostNew, PostDetail)을 렌더링 하기 위한 뷰가 필요하므로 <router-view> 태그를 삽입
    • 중첩된 하위 경로가 변경될 때 이 부분에 해당 컴포넌트가 그려짐
  • children 속성
    • 중첩된 라우터는 children 속성으로 하위 라우터를 정의함
    • /posts 경로를 포함한 하위 경로인 /posts/new와 /posts/id을 children 옵션으로 설정
  • 네비게이션 메뉴에 추가
    • 생성한 라우터 링크를 루트 컴포넌트 네비게이션 메뉴에 추가함

📋 src/views/Posts.vue 📋

<template>
  <div>
    <h1>Posts</h1>
    <router-view></router-view>
  </div>
</template> 

📋 src/views/PostNew.vue 📋

<template>
  <div>
    <h1>Post New page</h1>
  </div>
</template>

📋 src/views/PostDetail.vue 📋

<template>
  <div>
    <h1>Post Detail page</h1>
  </div>
</template>

📋 src/router/index.js 📋

import Vue from 'vue'
import VueRouter from 'vue-router'
import HomeView from '../views/HomeView.vue'
/* posts를 import */
import Posts from '@/views/PostList.vue'
import PostNew from '@/views/PostNew.vue'
import PostDetail from '@/views/PostDetail.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/about',
    name: 'about',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
  },
  /* PostList, PostNew, PostDetail 추가 */
  {
    path: '/posts', component: PostList,
    children: [
      { path: 'new', component: PostNew },
      { path: ':id', component: PostDetail, name: 'post' },
    ]
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

📋 src/App.vue 📋

<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
      <!-- posts 속성 추가 -->
      <router-link to="/posts">Posts</router-link> |
      <router-link to="/posts/new" exact>New Post</router-link>
    </nav>
    <router-view />
  </div>
</template>

5) 동적 라우팅 매핑

  • index.js 수정
    • /posts/detail을 post id에 따라 내용이 달라지도록 라우터 경로에 추가
    • /posts/1, /posts/2
  • PostDetail 수정
    • 동적 라우트 매핑으로 그려진 컴포넌트에서 id 값에 접근할 때는 route 변수로 라우터에 접근할 수 있으며 route.params.id로 id 값을 가져옴

📋 src/router/index.js 📋

export default new Router({
  routes: [
      { path: '/posts', component: Posts,
        children: [
        { path: 'new', component: PostNew},
        { path: ':id', name: 'post',
          component: PostDetail }
      ]
    }
  ]
})

📋 src/views/PostDetail.vue 📋

<template>
  <div>
    <h1>This is an id: {{route.params.id}}
    Post Detail page</h1>
  </div>
</template>

6) 라우터 링크 스타일

  • 경로에 따라 CSS 클래스 명이 자동으로 추가됨
    • Vue.js가 알아서 CSS 클래스명을 추가하기 때문에 개발자는 스타일을 위해 CSS 클래스 정의만 추가
    • 루트 컴포넌트(App.vue)에 CSS 추가
  • router-link-active : 경로 앞부분만 일치해서 추가 되는 클래스
  • router-link-exact-active : 모든 경로가 일치해야만 추가 되는 클래스

📋 src/App.vue 📋

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;
}

nav a {
  font-weight: bold;
  color: #2c3e50;
}

nav a.router-link-exact-active {
  color: #42b983;
}
</style>

7) 데이터 가져오기

  • 서버로부터 데이터를 가져오는 기능 (Data Fetching)
  • 각 화면별로 라우팅이 일어나는 시점에 데이터를 불러와야 함
  • PostDetail.vue
    • Post의 id로 상세 정보를 가져옴
    • 컴포넌트 생성시 created() lifecycle 메서드에 데이터를 fetch함
    • 라우터 링크를 통해 경로가 변경되는 경우 /posts/:id 경로에 매칭되는 컴포넌트(Post 컴포넌트)는 화면이 refresh될 경우에만 created() 메서드가 동작함
    • 단순히 :id 값이 변경되어도 created() 메서드에서 호출하는 fetchData() 함수가 호출되지 않음
    • Route 객체는 라우팅 변경이 일어날 때마다 호출됨
  • watch에서 감시하고 있다가 변경되면 즉시 fetchData() 함수를 호출하는 로직 추가

📋 src/store/modules/post.js 📋

import axios from "axios";

const api_url = process.env.VUE_APP_APIURL;
const post_url = `${api_url}/posts`;

const state = {
  posts: [],
  post: {},
};
const getters = {
  getPosts(state) {
    return state.posts;
  },
  getPost(state) {
    return state.post;
  },
};
const actions = {
  loadPosts(context) {
    axios
      .get(`${post_url}`)
      .then((res) => res.data)
      .then((items) => context.commit("setPosts", items))
      .catch((err) => console.error(err));
  },
  loadPost(context, payload) {
    axios
      .get(`${post_url}/${payload.id}`)
      .then((res) => res.data)
      .then((item) => context.commit("setPost", item))
      .catch((err) => console.error(err));
  },
  removePost(context, id) {
    axios
      .delete(`${post_url}/${id}`)
      .then((res) => res.data)
      .then((items) => context.commit("setPosts", items))
      .catch((err) => console.error(err));
  },
  addPost(context, payload) {
    axios
      .post(`${post_url}`, payload)
      .then((res) => res.data)
      .then((items) => context.commit("setPosts", items))
      .catch((err) => console.error(err));
  },
};
const mutations = {
  setPosts(state, items) {
    state.posts = items;
  },
  setPost(state, item) {
    state.post = item;
  },
};
export default {
  state,
  getters,
  actions,
  mutations,
};

📋 src/store/modules/modulePost.js 📋

const actions = {
    loadPosts(context) {
        axios
            .get(`${post_url}`)
            .then((res) => res.data)
            .then((items) => context.commit("setPosts", items))
            .catch((err) => console.error(err));
    },
    loadPost(context, payload) {
        axios
            .get(`${post_url}/${payload.id}`)
            .then((res) => res.data)
            .then((item) => context.commit("setPost", item))
            .catch((err) => console.error(err));
    },
    removePost(context, id) {
        axios
            .delete(`${post_url}/${id}`)
            .then((res) => res.data)
            .then((items) => context.commit("setPosts", items))
            .catch((err) => console.error(err));
    },
    addPost(context, payload) {
        axios
            .post(`${post_url}`, payload)
            .then((res) => res.data)
            .then((items) => context.commit("setPosts", items))
            .catch((err) => console.error(err));
    },
};
export default {
    state, getters, actions, mutations,
};

📋 src/store/store.js 📋

import Vue from 'vue';
import Vuex from 'vuex';
import axios from 'axios';
import VueAxios from 'vue-axios';
import todo from './modules/todo';
/* post를 import */
import post from './modules/post';

Vue.use(Vuex);
Vue.use(VueAxios, axios);

export const store = new Vuex.Store({
    modules: {
        todo, 
        /* post 추가 */
        post
    }
});

📋 src/views/PostList.vue 📋

<template>
    <div class="posts">
        <h1>Posts</h1>
        <div v-if="loading">Loading...</div>
        <div v-for="post in getPosts" :key="post.id">
            <router-link :to="{ name: 'post', params: { id: post.id } }">
                [ID: {{ post.id }}] {{ post.text | summary }}
            </router-link>
        </div>
        <router-view></router-view>
    </div>
</template>

<script>
import { mapGetters } from 'vuex';

export default {
    data() {
        return { loading: true };
    },
    //lifecycle method
    created() {
        this.fetchData();
    },
    filters: {
        summary(val) {
            return val.substring(0, 20) + "...";
        },
    },
    computed: {
        ...mapGetters(['getPosts'])
    },
    methods: {
        fetchData() {
            this.loading = true;
            this.$store.dispatch('loadPosts').then(() => {
                this.loading = false;
            });
        },
    },
};
</script>

📋 src/views/PostDetail.vue 📋

<template>
  <div>
    <h2>View Post</h2>
    <div v-if="loading">Loading...</div>
    <div v-if="getPost">
      <h3>[ID: {{ getPost.id }}]</h3>
      <div>{{ getPost.text }}</div>
    </div>
  </div>
</template>

<script>
import { mapGetters } from "vuex";
export default {
  data() {
    return {
      loading: true,
    };
  },
  created() {
    this.fetchData();
  },
  watch: { $route: "fetchData" },
  computed: {
    ...mapGetters(["getPost"]),
  },
  methods: {
    fetchData() {
      this.loading = true;
      this.$store
        .dispatch("loadPost", { id: this.$route.params.id })
        .then(() => {
          this.loading = false;
        });
    },
  },
};
</script>
<style scoped>
button {
  margin: 1rem 0;
}
</style>

📋 src/views/PostNew.vue 📋

<template>
  <div>
    <h2>New Post</h2>
    <form @submit.prevent="onSubmit">
      <textarea
        cols="30"
        rows="10"
        v-model="inputTxt"
        :disabled="disabled"
      ></textarea
      ><br />
      <input type="submit" :value="btnTxt" :disabled="disabled" />
    </form>
  </div>
</template>

<script>
export default {
  data() {
    return {
      isSaving: false,
      inputTxt: "",
    };
  },
  computed: {
    btnTxt() {
      return this.isSaving ? "Saving..." : "Save";
    },
    disabled() {
      return this.isSaving;
    },
  },
  methods: {
    onSubmit() {
      this.isSaving = true;
      const post = { text: this.inputTxt };
      this.$store.dispatch("addPost", post).then(() => {
        this.isSaving = false;
        this.inputTxt = "";
        this.$router.push("/posts");
      });
    },
  },
};
</script
>
<style scoped>
input {
  margin: 1rem 0;
}
</style>

profile
Notion으로 이동 (https://24tngus.notion.site/3a6883f0f47041fe8045ef330a147da3?v=973a0b5ec78a4462bac8010e3b4cd5c0&pvs=4)

0개의 댓글