Vue.js | 컴포넌트 및 부모와 자식 간의 데이터 송수신

컴포넌트를 사용하여 사이트의 부품을 만든다.

컴포넌트의 사용

예를 들어, 장바구니의 경우, 헤더, 메뉴, 제품 목록, 검색 제품와 같이 정리되어 있으며, 또한 제품 목록 중에는 하나의 상품 정보, 그 중에는 장바구니에 담기 폼에 중첩된 부품도 있기도 하다. 이것을 하나 하나 독립시킬 수 있다.

쇼핑 카트

컴포넌트화하는 것으로 JavaScript 및 HTML/CSS를 분리해 관리할 수 있기 때문에 보수성을 향상시키고 필요한 화면마다 재사용할 수 있게 된다. 이 그림을 그리는 데도 사용하고 있는 그림 툴에서도 비슷하다. 분할 단위는 자유롭게 할 수 있기 하지만, 사이트의 규모와 컴포넌트의 설계에 의해 추후에 유지 보수 비용도 달라진다.

컴포넌트를 만드는 방법

컴포넌트는 루트 생성자 new Vue()의 옵션과 마찬가지로 데이터와 메소드 외에 컴포넌트용 템플릿을 정의한다.

Vue.component를 사용하여 첫번 째 인수로 이름을 넣으면 글로벌로 등록되므로 어디서든 사용할 수 있다. 두 번째 인수로 옵션 객체를 전달한다.

Vue.component('이름', {
  template: '<p>example</p>' // 컴포넌트에는 EL이 아닌 template로 지정
})

옵션 객체를 특정 컴포넌트의 components에 등록하는 것으로, 그 컴포넌트에서만 사용 가능하게 할 수 있다.

var myComponent = { template: '<p>example</p>' }
new Vue({
  components: {
    'my-component': myComponent
  }
})

또한, Vue.extend을 사용하여 서브 클래스를 만들 수도 있고, 그대로 컴포넌트로 등록 할 수도 있다.

var myComponent = Vue.extend({ template: '<p>example</p>' })
new Vue({
  components: {
    'my-component': myComponent
  }
})

컴포넌트를 등록할 때 옵션 객체를 Vue.extend을 통하여 서브 컴퍼넌트에 사용하는 경우에 어느 쪽을 전달하여도 같다. Vue.extend에 대해서는 아래에 다시 좀 더 자세히 설명하도록 하겠다.

컴포넌트 호출 방법

템플릿를 표시할 위치에 사용자 정의 태그를 작성하여 사용한다.

<div id="app">
  <my-component></my-component>
</div>

사용자 정의 태그 자체에 ID와 같은 HTML 속성을 붙이고 경우, 컴포넌트에 템플릿의 루트 태그로 덮어 써서, 여러 값이 지정 가능한 class 등은 병합된다.

<div id="app">
  <my-component class="p"></my-component>
</div>

실제로 표시되는 HTML는 아래와 같다.

<div id="app">
  <p class="p">example</p>
</div>

조건에 의해 컴포넌트를 선택 등 특별한 사유가 있는 경우 is="컴포넌트 이름"을 사용하여 요소를 컴포넌트를 대체하는 것도 가능하다.

<div is="my-component"></div>
컴포넌트 이름을 반환하는 데이터와 수식도 사용할 수 있다.
<div :is="currentComponent"></div>

데이터는 함수로 만든다

컴포넌트의 데이터는 데이터를 반환하는 함수로 만든다. 이것은 인스턴스마다 데이터를 구분하기 위해 변화를 감지하기 위한 것이다.

Vue.component('my-component', {
  template: '<p>example</p>',
	// 루트의 옵션과 약간 다르므로 주의
  data: function() {
    return {
      message: 'hello!'
    }
  }
})

범위(scope)의 존재

범위라는 것은 영향 범위에서 컴포넌트를 사용하는 경우 발생한다. 대략적으로 말하면 자신 만든 데이터와 메소드뿐만 아니라 자신의 템플릿을 자신의 범위이다. 각 컴포넌트는 각각의 범위를 가지고 있기 때문에 다른 데이터와 메소드에 액세스 할 수 없지만, 완전히 액세스 할 수 없으면 불편하기 때문에 물론 액세스하는 방법도 있다.

부모와 자식 간에 데이터를 전달하기

템플릿에서 다른 컴포넌트를 사용하면 부모 관계가 된다. 이 문서에서는 컴포넌트를 사용하는 “부모"로써 “루크"를 가리키는 것이 있지만, 편의상 함께 “루트 컴포넌트"라고 하겠다.

