Vue 컴포넌트

0

Vue 컴포넌트란?

컴포넌트는 Vue의 가장 강력한 기능 중 하나로 기본 HTML 엘리먼트를 확장하여 재사용 가능한 코드를 캡슐화할 수 있는데, 상위 수준에서 컴포넌트는 Vue의 컴파일러에 의해 동작이 추가된 사용자 지정 엘리먼트를 뜻하고, 경우에 따라 특별한 is 속성으로 확장 된 원시 HTML 엘리먼트로 나타낼 수도 있습니다.

Vue 컴포넌트는 Vue 인스턴스이기도 하기 때문에 루트에만 사용하는 옵션을 제외한 나머지 모든 옵션 객체를 사용할 수 있고, 같은 라이프사이클 훅도 사용할 수 있습니다.

컴포넌트 사용하기

전역 등록

새 Vue 인스턴스 생성
new Vue({
  el: '#some-element',
  // 옵션
})

새 Vue 인스턴스를 만드는 경우 위와 같은 코드를 사용하면 되는데, 전역 컴포넌트를 등록하는 경우에는 다음과 같이 Vue.component(tagName, options)를 사용하면 됩니다.

전역 컴포넌트 생성
Vue.component('my-component', {
  // 옵션
})

W3C에서는 사용자 지정 태그 이름에 대해 모두 소문자이어야 하고 하이픈을 포함해야 한다는 규칙이 있습니다. 하지만 Vue는 사용자 지정 태그 이름에 대한 W3C 규칙을 적용하지는 않는데, 이 규칙은 가급적 따르는 것이 좋습니다.

전역 컴포넌트가 등록되면, 컴포넌트는 인스턴스의 템플릿에서 커스텀 엘리먼트를 사용할 수 있습니다. 즉, 앞에서 전역 컴포넌트인 my-component를 등록했기 때문에 다음과 같이 인스턴스의 템플릿에서 <my-component></my-component>라는 커스텀 엘리먼트를 사용할 수 있습니다.

커스텀 엘리먼트 사용하기
<div id="example">
  <my-component></my-component>
</div>
컴포넌트 등록 및 생성하기
// 등록
Vue.component('my-component', {
  template: '<div>사용자 정의 컴포넌트 입니다!</div>'
})

// 루트 인스턴스 생성
new Vue({
  el: '#example'
})

지역 등록

모든 컴포넌트를 전역으로 등록 할 필요는 없는데, 컴포넌트를 components 인스턴스의 옵션으로 등록함으로써 다른 인스턴스 또는 컴포넌트의 범위에서만 사용할 수있는 컴포넌트를 만들 수 있습니다.

var Child = {
  template: '<div>사용자 정의 컴포넌트 입니다!</div>'
}

new Vue({
  // ...
  components: {
    // <my-component> 는 상위 템플릿에서만 사용 가능
    'my-component': Child
  }
})

참고로, 위 코드와 동일한 캡슐화는 디렉티브 같은 다른 등록 가능한 Vue 기능에도 적용이 되는 기법입니다.

DOM 템플릿 구문 분석 경고

el 옵션을 사용하여 기존 콘텐츠가 있는 엘리먼트를 마운트하는 경우와 같이 DOM을 템플릿으로 사용할 때, Vue는 템플릿의 콘텐츠만 가져올 수 있기 때문에 HTML이 작동하는 방식에 고유한 몇 가지 제한 사항이 적용됩니다.

이런 제한 사항은 브라우저가 구문 분석과 정규화를 실행한 후에 작동하는데, 특히 <ul>, <ol>, <table>, <select>와 같은 일부 엘리먼트는 그 안에 어떤 엘리먼트가 나타날 수 있는지에 대한 제한을 가지고 있고, <option>과 같이 특정 다른 엘리먼트 안에서만 나타날 수 있기 때문에, 이렇게 제한이 있는 엘리먼트가 있는 사용자 지정 컴포넌트를 사용하면 다음과 같은 문제가 발생할 수 있습니다.

렌더링 시 에러 발생
<table>
  <my-row>...</my-row>
</table>

위 코드와 같이 사용자 지정 컴포넌트인 <my-row>는 잘못 된 컨텐츠로 해석이 되어, 결과적으로 렌더링 시 에러를 발생시키는데, 이를 해결하기 위한 방법은 is라는 특수 속성을 사용하는 겁니다.

is 속성의 사용 예
<table>
  <tr is="my-row"></tr>
</table>

만약 다음 소스 중 한가지에 해당되는 경우라면 문자열 템플릿을 사용하는 경우 이런 제한 사항이 적용되지 않기 때문에 가능한 경우에는 항상 문자열 템플릿을 사용하는 것이 좋습니다.

  • <script type="text/x-template">
  • JavaScript 인라인 템플릿 문자열
  • .vue 컴포넌트

data 함수

Vue 생성자에 사용할 수 있는 대부분의 옵션은 컴포넌트에서 사용할 수 있지만, data는 반드시 함수로 사용해야 합니다.

<div id="example-2">
  <simple-counter></simple-counter>
  <simple-counter></simple-counter>
  <simple-counter></simple-counter>
</div>
같은 객체 참조를 반환하는 경우
var data = { counter: 0 }

Vue.component('simple-counter', {
  template: '<button v-on:click="counter += 1">{{ counter }}</button>',
  // data는 기술적으로 함수이지만, 각 컴포넌트 인스턴스에 대해 같은 객체 참조를 반환
  data: function () {
    return data
  }
})

new Vue({
  el: '#example-2'
})

위 코드와 같이 세 개의 동일한 컴포넌트를 추가한 경우, 세 개의 컴포넌트 인스턴스가 모두 같은 data 객체를 공유하기 때문에 하나의 카운터를 증가 시키면 모두 증가하는 오동작을 발생시키게 되지만, 다음과 같이 새로운 데이터 객체를 반환하면 이 문제를 해결할 수 있습니다.

새로운 데이터 객체를 반환하는 경우
data: function () {
  return {
    counter: 0
  }
}

컴포넌트 작성

