프론트엔드

Vue.js를 이용한 Todo리스트 예제 만들기(1)

CyberI 2018. 3. 15. 16:15

 

Vue.js를 이용한 Todo리스트 예제 만들기(1)

 

프론트엔드 개발에 관심이 있으시다면 Vue.js를 한 번 쯤은 들어보거나 이미 프로젝트에서 사용하신 경험이 있으시리라 생각합니다. 저는 실무에서 Vue.js를 사용해볼 경험이 없어서, 'Vue.js라는 javascript 프레임워크가 있다.' 정도만 알고, 공식 사이트에 가서 대충 내용만 훑어본 적이 있습니다.

그런데 최근에 주위에서 실시간 데이터 바인딩이 필요한 프로젝트에서 Vue.js를 사용한다는 이야기를 들었습니다. 저도 머지않아 실무에서 사용할 일이 있을 것이라는 생각이 들어, 개인적으로 공부를 해보기로 하였습니다. 가이드 문서가 아주 잘 정리되어 있어서, 가이드 문서만 쭉 읽어내려가다보니 이론적으로만 접근하는 것은 무언가 부족한 느낌이 들었습니다.

아주 간단한 것이라도 예제를 만들어보아야 실무에서도 좀 더 잘 활용할 수 있을 것 같아서 간단한 'Todo 리스트'를 Vue.js를 사용하여 만들어 보았습니다. 목적없이 문서를 읽을 때보다 예제를 만들면서 읽으니 필요한 기능을 더욱 적극적으로 익힐 수 있었습니다. 이 포스트를 포함하여 두 번의 포스트에 걸쳐 예제를 만들면서 각각의 기능에서 사용한 vue.js의 사용법을 소개해보려고 합니다.

 

우선 1차적으로 완성된 화면입니다. 느낌 가는대로 만들어서 디자인은 약간 정돈되지 않아 보일 수도 있습니다.(저에게는 최선입니다.ㅎㅎ) 필요한 기능만 간단하게 만들거라서 모바일 화면 사이즈에 맞게 크기를 잡았습니다. 현재 구현된 기능은 다음과 같습니다.

  1. todo 리스트 구성하기
  2. 카테고리, 검색어 입력을 통한 검색 기능
  3. 보기 방식 변경
  4. 정렬 방식 변경
  5. 완료한 항목 체크

vue.js를 설치하는 방법은 상황에 맞게 여러가지가 있으니, 골라서 사용하시면 됩니다.(Vue.js Installation guide)저는 minified된 소스를 다운받아서 화면에 직접 import하는 방식을 택했습니다.

그리고 예제 데이터를 json 파일로 만들어서 준비하였습니다. 예제 데이터는 영어로 해야 화면이 예쁘게 나오는 것 같아서 생각나는 항목들을 영어로 만들었습니다. 썸네일 방식을 위한 이미지도 구글에서 찾아두었습니다.

{"todos": [
  {
    "thumbnail": "/data/images/vegetables.jpg",
    "category": "shopping",
    "title": "Buying some healthy food",
    "startDate": "20180310152031",
    "deadline": "20180314180000",
    "endDate": "",
    "important": "3",
    "filtered":"n"
  },
  {
    "thumbnail": "/data/images/book.jpg",
    "category": "shopping",
    "title": "Buying some new books",
    "startDate": "20180310152031",
    "deadline": "20180317180000",
    "endDate": "",
    "important": "2",
    "filtered":"n"
  },
  {
    "thumbnail": "/data/images/cleaning.jpg",
    "category": "cleaning",
    "title": "Spring Cleaning",
    "startDate": "20180315120000",
    "deadline": "20180317120000",
    "endDate": "",
    "important": "5",
    "filtered":"n"
  },
  {
    "thumbnail": "/data/images/jogging.jpg",
    "category": "exercise",
    "title": "Jogging at the park",
    "startDate": "20180318093000",
    "deadline": "20180318130000",
    "endDate": "",
    "important": "3",
    "filtered":"n"
  },
  {
    "thumbnail": "/data/images/present.jpg",
    "category": "shopping",
    "title": "Buying mom's present",
    "startDate": "20180317120000",
    "deadline": "20180319120000",
    "endDate": "",
    "important": "4",
    "filtered":"n"
  },
  {
    "thumbnail": "/data/images/yoga.jpg",
    "category": "exercise",
    "title": "Enroll yoga class",
    "startDate": "20180320093000",
    "deadline": "20180330130000",
    "endDate": "",
    "important": "3",
    "filtered":"n"
  }
],
"category": ["shopping", "cleaning", "exercise"]
}

1. Vue 객체 만들기

<body>
    <div id="app">
    </div>