본론으로 들어가서 앞으로 절대적으로 필요한 부모와 자식 사이의 데이터 교환을 보도록 하자. 자신의 범위 밖에 있는 데이터에 액세스하려면, 무엇을 사용하는가 그리고 무엇을 사용할지 명시해야 한다. 처음에는 약간 귀찮게 느낄지도 모르지만 공식으로도 권장되고 있는 컴포넌트 밀접한 결합을 억제하고 협력하는 방법이다.

부모에서 자식

부모가 가지고 있는 데이터를 자식 템플릿에 표시시켜 보자. 사용자 정의 태그를 작성할 때 간단한 속성 또는 v-bind 지시문 (:으로 생략 가능)을 사용한 바인딩 유형의 속성에서 사용하는 데이터를 지정한다.

소스 코드

<div id="app">
  <!-- A : 간단한 속성은 문자열을 전달할 수 있다 -->
  <child-component val="message"></child-component>
  <!-- B : 바인딩 유형의 특성은 데이터이나 수식을 전달할 수 있다 -->
  <child-component :val="message"></child-component>
</div>

자식은 props으로 그 특성을 받아 자신의 데이터와 같이 사용할 수 있다. 다른 데이터에 대입하는 것 같은 느낌이지만 범위가 다르므로 속성 이름은 데이터와 같아도 다른 이름을 사용해도 된다.

// 자식
Vue.component('child-component', {
  template: '<p>{{ val }}</p>',
  props: ['val'] // 받는 속성 이름을 지정
})
// 부모
new Vue({
  el: '#app',
  data: {
    message: 'hello!' // 부모가 가지고 있는 텍스트 데이터
  }
})

코드 실행

결과

message
hello!

A의 경우는 단순히 텍스트가 전달되는 것이기에 “message”, B의 경우는 부모의 데이터 message 값인 “hello!“가 나타날 것이다.

props는 받는 데이터 형을 지정해 두는 것이 권장되고 있다. 스타일 가이드적으로는 필수적인 것 같다. 무심코 지정한 데이터 형 이외에 다른 것을 넣으면 오류가 나게 되므로 버그를 바로 알 수 있다.

Vue.component('child-component', {
  props: {
    val: String // 문자열 데이터만 허용한다.
  },
  mounted() {
    // props으로 받으면 자신의 데이터와 마찬가지로 this로 사용할 수 있게 된다.
    console.log(this.val)
    this.val = '마음대로 변경해 준다' // 에러 발생!
  }
})

이 때, val은 부모로 부터 빌렸을 뿐이므로 자식 측에 내용을 쓰려고 하면 에러가 발생한다. 이럴 때는 $emit 사용하여 부모에게 변경할 것을 전달할 수 있다.

자식에서 부모

부모가 가지고 있는 데이터를 자식에 의해서 변경하고 싶은 경우와 자식이 가지고 있는 데이터를 부모에게 보내고 싶은 경우에는 사용자 지정 이벤트$emit를 사용한다. 사용자 지정 이벤트는 @click와 같은 이벤트를 만들어 jQuery의 on과 유사하게 동작한다.

사용자 정의 태그를 작성할 때 v-on 지시문(@으로 생략 가능)에서 자식의 이벤트를 핸들을 지정한다. (이벤트를 받는) 자식이 적당한 타이밍에서 $emit을 사용하여 자신의 이벤트를 발생시키는 것으로, 부모 측에 등록되어 있는 핸들러가 호출된다.

다음 예제에서는 childs-event라는 이벤트 핸들러에 parentsMethod라는 메소드를 등록한다. 값의 부분 식으로도 가능하다.

부모 측의 템플릿

<child-component @childs-event="parentsMethod"></child-component>

자식 측에서 이벤트를 발생

부모에 데이터를 전달하고 싶은 경우는 인수를 가지고 발생할 수 있다.

this.$emit('childs-event', 'hello!')

부모 측에서 받기

new Vue({
  el: '#app',
  methods: {
    parentsMethod: function(message) {
      alert(message) // 자식으로 부터받은 메시지를 사용
    }
  }
})

덧붙이자면 함수를 괄호를 붙여서 인라인으로 실행하는 경우 갖게 되는 매개 변수는 부모 범위의 데이터이다.

<child-component @childs-event="parentsMethod(parentsData)"></child-component>