컴포넌트는 일반적으로 부모-자식 관계에서 함께 사용하기 위한 것인데, 컴포넌트 A라는 자체 템플릿에서 컴포넌트 B를 사용하는 경우 두 컴포넌트는 필연적으로 의사 소통이 필요하게 됩니다. 즉 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달하거나, 자식 컴포넌트의 실행 결과를 부모 컴포넌트에게 전달해 줄 필요가 있는 것이죠.

반면 부모 컴포넌트와 자식 컴포넌트가 명확하게 정의된 인터페이스를 통해 가능한 분리된 상태를 유지하는 것도 매우 중요한데, 이렇게하면 각 컴포넌트의 코드를 상대적으로 독립적인 상태로 작성하고 추론할 수 있기 때문에 유지 관리가 쉽고, 잠재적으로 쉽게 재사용성을 제공할 수 있기 때문입니다.

Vue에서 부모-자식 컴포넌트의 관계는 “props는 아래로, events는 위로” 라고 요약 할 수 있는데, 부모는 props를 통해 자식에게 데이터를 전달하고 자식은 events를 통해 부모에게 메시지를 보낼 수 있기 때문이죠.

부모-자식 간의 데이터 전달 방식

Props

Props로 데이터 전달

모든 컴포넌트 인스턴스에는 자체적으로 격리 된 범위가 존재하는데, 하위 컴포넌트의 템플릿에서는 상위 데이터를 직접 참조할 수 없고, 그렇게 해서도 안됩니다. 하지만 데이터는 props 옵션을 사용하여 하위 컴포넌트로 전달 될 수 있습니다.

prop은 상위 컴포넌트의 정보를 전달하기위한 사용자 지정 특성인데, 하위 컴포넌트는 props 옵션을 사용하여 “수신 할 것으로 기대되는” props를 명시적으로 선언해 줄 필요가 있습니다.

prop 정의하기
Vue.component('child', {
  // props 정의
  props: ['message'],
  // 데이터와 마찬가지로 prop은 템플릿 내부에서 사용 가능
  // vm의 this.message로도 사용할 수 있음
  template: '<span>{{ message }}</span>'
})

컴포넌트을 사용하는 경우에는 다음과 같이 message라는 prop 속성을 통해 일반 문자열을 전달할 수 있습니다.

컴포넌트에 데이터 전달하기
<child message="안녕하세요!"></child>

문법 규칙

HTML 속성은 대소문자를 구분하지 않기 때문에 문자열이 아닌 템플릿을 사용하는 경우에는 camelCased로 정의된 prop의 이름에 해당하는 “하이픈으로 구분된 문자열”, 즉 kebab-case를 사용해야 하는데, 문자열 템플릿을 사용하는 경우에는 이런 제한은 적용되지 않습니다.

prop을 정의하는 경우 camelCase 사용
Vue.component('child', {
  // JavaScript는 camelCase
  props: ['myMessage'],
  template: '<span>{{ myMessage }}</span>'
})
컴포넌트를 사용하는 경우에는 kebab-case 사용
<child my-message="안녕하세요!"></child>

동적 Props

Vue는 정규 속성을 표현식에 바인딩하는 것과 비슷하게, v-bind를 사용하여 부모의 데이터에 props를 동적으로 바인딩 할 수 있는데, 데이터가 상위에서 업데이트 될 때마다 하위 데이터로도 전달이 됩니다.

props 동적 바인딩하기
<div>
  <input v-model="parentMsg">
  <br>
  <child v-bind:my-message="parentMsg"></child>
</div>

위 코드는 다음과 같이 v-bind에 대한 단축 구문을 사용할 수도 있습니다.

v-bind 단축 구문 사용하기
<child :my-message="parentMsg"></child>

객체의 모든 속성을 props로 전달하는 경우, 즉 v-bind:prop-name 대신 v-bind를 사용하는 것 처럼 인자없이 v-bind를 쓸 수 있습니다.

prop 데이터 객체
todo: {
  text: 'Learn Vue',
  isComplete: false
}
v-bind 인자없이 모든 객체 데이터 전달하기
<todo-item v-bind="todo"></todo-item>

위와 같이 v-bind의 인자없이 객체의 이름을 전달하는 것은 다음의 동작을 하는 것과 같습니다.

<todo-item
  v-bind:text="todo.text"
  v-bind:is-complete="todo.isComplete"
></todo-item>

리터럴 방식과 동적 방식

문자열로 전달되는 리터럴 방식
<comp some-prop="1"></comp>

위 코드와 같이 리터럴 구문을 사용하여 숫자를 전달하는 경우, prop의 속성이 문자열이기 때문에 그 값은 숫자형 데이터가 아닌 일반 문자열 데이터인 "1"로 전달이 되는데, 만약 숫자를 숫자형 데이터로 전달하고 싶다면 다음과 같이 값이 JavaScript 표현식으로 평가되도록 v-bind로 전달해 주어야 합니다.

실제 숫자로 전달되는 동적 방식
<comp v-bind:some-prop="1"></comp>

단방향 데이터 흐름

모든 props는 하위 속성과 상위 속성 사이의 단방향 바인딩을 형성하는데, 상위 속성이 업데이트되면 하위로 흐르지만 그 반대로는 흐르지 않습니다. 이것은 하위 컴포넌트가 실수로 부모의 상태를 변경하여 앱의 데이터 흐름을 추론하기 어렵게 만드는 것을 방지하기 위해서 입니다.

일반적으로 prop을 변경시키려는 경우는 다음과 같은 두 가지 상황이라고 할 수 있습니다.

  • prop은 초기 값을 전달 하는데만 사용되고, 하위 컴포넌트는 이후에 이를 로컬 데이터 속성으로만 사용
  • prop은 변경되어야 할 원시 값으로 전달

위와 같은 상황에 대한 적절한 대응책으로 첫 번째 경우는 다음과 같이 prop의 초기 값을 초기 값으로 사용하는 로컬 데이터 속성을 정의해 주면 됩니다.

