可复用的组件是开发中经常会遇到的,不管是基础组件,还是业务组件,将可能在多个地方使用的业务逻辑封装成公共的可复用组件是一个很好的开发习惯。
如何使用 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 步:
- 定义
modelValueprops - 定义
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>