[Vue.js] Higher Order Component (고차 컴포넌트) 를 활용하여 로딩 버튼 만들기

개요

본 글은 vue 버전이 2 입니다! 대상 독자는 외부 컴포넌트를 불러올 수 있고, Single File 컴포넌트를 직접 만들어 사용할 줄 아는 분들입니다. 즉 props와 이벤트의 구조도 알고 있어야 글을 이해하실 수 있을 것입니다.

우리는 종종 래퍼 컴포넌트가 필요할 때가 있습니다. 우리는 bootstrap-vue 라이브러리의 Button 의 기능을 확장하여, 만약 로딩이라면 로딩바가 돌아가는 버튼을 만들고자 합니다. 이게 왜 필요하냐구요? 예를 들어 “변경사항 적용” 버튼을 눌렀을 때 인터넷 속도에 따라 응답을 기다려야 할 필요가 있습니다. 이를 사용자에게 알리기 위해 버튼 자체에 로딩 애니메이션을 띄우면 직관적인 디자인이 되겠지요!

코드와 예제

선 코드&예제 후 설명 ㄱㄱ

Higher Order Component (고차 컴포넌트) 데이터 전달하기

고차 컴포넌트, 및 래퍼 컴포넌트를 만들 때 가장 주의깊게 고려해야 할 것은 어떻게 데이터들을 그대로 전달할 것인가 입니다. 데이터라는 것은 맥락에 따라 다양하게 읽힐 수 있는데, 함수의 경우에는 인수(parameter)를 그대로 받아서 전달해야 하고, 거기에 따른 리턴값을 받아서 또 그대로 전달해야 합니다. vue 컴포넌트의 경우에는 컴포넌트를 만들면서 설정할 수 있는 props, 각종 속성, 그리고 이벤트 리스너를 그대로 전달할 수 있어야 합니다. 우리는 인스턴스 속성에 접근하여 이를 해결할 수 있습니다.

Vue Instance (this) 에는 $ 접두사로 붙은 다양한 인스턴스 속성이 있습니다. 어떤 것들이 있는지는 공식 문서의 Instance Properties 에서 확인할 수 있지만, 우리가 여기서 다룰 것은 $attrs, $listners 두 가지입니다. $props 는 직접적으로 다루지는 않지만, 헷갈리는 부분이 있어서 간단한 언급을 하도록 하겠습니다.

$props 는 어떤 컴포넌트에서 props: { ... } 로 정의한 것들만 들어갑니다. 그러니까 우리가 v-bind 로 props 로 연결하려는 시도를 하더라도 해당 컴포넌트가 정의해놓은 props에 없다면 props 로 들어가지 않습니다. 그래서 props는 아주 예측하기 쉽습니다. 우리가 정의해놓은 것만 따로 처리를 해주면 됩니다. 위 예제에서 LoadingButton 코드를 본다면, 내부에서 disabled, loading, loadingLabel 세 개가 props로 정의되어 있습니다. 그래서 이 컴포넌트는 무조건 이 3개 말고는 어떠한 값도 props로 들어오지 않습니다. 그렇다면 다른 값들은 어디에 남아있을까요?

바로 $attrs 입니다. $attrs에는 $props 를 제외한, 또 class와 style 속성을 제외한 모든 속성들이 들어갑니다. class 와 style 은 그 특성상 특수하게 처리되어야 하고 해당 HTML Tag 에 직접적으로 적용되야 하는 것이기 때문에 별도로 값을 처리할 수 없습니다. 자 그러면 이 $attrs를 그대로 전달하려면 그냥 v-bind에 이름을 주지 말고 전달하면 됩니다. 와!

<!-- App.vue --> 
<LoadingButton
  :loading="loading"      
  :size="'sm'"
>
  2초 동안 로딩이 됩니다.
