Vue3 SFC + 타입스크립트로 컴포넌트 만들면서 느낀점 총 정리

김승우·2021년 12월 20일
0

TIL

목록 보기
67/68

Vue3 SFC + typescript로 테이블 컴포넌트를 만들면서 느낀점 정리

이번에 회사에서 진행하는 프로젝트에 vue3와 타입스크립트를 도입했습니다.
화면에 사용할 UI 컴포넌트를 만드는 작업을 담당했는데, 그 중에서 테이블을 만들면서 vue와 타입스크립트에 대한 이해도를 높이는 좋은 경험을 했습니다. 작업하면서 느꼈던 점을 정리하려고 합니다.🙂
작업하는 과정에서 많은 자료를 참고했는데, Vuetify의 테이블 컴포넌트와 브랜디 랩스 재사용 가능한 컴포넌트 만들기를 보면서 많은 도움을 받았습니다.

목차


주요 개념

Vue3 SFC

슬롯(Slot)

슬롯이란?

: 슬롯(Slot)이란 컴포넌트의 재사용성을 높여주는 기능으로, 하위 컴포넌트의 마크업을 확장하거나 재정의할 수 있는 유용한 기능입니다. (구현을 사용하는 컴포넌트에서 직접 정의할 수 있습니다.)

컴포넌트간 통신

Vue 컴포넌트

props와 emit


컴포넌트 구현 중요 목적

사용이 쉽고, 재활용하기 어렵지 않은 컴포넌트를 만드는게 목적이었기 때문에 재활용할 부분과 공통적으로 정의할 내용을 분리하는 것이 중요하다고 생각했습니다.


컴포넌트 구현

table-header 컴포넌트

: 테이블 컴포넌트의 thead 영역을 담당하는 컴포넌트입니다.
table-body 컴포넌트와 마찬가지로 복잡한 로직 없이 오직렌더링을 담당하는 컴포넌트입니다.
뷰 강의에서 "하위 컴포넌트는 최대한 멍청하게 만드는 것이 좋다."라는 글을 보고, 복잡한 로직은 테이블 컴포넌트에서 다루거나 <slot>을 이용해서 상위 컴포넌트에서 직접 다루도록하는 것이 좋겠다고 생각했습니다.

타입 정의

우선 컴포넌트가 전달받을 props인 header에 들어갈 속성(header 객체의 스펙)을 정의했습니다.

type AlignType = 'left' | 'center' | 'right';

interface Header {
 text: string, // 헤더에 표시될 텍스트
 value: string, // 데이터와 매핑될 값
 width?: string | number, // 헤더 너비
 align?: AlignType, // 헤더 가로 정렬
}

props 정의

  • ✨ Vue3 sfc에서는 setup script에서 defineProps라는 메서드를 이용해서 props를 정의합니다.

  • ✨ Vue에서 제공하는 PropType을 이용해서 타입스크립트를 통해서 정의한 타입을 이용해 프로퍼티의 타입을 정의할 수 있습니다.

/* table-header.vue */
const props = defineProps({
 // 유니크한 키 값으로 사용할 헤더의 속성
 itemKey: {
    type: String,
    default: 'id',
    required: false,
  },

 // 헤더 렌더링에 필요한 데이터
  headers: {
    type: Array as PropType<Header[]>,
    default: () => [],
    required: true,
  },
});

template 영역

<template>
 <thead class="table-header">
  <tr>
   <!-- 1. 체크 박스 슬롯 -->
   <th v-if="slots.checkbox">
    <slot name="checkbox"></slot>
   </th>

   <!-- 2. 전체 영역을 커스텀할 수 있는 슬롯 -->
   <slot name="headers" :headers="headers">
    <th
     v-for="(header, index) in headers"
     :key="index"
     :data-column-name="header.value"
    >
     <!-- 3. 헤더 개별 영역 슬롯 -->
     <slot 
      :name="`header(${header.value})`"
      :header="header"
      :index="index"
     >
      {{ header.text }}
     </slot>
    </th>
   </slot>
  </tr>
 </thead>