prop의 초기 값을 초기 값으로 사용하는 로컬 데이터 속성 정의하기
props: ['initialCounter'],
data: function () {
  return { counter: this.initialCounter }
}

두 번째의 경우는 prop 값으로 부터 계산된 속성을 정의해 주면 됩니다.

prop 값으로 부터 계산된 속성 정의하기
props: ['size'],
computed: {
  normalizedSize: function () {
    return this.size.trim().toLowerCase()
  }
}

Javascript의 객체와 배열은 참조로 전달되는데, prop이 배열이나 객체인 경우 하위 객체 또는 배열 자체를 부모 상태로 변경하면 부모 상태에 영향을 주기 때문에 주의할 필요가 있습니다.

Prop 검증

Vue는 컴포넌트가 받고 있는 prop에 대한 요구사항을 지정할 수 있는데, 요구사항이 충족 되지 않으면 Vue에서 경고가 발생됩니다. 이 기능은 다른 사용자가 사용할 컴포넌트를 제작할 때 특히 유용한데, props를 문자열 배열로 정의하는 대신 유효성 검사 요구사항이 있는 객체를 사용할 수 있습니다.

Prop 검증 예제
Vue.component('example', {
  props: {
    // 기본 타입 확인(`null` 은 어떤 타입이든 가능)
    propA: Number,
    // 여러개의 가능한 타입
    propB: [String, Number],
    // 문자열, 필수
    propC: {
      type: String,
      required: true
    },
    // 숫자, 기본 값 정의
    propD: {
      type: Number,
      default: 100
    },
    // 객체 또는 배열의 기본값은 팩토리 함수에서 반환 필요
    propE: {
      type: Object,
      default: function () {
        return { message: 'hello' }
      }
    },
    // 사용자 정의 유효성 검사
    propF: {
      validator: function (value) {
        return value > 10
      }
    }
  }
})

type은 다음 네이티브 생성자 중 하나를 사용할 수 있습니다.

  • String
  • Number
  • Boolean
  • Function
  • Object
  • Array
  • Symbol

type은 커스텀 생성자 함수가 될 수도 있는데, assertioninstanceof 체크로 만들어집니다. assertion은 함수에 전달된 표현식이 false인 경우 오류를 발생시키는 함수로, 일반적으로는 “테스트” 또는 “디버그 빌드”에서 사용됩니다.

props 검증이 실패하면 Vue는 콘솔에서 경고를 출력(개발 빌드)하게 되는데, props는 컴포넌트 인스턴스가 생성되기 전에 검증되기 때문에 default 또는 validator 함수 내에서 data, computed 또는 methods와 같은 인스턴스 속성을 사용할 수 없습니다.

Props가 아닌 속성

Props가 아닌 속성은 컴포넌트로 전달되지만 해당 props는 정의되지 않은 속성을 말하는데, 명시적으로 정의된 props는 하위 컴포넌트에 정보를 전달하는데 적절하지만 컴포넌트 라이브러리를 만드는 경우 컴포넌트가 사용될 수있는 상황을 항상 예측할 수는 없기 때문에 컴포넌트가 컴포넌트의 루트 요소에 추가되는 임의의 속성을 허용해 주어야 할 필요가 있습니다.

만약 inputdata-3d-date-picker 속성을 요구하는 부트스트랩 플러그인으로 써드 파티 bs-date-input 컴포넌트를 사용하고 있는 경우라면, 우리는 이 속성을 다음과 같이 컴포넌트 인스턴스에 추가 할 수 있는데, 이 때 data-3d-date-picker="true"속성은 bs-date-input의 루트 엘리먼트에 자동으로 추가됩니다.

써드파티 컴포넌트에 속성 추가하기
<bs-date-input data-3d-date-picker="true"></bs-date-input>

존재하는 속성의 교체와 병합

<input type="date" class="form-control">

위 컴포넌트가 bs-date-input의 템플릿이라고 가정했을 때, 데이트피커 플러그인에 테마를 추가하기 위해 다음과 같이 특정 클래스를 추가해야 하는 경우가 있습니다.

플러그인 테마 클래스 추가하기
<bs-date-input
  data-3d-date-picker="true"
  class="date-picker-theme-dark"
></bs-date-input>

이 경우 템플릿의 컴포넌트에 의해 설정된 form-control과 부모에 의해 컴포넌트로 전달되는 date-picker-theme-dark라는 두 개의 서로 다른 class 값이 정의됩니다.

대부분의 속성에서 컴포넌트에 제공된 값은 컴포넌트에서 설정된 값을 대체하는데, 예를 들어 type="large"가 전달되면 type="date"를 대체하여 기존의 속성을 없애는 결과가 생기지만, classstyle 속성의 경우에는 컴포넌트의 두 값이 합쳐져 form-control date-picker-theme-dark 처럼 병합된 최종 결과를 만들어주는 것을 확인할 수 있습니다.

v-on 사용자 지정 이벤트

모든 Vue 인스턴스는 $on, $emit과 같은 이벤트 인터페이스를 구현하는데, 부모 컴포넌트는 자식 컴포넌트가 사용되는 템플릿에서 직접 v-on을 사용하여 자식 컴포넌트에서 보내진 이벤트를 청취할 수 있습니다.

  • $on(eventName)을 사용하여 이벤트를 감지
  • $emit(eventName)을 사용하여 이벤트를 트리거

Vue의 이벤트 시스템은 브라우저의 EventTarget API와는 별개의 시스템으로, 비슷하게 작동하지만 $on$emitaddEventListenerdispatchEvent의 별칭으로 사용되는 것은 아닙니다.

$on은 자식에서 호출한 이벤트는 감지하지 않지만, 다음과 같이 v-on은 템플릿에 반드시 지정해 줄 필요가 있습니다.

v-on으로 사용자 이벤트 지정하기
<div id="counter-event-example">
  <p>{{ total }}</p>
  <button-counter v-on:increment="incrementTotal"></button-counter>
  <button-counter v-on:increment="incrementTotal"></button-counter>
