vue3造轮子实现tab标签页,代码极其简洁易懂

UI库已上传至npm,可安装体验。文档地址:https://chenxuba.github.io/bibi-ui/#/

先上图:


image.png
/**
 * 第一步,先判断传入的子标签是否是bb-Tab
 * 通过context.slots.default()拿到所有的子标签,然后循环遍历
 * 取每个子标签的type和Tab做对比,这里可以log看下打印
 * console.log(context.slots.default())
 * console.log(Tab)
 * 如果传入的子标签是bb-tab,子标签的type和Tab是完全相等的,由此可判断
 * 传入的子标签是否是bb-Tab,不是的话就抛出错误,让其开发者修改子标签!!!
 */
 setup(props, context){
    const defaults = context.slots.default()
     defaults.forEach(tag => {
      if (tag.type !== Tab) {
        throw new Error("Tabs 子标签必须是bb-Tab")
      }
    })
  }
/**
 * 第二步,获取传入的title(标签名)
 * 打印console.log(defaults)可以拿到props中的title
 * 使用map循环遍历return成一个数组
 */
const titles = defaults.map(tag => {
      return tag.props.title
 })
return { defaults, titles}
/**
 * 第三步,实现基本布局样式
 */
<div class="bb-tabs">
    <div class="bb-tabs__wrap">
      <div class="bb-tabs__nav bb-tabs__nav--line">
        <div class="bb-tab" v-for="(item,i) in titles" :key="i" >
          <span class="bb-tab__text bb-tab__text--ellipsis">{{item}}</span>
        </div>
        <div class="bb-tabs__line">
        </div>
      </div>
    </div>
  </div>

<style lang="scss" scoped>
.bb-tabs {
  width: 100%;
  position: relative;
  .bb-tabs__wrap {
    height: 44px;
    overflow: hidden;
    .bb-tabs__nav {
      position: relative;
      display: flex;
      background-color: #fff;
      user-select: none;
    }
    .bb-tabs__nav--line {
      box-sizing: content-box;
      height: 100%;
      padding-bottom: 15px;
    }

    .bb-tab {
      position: relative;
      display: flex;
      flex: 1;
      align-items: center;
      justify-content: center;
      box-sizing: border-box;
      padding: 0 4px;
      color: #646566;
      font-size: 14px;
      line-height: 20px;
      cursor: pointer;
      .bb-tab__text--ellipsis {
        display: -webkit-box;
        overflow: hidden;
        -webkit-line-clamp: 1;
        -webkit-box-orient: vertical;
      }
    }
    .bb-tab--active {
      color: #ee0a24;
      font-weight: 500;
    }
    .bb-tabs__line {
      position: absolute;
      bottom: 15px;
      left: 0;
      z-index: 1;
      width: 30px;
      height: 3px;
      background-color: #ee0a24;
      border-radius: 3px;
    }
  }
}
</style>

实现的样子


image.png
/**
 * 第四步,动态绑定class
 * :class="active==i?'bb-tab--active':''"
 * tab选中状态
 */
<div class="bb-tabs__wrap">
      <div class="bb-tabs__nav bb-tabs__nav--line">
        <div class="bb-tab" v-for="(item,i) in titles" :key="i" :class="active==i?'bb-tab--active':''" >
          <span class="bb-tab__text bb-tab__text--ellipsis">{{item}}</span>
        </div>
        <div class="bb-tabs__line">
        </div>
      </div>
    </div>
/**
     * 第五步,实现点击切换tab选中,暂时先实现颜色切换
     * 给tab绑定一个方法,定义emit传索引匹配
     * 父组件通过 v-model:active='active'双向绑定,实现颜色切换
     * const change = index => {
     * context.emit("update:active", index)
     * }
     */
<div class="bb-tab" v-for="(item,i) in titles" :key="i" :class="active==i?'bb-tab--active':''" @click="change(i)">
          <span class="bb-tab__text bb-tab__text--ellipsis">{{item}}</span>
</div>
const change = index => {
      context.emit("update:active", index)    
}
/**
     * 第六步,动态绑定style,动态改变 小横条 的 translateX
     * 实现点击切换tab动态匀速运动
     * :style="styleObject"
     * const styleObject = reactive({
     * transform: "translateX(35px) translateX(-50%)"
     * })
     * 先模拟一下,在change方法内加入这一行代码
     * styleObject.transform = `translateX(105px) translateX(-50%)`
     * 经测试,点击标签二可实现小横条匀速运动
     */