</template>

script 영역

<script lang="ts">
 import { defineProps, useSlots } from 'vue';

 export default {
  name: 'TableHeader',
 };
</script>

<!-- 👀 lang="ts"를 입력해서 타입스크립트를 사용합니다. -->
<script setup lang="ts">
 // props 정의
 const headerProps = {
  itemKey: {
   type: String,
   default: 'id',
   required: false,
  },

  headers: {
   type: Array as PropType<Header[]>,
   default: () => [],
   required: true,
  },
 };

 // 1. 
 const slots = useSlots();
</script>

: vue sfc에서는 useSlots라는 메서드를 이용해서 현재 컴포넌트에 사용된 slots에 대한 정보가 담긴 객체를 가져올 수 있습니다.

상위 컴포넌트에서 checkbox라는 이름을 가진 슬롯을 작성했다면 slots 내부에

{
 checkbox: renderFnWithContext(...args),
}

이렇게 객체 형태로 해당 슬롯에 대한 정보가 담겨있는 것을 확인할 수 있습니다.(Vue devTools를 설치해야됩니다.)

setup script에 정의한 변수들은 모두 템플릿에 노출되므로 slots 또한 템플릿에서 접근할 수 있고, 해당 슬롯이 있는지 유무를 판단해서 화면을 컨트롤할 수 있습니다.


table-body 컴포넌트

: 테이블 컴포넌트의 tbody 영역을 담당하는 컴포넌트입니다.
table-header 컴포넌트와 마찬가지로 slot 기능을 이용해서 복잡한 로직을 사용하는 컴포넌트에서 구현하도록 설계했습니다.

타입 정의

: 타입스크립트가 아직 익숙하지 않아서 body에 렌더링될 데이터의 스펙을 정의하는 것이 어려웠습니다.

키 : 값으로 매칭되는 형태를 가지고 있고, 값에는 모든 타입의 데이터가 올 수 있으므로 다음과 같이 타입을 정의했습니다.

checked의 경우에는 체크박스와 v-model로 연결하기 위해서 직접 바인딩한 데이터인데 체크박스를 사용하지 않으면 값이 없을 수 있으므로 옵션 속성으로 처리했습니다.

interface Item {
  [key: string]: any,
  checked?: boolean,
}

props 정의

: Table 컴포넌트에서 headers 프로퍼티를 전달받고, 다시 이 속성을 각각 table-headertable-body에 전달해서 화면을 렌더링할때 사용하도록 했습니다.

초기에는 같은 headers 데이터를 table-headertable-body에 동일하게 전달했는데, 추가적으로 다중 헤더 테이블을 개발하게 되면서 Table 컴포넌트에서 headers 중 루트 노드만 정제해서 table-body 컴포넌트에 전달하는 방식으로 수정했습니다.
따라서, 이름도 혼동되지 않게 columns로 변경했습니다.

const bodyProps = {
 // 유니크한 키 값으로 사용할 헤더의 속성
  itemKey: {
    type: String,
    default: 'id',
    required: false,
  },

 // 1. 각 행을 렌더링할 때 매핑하기 위한 테이블 헤더의 데이터
  columns: {
    type: Array as PropType<Header[]>,
    default: () => [],
    required: true,
  },

  // 테이블에 렌더링할 데이터
  items: {
    type: Array as PropType<Item[]>,
    default: () => [],
    require: false,
  },
};

template 영역

