Vue3 造轮子: Tabs组件

2,238 阅读2分钟

Tabs 组件

标签页组件

需求

  • 点击 Tab 切换内容

  • 有一条横线在动

API 设计

  • Tabs 组件怎么用
<Tabs>
  <Tab title="导航1">内容1</Tab>
  <Tab title="导航2"><Component1 /></Tab>
  <Tab title="导航3"><Component1 x="hi" /></Tab>
</Tabs><Tabs :data="[
  {title:'导航1',content: '内容1'},
  {title:'导航2',content: Component1},
  {title:''导航3,content: h(Compomnent1,{x:'hi'})},
]"/> 

第二种写法就要兼容更多的方案,如果想随时新增一个type,就可以直接在data里面新增一个数组项就可以了,第一种方案不能直接加,只能用<Tab v-for="item in items">,修改items

创建Tabs和Tab组件

在lib里面创建Tabs.vue和Tab.vue

如何检查子组件的类型

如何在运行时确认子组件的类型检查context.slots.default()数组

用JS获取插槽内容:const defaults = context.slots.default()

如果能够检查Tab的类型,就来到Tabs.vue检查

首先在Tabs.vue里面使用console.log调试法

<script lang="ts">
export default {
  setup(props, context) {
    console.log({
      ...context.slots.default()[0],
    });
    console.log({
      ...context.slots.default()[1],
    }); 
  },
};
</script>

打印出...context,找到slots,然后打印出...context.slots,会出现default,然后再打印出...context.slots.default(),在这里default是个函数,后面需要加括号。

image.png

可以通过拿到context.slots.default()的结果获取外面传给我们子内容,然后再看每个内容的标签。

通过检查defaults第n个type来判断子组件的类型,若为true,则子组件的类型正确,否则为false。

Vue深入的原理:每一个Tab.vue,最终会导出成一个对象(Tab),因为这个对象和这个type是全等的,type里面的属性有一个render属性,那么我们写的.vue文件最终都会被变成一个Tab,那么Tab就是这么一个对象(如图)

image.png

<template>
  <div>
    Tabs 组件
    <component :is="defaults[0]" />
    <component :is="defaults[1]" />
  </div>
</template>

<script lang="ts">
import Tab from "./Tab.vue";
export default {
  setup(props, context) {
    const defaults = context.slots.default();
    //default是个函数,必须加()
    console.log(defaults[0].type === Tab);
    //检查子组件的类型
    return { defaults };
  },
};
</script>

防御性编程:如果代码出错,就不再执行,会报出错误。

渲染嵌套的插槽

不能直接插入插槽<slot /> ,可以用component实现插槽

<component :is="defaults[0]" />
<component :is="defaults[1]" />
//等价于
<component v-for="c in defaults" :is="c" />
// compomnent 实现插槽

image.png

通过每个defaults遍历tag得到tag.props.title的值,返回titles,添加一个div标签,将TabsDemo.vue的title内容插到div里面。

注意:用v-for时,必须要写:key

显示被选中内容

第一种方法失败v-ifv-for官方文档不建议使用,所以会出现报错

第二种方法失败:使用<component :is=current />,依旧失败,发现是Vue3的bug

第三种方法成功:使用CSS切换内容

切换标签页:用selected标记被选中额标签页(最终选择用title)

  • selected 用index表示,不推荐

  • selected 用name表示,不方便

  • selected 用title表示,有漏洞

由于Vue3不建议用v-ifv-for一起使用,所以就用:class

<component class="gulu-tabs-content-item" :class="{selected: c.props.title === selected }" v-for="c in defaults" :is="c" />
//当c的selected.props.title正好等于selected时,表示它是否展示

image.png

动态设置div的高度和宽度

用到的钩子

  • onMounted / onUpdated / watchEffect

TypeScript 泛型

  • const indicator = ref <HTMLDivElement> (null)

获取宽高的位置

  • const { width, left } = el.getBoundingClientRect()

ES6 析构赋值的重命名语法

const { left: left1 } = x.getBoundingClientRect()
const { left: left2 } = y.getBoundingClientRect()
const left = left2 - left1;

代码

Tabs.vue

<template>
  <div class="circle-tabs">
    <div class="circle-tabs-nav" ref="container">
      <div
        class="circle-tabs-nav-item"
        v-for="(t, index) in titles"
        :ref="
          (el) => {
            if (t === selected) selectedItem = el;
          }
        " 
        @click="select(t)"
        :class="{ selected: t === selected }"
        :key="index"
      >
        {{ t }}
      </div>
      <div class="circle-tabs-nav-indicator" ref="indicator"></div>
    </div>
    <div class="circle-tabs-content">
      <component :is="current" :key="current.props.title" />
    </div>
  </div>
</template>

<script lang="ts">
import Tab from "./Tab.vue";
import { computed, ref, watchEffect, onMounted } from "vue";
export default {
  props: {
    selected: {
      type: String,
    },
  },
  setup(props, context) {
    const selectedItem = ref<HTMLDivElement>(null);  //被选中的item
    const indicator = ref<HTMLDivElement>(null);  //移动条
    const container = ref<HTMLDivElement>(null);  //整个导航
    onMounted(() => {
      watchEffect(() => {
        const { width } = selectedItem.value.getBoundingClientRect();
        indicator.value.style.width = width + "px";
        const { left: left1 } = container.value.getBoundingClientRect();
        const { left: left2 } = selectedItem.value.getBoundingClientRect();
        const left = left2 - left1;
        indicator.value.style.left = left + "px";
      });
    });
    const defaults = context.slots.default();  //获取插槽内容
    defaults.forEach((tag) => {
      if (tag.type !== Tab) {
        throw new Error("Tabs 子标签必须是 Tab");
      }
    });
    const current = computed(() => {
      return defaults.find((tag) => tag.props.title === props.selected);
    });  //current等于结算出来的:defaults找到tag.props.title等于被选中的元素
    const titles = defaults.map((tag) => {
      return tag.props.title;
    });
    const select = (title: string) => {
      context.emit("update:selected", title);
    };
    return {
      current,
      defaults,
      titles,
      select,
      selectedItem,
      indicator,
      container,
    };
  },
};
</script>

TabsDemo.vue

<template>
  <div>Tabs 示例</div>
  <h1>示例1</h1>
  <Tabs v-model:selected="x">
    <Tab title="导航1">内容1</Tab>
    <Tab title="导航2">内容2</Tab>
  </Tabs>
</template>

<script lang="ts">
import Tabs from "../lib/Tabs.vue";
import Tab from "../lib/Tab.vue";
import { ref } from "vue";
export default {
  components: {
    Tabs,
    Tab,
  },
  setup() {
    const x = ref("导航2");
    return {
      x,
    };
  },
};
</script>