</div>
템플릿에 v-on 지정하기
Vue.component('button-counter', {
  template: '<button v-on:click="incrementCounter">{{ counter }}</button>',
  data: function () {
    return {
      counter: 0
    }
  },
  methods: {
    incrementCounter: function () {
      this.counter += 1
      this.$emit('increment')
    }
  },
})

new Vue({
  el: '#counter-event-example',
  data: {
    total: 0
  },
  methods: {
    incrementTotal: function () {
      this.total += 1
    }
  }
})

위 예제에서는 하위 컴포넌트가 외부에서 발생 하는 것과 완전히 분리 된다는 점에 유의해야 하는데, 이는 부모 컴포넌트가 신경 쓸 수 있는 경우를 대비해 자체 활동에 대한 정보를 보고 하는 것으로 생각할 수 있습니다.

컴포넌트에 네이티브 이벤트 바인딩하기

컴포넌트의 루트 엘리먼트에서 네이티브 이벤트를 수신해야 하는 경우에는 다음과 같이 v-on.native 수식어를 사용할 수 있습니다.

.native 수식어 사용하기
<my-component v-on:click.native="doTheThing"></my-component>

.sync 수식어

일부의 경우 속성에 “양방향 바인딩”이 필요할 수 있는데, 2.3.0+ 부터 사용할 수 있는 .sync 수식어는 자식 컴포넌트가 .sync를 가지는 속성을 변경하면 값의 변경이 부모에 반영되도록 하는 일종의 “문법적 설탕”입니다.

.sync 수식어는 편리하지만 단방향 데이터 흐름이 아니기 때문에 장기적으로 유지보수에 문제가 생길 수 있는데, 자식 속성을 변경하는 코드는 부모의 상태에 영향을 미칠 수 있습니다.

하지만 .sync 수식어는 재사용 가능한 컴포넌트를 만들 때 유용할 수 있기 때문에 부모 상태에 영향을 미치는 코드는 더욱 일관적이고 명백하게 만들 필요가 있습니다.

<comp :foo.sync="bar"></comp>

위의 코드는 다음과 같은 코드와 같은 동작을 하는데, 하위 컴포넌트가 foo를 갱신하기 위해서는 속성을 변경하는 대신 명시적으로 이벤트를 보낼 필요가 있습니다.

<comp :foo="bar" @update:foo="val => bar = val"></comp>
명시적 이벤트 전달
this.$emit('update:foo', newValue)

사용자 정의 이벤트로 폼 입력 컴포넌트 생성하기

사용자 정의 입력 만들기
<input v-model="something">

사용자 정의 이벤트는 v-model에서 작동하는 사용자 정의 입력을 만드는데에도 사용할 수 있는데, 위의 코드는 다음의 코드와 같은 동작을 합니다.

<input
  v-bind:value="something"
  v-on:input="something = $event.target.value">

이 경우 컴포넌트와 함께 사용하면 다음과 같이 간단하게 구현할 수 있습니다.

사용자 정의 입력 컴포넌트 만들기
<custom-input
  :value="something"
  @input="value => { something = value }">
</custom-input>

위의 코드와 같이 v-model을 사용하는 컴포넌트는 value prop를 갖게 되고, 새로운 값으로 input 이벤트를 내보낼 수 있습니다.

통화 입력 컴포넌트 예제
<currency-input v-model="price"></currency-input>
통화 입력 컴포넌트 생성하기
Vue.component('currency-input', {
  template: '\
    <span>\
      $\
      <input\
        ref="input"\
        v-bind:value="value"\
        v-on:input="updateValue($event.target.value)">\
    </span>\
  ',
  props: ['value'],
  methods: {
    // 값을 직접 업데이트하는 대신 updateValue 메소드를 사용하여 입력 값에 대한 서식을 지정하고 배치함
    updateValue: function (value) {
      var formattedValue = value
        .trim() // 공백 제거
        .slice( // 소수점 2자리
          0,
          value.indexOf('.') === -1
            ? value.length
            : value.indexOf('.') + 3
        )
      // 값이 아직 정규화 되지 않은 경우 이를 수동으로 재정의하여 조건을 충족시킴
      if (formattedValue !== value) {
        this.$refs.input.value = formattedValue
      }
      // 입력 이벤트를 통해 숫자 값 내보내기
      this.$emit('input', Number(formattedValue))
    }
  }
})

컴포넌트의 v-model 사용자 정의하기

기본적으로 컴포넌트의 v-modelvalue를 보조 변수로 사용하고 input을 이벤트로 사용하지만 체크 박스, 라디오 버튼과 같은 일부 입력 타입은 다른 목적으로 value 속성을 사용할 수 있는데, 이 경우 model 옵션을 사용하면 다음과 같은 경우에 충돌을 피할 수 있습니다.

model 옵션 사용하기
Vue.component('my-checkbox', {
  model: {
    prop: 'checked',
    event: 'change'
  },
  props: {
    // 다른 목적을 위해 `value` prop을 사용할 수 있음
    checked: Boolean,
    value: String
  },
  // ...
})
<my-checkbox v-model="foo" value="some value"></my-checkbox>

위의 코드는 다음의 코드와 같은데, `checked` prop은 명시적으로 선언해 주어야 합니다.

<my-checkbox
  :checked="foo"
  @change="val => { foo = val }"
  value="some value">
</my-checkbox>

비 부모-자식간 통신하기

서로 다른 두 컴포넌트가 통신을 해야 하는 경우, 두 컴포넌트가 서로 부모-자식의 관계가 아닌 경우라면 비어있는 Vue 인스턴스를 중앙 이벤트 버스로 사용해 통신을 할 수 있습니다.

이벤트 버스 생성
var bus = new Vue()
컴포넌트 A의 메소드
bus.$emit('id-selected', 1)
컴포넌트 B의 created 훅
bus.$on('id-selected', function (id) {
  // ...
})

이벤트 버스를 이용하는 방법이 가장 간단한 방법이지만, 조금 더 복잡한 경우에는 상태 관리 패턴을 고려할 필요가 있습니다.

슬롯 사용 컨텐츠 배포