<template>
 <tbody class="table-body">
  <template v-if="items.length">
   <template
    v-for="(item, index) in items"
    :key="`row-${index}`"
   >
    <tr>
     <!-- 1. 체크 박스 슬롯 -->
     <td v-if="slots.checkbox">
      <slot name="checkbox"></slot>
     </td>

     <!-- 2. 테이블 한 행 슬롯 -->
     <slot name="row" :item="item">
      <template
       v-for="(col, colIndex) in columns"
       :key="`col-${colIndex}`"
      >
       <td>
        <!-- 3. 테이블 행 개별 영역 슬롯 -->
        <slot
         :name="`row(${col.value})`"
         :item="item"
         :col="col"
         :row-index="index"
         :col-index="colIndex"
         :value="item[col.value]"
        >
         <span>{{ item[col.value] }}</span>
        </slot>
       </td>
      </template>
     </slot>
    </tr>
   </template>
  </template>

  <template v-else>
   <tr>
    <td colspan="">
     <!-- empty 슬롯 -->
     <slot name="empty">
      <span>내용이 없습니다.</span>
     </slot>
    </td>
   </tr>
  </template>
 </tbody>
</template>

script 영역

<script lang="ts">
import { defineProps, toRefs } from 'vue';

export default {
 name: 'TableBody'
}
</script>

<script lang="ts" setup>
 // props 정의
 const props = defineProps(bodyProps);

 /*
  es6의 구조분해할당을 사용하면 데이터의 반응형이 깨지기 때문에 toRefs()를 사용해야 합니다.
 */
 const { columns, items, itemKey } = toRefs(props);
</script>

Table 컴포넌트

처음에는 테이블의 <thead></thead> 부분과 <tbody></tbody>부분을 컴포넌트로 분리하지 않고, Table 컴포넌트 하나에 모두 작성했는데, Table 컴포넌트의 가독성을 높이기 위해서 따로 컴포넌트로 분리했습니다.

따라서, Table, header, body 각각 전달받을 props를 정의하고, Table 컴포넌트가 전달받은 props를 각 자식 컴포넌트에 전달하는 방식이 되었습니다.

<template>
 <table class="table">
  <!-- 테이블 header 컴포넌트 -->
  <table-header></table-header>

  <!-- 테이블 body 컴포넌트 -->
  <table-body></table-body>
 </table>
</template>

props 정의

: 처음 테이블을 개발할 때 추가적으로 들어갈 기능을 생각하지 않고 개발해서 중간 중간 소스를 다시 뒤집어 엎는 일이 많았습니다.

그래서 다시 개발할 때는 필수적으로 들어가는 기능을 정의하고, 추가적으로 어떤 기능이 들어가는지 파악한 다음, 기능을 확장할 수 있도록 컴포넌트를 개발하는 것에 노력했습니다.

컴포넌트 및 코드를 확장성있게 작성하기 위해 자료를 찾던 도중 클린 코드라는 키워드에 대해서 알게 되었고, 클린 코드의 중요한 원칙을 생각하며 코드를 작성하기 위해 노력했습니다.

또한, vue composition api 예제를 참고해서 기능 별로 파일을 나누고, 컴포지션 API를 이용해서 로직의 응집도를 높였습니다.

const tableProps = {
  itemKey: {
    type: String,
    default: 'id',
    required: false,
  },

  headers: {
    type: Array as PropType<Header[]>,
    default: () => [],
    required: true,
  },

  // 테이블에 렌더링할 데이터
  items: {
    type: Array as PropType<Item[]>,
    default: () => [],
    require: false,
  },

  sortable: {
    type: Boolean,
    default: false,
    required: false,
  },

  // 정렬할 column 명 ['컬럼 1', '컬럼 2']
  sortBy: {
    type: Array as PropType<string[]>,
    default: () => [],
    required: false,
  },

  // 정렬할 column 별 방향 [true, false]
  // true: 내림차순(desc), false: 오름차순(asc)
  sortDesc: {
    type: Array as PropType<boolean[]>,
    default: () => [],
    required: false,
  },

  // 다중 선택 사용 여부
  multiSort: {
    type: Boolean,
    default: false,
    required: false,
  },

  // 행 병합할 헤더 column 명 ['컬럼 1', '컬럼 2']
  mergeColumns: {
    type: Array as PropType<Header['value'][]>,
    default: () => [],
    required: false,
  },
  
  // 체크 박스 사용 여부
  useCheckBox: {
    type: Boolean,
    default: false,
    required: false,
  },

  // 행 선택 가능 여부
  selectable: {
    type: Boolean,
    default: false,
    required: false,
  },

  // 다중 선택 사용 여부
  multiSelect: {
    type: Boolean,
    default: false,
    required: false,
  },
};

