你的第一个 vue3 可复用组件

2,443 阅读1分钟

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

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

<Tabs :tabs="tabs" v-model.lower="activeTab" @change="changeTab" />

定义 props

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

<script setup>
  const props = defineProps({
    tabs: {
      type: Array,
      default () {
        return []
      }
    }
  })
</script>

<script setup> + typescript 的写法:

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

  const props = defineProps<Props>()
</script>

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

使用 v-model

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

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

要达到双向绑定的效果,只需要组件内做到下面这 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>

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

v-model 修饰符

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

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

父组件:

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

子组件:

<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>

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

自定义事件

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

<script setup lang="ts">
  // ...
  const emit = defineEmits<{
    (e: 'change', value: string): string
    (e: 'update:modelValue', value: string): string
  }>()
</script>

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

完整代码

<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>