Skip to content

vue3 公共组件

可复用的组件是开发中经常会遇到的,将可能在多个地方使用的业务逻辑封装成公共的可复用组件是一个很好的开发习惯。

使用 vue3 开发公共组件,经常会涉及到在子组件上使用 v-model,给子组件定义事件等问题。

下面以开发一个分段选择组件为例,来讲解开发可复用组件中的要点。

教程
精选文章
你好开源

定义 props

首先确认组件的 props,该组件需要接收一个 名为tabs的 props,用于遍历渲染出选项。

<script setup>
  const props = defineProps({
    tabs: {
      type: Array,
      default () {
        return []
      }
    }
  })
</script>
1
2
3
4
5
6
7
8
9
10

<script setup> + typescript 的写法:

<script setup lang="ts">
  interface Props {
    tabs: {
      name: string
      value: string
    }[]
  }

  const props = defineProps<Props>()
</script>
1
2
3
4
5
6
7
8
9
10

下面将默认使用 <script setup> + typescript 写法。

使用 v-model

在组件上使用 v-model 来创建双向绑定。

// 父组件调用时
<template>
  <Tabs :tabs="tabs" v-model="activeTab" />
</template>
1
2
3
4

要达到双向绑定的效果,只需要组件内做到下面这 2 步:

  1. 定义 modelValue props
  2. 定义 update:modelValue 事件
<script setup lang="ts">
  interface Props {
    modelValue: string
    tabs: {
      name: string
      value: string
    }[]
  }

  const props = defineProps<Props>()
  const emit = defineEmits<{
    (e: 'update:modelValue', value: string): string
  }>()
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在父组件内调用该组件时,就能正常使用 v-model 指令了。

v-model 修饰符

在子组件的 props 中定义一个 modelModifiers 属性,就可以获取到所有修饰符。

现在来创建一个lower修饰符。

父组件:

<template>
  <Tabs :tabs="tabs" v-model.lower="activeTab" />
</template>
1
2
3

子组件:

<script setup lang="ts">
  // 定义 props 类型
  interface Props {
    modelValue: string
    modelModifiers?: {
      lower: boolean
    } // 修饰符
    tabs: {
      name: string
      value: string
    }[]
  }

  // 使用 withDefaults 设置默认值
  const props = withDefaults(defineProps<Props>(), {
    modelModifiers: () => ({ lower: false })
  })
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

下面将使用这个修饰符,在 change 事件中将返回值都转换为小写。

自定义事件

在点击切换选项时,我们还需要触发一个change事件,来传递更改后的选项值。

<script setup lang="ts">
  // ...
  const emit = defineEmits<{
    (e: 'change', value: string): string
    (e: 'update:modelValue', value: string): string
  }>()
</script>
1
2
3
4
5
6
7

到这里,开发一个公共组件的基本内容我们就确定好了,下面就是具体逻辑实现。

完整代码

<template>
  <div class="tabs-rail">
    <div 
      class="tabs-tab-wrapper"
      v-for="(item, index) in tabs"
      :key="item.value"
      :ref="item.value"
    >
      <div
        class="tabs-tab"
        :class="{ 'tabs-tab-active': activeTab == item.value }"
        @click="handleClick(item.value)"
      >
        <span class="tabs-tab__label">{{ item.name }}</span>
      </div>
    </div>
    <div class="tabs-bar" :style="barStyle"></div>
  </div>
</template>

<script setup lang="ts">
  import { ref, watch, onMounted, nextTick, getCurrentInstance } from 'vue'
  import type { CSSProperties } from 'vue'

  // 定义 props 类型
  interface Props {
    modelValue: string
    modelModifiers?: {
      lower: boolean
    } // 修饰符
    tabs: {
      name: string
      value: string
    }[]
  }

  const props = withDefaults(defineProps<Props>(), {
    modelModifiers: () => ({ lower: false })
  })
  // 自定义事件 change 和 v-model 事件
  const emit = defineEmits<{
    (e: 'change', value: string): string
    (e: 'update:modelValue', value: string): string
  }>()

  const instance = getCurrentInstance()!
  const activeTab = ref(props.modelValue)
  const barStyle = ref<CSSProperties>()

  /**
   * 处理点击切换时tabBar的跟随效果
   */
  const getBarStyle = () => {
    let offset = 0
    let barSize = 0

    props.tabs.every((tab) => {
      const $refs = instance.refs?.[`${tab.value}`] as HTMLElement[]
      const $el = $refs[0] as HTMLElement
      if (!$el) return false
      if (tab.value !== activeTab.value) {
        return true
      }
      // dom 的样式
      const tabStyles = window.getComputedStyle($el.parentElement!)
      // tabBar 的宽度
      barSize = $el.clientWidth
      // 获取到 tabBar 的 x 轴偏移量
      offset = $el.getBoundingClientRect().left -
        ($el.parentElement?.getBoundingClientRect().left ?? 0) - 
        parseFloat(tabStyles.paddingLeft)

      return false
    })
    return {
      width: `${barSize}px`,
      transform: `translateX(${offset}px)`
    }
  }

  const updateStyle = () => (barStyle.value = getBarStyle())

  const setActiveTab = async (value: any) => {
    let tabValue = value
    if (activeTab.value === tabValue || tabValue === undefined) return

    activeTab.value = tabValue
    updateStyle()
    // 根据修饰符,转换小写
    if (props.modelModifiers.lower) {
      tabValue = tabValue.toLowerCase()
    }
    // 点击后触发事件
    emit('update:modelValue', tabValue)
    emit('change', tabValue)
  }

  const handleClick = (v: string) => {
    setActiveTab(v)
  }

  watch(
    () => props.modelValue,
    async (modelValue) => {
      await nextTick()
      setActiveTab(modelValue)
    }
  )

  onMounted(() => {
    updateStyle()
    // 处理窗口缩放
    window.onresize = () => {
      updateStyle()
    }
  })
</script>

<style scoped lang="less">
  .tabs-rail {
    position: relative;
    padding: 2px;
    border-radius: 8px;
    display: flex;
    align-items: center;
    background-color: #f5f5f5;
    transition: background-color 0.2s ease;
    .tabs-tab-wrapper {
      flex-basis: 0;
      flex-grow: 1;
      display: flex;
      align-items: center;
      justify-content: center;
      margin: auto 0px;
      .tabs-tab {
        z-index: 1;
        overflow: hidden;
        width: 100%;
        display: flex;
        align-items: center;
        justify-content: center;
        cursor: pointer;
        white-space: nowrap;
        flex-wrap: nowrap;
        background-clip: padding-box;
        font-size: 14px;
        padding: 6px 0;
        font-weight: 500;
        &-active {
          font-weight: 700;
          transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
        }
        .tabs-tab__label {
          display: flex;
          align-items: center;
          color: #333;
        }
      }
    }
    .tabs-bar {
      position: absolute;
      height: 34px;
      display: inline-block;
      border-radius: 8px;
      background-color: #fff;
      box-shadow: 0px 1px 3px 0px rgba(73, 64, 64, 0.1);
      transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1), width 0.2s cubic-bezier(0.4, 0, 0.2, 1),
        background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
    }
  }
</style>
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
vue3 公共组件 has loaded