기본 기능: 데이터 렌더링하기

table-headertable-body 컴포넌트에 필요한 props를 전달합니다.
기본적인 기능만 필요한 상태여서 columns를 따로 계산하지 않고, headers를 그대로 전달했습니다.

<table class="table">
 <table-header
  :item-key="itemKey"
  :headers="headers"
 >
 </table-header>

 <table-body
  :item-key="itemKey"
  :columns="headers"
  :items="items"
 ></table-body>
</table>

헤더 영역에 전체 체크박스 추가하기

: 처음에는 이 기능을 table 컴포넌트의 useCheckBox 속성을 하위 컴포넌트에도 각각 전달하여서 하위 컴포넌트에서 직접 체크 박스를 구현할까 생각했었습니다.

하지만 체크박스에 change이벤트를 바인딩할 필요성이 생겼고, 하위 컴포넌트에서 emit을 이용하거나 직접 props로 전달받은 데이터를 사용하는 것 보다는 slot을 이용해서 상위 컴포넌트가 직접 이 부분을 정의해서 사용하는 것이 좋다고 생각되었습니다.

: table-header 영역에 checkbox가 들어갈 슬롯 추가하기

<!-- table.vue -->
<table class="table">
 <table-header
  :item-key="itemKey"
  :headers="headers"
 >
  <!-- 1. -->
  <template 
   v-if="useCheckBox"
   #checkbox
  >
   <!-- 2. -->
   <template v-if="multiSelect">
    <!-- 3. -->
    <input type="checkbox" v-model="isAllChecked">
   </template>
  </template>
 </table-header>
</table>

: 1번에서 useCheckBox의 체크 박스 영역의 렌더링 여부를 결정하고, 2번에서 multiSelect 옵션을 통해서 실제 체크 박스 태그의 노출 여부를 결정합니다.

useCheckBox 옵션이 true일 경우 바디 영역에 체크 박스가 생성되기 때문에, 헤더에 전체 체크 박스를 사용하지 않더라도 영역은 잡아줘야지 칸 수가 일치해서 레이아웃이 깨지지 않았습니다.