기본 컴포넌트 레이아웃
<app>
  <app-header></app-header>
  <app-footer></app-footer>
</app>

컴포넌트를 사용할 때는 위와 같이 컴포넌트를 구성하는 것이 좋은데, 다음과 같은 사항에 주의할 필요가 있습니다.

  • <app> 컴포넌트는 어떤 컨텐츠를 받을지 모르며, 그것은 <app>이 사용하는 컴포넌트에 의해 결정된다.
  • <app> 컴포넌트에는 자체 템플릿이 있을 가능성이 높다.

위의 기본 컴포넌트 레이아웃과 같은 구성으로 작동하도록 만들기 위해서는 부모 “content”컴포넌트의 자체 템플릿을 섞는 방법이 필요한데, Vue는 콘텐츠 배포 프로세스를 위해 웹 컴포넌트 사양의 초안을 모델로 한 콘텐츠 배포 API를 구현하고 있고, 원본 콘텐츠의 배포판 역할을 위한 <slot>이라는 특수한 엘리먼트를 사용하고 있습니다.

범위 컴파일

API를 분석하기 전에 우선 내용이 컴파일되는 범위를 명확히 할 필요가 있는데, 다음과 같은 템플릿이 있을 경우 message는 부모 데이터에 바인딩되어야 합니다.

<child-component>
  {{ message }}
</child-component>

컴포넌트 범위에 대한 간단한 법칙은 다음과 같은데, 일반적으로 많이 하는 실수는 부모 템플릿의 하위 속성 또는 메소드에 디렉티브를 바인딩하려고 하는 겁니다.

  • 상위 템플릿의 모든 내용은 상위 범위로 컴파일된다.
  • 하위 템플릿의 모든 내용은 하위 범위에서 컴파일된다.
잘못된 바인딩의 예
<!-- 작동하지 않음 -->
<child-component v-show="someChildProperty"></child-component>

someChildProperty가 자식 컴포넌트의 속성이라고 가정했을 때 위의 예제는 작동하지 않는데, 상위 템플릿은 하위 컴포넌트의 상태를 인식하지 못하기 때문입니다.

컴포넌트의 루트 노드에서 하위 범위의 디렉티브를 바인딩 해야하는 경우에는 하위 컴포넌트의 자체 템플릿에서 하위 범위 디렉티브를 바인딩해야 되고, 마찬가지로 분산된 콘텐츠는 상위 범위에서 컴파일됩니다.

Vue.component('child-component', {
  // 정상적으로 작동함
  template: '<div v-show="someChildProperty">Child</div>',
  data: function () {
    return {
      someChildProperty: true
    }
  }
})

단일 슬롯

하위 컴포넌트 템플릿에 최소 하나의 <slot> 콘텐츠가 포함되어 있지 않으면 부모 콘텐츠는 삭제되는데, 속성이 없는 슬롯이 하나 뿐인 경우에는 전체 내용 조각이 DOM의 해당 위치에 삽입되어 슬롯 자체를 대체하게 됩니다.

<slot> 태그 안에 있는 내용은 대체 콘텐츠로 간주되는데, 대체 콘텐츠는 하위 범위에서 컴파일되고 호스팅 엘리먼트가 비어 있고 삽입할 콘텐츠가 없는 경우에만 표시됩니다.

my-component 컴포넌트
<div>
  <h2>나는 자식 컴포넌트의 제목입니다</h2>
  <slot>
    제공된 컨텐츠가 없는 경우에만 보실 수 있습니다.
  </slot>
</div>
my-component를 사용하는 부모 컴포넌트
<div>
  <h1>나는 부모 컴포넌트의 제목입니다</h1>
  <my-component>
    <p>이것은 원본 컨텐츠 입니다.</p>
    <p>이것은 원본 중 추가 컨텐츠 입니다</p>
  </my-component>
</div>

위와 같이 my-component라는 컴포넌트가 있고, 이 컴포넌트를 사용하는 부모 컴포넌트가 있다면 렌더링을 했을 때 다음과 같은 모습이 돼.

최종 렌더링된 모습
<div>
  <h1>나는 부모 컴포넌트의 제목입니다</h1>
  <div>
    <h2>나는 자식 컴포넌트의 제목 입니다</h2>
    <p>이것은 원본 컨텐츠 입니다.</p>
    <p>이것은 원본 중 추가 컨텐츠 입니다</p>
  </div>
</div>

이름이 있는 슬롯

<slot> 엘리먼트는 name이라는 특별한 속성을 가지고 있는데, 이 속성은 콘텐츠의 배포 방식을 커스터마이징하는 데 사용할 수 있습니다. 이름이 다른 슬롯은 여러 개 있을 수 있고, 이름을 가진 슬롯은 내용 조각에 해당 slot 속성이 있는 모든 엘리먼트와 일치합니다.

반면 명명되지 않은 슬롯이 하나 있을 수 있는데, 기본 슬롯은 일치하지 않는 콘텐츠의 포괄적인 컨텐츠 역할을 하게 되고, 기본 슬롯이 없으면 일치하지 않는 콘텐츠가 삭제됩니다.

<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

위와 같은 템플릿을 가진 app-layout 컴포넌트가 있을 경우, 부모 컴포넌트의 마크업은 다음과 같습니다.

app-layout을 포함하는 부모 컴포넌트
<app-layout>
  <h1 slot="header">여기에 페이지 제목이 위치합니다</h1>

  <p>메인 컨텐츠의 단락입니다.</p>
  <p>하나 더 있습니다.</p>

  <p slot="footer">여기에 연락처 정보입니다.</p>
</app-layout>

이 컴포넌트는 다음과 같이 렌더링되는데, 콘텐츠 배포 API는 함께 구성할 컴포넌트를 디자인 할 때 매우 유용한 메커니즘입니다.

렌더링 된 HTML 마크업
<div class="container">
  <header>
    <h1>여기에 페이지 제목이 위치합니다</h1>
  </header>
  <main>
    <p>메인 컨텐츠의 단락입니다.</p>
    <p>하나 더 있습니다.</p>
  </main>
  <footer>
    <p>여기에 연락처 정보입니다.</p>
  </footer>