<div class="bb-tabs__line" style="transition-duration: 0.3s;" :style="styleObject"> </div>
const styleObject = reactive({
      transform: "translateX(35px) translateX(-50%)"
      //styleObject.transform = `translateX(105px) translateX(-50%)`
})
return { defaults, titles, change, styleObject }
/**
     * 第七步,因为tab的title字数不固定,所以宽度也不固定,要动态获取选中tab的宽度
     * 要用到ref,给tab动态绑定ref,怎么绑定呢?
     * :ref="el =>{if (el) navItems[index] = el}"
     * const navItems = ref([])
     * console.log({ ...navItems.value })
     * 记得要在onMounted函数内打印才能获取到dom
     */
 <div class="bb-tab" v-for="(item,i) in titles" :key="i" :class="active==i?'bb-tab--active':''" @click="change(i)"
          :ref="el =>{if (el) navItems[i] = el}">
          <span class="bb-tab__text bb-tab__text--ellipsis">{{item}}</span>
 </div>
const navItems = ref([])
onMounted(() => {
      // console.log({ ...navItems.value })
      /**
       * 第八步,拿到选中的tab的Dom
       * const doms = navItems.value
       * const activeDom = doms.filter(div =>
       * div.classList.contains("bb-tab--active"))[0]
       */
      const doms = navItems.value
      const activeDom = doms.filter(div => div.classList.contains("bb-tab--active"))[0]
      // console.log(activeDom)
      const { width } = activeDom.getBoundingClientRect()
      // console.log(width)
      /**
       *  动态改变styleObject中的transform,先把 styleObject.transform = ""
       *  styleObject.transform =
       * `translateX(${(width * (props.active + (props.active + 1))) / 2}px)
       *  translateX(-50%)`
       *  解释一下:为什么是 ${(width * (props.active + (props.active + 1))) / 2}px)
       *  通过拿到的选中dom的宽度计算,得出规律公式,width * (索引 + (索引+1)) / 2
       */
      styleObject.transform = `translateX(${(width * (props.active + (props.active + 1))) / 2}px) translateX(-50%)`
    })

return { defaults, titles, change, styleObject, navItems }
const change = index => {
      context.emit("update:active", index)
      // styleObject.transform = `translateX(105px) translateX(-50%)`
      /**
       * 第九步,点击改变 styleObject.transform ,这里的获取width代码有点重复,大家有想法的可以
       * 自行优化。
       */
      const doms = navItems.value
      const activeDom = doms.filter(div => div.classList.contains("bb-tab--active"))[0]
      const { width } = activeDom.getBoundingClientRect()
      styleObject.transform = `translateX(${(width * (index + (index + 1))) / 2}px) translateX(-50%)`
    }
/**
 * 第十步,完成 content 布局样式html、css
 */
<div class="bb-tabs__content">
      <component class="bb-tabs-content-item" v-for="(item,index) in defaults" :key="index" :is="item"
        :class="active===index?'bb-tabs-content--active':''" />
    </div>
 /**
     * 最后一步:
     * <component class="bb-tabs-content-item" v-for="(item,index) in defaults"
     * :key="index" :is="item"
     * :class="active===index?'bb-tabs-content--active':''" />
     * 完事在Tab组件内 写样式:
     * .bb-tabs-content-item {
        display: none;
        padding: 24px 20px;
        background-color: #fff;
        &.bb-tabs-content--active {
          display: block;
          color: #323233;
          font-size: 16px;
        }
      }
     */

最终代码

<template>
  <!-- tabs - nav -->
  <div class="bb-tabs">
    <div class="bb-tabs__wrap">
      <div class="bb-tabs__nav bb-tabs__nav--line" :style="tabNavStyle">
        <div class="bb-tab" v-for="(item,i) in titles" :key="i" :class="active==i?'bb-tab--active':''" @click="change(i)"
          :ref="el =>{if (el) navItems[i] = el}" :style="active==i?activeObj:inactiveObj">
          <span class="bb-tab__text bb-tab__text--ellipsis">{{item}}</span>
        </div>
        <!-- bb-tabs__line -->
        <div class="bb-tabs__line" style="transition-duration: 0.3s;" :style="styleObject">
        </div>
      </div>
    </div>
    <!-- bb-tabs__content -->
    <div class="bb-tabs__content">
      <component class="bb-tabs-content-item" v-for="(item,index) in defaults" :key="index" :is="item"
        :class="active===index?'bb-tabs-content--active':''" />
    </div>
  </div>
