原作中叫Nav组件哈,感谢文佬的开源
从第一个全干项目开始用colorUI现在是第四个年头了,虽然两年没写Uniapp(业务需要,但是这确实个不错的生态)但是只要是vue体系的一般都用大佬的css和设计思路开头。最近新开Fastify的IM基础服务中业务系统以及nuxt3重写博客都需要用到索性就从头改造组件了。
再次感谢大佬 仓库传送门 @coloruicss @作者大佬-文晓港
【成品镇楼图】
基础概念
Tabs组件是一种常见的用户界面组件,用于在一个界面中展示多个内容区域,并允许用户通过点击不同的标签来切换可见的内容。它的实现原理主要涉及HTML、CSS和JavaScript,以下是一个基本的实现步骤:
1. HTML结构
首先,需要定义一个基本的HTML结构,包括标签(tabs)和内容(tab content)部分。每个标签对应一个内容区域。
<div class="tabs">
<div class="tab" data-tab="1">Tab 1</div>
<div class="tab" data-tab="2">Tab 2</div>
<div class="tab" data-tab="3">Tab 3</div>
</div>
<div class="tab-content" data-tab="1">Content 1</div>
<div class="tab-content" data-tab="2">Content 2</div>
<div class="tab-content" data-tab="3">Content 3</div>
2. CSS样式
接下来,使用CSS来定义标签和内容区域的样式,尤其是如何在不同的标签被选中时显示或隐藏内容。
.tabs {
display: flex;
}
.tab {
padding: 10px;
cursor: pointer;
background-color: #f1f1f1;
border: 1px solid #ccc;
margin-right: 2px;
}
.tab.active {
background-color: #ddd;
}
.tab-content {
display: none;
padding: 10px;
border: 1px solid #ccc;
margin-top: 10px;
}
.tab-content.active {
display: block;
}
3. JavaScript交互
最后,使用JavaScript来处理标签的点击事件,并根据点击的标签来显示相应的内容区域。
document.addEventListener('DOMContentLoaded', function() {
const tabs = document.querySelectorAll('.tab');
const contents = document.querySelectorAll('.tab-content');
tabs.forEach(tab => {
tab.addEventListener('click', function() {
const tabId = this.getAttribute('data-tab');
// 移除所有标签和内容的active类
tabs.forEach(t => t.classList.remove('active'));
contents.forEach(c => c.classList.remove('active'));
// 为当前选中的标签和相应的内容添加active类
this.classList.add('active');
document.querySelector(`.tab-content[data-tab="${tabId}"]`).classList.add('active');
});
});
});
工作原理
- HTML结构:定义标签和内容区域,使用
data-tab属性来关联标签和内容。 - CSS样式:初始状态下所有内容区域都是隐藏的(
display: none),只有被选中的内容区域才会显示(display: block)。 - JavaScript交互:
- 监听每个标签的点击事件。
- 当一个标签被点击时,移除所有标签和内容区域的
active类。 - 为被点击的标签和相应的内容区域添加
active类,从而显示对应的内容。
通过这种方式,Tabs组件能够在用户点击不同的标签时,动态地显示和隐藏相应的内容区域,从而实现标签切换的功能。
Vue3
在Vue 3中,可以使用组件化的方式来实现Tabs组件。以下是一个基本的实现步骤:
1. 创建Tabs组件
首先,创建一个Tabs组件,用于容纳所有的标签和内容。
<!-- Tabs.vue -->
<template>
<div>
<div class="tabs">
<div
v-for="(tab, index) in tabs"
:key="index"
:class="['tab', { active: activeTab === index }]"
@click="selectTab(index)"
>
{{ tab.label }}
</div>
</div>
<div class="tab-content">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
data() {
return {
activeTab: 0,
tabs: []
};
},
methods: {
selectTab(index) {
this.activeTab = index;
},
addTab(tab) {
this.tabs.push(tab);
}
},
provide() {
return {
registerTab: this.addTab,
activeTab: () => this.activeTab
};
}
};
</script>
<style>
.tabs {
display: flex;
}
.tab {
padding: 10px;
cursor: pointer;
background-color: #f1f1f1;
border: 1px solid #ccc;
margin-right: 2px;
}
.tab.active {
background-color: #ddd;
}
.tab-content {
padding: 10px;
border: 1px solid #ccc;
margin-top: 10px;
}
</style>
2. 创建Tab组件
接下来,创建一个Tab组件,用于定义每个标签和其对应的内容。
<!-- Tab.vue -->
<template>
<div v-show="isActive">
<slot></slot>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
required: true
}
},
inject: ['registerTab', 'activeTab'],
computed: {
isActive() {
return this.activeTab() === this.index;
}
},
data() {
return {
index: null
};
},
mounted() {
this.index = this.$parent.tabs.length;
this.registerTab(this);
}
};
</script>
3. 使用Tabs和Tab组件
最后,在你的主组件中使用Tabs和Tab组件。
<!-- App.vue -->
<template>
<Tabs>
<Tab label="Tab 1">Content 1</Tab>
<Tab label="Tab 2">Content 2</Tab>
<Tab label="Tab 3">Content 3</Tab>
</Tabs>
</template>
<script>
import Tabs from './Tabs.vue';
import Tab from './Tab.vue';
export default {
components: {
Tabs,
Tab
}
};
</script>
工作原理
- Tabs组件:管理标签和内容的显示。它包含一个
tabs数组,用于存储所有的标签信息,以及一个activeTab变量,用于记录当前选中的标签索引。selectTab方法用于切换标签,addTab方法用于添加标签。 - Tab组件:定义每个标签和其对应的内容。它通过
props接收标签的名称,并在mounted生命周期钩子中将自己添加到父组件的tabs数组中。isActive变量用于控制内容的显示与隐藏。 - 主组件:使用
Tabs和Tab组件,通过slot机制将内容传递给Tabs组件,并根据activeTab动态显示对应的内容。
通过这种方式,可以在Vue 3中实现一个功能完整的Tabs组件。
ColorUI Nav组件源码
<template>
<view>
<cu-custom bgColor="bg-gradual-pink" :isBack="true"><block slot="backText">返回</block><block slot="content">导航栏</block></cu-custom>
<view v-for="(item,index) in 10" :key="index" v-if="index==TabCur" class="bg-grey padding margin text-center">
Tab{{index}}
</view>
<view class="cu-bar bg-white solid-bottom">
<view class="action">
<text class="cuIcon-title text-orange"></text> 默认
</view>
</view>
<scroll-view scroll-x class="bg-white nav" scroll-with-animation :scroll-left="scrollLeft">
<view class="cu-item" :class="index==TabCur?'text-green cur':''" v-for="(item,index) in 10" :key="index" @tap="tabSelect" :data-id="index">
Tab{{index}}
</view>
</scroll-view>
<view class="cu-bar bg-white margin-top solid-bottom">
<view class="action">
<text class="cuIcon-title text-orange"></text> 居中
</view>
</view>
<scroll-view scroll-x class="bg-white nav text-center">
<view class="cu-item" :class="index==TabCur?'text-blue cur':''" v-for="(item,index) in 3" :key="index" @tap="tabSelect" :data-id="index">
Tab{{index}}
</view>
</scroll-view>
<view class="cu-bar bg-white margin-top solid-bottom">
<view class="action">
<text class="cuIcon-title text-orange"></text> 平分
</view>
</view>
<scroll-view scroll-x class="bg-white nav">
<view class="flex text-center">
<view class="cu-item flex-sub" :class="index==TabCur?'text-orange cur':''" v-for="(item,index) in 4" :key="index" @tap="tabSelect" :data-id="index">
Tab{{index}}
</view>
</view>
</scroll-view>
<view class="cu-bar bg-white margin-top solid-bottom">
<view class="action">
<text class="cuIcon-title text-orange"></text> 背景
</view>
</view>
<scroll-view scroll-x class="bg-red nav text-center">
<view class="cu-item" :class="index==TabCur?'text-white cur':''" v-for="(item,index) in 3" :key="index" @tap="tabSelect" :data-id="index">
Tab{{index}}
</view>
</scroll-view>
<view class="cu-bar bg-white margin-top solid-bottom">
<view class="action">
<text class="cuIcon-title text-orange"></text> 图标
</view>
</view>
<scroll-view scroll-x class="bg-green nav text-center">
<view class="cu-item" :class="0==TabCur?'text-white cur':''" @tap="tabSelect" data-id="0">
<text class="cuIcon-camerafill"></text> 数码
</view>
<view class="cu-item" :class="1==TabCur?'text-white cur':''" @tap="tabSelect" data-id="1">
<text class="cuIcon-upstagefill"></text> 排行榜
</view>
<view class="cu-item" :class="2==TabCur?'text-white cur':''" @tap="tabSelect" data-id="2">
<text class="cuIcon-clothesfill"></text> 皮肤
</view>
</scroll-view>
</view>
</template>
<script>
export default {
data() {
return {
TabCur: 0,
scrollLeft: 0
};
},
methods: {
tabSelect(e) {
this.TabCur = e.currentTarget.dataset.id;
this.scrollLeft = (e.currentTarget.dataset.id - 1) * 60
}
}
}
</script>
vue3+ts改造
首先我们先来看一下原作者大佬的组件设计
需求整理
从原组件改造整理了如下需求
- 支持不同的布局:
- 默认布局
- 居中布局
- 平分布局
- 支持不同的背景样式:
- 默认背景
- 自定义背景颜色
- 支持图标和背景色的标签项
初步实现
tabs组件
<!-- Tabs.vue -->
<template>
<div :class="isCard !== false ? 'is-card' : ''">
<div
class="nav flex"
:class="[, `bg-${bg}`, `text-${text}`]"
:style="getFlex"
>
<div
class="cu-item"
v-for="(tab, index) in tabs"
:key="index"
@click="selectTab(index, tab)"
:class="[activeTab === index ? 'cur text-blue' : '']"
>
<i
v-if="tab.icon"
:class="`cuIcon-${tab.icon} text-${
activeTab === index ? 'blue' : tab.iconColor
}`"
></i>
{{ tab.label }}
</div>
</div>
<div class="tab-content"><slot :tab="tabs[activeTab]"></slot>·</div>
</div>
</template>
<script setup lang="ts">
import { ref, provide, computed, defineEmits, watch } from "vue";
interface TabItem {
label: string;
icon?: string;
iconColor?: string;
bgColor?: string;
}
const tabs = ref<TabItem[]>([]);
const activeTab = ref(0);
const emit = defineEmits(["update:modelValue", "select"]);
const selectTab = (index: number, tab: TabItem) => {
activeTab.value = index;
emit("update:modelValue", index);
emit("select", tab);
};
const addTab = (tab: TabItem) => {
tabs.value.push(tab);
return tabs.value.length - 1; // 返回新添加的tab的索引
};
provide("registerTab", addTab);
provide("activeTab", activeTab);
const props = withDefaults(
defineProps<{
modelValue: number;
center?: boolean;
bg?: string;
text?: string;
isCard?: boolean;
mode?: "center" | "flex-start" | "space-between";
}>(),
{
center: false,
bg: "white",
isCard: false,
mode: "flex-start",
modelValue: 0,
}
);
const getFlex = computed(() => {
if (props.center !== false) {
return "justify-content:center;";
}
return `justify-content:${props.mode}`;
});
watch(
() => props.modelValue,
(newVal) => {
activeTab.value = newVal;
}
);
</script>
<script lang="ts">
export default {
name: "TTabs",
};
</script>
<style>
.tab-content {
padding: 10px 16px;
background: #fff;
}
</style>
解释
- Tabs.vue:
- 添加了
center、bg、text和mode属性,以支持不同的布局和背景样式。 - 使用
computed属性getFlex动态计算布局样式。 - 通过
provide和inject实现子组件与父组件之间的数据传递和状态管理。
- 添加了
- Tab.vue:
- 定义了
label、icon和iconColor属性,以支持图标和颜色的自定义。 - 使用
inject获取父组件提供的registerTab和activeTab,并在组件挂载时进行注册。
- 定义了
通过这些改造,我们的组件能够支持不同的布局和背景样式,并且标签项可以包含图标和自定义背景色。这样就满足了原组件的所有需求。
思考
那是不是就满足了我们日常的开发需求呢,我的回答是我的是基本上满足了,但还应该增加几个常用功能
双向绑定的当前选中变量:组件联动、动态内容切换、状态同步;
select 事件回调并且传回的是 tab:日志记录、业务处理、路由导航;
自定义插槽:自定义样式、复杂内容、图标和文本组合。
代码改进
为了满足上述需求,我们可以进一步改进组件,增加以下功能:
- 双向绑定的当前选中变量:通过
v-model实现。 - select 事件回调并且传回的是 tab:在
selectTab方法中触发事件并传递当前选中的 tab 信息。 - 自定义插槽:允许用户自定义标签项的内容。
改进后的 Tabs 组件
<!-- Tabs.vue -->
<template>
<div :class="isCard !== false ? 'is-card' : ''">
<div
class="nav flex"
:class="[, `bg-${bg}`, `text-${text}`]"
:style="getFlex"
>
<div
class="cu-item"
v-for="(tab, index) in tabs"
:key="index"
@click="selectTab(index, tab)"
:class="[modelValue === index ? 'cur text-blue' : '']"
>
<i
v-if="tab.icon"
:class="`cuIcon-${tab.icon} text-${
modelValue === index ? 'blue' : tab.iconColor
}`"
></i>
{{ tab.label }}
</div>
</div>
<div class="tab-content">
<slot :tab="tabs[activeTab]"></slot>·
</div>
</div>
</template>
<script setup lang="ts">
import { ref, provide, computed, defineEmits, watch } from "vue";
interface TabItem {
label: string;
icon?: string;
iconColor?: string;
bgColor?: string;
}
const tabs = ref<TabItem[]>([]);
const activeTab = ref(0);
const emit = defineEmits(["update:modelValue", "select"]);
const selectTab = (index: number, tab: TabItem) => {
activeTab.value = index;
emit("update:modelValue", index);
emit("select", tab);
};
const addTab = (tab: TabItem) => {
tabs.value.push(tab);
return tabs.value.length - 1; // 返回新添加的tab的索引
};
provide("registerTab", addTab);
provide("activeTab", activeTab);
const props = withDefaults(
defineProps<{
modelValue: number;
center?: boolean;
bg?: string;
text?: string;
isCard?: boolean;
mode?: "center" | "flex-start" | "space-between";
}>(),
{
center: false,
bg: "white",
isCard: false,
mode: "flex-start",
}
);
const getFlex = computed(() => {
if (props.center !== false) {
return "justify-content:space-between;";
}
return `justify-content:${props.mode}`;
});
watch(
() => props.modelValue,
(newVal) => {
activeTab.value = newVal;
}
);
</script>
<script lang="ts">
export default {
name: "TTabs",
};
</script>
<style>
.tab-content {
padding: 10px 16px;
background: #fff;
}
</style>
改进后的 TabItem 组件
<!-- Tab.vue -->
<template>
<div v-show="isActive">
<slot></slot>
<slot name="custom" :tab="tabData"></slot>
</div>
</template>
<script setup lang="ts">
import { inject, ref, computed, onMounted, Ref } from "vue";
const props = withDefaults(
defineProps<{
label: string;
icon?: string;
iconColor?: string;
}>(),
{
icon: "",
iconColor: "black",
}
);
const registerTab =
inject<(tab: { label: string; icon?: string; iconColor?: string }) => number>(
"registerTab"
);
const activeTab = inject<Ref<number>>("activeTab");
const index = ref<number | null>(null);
const isActive = computed(() => {
return activeTab?.value === index.value;
});
const tabData = computed(() => ({
label: props.label,
icon: props.icon,
iconColor: props.iconColor,
}));
onMounted(() => {
if (registerTab) {
index.value = registerTab({
label: props.label,
icon: props.icon,
iconColor: props.iconColor,
});
console.log(`Tab ${props.label} registered with index ${index.value}`);
}
});
</script>
<script lang="ts">
export default {
name: "TTab",
};
</script>
解释一下
我们在创建一个 Tabs 组件系统,其中包括 Tabs 和 Tab 两个组件:
Tabs组件:负责管理多个Tab组件。它提供了一个导航栏,用户可以点击不同的标签来切换内容。Tab组件:表示单个标签页的内容。每个Tab组件通过label、icon等属性来定义其显示内容。
通过 provide 和 inject 机制,Tab 组件可以注册到 Tabs 组件中,并且 Tabs 组件可以管理和控制哪些 Tab 组件是激活状态。
provide 和 inject
provide 和 inject 是 Vue 3 中用于跨组件通信的两个 API,特别适用于祖孙组件之间的数据传递。
provide:在祖先组件中使用,提供数据或方法给后代组件。inject:在后代组件中使用,接收祖先组件提供的数据或方法。
在我们的例子中,Tabs 组件使用 provide 来提供 registerTab 和 activeTab,而 Tab 组件使用 inject 来接收这些数据。
自定义插槽
自定义插槽允许我们在组件中定义可插入的内容,并且可以传递数据给插槽内容。
- 普通插槽:默认插槽,不需要命名。
- 命名插槽:通过
name属性命名,可以在使用组件时指定不同的内容插入到不同的插槽中。 - 作用域插槽:可以传递数据给插槽内容。
跨组件自定义插槽传值
跨组件自定义插槽传值结合了 provide/inject 和作用域插槽的概念。通过 Tabs 组件提供的数据,Tab 组件可以在自定义插槽中使用这些数据。
使用示例
在使用时,确保你在自定义插槽中正确地接收传递的数据:
<template>
<div>
<TTitle>综合示例:双向绑定、事件回调、自定义插槽</TTitle>
<TTabs v-model="selectedTab" @select="handleSelect">
<TTab label="Tab 1">
<p>Content for Tab 1</p>
</TTab>
<TTab label="Tab 2">
<p>Content for Tab 2</p>
</TTab>
<TTab label="Tab 3" icon="rank" icon-color="red">
<template #custom="{ tab }">
{{ tab.label }} 自定义插槽
</template>
</TTab>
</TTabs>
<p>当前选中的标签索引:{{ selectedTab }}</p>
<p>选中的标签信息:{{ selectedTabInfo }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const selectedTab = ref(0);
const selectedTabInfo = ref(null);
const handleSelect = (tab) => {
selectedTabInfo.value = tab;
};
</script>
总结
本文详细介绍了如何实现和改造Tabs组件,涵盖了从基础的HTML、CSS和JavaScript实现,到在Vue 3中实现组件化,再到进一步的功能改造。通过逐步完善Tabs组件的功能,使其能够满足更多的开发需求。