</div>

범위를 가지는 슬롯

범위가 지정된 슬롯은 이미 렌더링 된 엘리먼트 대신 데이터를 전달할 수 있는 재사용 가능한 템플릿으로 작동하는 특별한 유형의 슬롯입니다. 이 슬롯은 prop을 컴포넌트에게 전달하는 것처럼, 하위 컴포넌트에서 단순히 데이터를 슬롯에 전달해 주면 됩니다.

범위를 가지는 슬롯
<div class="child">
  <slot text="hello from child"></slot>
</div>

부모 컴포넌트에서는 slot-scope라는 특별한 속성을 가진 <template> 엘리먼트가 있어야 하는데, 이것은 범위를 가지는 슬롯을 위한 템플릿임을 나타내고, slot-scope의 값은 자식으로부터 전달 된 props 객체를 담고있는 임시 변수의 이름입니다.

참고로, 2.5.0+ 부터는 slot-scope를 <template> 뿐만 아니라 컴포넌트나 엘리먼트에서도 사용할 수 있습니다.

범위를 가지는 슬롯의 부모 컴포넌트
<div class="parent">
  <child>
    <template slot-scope="props">
      <span>hello from parent</span>
      <span>{{ props.text }}</span>
    </template>
  </child>
</div>

위의 코드를 렌더링하면 다음과 같은 마크업이 출력됩니다.

렌더링 된 HTML 마크업
<div class="parent">
  <div class="child">
    <span>hello from parent</span>
    <span>hello from child</span>
  </div>
</div>
사용자 정의 리스트 컴포넌트

범위가 지정된 슬롯 보다 일반적인 사용 사례는 컴포넌트 사용자가 리스트의 각 항목을 렌더링하는 방법을 사용자 정의할 수 있는 리스트 컴포넌트입니다.

사용자 정의 리스트 컴포넌트
<my-awesome-list :items="items">
  <!-- scoped slot 역시 이름을 가질 수 있음 -->
  <li
    slot="item"
    slot-scope="props"
    class="my-fancy-item">
    {{ props.text }}
  </li>
</my-awesome-list>
리스트 컴포넌트의 템플릿
<ul>
  <slot name="item"
    v-for="item in items"
    :text="item.text">
    <!-- 대체 컨텐츠 삽입 -->
  </slot>
</ul>
디스트럭처링

slot-scope 값은 실제로 함수 서명의 인수 위치에 나타날 수 있는 유효한 JavaScript 표현식인데, 이는 지원되는 환경(싱글 파일 컴포넌트 또는 최신 브라우저)에서 ES2015 디스트럭처를 사용할 수 있다는 것을 의미합니다.

<child>
  <span slot-scope="{ text }">{{ text }}</span>
</child>

동적 컴포넌트

동적 컴포넌트는 같은 마운트 포인트를 사용하고, 예약된 <component> 엘리먼트를 사용하여 여러 컴포넌트 간에 동적으로 트랜지션하고, is 속성에 동적으로 바인드 할 수 있습니다.

동적 컴포넌트 구현
var vm = new Vue({
  el: '#example',
  data: {
    currentView: 'home'
  },
  components: {
    home: { /* ... */ },
    posts: { /* ... */ },
    archive: { /* ... */ }
  }
})
동적 컴포넌트 바인딩
<component v-bind:is="currentView">
  <!-- vm.currentView가 변경되면 컴포넌트가 변경됨 -->
</component>

원하는 경우에는 다음과 같이 컴포넌트 객체에 직접 바인딩 할 수도 있습니다.

컴포넌트 객체에 직접 바인딩하기
var Home = {
  template: '<p>Welcome home!</p>'
}

var vm = new Vue({
  el: '#example',
  data: {
    currentView: Home
  }
})

keep-alive

트랜지션된 컴포넌트를 메모리에 유지하여 상태를 보존하거나 다시 렌더링하지 않도록하려면 다음과 같이 동적 컴포넌트를 <keep-alive> 엘리먼트에 래핑하면 됩니다.

keep-alive 래핑하기
<keep-alive>
  <component :is="currentView">
    <!-- 비활성화 된 컴포넌트는 캐시됨 -->
  </component>
</keep-alive>

기타

재사용 가능한 컴포넌트 제작

컴포넌트를 작성할 때 나중에 다른 곳에서 다시 사용할 것인지에 대한 여부를 고려할 필요가 있는데, 재사용 가능한 컴포넌트는 깨끗한 공용 인터페이스를 정의해야하고, 사용된 컨텍스트에 대한 가정을 하지 않는 것이 중요합니다.

  • Props는 외부 환경이 데이터를 컴포넌트로 전달하도록 허용
  • 이벤트를 통해 컴포넌트가 외부 환경에서 사이드이펙트를 발생할 수 있도록 함
  • 슬롯을 사용하면 외부 환경에서 추가 컨텐츠가 포함된 컴포넌트를 작성할 수 있음

Vue 컴포넌트의 API는 위와 같이 prop, 이벤트슬롯의 세 부분으로 나뉘어지는데, v-bindv-on을 위한 전용 약어문을 사용하여 의도를 명확하고 간결하게 템플릿에 전달할 수 있습니다.

템플릿에 전용 약어문 사용하기
<my-component
  :foo="baz"
  :bar="qux"
  @event-a="doThis"
  @event-b="doThat"
>
  <img slot="icon" src="...">
  <p slot="main-text">Hello!</p>
</my-component>

자식 컴포넌트 참조

props이벤트가 있음에도 불구하고 때로는 JavaScript로 하위 컴포넌트에 직접 액세스 해야 하는 경우도 있는데, 이를 위해 ref를 이용하여 참조 컴포넌트 ID자식 컴포넌트에 할당할 수 있습니다.

부모 컴포넌트
<div id="parent">
  <user-profile ref="profile"></user-profile>
