一、功能概述
本章记录一下基于Vue
实现一个的tabs
选项卡切换组件(iview组件库tabs
组件源码)。需要将实现的功能一步一步细化拆解出来,然后逐步实现。目前实现的功能包括:
- 实现基础选项卡切换功能。
- 实现当前选中的页签底部条高亮显示并且加入移动的动画。
- 当空间不足时,显示左右箭头点击后可以进行移动页签。
... 后续有时间还会继续累加功能。
二、疑问🤔️
不管是
elementui
还是iview
组件,组件库设计的时候为什么tabs
组件下面要设计pane
组件呢?
可能首先想到的解决方案是写一个tabs
组件,传入tabs
数据,再定义一个插槽来显示内容,类似下面这种代码:
<template>
<div>
<tabs :data="['标签1','标签2','标签3']" @toggle="toggle">
<div :class="[active === 0 ? 'showActive' : '']">1</div>
<div :class="[active === 1 ? 'showActive' : '']">2</div>
<div :class="[active === 2 ? 'showActive' : '']">3</div>
</tabs>
</div>
</template>
<script>
export default {
data() {
return {
active: 0
}
},
methods: {
toggle(tab) {
this.active = tab.index
}
}
}
</script>
<style scoped>
.showActive {
display: block;
}
</style>
上面的实现方案通过@toggle
方法接收到切换通知时,显示和隐藏相关的div
。显示隐藏功能是与业务无关的交互逻辑。而这部分逻辑最好组件本身帮忙写好了,我们只注重于内容本身。
所以我们要再定义一个子组件pane
, 嵌套在标签页组件tabs
里,我们的业务代码都放在pane
的 slot
内,而所有pane
组件作为整体成为tabs
的slot
,类似下面这种:
<tabs value="name1">
<panel label="标签1" name="name1">标签一的内容</TabPane>
<panel label="标签2" name="name2">标签二的内容</TabPane>
<panel label="标签3" name="name3">标签三的内容</TabPane>
</tabs>
所以需要新建tabs组件
和pane组件
两个组件来现实功能。接下里把实现方案一步步拆解出来。
三、实现基础选项卡切换功能
3.1、定义tabs
组件和pane
组件,实现基础样式
首先编写tabs.vue
组件,定义html
结构和初始化基础样式。tabs
组件需要传入value
,表示当前激活的tab
面板。data
里定义navListv
用来表示的pane
组件传入的label
和name
列表,用于动态渲染tabs
的标题。activeKey
表示当前激活的tab
面板。
tabs.vue
<template>
<!--tabs容器-->
<div class="tabs">
<!--标签页容器-->
<div ref="navWrap" class="tabs-nav-wrap">
<!--标签页头label-->
<div class="tabs-tab" v-for="(item, index) in navList" :key="index">{{item.label}}</div>
</div>
<!--所有pane组件使用的slot容器-->
<div class="pane-content">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: 'Tabs',
props: {
value: {
type: [String, Number]
},
},
data() {
return {
navList: [],
activeKey: this.value
}
}
}
</script>
<style scoped>
.tabs-nav-wrap {
position: relative;
border-bottom: 1px solid #dcdee2;
margin-bottom: 16px;
}
.tabs-tab {
display: inline-block;
margin-right: 16px;
padding: 8px 16px;
cursor: pointer;
}
</style>
接下来编写pane.vue
组件的基本的html
布局,供tabs.vue
来使用。pane.vue
组件需要传入name
用于标识当前面板,对应 value
,如果不传默认值是索引值。label
选项表示对应的tab
头显示的文字。pane
需要控制标签页内容的显示与隐藏, 设置一个data: show
,井且用 v-show
指令来控制元素。
pane.vue
<template>
<div v-show="show">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'TabPane',
props: {
name: {
type: String
},
label: {
type: [String, Function],
default: ''
}
},
data() {
return {
show: true
}
}
}
</script>
3.2、显示用户传入的数据
- 首先获取
tabs.vue
下面的所有pane
组件。 - 通过
updateNav
方法来更新标题。 - 由于
label
和name
用户是可以动态修改的,所以在pane
初始化及label
更新时,都要通知父组件也更新。所以要在pane
组件初始化和监听label
和name
的更新。在pane
组件里使用provide / inject
的方式来获取父组件。
tab.vue
export default {
provide() {
return { TabsInstance: this }
},
methods: {
//初始化更新
initTabs() {
this.updateNav()
}
//获取tabs下的所有pane实例
getTabs() {
return this.$children.filter(item => item.$options.name === 'TabPane')
},
//获取所有pane组件用户传入的props
updateNav() {
this.navList = []
this.getTabs().forEach((pane, index) => {
this.navList.push({
label: pane.label,
name: pane.name || index
})
//如果不传value,默认选中第一项
if (index === 0 && !this.activeKey) {
this.activeKey = pane.name
}
})
}
}
}
pane.vue
export default {
inject: ['TabsInstance'],
mounted() {
this.TabsInstance.initTabs()
},
watch: {
name() {
this.TabsInstance.initTabs()
}
}
}
3.3、 显示activeKey
当前激活的tab面板的内容,隐藏其他内容
先定义updateStatus
方法,设置每个show
属性的值。初始化更新一次。updateNav
还需要监听value
和activeKey
,实现动态更新content
内容。
export default {
provide() {
return { TabsInstance: this }
},
methods: {
//初始化更新
initTabs() {
this.updateNav()
this.updateStatus()
}
//显示当前tab激活的content的内容
updateStatus() {
const tabs = this.getTabs()
tabs.forEach(tab => (tab.show = tab.name === this.activeKey))
}
}
watch: {
value(val) {
this.activeKey = val
},
activeKey() {
this.updateStatus()
}
}
}
3.4、点击标签页动态切换内容
在tabs.vue
里添加点击事件handleChange
。
tab.vue
<div
class="tabs-tab"
v-for="(item, index) in navList"
:key="index"
@click="handleChange(index)"
>{{item.label}}</div>
export default {
methods: {
//改变activeKey,并监听activeKey重新更新显示状态
handleChange(index) {
const nav = this.navList[index]
this.activeKey = nav.name
}
}
}
四、当前选中的页签底部条高亮显示并且加入移动的动画
4.1、 实现底部条基础样式
tabs.vue
<div ref="navWrap" class="tabs-nav-wrap">
<!--底部底部条-->
<div class="tabs-inv-bar"></div>
<div
class="tabs-tab"
v-for="(item, index) in navList"
:key="index"
@click="handleChange(index)"
>{{item.label}}</div>
</div>
<style>
.tabs-inv-bar {
position: absolute;
left: 0;
bottom: 0;
background-color: #2d8cf0;
height: 2px;
transition: transform 300ms ease-in-out;
}
</style>
4.2、动态计算当前选中的标签宽度和偏移量
在data
里定义barWidth
,barOffset
属性,默认为0
。使用计算属性动态绑定style
属性,通过计算属性绑定标签页的宽度barWidth
宽度和barOffset
偏移量。定义updataBar()
方法动态计算barWidth
和barOffset
的具体值。并且需要监听activeKey
改变的时候再次调用updataBar()
。
tabs.vue
<!--绑定barStyle-->
<div class="tabs-inv-bar" :style="barStyle"></div>
export default {
data() {
return {
barWidth: 0,
barOffset: 0,
}
},
computed: {
barStyle() {
return {
width: `${this.barWidth}px`,
transform: `translate3d(${this.barOffset}px,0px,0px)`
}
}
},
methods: {
//初始化更新
initTabs() {
this.updateNav()
this.updateStatus()
this.updateBar()
}
updataBar() {
//等待dom更新完毕后获取dom节点
this.$nextTick(() => {
//当前选中的activeKey下标
const index = this.navList.findIndex(nav => nav.name === this.activeKey)
//获取navWrap元素下的所有tab的元素
const elemTabs = this.$refs.navWrap.querySelectorAll('.tabs-tab')
//获取当前选中的元素
const elemTab = elemTabs[index]
this.barWidth = elemTab ? elemTab.offsetWidth : 0
//计算需要移动的距离,当index > 0时进行累加
if (index > 0) {
let offset = 0
for (let i = 0; i < index; i++) {
offset += elemTabs[i].offsetWidth + 16
}
this.barOffset = offset
} else {
this.barOffset = 0
}
})
}
},
watch: {
activeKey() {
this.updataBar()
}
}
}
五、 当空间不足时显示左右箭头,点击后可以进行移动页签
要实现这个功能需要监听vue
中元素大小变化。当空间不足的时候显示左右箭头。
这里使用element-resize-detector
库。
npm i element-resize-detector -S
要实现移动页签的功能,要实现当父元素宽度随着页面宽度增大或缩小也相应的增大或缩小,子元素宽度始终为内容宽度。类似下面这种布局:
.father {
overflow: hidden;
white-space: nowrap;
}
.children {
float: left;
}
<div class="father clearfix">
<div ref="nav" class="children">
我是好多好多内容内容...
</div>
</div>
5.1、 基础布局
接下来开始稍微修改一下tabs.vue
的标签页容器布局和样式。要在标签页多加两层容器来实现。
tabs.vue
<template>
<div class="tabs">
<div ref="navWrap" class="tabs-nav-wrap">
<!--当宽度不够的时候,显示左右按钮-->
<span class="tabs-nav-prev"><</span>
<span class="tabs-nav-next">></span>
<!--父元素宽度随着页面宽度增加或缩小-->
<div ref="navScroll" class="tabs-nav-scroll clearfix">
<!--子元素宽度始终为内容宽度-->
<div ref="nav" class="tabs-nav">
<div class="tabs-inv-bar" :style="barStyle"></div>
<div
class="tabs-tab"
v-for="(item, index) in navList"
:key="index"
@click="handleChange(index)"
>{{item.label}}</div>
</div>
</div>
</div>
<!--所有pane组件使用的slot容器-->
<div class="pane-content">
<slot></slot>
</div>
</div>
</template>
</style scoped>
.tabs-nav-scroll {
overflow: hidden;
white-space: nowrap;
}
.tabs-nav {
position: relative;
float: left;
transition: transform 0.5s ease-in-out;
}
/*设置左右箭头样式*/
.tabs-nav-prev, .tabs-nav-next {
position: absolute;
width: 32px;
line-height: 32px;
text-align: center;
cursor: pointer;
}
.tabs-nav-prev {
left: 0;
}
.tabs-nav-next {
right: 0;
}
</style>
5.2、 动态计算是否隐藏左右按钮图标
我们需要 element-resize-detector
来监听元素是否需要显示和隐藏按钮图标。并且需要动态的定义显示时的样式。 使用scrollable
属性来控制
tabs.vue
<template>
<div ref="navWrap" class="tabs-nav-wrap" :class="[scrollable ? 'tabs-nav-scrollable' : '']">
<!--当宽度不够的时候,显示左右按钮-->
<span class="tabs-nav-prev" :class="[scrollable ? '' : 'tabs-nav-scroll-disabled']"><</span>
<span class="tabs-nav-next" :class="[scrollable ? '' : 'tabs-nav-scroll-disabled']">></span>
</div>
</template>
//引入element-resize-detector
import elementResizeDetectorMaker from 'element-resize-detector'
export default {
data() {
return {
//宽度不够是否显示图标
scrollable: false
}
},
methods: {
handleResize() {
//得到实际内容宽度
const navWidth = this.$refs.nav.offsetWidth
//得到当前页面宽度
const scrollWidth = this.$refs.navScroll.offsetWidth
if (scrollWidth < navWidth) {
this.scrollable = true
} else {
this.scrollable = false
}
}
}
mounted() {
//创建
this.observer = elementResizeDetectorMaker()
this.observer.listenTo(this.$refs.navWrap, this.handleResize)
},
beforeDestroy() {
//销毁
this.observer.removeListener(this.$refs.navWrap, this.handleResize)
}
}
<style scoped>
/* 如果需要左右滚动,则需要添加箭头图标,设置左右padding */
.tabs-nav-scrollable {
padding: 0 32px;
}
/ *隐藏左右按钮 */
.tabs-nav-scroll-disabled {
display: none;
}
</style>
5.3、点击左右箭头后移动页签
首先添加两个click
方法来实现左右页签的点击事件。还需要在data
增加一个属性navStyle
绑定在class
为tabs-nav
的元素上,来实现动态改变transfrom
属性,实现移动效果。当页面慢慢增大的时候,需要定义updateMove
方法,作用是改变偏移值让transfrom
回到正常状态。
<template>
<div ref="navWrap" class="tabs-nav-wrap">
<!--当宽度不够的时候,显示左右按钮-->
<span class="tabs-nav-prev" @click="scrollPrev"><</span>
<span class="tabs-nav-next" @click="scrollNext">></span>
<div ref="navScroll" class="tabs-nav-scroll clearfix">
<div ref="nav" class="tabs-nav" :style="navStyle">
/* ... */
</div>
</div>
</div>
</template>
export default {
data() {
return {
navStyle: {
transform: ''
}
}
},
methods: {
//获取当前navStyle元素transformX的移动距离
getCurrentScrollOffset() {
const { navStyle } = this
const reg = /translateX\(-(\d+(\.\d+)*)px\)/
return navStyle.transform ? Number(navStyle.transform.match(reg)[1]) : 0
},
scrollPrev() {
//得到当前页面宽度
const containerWidth = this.$refs.navScroll.offsetWidth
const currentOffset = this.getCurrentScrollOffset()
//如果为0,return
if (!currentOffset) return
let newOffset = 0
//移动距离 > 当前页面宽度
if (currentOffset > containerWidth) {
//移动距离 - 当前页面宽度
newOffset = currentOffset - containerWidth
}
this.navStyle.transform = `translateX(-${newOffset}px)`;
},
scrollNext() {
//实际内容的宽度
const navWidth = this.$refs.nav.offsetWidth
//当前页面宽度
const containerWidth = this.$refs.navScroll.offsetWidth
//当前navStyle元素的移动距离
const currentOffset = this.getCurrentScrollOffset()
// 实际内容的宽度 - 当前navStyle元素的移动距离 <= 当前页面宽度 return
if (navWidth - currentOffset <= containerWidth) return
let newOffset = null
//实际内容的宽度 - 当前navStyle元素的移动距离 > 当前页面宽度 * 2
if (navWidth - currentOffset > containerWidth * 2) {
//当前移动位置加上当前页面宽度
newOffset = currentOffset + containerWidth
} else {
newOffset = navWidth - containerWidth
}
this.navStyle.transform = `translateX(-${newOffset}px)`
},
handleResize() {
this.updateMove()
}
updateMove() {
const navWidth = this.$refs.nav.offsetWidth
const scrollWidth = this.$refs.navScroll.offsetWidth
const currentOffset = this.getCurrentScrollOffset()
if (scrollWidth < navWidth) {
if (navWidth - currentOffset < scrollWidth) {
this.navStyle.transform = `translateX(-${navWidth - scrollWidth}px)`
}
} else {
if (currentOffset > 0) {
this.navStyle.transform = `translateX(-${0}px)`
}
}
}
}
}
六、源码
tabs.vue
<template>
<div class="tabs">
<div ref="navWrap" class="tabs-nav-wrap" :class="[scrollable ? 'tabs-nav-scrollable' : '']">
<span
class="tabs-nav-prev"
:class="[scrollable ? '' : 'tabs-nav-scroll-disabled']"
@click="scrollPrev"
><</span>
<span
class="tabs-nav-next"
:class="[scrollable ? '' : 'tabs-nav-scroll-disabled']"
@click="scrollNext"
>></span>
<div ref="navScroll" class="tabs-nav-scroll">
<div ref="nav" class="tabs-nav" :style="navStyle">
<div class="tabs-inv-bar" :style="barStyle"></div>
<div
class="tabs-tab"
v-for="(item, index) in navList"
:key="index"
@click="handleChange(index)"
>{{item.label}}</div>
</div>
</div>
</div>
<div class="pane-content">
<slot></slot>
</div>
</div>
</template>
<script>
import elementResizeDetectorMaker from 'element-resize-detector'
export default {
name: 'Tabs',
provide() {
return { TabsInstance: this }
},
props: {
value: {
type: [String, Number]
}
},
data() {
return {
navList: [],
activeKey: this.value,
barWidth: 0,
barOffset: 0,
scrollable: false,
navStyle: {
transform: ''
}
}
},
computed: {
barStyle() {
return {
width: `${this.barWidth}px`,
transform: `translate3d(${this.barOffset}px,0px,0px)`
}
}
},
methods: {
getTabs() {
return this.$children.filter(item => item.$options.name === 'TabPane')
},
initTabs() {
this.updateNav()
this.updateStatus()
this.updataBar()
},
updateNav() {
this.navList = []
this.getTabs().forEach((pane, index) => {
this.navList.push({
label: pane.label,
name: pane.name || index
})
if (index === 0 && !this.activeKey) {
this.activeKey = pane.name
}
})
},
updataBar() {
this.$nextTick(() => {
const index = this.navList.findIndex(nav => nav.name === this.activeKey)
const elemTabs = this.$refs.navWrap.querySelectorAll('.tabs-tab')
const elemTab = elemTabs[index]
this.barWidth = elemTab ? elemTab.offsetWidth : 0
if (index > 0) {
let offset = 0
for (let i = 0; i < index; i++) {
offset += elemTabs[i].offsetWidth + 16
}
this.barOffset = offset
} else {
this.barOffset = 0
}
})
},
updateStatus() {
const tabs = this.getTabs()
tabs.forEach(tab => (tab.show = tab.name === this.activeKey))
},
handleChange(index) {
const nav = this.navList[index]
this.activeKey = nav.name
},
handleResize() {
const navWidth = this.$refs.nav.offsetWidth
const scrollWidth = this.$refs.navScroll.offsetWidth
if (scrollWidth < navWidth) {
this.scrollable = true
} else {
this.scrollable = false
}
this.updateMove()
},
updateMove() {
const navWidth = this.$refs.nav.offsetWidth
const scrollWidth = this.$refs.navScroll.offsetWidth
const currentOffset = this.getCurrentScrollOffset()
if (scrollWidth < navWidth) {
if (navWidth - currentOffset < scrollWidth) {
this.navStyle.transform = `translateX(-${navWidth - scrollWidth}px)`
}
} else {
if (currentOffset > 0) {
this.navStyle.transform = `translateX(-${0}px)`
}
}
},
getCurrentScrollOffset() {
const { navStyle } = this
const reg = /translateX\(-(\d+(\.\d+)*)px\)/
return navStyle.transform ? Number(navStyle.transform.match(reg)[1]) : 0
},
setOffset(value) {
this.navStyle.transform = `translateX(-${value}px)`
},
scrollPrev() {
const containerWidth = this.$refs.navScroll.offsetWidth
const currentOffset = this.getCurrentScrollOffset()
if (!currentOffset) return
let newOffset = 0
if (currentOffset > containerWidth) {
newOffset = currentOffset - containerWidth
}
this.navStyle.transform = `translateX(-${newOffset}px)`
},
scrollNext() {
const navWidth = this.$refs.nav.offsetWidth
const containerWidth = this.$refs.navScroll.offsetWidth
const currentOffset = this.getCurrentScrollOffset()
if (navWidth - currentOffset <= containerWidth) return
let newOffset = null
if (navWidth - currentOffset > containerWidth * 2) {
newOffset = currentOffset + containerWidth
} else {
newOffset = navWidth - containerWidth
}
this.navStyle.transform = `translateX(-${newOffset}px)`
}
},
watch: {
value(val) {
this.activeKey = val
},
activeKey() {
this.updateStatus()
this.updataBar()
}
},
mounted() {
this.observer = elementResizeDetectorMaker()
this.observer.listenTo(this.$refs.navWrap, this.handleResize)
},
beforeDestroy() {
this.observer.removeListener(this.$refs.navWrap, this.handleResize)
}
}
</script>
<style lang="stylus" scoped>
.tabs {
.tabs-nav-wrap {
position: relative;
border-bottom: 1px solid #dcdee2;
margin-bottom: 16px;
}
.tabs-tab {
position: relative;
display: inline-block;
margin-right: 16px;
padding: 8px 16px;
cursor: pointer;
}
.tabs-inv-bar {
position: absolute;
left: 0;
bottom: 0;
background-color: #2d8cf0;
height: 2px;
transition: transform 300ms ease-in-out;
}
.tabs-nav-scroll {
overflow: hidden;
white-space: nowrap;
}
.tabs-nav {
position: relative;
float: left;
transition: transform 0.5s ease-in-out;
}
.tabs-nav-prev, .tabs-nav-next {
position: absolute;
width: 32px;
line-height: 32px;
text-align: center;
cursor: pointer;
}
.tabs-nav-prev {
left: 0;
}
.tabs-nav-next {
right: 0;
}
.tabs-nav-scrollable {
padding: 0 32px;
}
.tabs-nav-scroll-disabled {
display: none;
}
}
</style>
pane.vue
<template>
<div v-show="show">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'TabPane',
inject: ['TabsInstance'],
props: {
name: {
type: String
},
label: {
type: String,
default: ''
}
},
data() {
return {
show: true
}
},
mounted() {
this.TabsInstance.initTabs()
},
watch: {
label() {
this.TabsInstance.initTabs()
},
name() {
this.TabsInstance.initTabs()
}
}
}
</script>