useCheckBoxtrue이면 해당 템플릿 안에있는 내용이 table-header 컴포넌트에 정의된 checkbox 슬롯을 대체합니다.
(#checkboxv-slot:checkbox로도 작성할 수 있습니다)

부모 템플릿 안에 있는 것들은 부모 컴포넌트의 범위에 컴파일되고 자식 템플릿 안에 있는 것들은 자식 컴포넌트의 범위에 컴파일됩니다.

슬롯을 대체하는 내용은 현재 Table 컴포넌트의 범위에서 컴파일되므로 Table 컴포넌트에 선언된 isAllChecked의 값에 접근할 수 있습니다.

만약 부모 템플릿 내에서 슬롯에 전달된(하위 컴포넌트에서 바인딩된) 데이터를 사용하려면 Vue에서 제공하는 범위가 있는 슬롯 기능을 사용해서 구현해야 합니다.

: 템플릿에 바인딩되는 isAllCheckedcomputed를 이용해서 구현했습니다.

const isAllChecked = computed({
 // 전체 체크박스가 선택되었는지 계산합니다.
 get(): boolean {
  return items.value.length > 0 && items.value.every(i => i.checked);
 },
 // 전체 체크박스의 값을 변경합니다.
 set(value: boolean) {
  items.value.forEach(i => i.checked = value);
 }
});

바디 영역에 체크 박스 추가하기

<table class="table">
 <table-body
  :item-key="itemKey"
  :columns="headers"
  :items="items"
 >
  <!-- 1. -->
  <template
   v-if="useCheckBox"
   #checkbox="{ row }"
  >
   <!-- 2. -->
   <input type="checkbox" v-model="row.checked">
  </template>
 </table-body>
</table>

: 헤더 영역과 마찬가지로 useCheckBox의 값이 truetable-body 컴포넌트의 checkbox라는 이름을 가진 슬롯을 대체하는 템플릿이 렌더링됩니다.

헤더 영역과의 차이점은 템플릿에 바인딩된 rowTable 컴포넌트의 데이터가 아니라 table-body 컴포넌트로부터 전달받은 데이터라는 점입니다.

위에서 언급했던 것 처럼 부모 컴포넌트에서 하위 컴포넌트의 데이터에 접근해야하는 경우가 있을 수 있습니다.

이때, Vue의 범위가 있는 슬롯을 사용해서 하위 컴포넌트의 데이터에 접근할 수 있습니다.

<!-- table-body.vue -->
<slot
 name="checkbox"
 :row="row"
 :abc="'abcdefg'"
></slot>

: 하위 컴포넌트는 이처럼 v-bind를 이용해서 슬롯에 데이터를 전달할 수 있고,

<!-- table.vue -->
<!-- 1. -->
<template
 #checkbox="slotProps"
></template>

<!-- 2. -->
<template
 #checkbox="{ row, abc }"
></template>
// 편의상 나타내면
const slotProps = {
 row: [],
 abc: 'abcdefg'
};

: 상위 컴포넌트에서는 위처럼 작성해서 하위 컴포넌트가 바인딩한 데이터에 접근할 수 있습니다.

위 두가지 방식 모두 사용할 수 있으며, 2번의 내용은 slotProps에 바인딩된 데이터를 es6의 구조분해할당을 이용해서 접근한 것입니다.

2번의 방식으로 데이터를 꺼낸 경우에 템플릿 상위 내용에서 같은 이름의 변수가 존재하면 충돌이 발생해서 오류 메시지가 나타날 수 있습니다.(보통 vue eslint가 검출해줍니다 ㅎㅎ)

✨ 셀을 커스텀할 수 있는 슬롯 만들기

: 테이블의 셀의 UI를 변경하거나 기능을 추가하기 위해서 한 칸(셀)을 커스터마이징할 수 있는 슬롯을 구현해야 했습니다.
현재 컴포넌트의 구조 상 Table 컴포넌트를 사용하는 컴포넌트에서 슬롯을 작성해야하고, 작성된 슬롯이 테이블 내용을 대체하기 위해서는 table-bodytable-header로 대체된 내용이 전달되어야만 합니다.

우선 table-body에 슬롯을 생성해야합니다.

<!-- table-body.vue -->
<tr
 v-for="(item, index) in items"
 :key="index"
>
 <template
  v-for="(col, colIndex) in columns"
  :key="`col-${colIndex}`"
 >
  <td>
   <!-- 🙂 슬롯을 추가할 부분 -->
   <span>{{ item[col.value] }}</span>
  </td>
 </template>
</tr>

: 현재 table-body에서 데이터를 렌더링하는 부분입니다. items 데이터를 v-for를 이용해서 tr 태그를 하나씩 렌더링하고, 헤더에 대한 정보가 들어있는 columns를 하나씩 탐색하면서 item객체 에서 현재 칸에 해당하는 프로퍼티를 찾아서 화면에 렌더링합니다.

특정 칸을 커스터마이징하기 위해서 이 부분에 슬롯을 추가했습니다. td 태그 자체를 슬롯으로 감싸도 되지만 프로젝트의 CSS를 사용하기 위해서 td 태그 내부에 슬롯을 작성했습니다.

<!-- table-body.vue -->
<tr
 v-for="(item, index) in items"
 :key="index"
>
 <template
  v-for="(col, colIndex) in columns"
  :key="`col-${colIndex}`"
 >
  <td>
   <!-- 🙂 슬롯을 추가할 부분 -->
   <slot
    name="col"
   >
    <span>{{ item[col.value] }}</span>
   </slot>
  </td>
 </template>
</tr>

: 이렇게 col이라는 이름을 가진 슬롯을 생성하면 상위 컴포넌트에서 작성한 내용이 모든 칸에 공통으로 적용됩니다.

작성된 슬롯을 사용하기 위해서 table 컴포넌트에도 슬롯을 추가합니다.

<!-- table.vue -->
<table-body>
  <!-- table-body 의 col 슬롯을 대체할 템플릿을 작성한다. -->
  <template #col="slotProps">
    <!-- table의 col 슬롯을 생성한다. -->
    <slot
      name="col"
      v-bind="slotProps"
    >
    </slot>
  </template>
</table-body>

: table 컴포넌트를 사용하는 곳
name에 해당하는 칸을 커스터마이징합니다.

<table-component>
  <template #col(name)="slotProps">
    <span>
      {{ slotProps }}
    </span>
  </template>
</table-component>

✨ 셀을 커스텀할 수 있는 슬롯 만들기

: 테이블의 특정 셀을 커스터마이징할 수 있는 슬롯을 생성하기 위해서 vuetify의 슬롯을 참고했습니다.
vuetify는 col(셀의 이름) 형식으로 슬롯을 생성해서 사용자가 특정 셀을 커스터마이징할 수 있는 기능을 제공하고 있습니다.

마찬가지로 table-body 컴포넌트에 슬롯을 추가합니다.
셀의 이름은 col.value의 값으로 지정할 수 있기 때문에 tr태그 내부에서 동적으로 생성해줘야합니다.
slot의 name 속성도 v-bind를 이용해서 바인딩할 수 있습니다. vuetify 라이브러리처럼 col(${col.value})의 값을 이용해 name을 설정합니다.

<!-- table-body.vue -->
<tr
 v-for="(item, index) in items"
 :key="index"
>
 <template
  v-for="(col, colIndex) in columns"
  :key="`col-${colIndex}`"
 >
  <td>
   <!-- 🙂 셀 커스텀 슬롯 -->
   <slot
    :name="`col(${col.value})`"
   >

   </slot>
  </td>
 </template>
</tr>

: 실제 사용하고 슬롯을 전달할 컴포넌트가 table-component이기 떄문에 table-component에도 매칭되는 슬롯을 생성해야합니다.
table-body 컴포넌트와 마찬가지로 col(칸의 이름) 형태로 슬롯을 생성합니다.
여기서 가장 중요한 부분은 table-component의 슬롯이 table-body의 슬롯에 전달하는 template 내부에 작성되었다는 점입니다.

table-body 컴포넌트에 동적으로 생성된 슬롯에 매핑하기 위해 v-slot:col(칸의 이름) 형태로 템플릿을 전달해줘야 하는데,

#[col(${col.value})]와 같이 작성해서 슬롯을 사용할 수 있었습니다.

결국 table 컴포넌트에 전달된 템플릿이 table 컴포넌트의 슬롯을 대체하고, 또 이 대체된 내용이 table-body 컴포넌트의 슬롯을 대체하는 방식으로 동작합니다.

<!-- table-component.vue -->
<table-body>
  <!-- table-body column 별 슬롯에 전달 -->
  <template
    v-for="(col, index) in columns"
    #[`col(${col.value})`]="colProps"
    :key="index"
  >
    <slot
      :name="`col(${col.value})`"
      v-bind="colProps"
    ></slot>
  </template>
</table-body>

: table 컴포넌트를 사용하는 곳에서 name에 해당하는 칸을 커스터마이징합니다.

<!-- page.vue -->
<table-component>
  <template
    #col(name)="colProps"
  >
    <span>{{ colProps }}</span>
  </template>
</table-component>

참고


profile
사람들에게 좋은 경험을 선사하고 싶은 주니어 프론트엔드 개발자

0개의 댓글