</body>
<script>
    var app = new Vue({
        el: "#app",
        data: {
            option: 'all',
            search: '',
            categories: [],
            todos: [],
            viewType: 'list',
            sortType: 'sort-numeric-up',
            buttons: {
                'view': [
                    {'class':'list', 'title':'view in list', selected:true},
                    {'class':'th-large', 'title':'view in thumbnail', selected:false}
                ],
                'sort': [
                    {'class':'sort-numeric-up', 'title':'sort by deadline', selected:true},
                    {'class':'sort-alpha-down', 'title':'sort by alphabet', selected:false},
                    {'class':'star', 'title':'sort by stars', selected:false}
                ]
            }
        },
        created: function(){
            sendAjax({
                url: '/data/todo.json',
                method:'GET',
                success: function(resp){
                    var respObj = JSON.parse(resp);
                    app.categories = respObj.category;
                    app.todos = respObj.todos;
                }
            });
        }
    })
</script>

Vue 객체 만들기는 아주 쉽습니다. Vue를 사용하여 렌더링할 래퍼 태그를 만들고, new Vue()로 vue 객체를 만들면 됩니다. Vue 객체를 만들 때 파라미터로 object을 넘기는데, 몇 가지 속성을 설정할 수 있습니다.

  1. el - vue를 사용할 엘리먼트
  2. data - vue에서 데이터 바인딩에 사용할 데이터
  3. created -  vue의 라이프사이클 중 인스턴스가 만들어진 후 단계의 콜백

데이터를 비동기로 호출하여 불러온다는 상황을 가정하여, 우선 data.categories와 data.todos를 빈 배열로 선언한 후 vue 인스턴스가 만들어진 후에 발생할 콜백(created) 함수에서 ajax로 데이터를 불러와 조회한 데이터로 다시 값을 할당해주었습니다.

 

2. todo 리스트 구성하기

* 결과 화면

* html(리스트만)

<ul class="todo-list list" v-if="viewType === 'list'">
    <li v-for="(item,index) in todos"
        v-bind:class="[substring(item.deadline, 0, 8) == getTodayDate? 'deadline':'', item.endDate? 'done':'']"
        v-show="item.filtered!=='y'"
        v-on:click="checkTodo(index)">
        <i class="far" v-bind:class="item.endDate? 'fa-check-square':'fa-square'"></i>
        <span class="title">{{ item.title }}</span>
    <span class="stars">
        <i v-for="n in toNumber(item.important)" class="fas fa-star"></i>
    </span>
    </li>
</ul>
<ul class="todo-list thumbnail" v-else>
    <li v-for="item in todos" v-show="item.filtered!=='y'" v-bind:class="item.endDate? 'done':''">
        <div class="label">
            <span class="category">{{item.category}}</span>
            <span class="stars">
                <i v-for="n in toNumber(item.important)" class="fas fa-star"></i>
            </span>
        </div>
        <img v-bind:src="item.thumbnail">
        <p class="info">
            <span class="title">{{ item.title }}</span>
            <span class="deadline">
                <i class="far" v-bind:class="item.endDate? 'fa-check-square':'fa-square'"></i>
                until {{ formatDate(item.deadline) }}
                <i v-if="substring(item.deadline, 0, 8) == getTodayDate" class="fas fa-bell"></i>
            </span>
        </p>
    </li>
</ul>

* js

var app = new Vue({
    el: "#app",
    data: {
        option: 'all',
        search: '',
        categories: [],
        todos: [],
        viewType: 'list',
        sortType: 'sort-numeric-up',
        buttons: {
            'view': [
                {'class':'list', 'title':'view in list', selected:true},
                {'class':'th-large', 'title':'view in thumbnail', selected:false}
            ],
            'sort': [
                {'class':'sort-numeric-up', 'title':'sort by deadline', selected:true},
                {'class':'sort-alpha-down', 'title':'sort by alphabet', selected:false},
                {'class':'star', 'title':'sort by stars', selected:false}
            ]
        }
    },
    methods: {
      toNumber: function(number){
          if(typeof number == 'string'){
            number = Number(number);
          }
          return number;
      },
      substring : function(str, startIdx, length){
          return str.substr(startIdx, length);
      },

      formatDate: function(str){
          var result = str.substr(0, 4)+'-'+str.substr(4, 2)+'-'+str.substr(6, 2);
          if(result.replace(/-/g, '') == this.getTodayDate){
              result = "today " + str.substr(8, 2)+':'+str.substr(10, 2)+':'+str.substr(12, 2);
          }
          return result;
      },
      checkTodo: function(index){
          var todoObj = this.todos[index];

          if(todoObj.endDate){
              todoObj.endDate = '';
          } else {
              var d = new Date();
              this.todos[index].endDate = this.getTodayDate 
                  + d.toTimeString().replace(/[^0-9]/g, '').substr(0, 6);
          }
      }
    },
    computed: {
      getTodayDate: function(){
          var d = new Date();
          var arr = d.toLocaleString().replace(/ /g, '').split('.');
          return arr[0]+(arr[1].length==1?"0"+arr[1]:arr[1])+(arr[2].length==1?"0"+arr[2]:arr[2]);
      }
    },
    created: function(){
        sendAjax({
            url: '/data/todo.json',
            method:'GET',
            success: function(resp){
                var respObj = JSON.parse(resp);
                app.categories = respObj.category;
                app.todos = respObj.todos;
            }
        });
    }
})