인라인으로 실행한 경우에 자식이 전달한 데이터는 $event로 사용할 수 있기 때문에 컴포넌트에 v-for를 사용하고 있는 부모 측에서도 인덱스 번호를 첨부하고 싶을 때 편리하다.

부모와 자식간에 데이터를 송수신하기

위의 주요한 포인트를 감안하여 컴포넌트를 만들어 보자.

<div id="app">
  <div class="box">
    <h3>여기는 부모 범위</h3>
    <p>{{ childMessage }}</p>
    <child-component
      :parent-message="parentMessage"
      @send-message="getChildMessage"></child-component>
  </div>
</div>
// 자식 컴포넌트
var childComp = Vue.extend({
	// 부모로 부터 parentMessage을 받는다.
	props: { parentMessage: String },
	template: '<div class="box"><h3>여기는 자식 범위</h3>' +
		'<p>{{ parentMessage }}</p></div>',

	// 컴포넌트 데이터는 객체를 반환하는 함수로 한다
	data: function() {
		return {
			childMessage: '이것은 자식 데이터이다'
		}
	},
	created: function() {

		this.$emit('send-message', this.childMessage)

		// 전달 후에 변경된다면 다시 emit하지 않으면 반영되지 않는다
		// 객체라면 참조가 되어 버리기 때문에 좋지 않다
		// 이런 관리가 힘들어진 경우는 상태 관리를 사용하자!		
		this.childMessage = '자식이 데이터를 변경했다'
	}
})

// 부모 루트
new Vue({
	el: '#app',
	data: {
		// 부모가 갖은 데이터
		parentMessage: '이것은 부모 데이터이다',
		// 자식으로 부터 받은 데이터를 보존하기 위해서 있는 빈 데이터
		childMessage: ''
	},
	components: {
		 // 자식 컴포넌트를 등록한다
		'child-component': childComp
	},
	methods: {
		// 자식이 이벤트를 발생하면 실행 처리
		getChildMessage: function(text) {
			this.childMessage = text
		}
	}
})

코드 실행

사실은 이 $emit 사용법, this.$on()으로 등록하고 $parent를 사용하여 실행하거나 여러번 작성을 해 보고 이해하는데 상당히 시간을 걸렸다. 처음이라 비교적 힘들 것으로 예상된다. 어설픈 결합이라면 $parent$children는 가급적 사용하지 않는 것이 좋은 것 같다.

인터랙티브(interactive) 컴포넌트

좀 더 실용적인 느낌의 예제로 친구의 목록을 컴포넌트를 사용하여 만들어 보겠다. “좋아요"를 누르면 카운터가 증가하고 계산된 속성에 카운터의 숫자 순으로 정렬되게 한다.

템플릿 작성은 inline-template로 쓰는 방법과 셀렉터를 지정하는 2종류를 사용했다.

<div id="app">
	<friends-list inline-template>
		<div>
			<transition-group name="flip-list" tag="ul">
				<friends-profile v-for="(friend, idx) in sortedFriends"
					v-bind="friend"
					:key="friend.id"
					@count-trigger="countUp(idx)">
              </friends-profile>
			</transition-group>
        </div>
	</friends-list>
</div>
<script type="text/x-template" id="tpl-friends-profile">
	<li>
		name:<span :style="{color: color}">{{ name }}</span> [{{ count }}]
		<span @click="$emit('count-trigger')" class="good">좋아요</span>
	</li>
</script>

설명서에서 복사하여 붙여 넣었을뿐 구성 컴포넌트의 <transition-group>도 사용해 보았다. 전환은 기본 CSS에 애니메이션을 하고 있는데, 끝난 시점에서 숨기기를 넣어주기 때문에 display : none;을 신경 쓰지 않고 쉽게 사용할 수 있다.

// 프로퍼티 상세 컴포넌트
var friendsProfile = Vue.extend({
  props: {
    id: Number,
    name: String,
    color: String,
    count: Number
  },
  template: '#tpl-friends-profile'
})

// 프로퍼티 목록 컴포넌트
var friendsList = Vue.extend({
  data: function() {
    return {
      friends: [
        { id: 1, name: '고양이', color: '#e69313', count: 0 },
        { id: 2, name: '여우', color: '#a7a264', count: 0 },
        { id: 3, name: '너구리', color: '#bbbbbb', count: 0 }
      ]
    }
  },
  computed: {
    // 좋아요가 많은 순서대로 정렬한 목록을 반환한다.
    // 또한 sort는 원래의 데이터의 순서를 다시 작성하기 때문에 복사를 하는 것이 좋을지도
    sortedFriends: function() {
      return this.friends.sort(function (a, b) {
        if (a.count < b.count) return 1
        if (a.count > b.count) return -1
        return 0
      })
    }
  },
  components: {
    'friends-profile': friendsProfile
  },
  methods: {
    countUp: function(idx) {
      this.friends[idx].count++
    }
  }
})