</div>
자식 인스턴스에 접근하기
var parent = new Vue({ el: '#parent' })
// 자식 컴포넌트 인스턴스에 접근
var child = parent.$refs.profile

참고로 refv-for와 함께 사용될 때, 얻을 수 있는 ref데이터 소스를 미러링하는 자식 컴포넌트를 포함하는 배열이 됩니다.

$refs는 컴포넌트가 렌더링 된 후에만 채워지고 반응성을 가지지 않는데, 이것은 직접 자식 조작을 위한 escape 해치를 의미하기 때문에 템플릿이나 계산 된 속성에서는 $refs를 사용하지 않는 것이 좋습니다.

비동기 컴포넌트

대규모 응용 프로그램에서는 응용 프로그램을 더 작은 덩어리로 나누고 실제로 필요할 때만 서버에서 컴포넌트를 로드하는 방식을 사용할 수도 있습니다. 이런 경우 Vue를 사용하면 컴포넌트 정의를 비동기식으로 해결하는 팩토리 함수로 컴포넌트를 정의 할 수 있는데, Vue는 컴포넌트가 실제로 렌더링되어야 할 때만 팩토리 기능을 트리거하고 이후의 리렌더링을 위해 결과를 캐시하게 됩니다.

비동기 컴포넌트 구현
Vue.component('async-example', function (resolve, reject) {
  setTimeout(function () {
    // 컴포넌트 정의를 resolve 콜백에 전달
    resolve({
      template: '<div>I am async!</div>'
    })
  }, 1000)
})

팩토리 함수는 resolve 콜백을 받는데, 이 콜백은 서버에서 컴포넌트 정의를 가져 왔을 때 호출되어야 하고, 또한 reject(reason)을 호출하여 로드가 실패 했음을 알릴 수 있습니다.

위 코드에서 setTimeout은 데모용으로, 권장되는 접근법 중 하나는 Webpack의 코드 분할 기능과 함께 비동기 컴포넌트를 사용하는 것이라고 합니다.

Webpack으로 비동기 컴포넌트 구현하기
Vue.component('async-webpack-example', function (resolve) {
  // require 구문은 Webpack이 Ajax 요청을 통해 로드되는 번들로 작성된 코드를 자동으로 분리하도록 지시함
  require(['./my-async-component'], resolve)
})

factory 함수에서 Promise를 반환할 수도 있는데, Webpack 2 + ES2015 구문의 조합으로 다음과 같은 코드를 구현할 수 있습니다.

Promise 반환하기
Vue.component(
  'async-webpack-example',
  // import 함수는 Promise를 반환
  () => import('./my-async-component')
)

지역 등록을 사용하는 경우에는 Promise를 반환하는 함수를 제공할 수 있습니다.

지역 등록 시 Promise 반환
new Vue({
  // ...
  components: {
    'my-component': () => import('./my-async-component')
  }
})

고급 비동기 컴포넌트

2.3+ 버전부터 비동기 컴포넌트 팩토리는 다음 형태의 객체를 반환할 수 있습니다.

const AsyncComp = () => ({
  // 로드하는 컴포넌트로반드시 Promise이어야 함
  component: import('./MyComp.vue'),
  // 비동기 컴포넌트가 로드되는 동안 사용할 컴포넌트
  loading: LoadingComp,
  // 실패했을 경우 사용하는 컴포넌트
  error: ErrorComp,
  // 로딩 컴포넌트를 보여주기전 지연 시간
  delay: 200,
  // 시간이 초과되면 에러용 컴포넌트가 표시되며, 기본값은 Infinity
  timeout: 3000
})

참고로 vue-router에서 라우트 컴포넌트로 사용하는 경우, 라우트 네비게이션이 발생하기전에 비동기 컴포넌트가 먼저 작동하기때문에 이러한 특성은 무시되는데, 라우트 컴포넌트에서 위의 문법을 사용하려면 vue-router 2.4.0 이상을 사용해야 한다고 합니다.

컴포넌트 이름 규약

컴포넌트 또는 prop을 등록할 때 사용하는 이름은 kebab-case 또는 camelCase, PascalCase를 사용할 수 있는데, HTML 템플릿 내에서는 정의된 컴포넌트의 이름을 kebab-case의 형태로 사용해야 합니다.

컴포넌트 정의 시 명명 규칙
components: {
  // kebab-case 사용
  'kebab-cased-component': { /* ... */ },
  // camelCase 사용
  'camelCasedComponent': { /* ... */ },
  // PascalCase 사용
  'PascalCasedComponent': { /* ... */ }
}
HTML 템플릿에서는 kebab-case 사용
<kebab-cased-component></kebab-cased-component>
<camel-cased-component></camel-cased-component>
<pascal-cased-component></pascal-cased-component>

문자열 템플릿을 사용하는 경우에는 HTML의 대소문자를 구분하지 않기 때문에, 템플릿에서도 CamelCase, PascalCase, kebab-case를 사용하여 컴포넌트와 prop을 참조할 수 있습니다.

일반적으로 PascalCase는 가장 보편적으로 사용되는 “선언적 컨벤션”이고, kebab-case는 가장 보편적으로 사용하는 “컨벤션”이라고 할 수 있습니다.

  • kebab-case
  • camelCase를 사용하여 컴포넌트가 정의된 경우 => camelCase 또는 kebab-case
  • PascalCase를 사용하여 컴포넌트가 정의된 경우 => kebab-case, camelCase, PascalCase
문자열 템플릿으로 컴포넌트 정의
components: {
  'kebab-cased-component': { /* ... */ },
  camelCasedComponent: { /* ... */ },
  PascalCasedComponent: { /* ... */ }
}
HTML 템플릿 사용 예제
<kebab-cased-component></kebab-cased-component>

<camel-cased-component></camel-cased-component>
<camelCasedComponent></camelCasedComponent>

<pascal-cased-component></pascal-cased-component>
<pascalCasedComponent></pascalCasedComponent>
<PascalCasedComponent></PascalCasedComponent>