1-1. v-if, v-else-if, v-else

todo 항목 리스트는 ul 태그를 사용하였고, 리스트 방식과 썸네일 방식 두 가지 화면을 보여주려고 하는데, 각각 방식마다 보여주는 정보와 태그 구성이 달라서 ul 태그를 두 개로 나누었습니다. ul 태그의 v-ifv-else지시어를 통해 사용자가 선택한 view 방식에 맞는 리스트를 보여줄 수 있습니다.

Vue.js에서는 값에 따라 렌더링 하는 태그를 지정할 때 v-if, v-else-if, v-else지시어를 사용합니다. 저는 이 방식이 아주 깔끔하다는 생각이 듭니다. 별도의 커스텀 태그를 추가하지 않고, 태그에 직접 attribute로 추가함으로써 깔끔하게 보여줄 수 있기 때문입니다. 다만, 같은 조건을 적용할 v-if, v-else-if, v-else 속성을 가진 태그는 바로 이전/이후 형제 노드로 구성되어야 하며, 그 사이에 다른 노드를 추가하면 인식하지 못하는 제약이 있습니다.

예제의 경우는 ul 태그 하나만 구분을 하면 되지만, 만약 다수의 태그가 조건에 따라 달라져야 한다면 불필요하게 div 태그 등으로 묶을 필요없이 아래와 같이 <template>태그로 해결할 수 있습니다.

1-2. v-for

반복을 해야하는 경우에는 v-for지시어를 사용합니다. 반복시킬 태그에 v-for attribute를 추가하고 value를 설정해주면 됩니다. value는 상황에 맞게 여러가지 방법으로 설정할 수 있습니다 반복을 돌릴 대상은 array도 되고 object도 가능합니다. 반복문을 설정할 때 영역 안에서 배열의 경우 index를, object의 경우 key, value, index 값을 사용할 수 있어 편리합니다.

  • array 반복(items->array명, item->array 요소[사용자 지정]): item in items, (item, index) in items
  • object 반복(object->object명): value in object, (value, key) in object, (value, key, index) in object

반복문을 array 혹은 object가 아니라 특정 숫자를 지정하여 사용하고 싶은 경우에는 v-for="n in 5"와 같은 식으로 사용할 수 있습니다. 저는 todo 리스트에서 각 아이템 마다 '중요도'라는 속성명으로 숫자를 부여하여 그 수만큼 별 아이콘을 생성하기 위하여 v-for="n in toNumber(item.important)"라는 구문을 사용하였습니다. toNumber는 Vue객체의 methods에 직접 정의한 문자열을 숫자로 바꿔 리턴하는 함수입니다.

1-3. v-bind

태그 attribute에 데이터를 바인딩할 때는 v-bind 지시어를 사용합니다. 위의 예제와 같이 v-bind:class="", v-bind:src="" 같은 식으로 사용할 수 있습니다. 단순히 데이터 바인딩을 할 수 있을 뿐만 아니라, 조건식을 넣어서 상황에 따라 다른 값이 바인딩되도록 할 수 있습니다.

v-bind:class="[substring(item.deadline, 0, 8) == getTodayDate? 'deadline':'', item.endDate? 'done':'']"

예제에서 이런 식으로 class 바인딩을 해보았습니다. 바인딩 시킬 클래스가 2개라서 [ ] 괄호로 감싸고 각각의 조건식을 , 로 연결해서 작성합니다. substring과 getTodayDate는 위의 js 소스에서 각각 methods와 computed속성에 정의한 함수입니다. 이렇게 조건식 안에서 Vue 객체에 정의한 함수들을 사용할 수 있습니다.

이 식의 의도는 '마감일(item.deadline)에서 앞 8자리(yyyyMMdd)와 오늘 날짜(yyyyMMdd) 문자열이 같다면deadline이라는 클래스를 추가하고, item.endDate에 값이 있다면done이라는 클래스를 추가한다.'는 것입니다.

1-4. v-show