</template>

<script lang='ts'>
import { computed, nextTick, onMounted, reactive, ref } from "vue"
import Tab from "./Tab.vue"
export default {
  props: {
    active: {
      type: Number,
      default: 0
    },
    background: {
      type: String
    },
    color: {
      type: String
    },
    lineWidth: {
      type: String
    },
    lineHeight: {
      type: String
    },
    titleActiveColor: {
      type: String
    },
    titleInactiveColor: {
      type: String
    }
  },
  setup(props, context) {
    // 获取选中tab宽度的公用方法
    function getActiveTabWidth() {
      const doms = navItems.value
      const activeDom = doms.filter(div => div.classList.contains("bb-tab--active"))[0]
      const { offsetLeft, offsetWidth } = activeDom
      const left = offsetLeft + offsetWidth / 2
      return left
    }
    /**
     * 动态绑定ref
     * :ref="el =>{if (el) navItems[i] = el}"
     */
    const navItems = ref([])
    /**
     * 验证子标签合法性
     */
    const defaults = context.slots.default()
    defaults.forEach(tag => {
      if (tag.type !== Tab) {
        throw new Error("Tabs 子标签必须是bb-Tab")
      }
    })
    /**
     * 获取tab标签名
     */
    const titles = defaults.map(tag => {
      return tag.props.title
    })
    /**
     * 动态绑定style,改变小横条的样式
     */
    const styleObject = reactive({
      transform: "",
      background: props.color,
      width: props.lineWidth,
      height: props.lineHeight
    })

    onMounted(() => {
      /**
       * 在Dom渲染完成后赋初始值,改变小横条的位置
       */
      styleObject.transform = `translateX(${getActiveTabWidth()}px) translateX(-50%)`
    })
    /**
     * 点击切换方法
     */
    const change = index => {
      context.emit("update:active", index)
      nextTick(() => {
        styleObject.transform = `translateX(${getActiveTabWidth()}px) translateX(-50%)`
      })
    }

    /**
     * 扩展:
     * 动态绑定nav的style,背景颜色
     */
    const tabNavStyle = reactive({
      background: props.background
    })
    /**
     * 扩展:
     * 动态设置选中的标签字体颜色
     */
    const activeObj = reactive({
      color: props.titleActiveColor
    })
    /**
     * 扩展:
     * 动态设置未选中的标签字体颜色
     */
    const inactiveObj = reactive({
      color: props.titleInactiveColor
    })
    return { defaults, titles, change, styleObject, navItems, tabNavStyle, activeObj, inactiveObj }
  }
}
</script>

<style lang="scss" scoped>
.bb-tabs {
  width: 100%;
  position: relative;
  .bb-tabs__wrap {
    height: 44px;
    overflow: hidden;
    .bb-tabs__nav {
      position: relative;
      display: flex;
      background-color: white;
      user-select: none;
      overflow-x: scroll;
      // overflow-x: hidden;
      -webkit-overflow-scrolling: touch;
      overflow-y: hidden;
    }
    .bb-tabs__nav--line {
      box-sizing: content-box;
      height: 100%;
      padding-bottom: 15px;
    }

    .bb-tab {
      position: relative;
      display: flex;
      flex: 1 0 auto;
      align-items: center;
      justify-content: center;
      box-sizing: border-box;
      padding: 0 4px;
      padding: 0 12px;
      color: #646566;
      font-size: 14px;
      line-height: 20px;
      cursor: pointer;
      .bb-tab__text--ellipsis {
        display: -webkit-box;
        overflow: hidden;
        -webkit-line-clamp: 1;
        -webkit-box-orient: vertical;
      }
    }
    .bb-tab--active {
      font-weight: 500;
    }
    .bb-tabs__line {
      position: absolute;
      bottom: 15px;
      left: 0;
      z-index: 1;
      width: 30px;
      height: 3px;
      background-color: #ee0a24;
      border-radius: 3px;
    }
  }
}
</style>
禁止转载,如需转载请通过简信或评论联系作者。

推荐阅读更多精彩内容