</LoadingButton>
// LoadingButton.vue
{
  props: {
    disabled: {
      type: [Boolean, String],
      default: false,
    },
    loading: {
      type: Boolean,
      default: false,
    },
    loadingLabel: {
      type: String,
      default: "로딩중입니다.",
    },
  },
}
<!-- LoadingButton.vue  -->
  <b-button
    :disabled="disabled || loading"
    v-bind="$attrs"
    class="loading-button"
    :aria-busy="loading"
    :class="{ loading }"
  > ... </b-button>

위와 같이 disabled, loading 등은 자기가 직접 알아서 다루면 되고 나머지는 $attrs로 넘기면 됩니다.

자 그렇다면, @click 과 같이 이벤트로 들어오는 메소드도 그대로 전달되어야 할 것입니다. 불행히도 이 리스너는 $attrs 에 담기지 않습니다. 그렇다면 어디에 담기는 걸까요? 바로 $listeners에 담깁니다! 이를 v-on directive 에 그대로 넘기면 완료!!

<!-- App.vue --> 
<LoadingButton
  :loading="loading"
  @click="buttonClicked"
  :size="'sm'"
>
  2초 동안 로딩이 됩니다.
</LoadingButton>
<!-- LoadingButton.vue  -->
<b-button
  :disabled="disabled || loading"
  v-bind="$attrs"
  v-on="$listeners"
  class="loading-button"
  :aria-busy="loading"
  :class="{ loading }"
>

$listeners 를 이용해서 커스텀 이벤트를 적절하게 다루는 공식 문서도 있으니까 참고해주시면 되겠습니다.

폭을 고정, 로딩 애니메이션 보이게 하기, 접근성 지원

<!-- LoadingButton.vue  -->
<!-- 속성 생략 -->
<b-button :class="{ loading }">
  <span class="text" :aria-hidden="loading">
    <slot> </slot>
  </span>
  <div class="spinner-wrapper" v-if="loading">
    <b-spinner class="spinner" size="sm"></b-spinner>
    <span class="sr-only">
      {{ loadingLabel }}
    </span>
  </div>
</b-button>
/* LoadingButton.vue  */
.loading .text {
  opacity: 0;
}

.spinner-wrapper {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
}

.spinner {
  width: 20px;
  height: 20px;
}

b-button 의 속성은 많이 감췄습니다. 정확한 예제는 가장 위 예제 코드를 참조해주세요. b-spinner 는 마찬가지로 bootstrap-vue 에서 제공하고 있는 컴포넌트로, 로딩 애니메이션을 보여줍니다. 로딩 애니메이션을 직접 구현하지는 않았습니다.

.loading .text 에 단순히 opacity: 0을 설정한 이유는 로딩이 되는 상태에서도 가로 사이즈를 유지하기 위해서 입니다. v-if 또는 display: none 과 같이 설정하지 않은 이유는, 만약 그렇게 한다면 요소가 아예 없는 것처럼 동작하므로 텍스트의 가로 사이즈만큼 버튼이 쪼그라들 것이기 때문입니다.

.spinner-wrapper 클래스는 스피너를 가운데 위치시키기 위함입니다.

코드에 접근성 관련 코드가 있으므로 간단하게 설명하겠습니다. :aria-hidden 속성은 스크린 리더 등의 보조 프로그램에게 해당 요소를 읽어야 하는지 아닌지를 설정해줍니다. true 라면 감춰라 라는 뜻이므로 스크린 리더가 읽지 않습니다. 맥락상 중요하지 않거나 숨겨야 하는 것을 true 로 설정합니다. 위 예제에서는 loading props가 true 라면 있는 상태라면 본래 텍스트를 숨기도록 되어 있습니다. sr-only 클래스는 bootstrap 에 포함되어 있는 것으로, 스크린 리더에게만 보이는 텍스트라는 뜻입니다. 공간을 하나도 차지하지 않고 위치도 화면에서 한참 벗어나 있기 때문에 일반 사용자는 볼 수 없지만, HTML 문서 구조상 존재하고 있으므로 스크린 리더만 읽을 수 있습니다. 보통 이미지나 아이콘으로만 전달되는 맥락을 글자로 설명하기 위해 넣습니다. 접근성에 관한 자세한 내용은 다른 글을 참조해주세요.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다

Scroll to top