조건에 따라 요소를 보여주거나 감추고 싶을 때는 v-show 지시어를 사용합니다. value로 요소를 보여줄 조건을 작성합니다. 저는 사용자의 키보드 이벤트를 받아 입력한 문자열에 따라 리스트를 필터링할 용도로 v-show 지시어를 사용했습니다. 어떻게 보면 v-if와 하는 일이 비슷합니다. 조건에 따라 요소를 보여주거나 안보여주거나 하는 역할을 하기 때문입니다. 하지만, 화면 뒤에서 일어나는 동작 방식에 차이가 있습니다.

v-if는 조건에 따라서 요소를 보여주거나 감출 때 노드를 삭제하고 다시 생성하는 방식을 사용합니다. 조건이 false인 경우에는 아예 렌더링 자체를 안해버리는 것이죠. 반면에,v-show는 조건과 상관없이 렌더링을 합니다. 다만 css 기반의 토글(display:block; display:none;)을 통해 보여주거나 감춥니다.

따라서 두 가지 지시어는 상황에 맞게 써야합니다. v-if는 노드 자체에 변화가 일어나므로 작업의 비용이 크다고 볼 수 있습니다. 그러므로 렌더링 후 toggle이 자주 일어나는 경우보다는 조건에 따라 요소를 렌더링 할지 말지를 결정하는 경우 사용하는 것이 더 좋겠습니다. v-show는 노드 자체의 변화가 일어나는 것은 아니므로 보다 작동 방식이 가볍습니다. 그러므로 사용자의 클릭이나 키보드 이벤트를 받아 요소를 보이거나 감추는 등의 비교적 자주 일어나는 경우에 사용하는 것이 적합할 것 같습니다.

1-5. v-on

이벤트를 바인딩할 때는 v-on: 지시어를 사용합니다. v-on:click처럼 콜론 뒤에 이벤트명을 씁니다. value로는 함수명을 적습니다. 저는 Vue 객체를 생성할 때 methods라는 속성에 정의한 함수를 이벤트 핸들러로 사용했지만, 꼭 methods에 정의한 함수만을 쓸 수 있는 것은 아닙니다.

v-on:submit, v-on:keyup 처럼 click 이벤트 외에도 다양한 이벤트를 바인딩 할 수 있습니다. 이벤트 바인딩 시 사용하기 정말 편리하다는 생각이 드는 기능이 있습니다. 그 중 하나는 v-on:click.prevent 와 같은 방식으로 이벤트명 뒤에 수식어(Modifiers)를 쓸 수 있다는 점입니다. .prevent를 붙이게 되면 이벤트 발생 시 event.preventDefault()를 호출합니다. .stop을 붙이면 이벤트 발생 시 event.stopPropagation()을 호출합니다. 수식어는 keyup이벤트에도 붙일 수 있는데, v-on:keyup.enter 혹은 v-in:keyup.13 과 같은 식으로 특정 키에 이벤트를 걸 수도 있습니다. 수식어를 통해 이벤트 핸들러에 필요한 로직 이외에 이벤트 제어와 관련한 코드를 넣지 않아도 되어서 더욱 깔끔한 코드 작성이 가능합니다.

1-6. computed/methods

데이터 바인딩 시 단순하게 불러온 데이터를 바인딩 하는 경우도 있지만, 가끔은 로직에 의해 데이터를 변형할 필요가 있는 경우가 있습니다. 만약 mustache 표현식 안에 javascript 로직을 넣는다면 화면 구성을 위한 마크업에 로직이 섞여 알아보기도, 수정하기도 힘든 코드가 될 것입니다.

그래서 vue에서는 복잡한 로직을 위해 methodscomputed 속성을 제공합니다. 이 속성으로 함수들을 정의할 수 있는데, 차이점은 computed 속성은 의존성에 기반을 두고 캐시가 된다는 점입니다. 즉, computed에 정의한 함수에서 사용한 데이터가 업데이트 되지 않는다면, 함수를 다시 실행하지 않고 바로 이전에 계산된 값을 반환합니다.

computed와 methods의 차이점에 근거하여, 예제를 만들 때 어떤 함수는 computed로 어떤 함수는 methods에 정의를 하였습니다. getTodayDate는 오늘 날짜를 yyyyMMdd 형태로 반환하는 함수입니다. 오늘 날짜는 한 번만 계산을 하면 호출할 때 마다 매번 코드가 실행될 필요는 없으므로 computed의 속성으로 설정하였습니다.

 

todo 리스트를 구성하는 부분까지 사용한 vue 기능을 정리를 해봤습니다. 글이 너무 길어지는 것 같아서 다음 포스트에서 검색 기능 구현부터 이어서 작성하도록 하겠습니다.

Vue.js에 대한 더 자세한 내용을 살펴보고 싶다면, 아래 링크를 클릭해주세요.

▶ Vue.js를 이용한 Todo리스트 예제 만들기(2)