메이플스토리 월드에서 사용하는 탭
메이플스토리 월드에서 사용하는 탭은 크게 두 종류(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>
기존 탭 컴포넌트에서 불편한 점을 알아보자!
- 공통으로 사용하는 탭 컴포넌트 내부에 특정 페이지의 스타일 코드가 존재한다. 이에 따라 스타일 코드가 얽혀 복잡했다.
- 탭 내부에 들어가는 내용이 슬롯이 아니라서 재사용성이 떨어진다. 이를테면 아이콘은 항상 텍스트 기준 좌측에 위치해야 하고 카운트(숫자)는 항상 텍스트 기준 우측에 위치해야 한다.
- 선택된 탭에 따라 보일 콘텐츠들은 사전에 약속한 슬롯 이름 규칙(
#cont0...N
)에 따라 슬롯을 선언해야 한다.
개선점
- 신규 컴포넌트에서는 테마(variant)별 스타일이 적용되어 있고 컴포넌트를 사용하는 측에서 레이아웃 관련 스타일(padding, margin, font-size 등)을 정의할 수 있도록 하고 싶다.
- 탭 내부 콘텐츠는 슬롯으로 전달받도록 해 사용하는 측에서 자유롭게 사용할 수 있도록 하고 싶다.
- 현재 선택한 탭에 따라 보일 콘텐츠는 슬롯 이름 규칙을 따라 슬롯을 정의하는 게 아니라 콘텐츠를 전담하는 컴포넌트에서
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>
결론
개선점에서 언급한 부분 대부분 구현한 것 같다. (๑•̀ㅂ•́)و