또 컴포넌트가 slot 엘리먼트를 통해 전달받을 내용이 없는 경우에는 이름 뒤에 /를 사용하여 자체적으로 닫는 형태의 문법을 사용할 수도 있는데, 이런 형태의 사용자 정의 엘리먼트는 유효한 HTML이 아니기 때문에 브라우저의 기본 파서는 이를 이해하지 못하고, 문자열 템플릿내에서만 제대로 작동될 수 있습니다.

<my-component/>

재귀 컴포넌트

컴포넌트는 자신의 템플릿에서 재귀적으로 호출할 수 있지만, 반드시 name 옵션을 사용해야 합니다.

재귀 호출을 위한 name 옵션
name: 'unique-name-of-my-component'

Vue.component를 사용하여 컴포넌트를 전역으로 등록하면 글로벌 ID가 컴포넌트의 name 옵션으로 자동 설정되는데, 이 때 주의하지 않을 경우 재귀적 컴포넌트로 인해 무한 루프가 발생할 수도 있습니다.

컴포넌트 전역 등록
Vue.component('unique-name-of-my-component', {
  // ...
})
무한 재귀 호출
name: 'stack-overflow',
template: '<div><stack-overflow></stack-overflow></div>'

위와 같은 컴포넌트의 경우 “최대 스택 크기 초과” 오류가 발생하게 되는데, 마지막에 false가 되는 [v-if]를 사용하여 재귀 호출을 조건부로 처리하고 있는지를 확인할 필요가 있습니다.

컴포넌트 순환 참조

Finder나 파일 탐색기와 같이 파일 디렉토리 트리를 작성하는 경우 다음과 같은 템플릿으로 tree-folder 컴포넌트를 만들 수 있습니다.

tree-folder 컴포넌트
<p>
  <span>{{ folder.name }}</span>
  <tree-folder-contents :children="folder.children"/>
</p>
tree-folder-contents 컴포넌트
<ul>
  <li v-for="child in children">
    <tree-folder v-if="child.children" :folder="child"/>
    <span v-else>{{ child.name }}</span>
  </li>
</ul>

자세히 살펴보면 이 컴포넌트가 실제 렌더링 트리에서 서로의 자식이자 조상인 패러독스로 구성된다는 것을 알 수 있는데, Vue.component를 이용해 전역으로 컴포넌트 등록할 때, 이 패러독스는 자동으로 해결이 되지만, 모듈 시스템을 사용하여 컴포넌트를 가져오는 경우 Webpack 또는 Browserify에서는 다음과 같은 오류가 발생하게 됩니다.

컴포넌트를 마운트하지 못했습니다 : 템플릿 또는 렌더링 함수가 정의되지 않았습니다.

A와 B라는 두 개의 컴포넌트 있다고 가정한다면 모듈시스템은 우선 A를 필요로 하게 되는데, 첫 번째 컴포넌트인 A는 B를 필요로 하고, B는 A를 필요로 하는 모순되는 상황이 벌어지게 됩니다. 즉 A의 의존성을 해결하지 않고서는 무한 루프에 빠져버리는 상황이 발생되는데, 이를 해결하기 위해서는 모듈 시스템에 “A는 B를 필요로 하지만 B를 먼저 해결할 필요가 없습니다.” 라고 말할 수있는 지점을 제공할 필요가 있습니다.

이를 위해 tree-folder 컴포넌트의 경우 패러독스를 만드는 자식은 tree-folder-contents 컴포넌트이기 때문에, beforeCreate 라이프 사이클의 훅 시점까지 기다렸다가 해당 컴포넌트를 등록하면 해결될 수 있습니다.

순환 참조 문제 해결하기
beforeCreate: function () {
  this.$options.components.TreeFolderContents = require('./tree-folder-contents.vue')
}

또는 컴포넌트를 지역등록 하는 경우에 Webpack의 비동기 import를 이용하는 방법을 사용할 수도 있습니다.

Webpack의 비동기 import 이용하기
components: {
  TreeFolderContents: () => import('./tree-folder-contents.vue')
}

인라인 템플릿

하위 컴포넌트에 inline-template이라는 특수한 속성이 존재하는 경우, 컴포넌트는 그 내용을 분산 된 내용으로 취급하지 않고 템플릿으로 사용하기 때문에 보다 유연한 템플릿 작성을 할 수 있습니다.

inline-template
<my-component inline-template>
  <div>
    <p>이것은 컴포넌트의 자체 템플릿으로 컴파일됩니다.</p>
    <p>부모가 만들어낸 내용이 아닙니다.</p>
  </div>
</my-component>

그렇지만 inline-template은 템플릿의 범위를 추론하기 더 어렵게 만드는데, 가장 좋은 방법은 template 옵션을 사용하거나 .vue 파일의 template 엘리먼트를 사용하여 컴포넌트 내부에 템플릿을 정의하는 겁니다.

X-Templates

템플릿을 정의하는 또 다른 방법은 text/x-template 유형의 스크립트 엘리먼트 내부에 ID로 템플릿을 참조하는 건데, 이 기능은 큰 템플릿이나 매우 작은 응용 프로그램의 데모에는 유용 할 수 있지만 템플릿을 나머지 컴포넌트 정의와 분리하기 때문에 사용하지 않는 것이 좋습니다.

x-template 사용하기
<script type="text/x-template" id="hello-world-template">
  <p>Hello hello hello</p>
</script>
x-template 등록하기
Vue.component('hello-world', {
  template: '#hello-world-template'
})

비용이 적게드는 v-once 정적 컴포넌트

일반 HTML 엘리먼트를 렌더링하는 것은 Vue에서 매우 빠르지만, 정적 콘텐츠가 많이 포함 된 컴포넌트가 있는 경우 v-once 디렉티브를 루트 엘리먼트에 추가함으로써 캐시가 한번만 실행되도록 하여 엘리먼트를 더 빠르게 렌더링 할 수 있습니다.

v-once 디렉티브 사용하기
Vue.component('terms-of-service', {
  template: '\
    <div v-once>\
      <h1>Terms of Service</h1>\
      ... a lot of static content ...\
    </div>\
  '
})

Leave a Reply