Home 컴포넌트 리팩토링 (Tab)
Post
Cancel

컴포넌트 리팩토링 (Tab)

메이플스토리 월드에서 사용하는 탭



메이플스토리 월드에서 사용하는 탭은 크게 두 종류(line, box)가 있다. 탭 컴포넌트를 사용하는 데 불편하다는 동료의 의견을 듣고 리팩토링을 해보려고 한다!

필요성

아래 코드는 기존 컴포넌트를 사용할 때 작성한 코드다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<v-tabs 
  v-model="tab" 
  :tab-list="tabData" 
  :tab-style="'box'" 
/>
<v-tab-contents
  v-model="tab"
  :tab-list="tabData"
  class="page-content__body"
>
  <template #cont0 ...>
  <template #cont1 ...>
  <template #cont2 ...>
  <template #cont3 ...>  
</v-tab-contents>

기존 탭 컴포넌트에서 불편한 점을 알아보자!

  1. 공통으로 사용하는 탭 컴포넌트 내부에 특정 페이지의 스타일 코드가 존재한다. 이에 따라 스타일 코드가 얽혀 복잡했다.
  2. 탭 내부에 들어가는 내용이 슬롯이 아니라서 재사용성이 떨어진다. 이를테면 아이콘은 항상 텍스트 기준 좌측에 위치해야 하고 카운트(숫자)는 항상 텍스트 기준 우측에 위치해야 한다.
  3. 선택된 탭에 따라 보일 콘텐츠들은 사전에 약속한 슬롯 이름 규칙(#cont0...N)에 따라 슬롯을 선언해야 한다.

개선점

  1. 신규 컴포넌트에서는 테마(variant)별 스타일이 적용되어 있고 컴포넌트를 사용하는 측에서 레이아웃 관련 스타일(padding, margin, font-size 등)을 정의할 수 있도록 하고 싶다.
  2. 탭 내부 콘텐츠는 슬롯으로 전달받도록 해 사용하는 측에서 자유롭게 사용할 수 있도록 하고 싶다.
  3. 현재 선택한 탭에 따라 보일 콘텐츠는 슬롯 이름 규칙을 따라 슬롯을 정의하는 게 아니라 콘텐츠를 전담하는 컴포넌트에서 v-show를 사용해 보이고/안 보이도록 하고 싶다.

리팩토링

아래 코드는 리팩토링한 컴포넌트를 사용할 때 작성한 코드다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<v-tab-context :current-value="tab" variant="box">
  <v-tabs v-model="tab">
    <v-tab
      v-for="({name}, index) in tabData"
      :key="index"
      :value="index"
    >
      <span class="tab_txt"></span>
    </v-tab>
  </v-tabs>
  <v-tab-panel class="page-content__body" :value="0" ...>
  <v-tab-panel class="page-content__body" :value="1" ...>
  <v-tab-panel class="page-content__body" :value="2" ...>
  <v-tab-panel class="page-content__body" :value="3" ...>
</v-tab-context>

VTabContext

자식 컴포넌트에서 공통으로 사용할 데이터(currentValue, variant)를 뷰에서 제공하는 provide 메서드를 통해 제공한다. VTabContext 컴포넌트를 만든 이유는 탭 관련 컴포넌트 각각 동일한 props를 전달하는 코드가 중복되어서 별도 컴포넌트로 추출했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<template>
  <div>
    <slot></slot>
  </div>
</template>
<script>
import { computed } from 'vue'
 
export default {
  provide() {
    return {
      variant: this.variant,
      currentValue: computed(() => this.currentValue)
    }
  },
  props: {
    currentValue: {
      type: [Number, String],
      default: 0
    },
    variant: {
      type: String,
      default: 'line' // line, box
    }
  }
}
</script>
<style lang="scss" scoped></style>

VTabs

탭 클릭 관련 이벤트를 처리 및 variant 별 스타일을 정의한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<template>
  <ul :class="classes" @click="handleChangeTab">
    <slot></slot>
  </ul>
</template>
<script>
export default {
  inject: ['variant'],
  props: {
    value: {
      type: [Number, String],
      default: 0
    }
  },
  data() {
    return {
      tabs: []
    }
  },
  computed: {
    classes() {
      return ['list_tab', this.variant]
    }
  },
  methods: {
    handleChangeTab(e) {
      const { value, type } = e.target.dataset
 
      if (type === 'number') {
        this.$emit('input', Number(value))
      } else {
        this.$emit('input', value)
      }
    }
  }
}
</script>
<style lang="scss" scoped>
.list_tab {
  display: flex;
 
  &.line {
    // ... 중략
  }
 
  &.box {
    // ... 중략
  }
}
</style>

VTab

탭 내부 내용 슬롯 처리 및 variant 별 스타일을 정의한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<template>
  <li :class="classes" :data-value="value" :data-type="valueType">
    <div class="wrapper_slot">
      <slot></slot>
    </div>
  </li>
</template>
<script>
export default {
  inject: ['variant', 'currentValue'],
  props: {
    value: {
      type: [Number, String],
      default: 0
    }
  },
  computed: {
    isActive() {
      return this.currentValue === this.value
    },
    valueType() {
      return typeof this.value
    },
    classes() {
      const result = ['item_tab', this.variant]
      if (this.isActive) {
        result.push('active')
      }
      return result
    }
  }
}
</script>
<style lang="scss" scoped>
.item_tab {
  position: relative;
  cursor: pointer;
 
  .wrapper_slot {
    pointer-events: none;
  }
 
  &.line {
    // ... 중략
  }
 
  &.box {
    // ... 중략
  }
}
</style>

VTabPanel

현재 활성화된 탭에 따라 패널을 보이고/안보이고 처리한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
  <div v-show="isActive">
    <slot></slot>
  </div>
</template>
<script>
export default {
  inject: ['currentValue'],
  props: {
    value: {
      type: [Number, String],
      default: 0
    }
  },
  computed: {
    isActive() {
      return this.currentValue === this.value
    }
  }
}
</script>
<style lang="scss" scoped></style>

결론

개선점에서 언급한 부분 대부분 구현한 것 같다. (๑•̀ㅂ•́)و