// 부모 루트 컴포넌트
new Vue({
  el: '#app',
  components: {
    'friends-list': friendsList
  }
})

코드 실행

일단 생각대로 동작하는걸 확인 할 수 있다. 이것으로 카운터를 Ajax 사용하여 저장 및 조회할 때처럼 비교적 느낌이 나는 것 같다.

이것은 프로그램에 의한 발생이 아니라 단순히 마우스 이벤트의 클릭으로 발생하기 때문에, 이벤트 명은 count-trigger 아니라 click으로 전달해도 된다는 것이다. 덧붙여서, 사용자 정의 태그에 있는 @click 등의 이벤트 핸들링은 사용자 정의 이벤트로 취급되기 때문에 $emit하지 않는 한 발생하지 않는다. .native 수식어을 붙여 원래의 이벤트를 발생할 수 있다.

<my-comp @click.native="handleClick"></my-comp>

그리고 props를 객체로 받는다면 버그를 찾기 어려워지기 때문에 validator를 사용하여 내용물의 형태 체크를 하는 것이 좋다. v-bind의 인수(v-bind:여기 인수)를 생략하면 속성마다 자동으로 v-bind 해준다.

컴포넌트에 v-model을 사용

위에서는 사용자 정의 이벤트를 만들고 있지만, 컴포넌트에 v-model으로 데이터를 연결하면, 일반 input 이벤트를 $emit 사용할 수 있다. 컴포넌트를 폼 아이템과 같이 취급할 수 있다.

<my-select v-model.number="current"></my-select>

대체로 좀 정교한 느낌의 양식을 만들 때 사용한다.

위 예에서 my-select 컴포넌트에 v-model으로 current를 연결하면, my-select 컴포넌트에서 input 이벤트를 발생시키면 부모 측에서 current에 대해 input 되게 된다.

this.$emit('input', newid)

실시간으로 반영시키고 싶은 경우는 이렇게 하는 것이 간단할 것이다.

Vue.extend에 대해서

클래스 확장 Vue.extend는 상당히 다양하게 사용할 수 있을 거다.

Vue.extend의 반환 값은 new로 인스턴스를 생성하여 자신의 요소에 마운트할 수 있다. 주로 단위 테스트에 사용되는 것이 많지만, 이 특성을 사용하여 기본 어플리케이션에서 동 떨어진 컴포넌트 영역을 만들 수 있다. 평상시는 그다지 사용 방법이 아니지만 Vue를 사용한 라이브러리를 만들 때 모달 창 등으로 유용하다. 객체를 돌려 사용하는 것을 제외하고는 new Vue와도 차이가 거의 없다.

<h2>app</h2>
<div id="app">
	<button @click="increment('sub1')">sub1</button>
	<button @click="increment('sub2')">sub2</button>
</div>
<h2>#sub1</h2>
<div id="sub1"></div>
<h2>#sub2</h2>
<div id="sub2"></div>
var store = new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    increment: function(state) {
      state.count++
    }
  }
})

/* 하위 영역용 컴포넌트 */
var ChildComp = Vue.extend({
  data: function() {
    return {
      count: 0
    }
  },
  template: '<div :id="$el.id"> Store:{{ $store.state.count }} Local:{{ count }}</div>',
  created: function() {
    this.$on('increment', function() {
      this.count++
    })
  }
})

/* 부모의 루트 생성자 */
var app = new Vue({
  el: '#app',
  store: store,
  data: {
    sub1: null,
    sub2: null
  },
  methods: {
    increment: function(key) {
      this[key].$emit('increment')
      this.$store.commit('increment')
    }
  },
  created: function() {
     // store를 그대로 사용하기 위해 parent를 설정하거나 store을 전달
    this.sub1 = new ChildComp({ el: '#sub1', parent: this })
    this.sub2 = new ChildComp({ el: '#sub2', store: store })
  }
})

코드 실행

독자적으로 마운트 시켰을 경우 디폴트 루트는 new 했을 때 컴포넌트가 되기 위해, 자식 컴포넌트로 취급하고 싶은 경우는 parent 옵션으로 부모 인스턴스를 설정하는 것이 스토어 등 그대로 사용할 수 있다.