制作 Tabs 组件

Tabs

[toc]


需求分析

  • 点击 Tab标签页 切换内容
  • 有一条横线跟随在动

API 设计

使用Tabs组件 标签式

1
2
3
4
5
<Tabs>
  <TabItem title="导航1">内容1</TabItem>
  <TabItem title="导航2"><Component1/></TabItem>
  <TabItem title="导航3"><Component1/ x="hi"></TabItem>
</Tabs>

使用Tabs组件 数据式

1
2
3
4
5
6
<Tabs :data="[
  {title: '导航1', content: '内容'},
  {title: '导航2', content: Component1},
  {title: '导航3', content: h(Component1, {x: 'hi'})}
]">
</Tabs>

创建 Tabs 和 Tab 组件

TabItem.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<script setup lang="ts">
</script>

<template>
  TabItem
</template>

<script lang="ts">
export default {
  name: 'TabItem'
};
</script>

Tabs.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<script setup lang="ts">

</script>

<template>
  Tabs
</template>

<script lang="ts">
export default {
  name: 'Tabs'
};
</script>

TabsDemo.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script setup lang="ts">
</script>

<template>
  <h1>示例一</h1>
  <Tabs>
    <TabItem title="导航1">内容1</TabItem>
    <TabItem title="导航2">内容2</TabItem>
    <TabItem title="导航3">内容3</TabItem>
  </Tabs>
</template>

<script lang="ts">
import Tabs from '@/lib/Tabs.vue';
import TabItem from '@/lib/TabItem.vue';

export default {
  name: 'TabsDemo',
  components: {
    Tabs,
    TabItem
  }
};
</script>

如何检查子组件的类型

规定子组件的类型为TabItem,否则抛错

  • 在父组件Tabs中检查子组件的类型为TabItem
  • setup(props, context)中获取[...context.slots.default()]数组
  • 或在script setup中的useSlots()获取[...slots.default()]
  • 使用console.log打印查看

Tabs.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<script setup lang="ts">
import {useAttrs, useSlots, defineEmits} from 'vue';

// 获取slots
const slots = useSlots();
if (slots.default) {
  console.log('slots.default(): \n', [...slots.default()]);
}

// 获取attrs
const attrs = useAttrs();
console.log('attrs: ', attrs);

// 获取 emit
const emit = defineEmits(['change', 'close']);
emit('change', 'change事件的payload');
emit('close', 'close事件的payload');

// 获取expose
const a = 1;
const b = 2;
defineExpose({
  a,
  b
});
</script>

<template>

</template>

<script lang="ts">
export default {
  name: 'Tabs'
};
</script>

1
2
3
4
5
6
7
8
// 获取slots
const slots = useSlots();
if (slots.default) {
  console.log('slots.default(): \n', [...slots.default()][0]);
  console.log('slots.default(): \n', [...slots.default()][1]);
  console.log('slots.default(): \n', [...slots.default()][2]);
}

  • 可以获取到Tabs中的虚拟节点
1
2
3
4
5
6
// 接收slots.defaults()数组
const slots = useSlots();
if (slots.default) {
  const defaults = [...slots.default()];
}

  • 使用<componnets :is="">来展示虚拟节点为DOM节点
1
2
3
4
5
6
// 接收slots.defaults()数组
const slots = useSlots();
if (slots.default) {
  const defaults = [...slots.default()];
}

渲染嵌套的插槽

  • 使用<compoent v-for="comp in defaluts" :is="comp" id="comp"循环渲染组件代替使用<slot/>
  • 即使在组件的标签内 不显式地写出<slot>后备内容</slot>,在组件标签内插入节点时,也可以获取到useSlots().default(),原来为$slots.default()

Tabs.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<script setup lang="ts">
import {useAttrs, useSlots} from 'vue';

let defaults;
// 获取slots
const slots = useSlots();
if (slots.default) {
  defaults = [...slots.default()];
}
</script>

<template>
  <div>
    <component v-for="comp in defaults"
               :is="comp"
               key="comp">
    </component>
  </div>
</template>

<script lang="ts">
export default {
  name: 'Tabs'
};
</script>

TabsDemo.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<script setup lang="ts">
</script>

<template>
  <div>
    <h1>示例一</h1>
    <Tabs>
      <TabItem title="导航1">内容1</TabItem>
      <TabItem title="导航2">内容2</TabItem>
      <TabItem title="导航3">内容3</TabItem>
    </Tabs>
  </div>
</template>

<script lang="ts">
import Tabs from '@/lib/Tabs.vue';
import TabItem from '@/lib/TabItem.vue';

export default {
  name: 'TabsDemo',
  components: {
    Tabs,
    TabItem
  }
};
</script>

TabItem.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<template>
  <div>
    <slot>
      Tab内容
    </slot>
  </div>
</template>

<script lang="ts">
export default {
  name: 'TabItem'
};
</script>

获取并渲染 title

  • 打印查看插槽中所有标签
  • 标签中有属性props
  • 属性props中有title属性
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import {useAttrs, useSlots} from 'vue';

let defaults;
// 获取slots
const slots = useSlots();
if (slots.default) {
  defaults = [...slots.default()];
}

defaults?.forEach((tag)=> {
  // console.log({...tag});
  // console.log({...tag.props});
  console.log(tag.props.title);
})

  • 使用变量获取到title
  • 放到模板插值中
1
2
3
4
const titles = defaults?.map((tag) => {
  return tag?.props?.title;
});


显示被选中的导航

Tabs.vue组件样式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
<script setup lang="ts">
import {useSlots} from 'vue';

let defaults;
// 获取slots
const slots = useSlots();
if (slots.default) {
  defaults = [...slots.default()];
}

const titles = defaults?.map((tag) => {
  return tag?.props?.title;
});

</script>

<template>
  <div class="vue-tabs">
    <div class="vue-tabs-nav">
      <div v-for="(title, index) in titles"
           :key="index"
           class="vue-tabs-nav-item">
        {{ title }}
      </div>
    </div>
    <div class="vue-tabs-content">
      <component v-for="(comp, index) in defaults"
                 :is="comp"
                 :key="index"
                 class="vue-tabs-content-item">
      </component>
    </div>
  </div>
</template>

<script lang="ts">
export default {
  name: 'Tabs'
};
</script>

<style lang="scss">
$blue-underscore: #40a9ff;
$color: #333;
$border-color: #d9d9d9;

.vue-tabs {
  &-nav {
    display: flex;
    color: $color;
    border-bottom: 1px solid $border-color;

    &-item {
      padding: 8px 0;
      margin: 0 16px;
      cursor: pointer;

      &:first-child {
        margin-left: 0;
      }

      &.selected {
        color: $blue-underscore;
      }
    }
  }

  &-content {
    padding: 8px 0;
  }
}
</style>

  • .selected标签导航被选中时的样式

切换标签页

selected标记被选中的标签页

  • selectedindex表示,不推荐,下标在增或删的情况下会出现问题
  • selectedname表示,不方便
  • selectedtitle表示,有漏洞,忌重复

先选择作titleselected标记的属性,类型为string

选中导航标签

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<template>
  <div class="vue-tabs">
    <nav class="vue-tabs-nav">
      <div class="vue-tabs-nav-item"
           v-for="(title, index) in titles"
           :key="index"
           @click="select(title)">
        {{ title }}
      </div>
    </nav>
    <div class="vue-tabs-content">...</div>
  </div>
</template>
<script setup lang="ts">
import {/*computed,*/ onMounted, onUpdated, ref, useSlots, VNode} from 'vue';
import TabItem from '@/lib/TabItem.vue';

// 获取slots
let defaults: VNode[];
const slots = useSlots();
defaults = [...(slots.default as Function)()];

// 检查子标签名方法
const checkTabItem = () => {
  defaults.forEach((tag: VNode) => {
    if (tag.type !== TabItem) {
      console.error(new Error('Tabs 子标签必须是 TabItem'));
    }
  });
};

// 获取子组件VNode中对应title属性组成的数组 titles: string[]
const titles = defaults?.map((tag: VNode) => {
  return tag?.props?.title;
});

// 声明外部数据 获取 props.selected 属性
const props = defineProps({
  selected: String
});

// 对比所有项目的title和当前选中项的title 获取当前选中项currentTab
const currentTab = defaults?.filter((tag: VNode) => {
    return tag?.props?.title === props.selected;
  })[0];

// 声明 发布方法名
const emits = defineEmits(['update:selected']);
// 点击选中项目时执行的方法 通知父组件当前的选中项
const select = (title: string) => {
  emits('update:selected', title);
};

</script>

  • @click="select(title)" 点击执行回调
  • 点击选中项目时执行的方法 通知父组件当前的选中项
    • const select = (title: string) => {emits('update:selected', title);};

选中内容

显示被选中的内容,失败

  • 官方不推荐v-ifv-for一起使用
  • 不循环渲染<component></component>,使用获取当前选中的标签传给<component :is="currentTab"></component>
  • 使用计算属性,实时响应currnetTab的变化
  • <component :is="currentTab" :key="currentTab" class="vue-tabs-content-item"></component>
    • 必须加上:key="currentTab"属性
    • component传递相同类型的vnode时,编译组件是竞态的,见 issue#2013

Tabs.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
<script setup lang="ts">
import {computed, useSlots, VNode} from 'vue';

// 获取slots
let defaults: VNode[];
const slots = useSlots();
defaults = [...(slots.default as Function)()];
// 获取对应title数组
const titles = defaults?.map((tag) => {
  return tag?.props?.title;
});
// 声明外部数据 获取 props.selected
const props = defineProps({
  selected: String
});
// 获取当前选中项的title
const currentTab = computed(() => {
  return defaults?.filter((tag) => {
    return tag?.props?.title === props.selected;
  })[0];
});
// 声明外部数据 获取 props.selected
const emits = defineEmits(['update:selected']);
// 选中项目方法 通知父组件当前的选中项
const select = (title: string) => {
  emits('update:selected', title);
};
</script>

<template>
  <div class="vue-tabs">
    <div class="vue-tabs-nav">
      <div v-for="(title, index) in titles"
           :key="index"
           @click="select(title)"
           class="vue-tabs-nav-item"
           :class="{selected: title === selected}">
        {{ title }}
      </div>
    </div>
    <div class="vue-tabs-content">
      <keep-alive>
        <component :is="currentTab"
                   class="vue-tabs-content-item">
        </component>
      </keep-alive>
    </div>
  </div>
</template>

<script lang="ts">
export default {
  name: 'Tabs'
};
</script>

<style lang="scss">
$blue-underscore: #40a9ff;
$color: #333;
$border-color: #d9d9d9;

.vue-tabs {
  &-nav {
    display: flex;
    color: $color;
    border-bottom: 1px solid $border-color;

    &-item {
      padding: 8px 0;
      margin: 0 16px;
      cursor: pointer;

      &:first-child {
        margin-left: 0;
      }

      &.selected {
        color: $blue-underscore;
      }
    }
  }

  &-content {
    padding: 8px 0;
  }
}
</style>

  • setup(){} 在页面挂载前时执行,普通方法或属性只会在渲染的时候计算一遍
  • 当数据变化时,需要使用计算属性来响应变更
    • currentTab变为计算属性

TabsDemo.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<script setup lang="ts">
import {ref, watchEffect} from 'vue';

const defaultTab = ref('导航1');
watchEffect(() => console.log(defaultTab.value));

</script>

<template>
  <div>
    <h1>示例一</h1>
    <Tabs v-model:selected="defaultTab">
      <TabItem title="导航1">内容1</TabItem>
      <TabItem title="导航2">内容2</TabItem>
      <TabItem title="导航3">内容3</TabItem>
    </Tabs>
  </div>
</template>

<script lang="ts">
import Tabs from '@/lib/Tabs.vue';
import TabItem from '@/lib/TabItem.vue';

export default {
  name: 'TabsDemo',
  components: {
    Tabs,
    TabItem
  }
};
</script>

  • TabsDemo.vue使用v-model:来监听子组件传来的更新属性事件,执行属性更新
  • 使用watchEffect(() => console.log(defaultTab.value));查看变更后的属性值

检查子组件名 Tabs.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<script setup lang="ts">
import {computed, onMounted, useSlots, VNode} from 'vue';
import TabItem from '@/lib/TabItem.vue';
// 获取slots
let defaults: VNode[];
const slots = useSlots();
defaults = [...(slots.default as Function)()];
...
// 检查子标签名
onMounted(() => {
  defaults.forEach((tag: VNode) => {
    if (tag.type !== TabItem) {
      console.error(new Error('Tabs 子标签必须是 TabItem'));
    }
  });
});

</script>

<template>
  <div class="vue-tabs">
    <div class="vue-tabs-nav">
      <div v-for="(title, index) in titles"
           :key="index"
           @click="select(title)"
           class="vue-tabs-nav-item"
           :class="{selected: title === selected}">
        {{ title }}
      </div>
    </div>
    <div class="vue-tabs-content">
      <keep-alive>
        <component :is="currentTab"
                   class="vue-tabs-content-item"
                   :key="currentTitle">
        </component>
      </keep-alive>
    </div>
  </div>
</template>
...

替代方案:使用 CSS 切换内容

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
<script setup lang="ts">
import {computed, onMounted, useSlots, VNode} from 'vue';
import TabItem from '@/lib/TabItem.vue';
// 获取slots
let defaults: VNode[];
const slots = useSlots();
defaults = [...(slots.default as Function)()];

// 获取VNode中对应title数组 titles: string[]
const titles = defaults?.map((tag: VNode) => {
  return tag?.props?.title;
});
// 声明外部数据 获取 props.selected
const props = defineProps({
  selected: String
});
// 对比所有项目的title和当前选中项的title 获取当前选中项currentTab
const currentTab = computed(() => {
  return defaults?.filter((tag: VNode) => {
    return tag?.props?.title === props.selected;
  })[0];
});

const currentTitle = computed(() => {
  return defaults.find((tag: VNode) => {
    return tag!.props!.title === props.selected;
  })!.props!.title;
});

// 声明外部数据 获取 props.selected
const emits = defineEmits(['update:selected']);
// 点击选中项目时执行的方法 通知父组件当前的选中项
const select = (title: string) => {
  emits('update:selected', title);
};

// 检查子标签名
onMounted(() => {
  defaults.forEach((tag: VNode) => {
    if (tag.type !== TabItem) {
      console.error(new Error('Tabs 子标签必须是 TabItem'));
    }
  });
});

</script>

<template>
  <div class="vue-tabs">
    <div class="vue-tabs-nav">
      <div v-for="(title, index) in titles"
           :key="index"
           @click="select(title)"
           class="vue-tabs-nav-item"
           :class="{selected: title === selected}">
        {{ title }}
      </div>
    </div>
    <div class="vue-tabs-content">
      <keep-alive>
        <component v-for="comp in defaults"
                   :is="comp"
                   class="vue-tabs-content-item"
                   :class="{selected: comp.props.title === selected}">
        </component>
      </keep-alive>
    </div>
  </div>
</template>

<script lang="ts">
export default {
  name: 'Tabs'
};
</script>

<style lang="scss">
$blue-underscore: #40a9ff;
$color: #333;
$border-color: #d9d9d9;

.vue-tabs {
  &-nav {
    display: flex;
    color: $color;
    border-bottom: 1px solid $border-color;

    &-item {
      padding: 8px 0;
      margin: 0 16px;
      cursor: pointer;

      &:first-child {
        margin-left: 0;
      }

      &.selected {
        color: $blue-underscore;
      }
    }
  }

  &-content {
    padding: 8px 0;

    &-item {
      display: none;

      &.selected {
        display: block;
      }
    }
  }
}
</style>

  • <component v-for="comp in defaults" :is="comp" class="vue-tabs-content-item" :class="{selected: comp.props.title === selected}"></component>
  • 使用display: none/block;来切换显示隐藏

动态设置 div 制作会动的横线

动态设置 div 的宽度

  • 在Template 使用Refs 配合v-for:
    • <div class="vue-tabs-nav-item" v-for="(title, index) in titles" :key="index" :ref="el => { if (el) navItems[index] = el }">...</div>
  • setup
    • 获取导航标签项目声明引用 const navItems = ref([]); ,注意传数组默认值 ref([]),而不是传空值ref()ref(null)
    • 使用泛型传参const navItems = ref<HTMLDivElement>([]);
    • 控制台打出onMounted(() => { console.log({...navItems.value});});查看
  • 获取选中的项目
    • const divs = navItems.value;
    • const result = divs.find(div => div.classList.contains('selected'));
  • 获取选中的项目元素宽度
    • const {width} = result.getBoundingClientRect();
  • 引用标签指示横线<div class="vue-tabs-nav-indicator" ref="indicator"></div>
    • const indicator = ref<HTMLDivElement>(null);
  • 将获取的宽度赋值给标签指示横线
    • indicator.value.style.width = `${width}px`;

动态设置 div 的位置

1.3

Tabs.vue

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
<script setup lang="ts">
import {onMounted, ref, useSlots, VNode} from 'vue';
import TabItem from '@/lib/TabItem.vue';
// 获取slots
let defaults: VNode[];
const slots = useSlots();
defaults = [...(slots.default as Function)()];

// 检查子标签名方法
const checkTabItem = () => {
  defaults.forEach((tag: VNode) => {
    if (tag.type !== TabItem) {
      console.error(new Error('Tabs 子标签必须是 TabItem'));
    }
  });
};

// 获取VNode中对应title数组 titles: string[]
const titles = defaults?.map((tag: VNode) => {
  return tag?.props?.title;
});

// 声明外部数据 获取 props.selected
const props = defineProps({
  selected: String
});

/*
// 对比所有项目的title和当前选中项的title 获取当前选中项currentTab
const currentTab = computed(() => {
  return defaults?.filter((tag: VNode) => {
    return tag?.props?.title === props.selected;
  })[0];
});

const currentTitle = computed(() => {
  return defaults.find((tag: VNode) => {
    return tag!.props!.title === props.selected;
  })!.props!.title;
});
*/

// 声明外部数据 获取 props.selected
const emits = defineEmits(['update:selected']);
// 点击选中项目时执行的方法 通知父组件当前的选中项
const select = (title: string) => {
  emits('update:selected', title);
};

// 获取导航标签项目引用
const navItems = ref<HTMLDivElement[]>([]);
// 获取导航标签指示横线引用
const indicator = ref<HTMLDivElement>(null);
// 获取外部导航div
const container = ref<HTMLDivElement>(null);

// 获取导航项目列表数组
const xxx = () => {
  // 获取导航项目列表数组
  const divs = navItems.value;
  // const result = divs.filter(div => div.classList.contains('selected'))[0];
  const result = divs.find(div => div.classList.contains('selected'));
  const {width, left} = result.getBoundingClientRect();
  const {left: containerLeft} = container.value.getBoundingClientRect();
  const leftPos = containerLeft - left;
  indicator.value.style.width = `${width}px`;
  indicator.value.style.left = `${leftPos}px`;
};

onMounted(() => {
  checkTabItem();
  xxx();
});

</script>

<template>
  <div class="vue-tabs">
    <nav class="vue-tabs-nav" ref="container">
      <div class="vue-tabs-nav-item"
           v-for="(title, index) in titles"
           :key="index"
           @click="select(title)"
           :class="{selected: title === selected}"
           :ref="el => { if (el) navItems[index] = el }">
        {{ title }}
      </div>
      <div class="vue-tabs-nav-indicator"
           ref="indicator">
      </div>
    </nav>
    <div class="vue-tabs-content">
      <keep-alive>
        <component v-for="comp in defaults"
                   :is="comp"
                   class="vue-tabs-content-item"
                   :class="{selected: comp.props.title === selected}">
        </component>
      </keep-alive>
    </div>
  </div>
</template>

<script lang="ts">
export default {
  name: 'Tabs'
};
</script>

<style lang="scss">
...
</style>

  • 发现不切换,因为onMounted只在第一次执行计算属性并渲染
    • onMounted改为onUpdated,拷贝onMounted同样代码
    • 提取方法xxx = () => {...}

代码待优化

  • 代码冗余
    • 可直接获取selectedItem代替使用navItems
  • 代码重复
    • onMounted(xxx()) 第一次执行
    • onUpdated(xxx()) 后面几次执行
  • 检查并删除无用变量

三处优化代码Tabs.vue

优化一:直接获取selectedItem

Tabs.vue原来:从标签数组中得到被选中的标签组件VNode,即

  • 获取导航标签项目引用const navItems = ref<HTMLDivElement[]>([]);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
...
  <div class="vue-tabs-nav-item"
       v-for="(title, index) in titles"
       :key="index"
       @click="select(title)"
       :class="{selected: title === selected}"
       :ref="el => { if (el) navItems[index] = el }">
  {{ title }}
  </div>
...
  • 获取选中的一项const result = navItems.value.find(div => div.classList.contains('selected'));
  • 改为直接获取选中的标签引用,不需要绕弯找div.classList.contains('selected')
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
<template>
  <div class="vue-tabs">
    <nav class="vue-tabs-nav" ref="container">
      <div class="vue-tabs-nav-item"
           v-for="(title, index) in titles"
           :key="index"
           @click="select(title)"
           :class="{selected: title === selected}"
           :ref="(el) => { if (el && (title === selected)) selectedItem = el">
        {{ title }}
      </div>
      <div class="vue-tabs-nav-indicator"
           ref="indicator">
      </div>
    </nav>
    <div class="vue-tabs-content">
      <keep-alive>
        <component v-for="comp in defaults"
                   :is="comp"
                   class="vue-tabs-content-item"
                   :class="{selected: comp.props.title === selected}">
        </component>
      </keep-alive>
    </div>
  </div>
</template>
<script setup lang="ts">
import {onBeforeUpdate,/*computed, */ onMounted, onUpdated, ref, useSlots, VNode} from 'vue';
import TabItem from '@/lib/TabItem.vue';

// 获取slots
let defaults: VNode[];
const slots = useSlots();
defaults = [...(slots.default as Function)()];

// 检查子标签名方法
const checkTabItem = () => {
  defaults.forEach((tag: VNode) => {
    if (tag.type !== TabItem) {
      console.error(new Error('Tabs 子标签必须是 TabItem'));
    }
  });
};

// 获取子组件VNode中对应title属性组成的数组 titles: string[]
const titles = defaults?.map((tag: VNode) => {
  return tag?.props?.title;
});

// 声明外部数据 获取 props.selected 属性
const props = defineProps({
  selected: String
});

/*
// 对比所有项目的title和当前选中项的title 获取当前选中项currentTab
const currentTab = computed(() => {
  return defaults?.filter((tag: VNode) => {
    return tag?.props?.title === props.selected;
  })[0];
});

const currentTitle = computed(() => {
  return defaults.find((tag: VNode) => {
    return tag!.props!.title === props.selected;
  })!.props!.title;
});
*/

// 声明 发布方法名
const emits = defineEmits(['update:selected']);
// 点击选中项目时执行的方法 通知父组件当前的选中项
const select = (title: string) => {
  emits('update:selected', title);
};

// 获取导航标签项目引用
const div = document.createElement('div');

// const navItems = ref<HTMLDivElement[]>([]);
let selectedItem = ref<HTMLDivElement>(div);
// 获取导航标签指示横线引用
const indicator = ref<HTMLDivElement>(div);
// 获取外部导航div
const container = ref<HTMLDivElement>(div);

// 确保在每次更新之前重置ref
onBeforeUpdate(() => {
  selectedItem.value = div
})

// 获取导航项目列表数组
const xxx = () => {
  // 获取导航项目列表数组
  // const divs = navItems.value;
  // const result = divs.filter(div => div.classList.contains('selected'))[0];
  // const result = divs.find(div => div.classList.contains('selected'));
  const {width, left} = selectedItem.value!.getBoundingClientRect();
  const {left: containerLeft} = container!.value!.getBoundingClientRect();
  const leftPos = left - containerLeft;
  indicator!.value!.style.width = `${width}px`;
  // indicator.value.style.left = `${leftPos}px`;
  indicator!.value!.style.transform = `translate3D(${leftPos}px, 0, 0)`;
};

onMounted(() => {
  checkTabItem();
  xxx();
});

onUpdated(() => {
  xxx();
});

</script>

优化二:干掉onMountedonUpdated

  • 使用watchEffect代替钩子函数onMountedonUpdated
  • watchEffect 文档
  • watchEffect会在第一次就执行回调函数,每次追踪到变更,都执行一次回调,相当于:
    • onMounted(xxx()) 第一次执行
    • onUpdated(xxx()) 后面几次执行
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<script setup lang="ts">
import {onBeforeUpdate,/*computed, */ onMounted, onUpdated, ref, useSlots, VNode, watchEffect} from 'vue';
import TabItem from '@/lib/TabItem.vue';

// 获取slots
let defaults: VNode[];
const slots = useSlots();
defaults = [...(slots.default as Function)()];

// 检查子标签名方法
const checkTabItem = () => {...};

// 获取子组件VNode中对应title属性组成的数组 titles: string[]
const titles = defaults?.map((tag: VNode) => {
  return tag?.props?.title;
});

// 声明外部数据 获取 props.selected 属性
const props = defineProps({
  selected: String
});

// 声明 发布方法名
const emits = defineEmits(['update:selected']);

// 点击选中项目时执行的方法 通知父组件当前的选中项
const select = (title: string) => {
  emits('update:selected', title);
};

// 获取导航标签项目引用
const div = document.createElement('div');
let selectedItem = ref<HTMLDivElement>(div);
// 获取导航标签指示横线引用
const indicator = ref<HTMLDivElement>(div);
// 获取外部div引用
const container = ref<HTMLDivElement>(div);

// 确保在每次更新之前重置ref
onBeforeUpdate(() => {
  selectedItem.value = div;
});

// 获取导航项目列表数组
const xxx = () => {
  const {width, left} = selectedItem.value!.getBoundingClientRect();
  const {left: containerLeft} = container!.value!.getBoundingClientRect();
  const leftPos = left - containerLeft;
  indicator!.value!.style.width = `${width}px`;
  indicator!.value!.style.transform = `translate3D(${leftPos}px, 0, 0)`;
};

watchEffect(xxx);

/*
onMounted(() => {
  checkTabItem();
  xxx();
});

onUpdated(() => {
  xxx();
});
*/

</script>
...
  • xxx方法只使用了一次,合并到watchEffect
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<script setup lang="ts">
import {onBeforeUpdate, onMounted, ref, useSlots, VNode, watchEffect} from 'vue';
import TabItem from '@/lib/TabItem.vue';

// 获取slots
let defaults: VNode[];
const slots = useSlots();
defaults = [...(slots.default as Function)()];

// 检查子标签名方法
const checkTabItem = () => {
  defaults.forEach((tag: VNode) => {
    if (tag.type !== TabItem) {
      console.error(new Error('Tabs 子标签必须是 TabItem'));
    }
  });
};

// 获取子组件VNode中对应title属性组成的数组 titles: string[]
const titles = defaults?.map((tag: VNode) => {
  return tag?.props?.title;
});

// 声明外部数据 获取 props.selected 属性
const props = defineProps({
  selected: String
});

// 声明 发布方法名
const emits = defineEmits(['update:selected']);
// 点击选中项目时执行的方法 通知父组件当前的选中项
const select = (title: string) => {
  emits('update:selected', title);
};

// 获取导航标签项目引用
const div = document.createElement('div');
let selectedItem = ref<HTMLDivElement>(div);
// 获取导航标签指示横线引用
const indicator = ref<HTMLDivElement>(div);
// 获取导航外部div引用
const container = ref<HTMLDivElement>(div);

// 确保在每次更新之前重置ref
onBeforeUpdate(() => {
  selectedItem.value = div;
});

// 获取导航项目列表数组
watchEffect(() => {
  const {width, left} = selectedItem.value!.getBoundingClientRect();
  const {left: containerLeft} = container!.value!.getBoundingClientRect();
  const leftPos = left - containerLeft;
  indicator!.value!.style.width = `${width}px`;
  indicator!.value!.style.transform = `translate3D(${leftPos}px, 0, 0)`;
});

onMounted(() => {
  checkTabItem();
});

</script>

  • watchEffect
    • 非惰性地执行副作用,即立即执行传入的回调函数
    • 响应式地追踪其依赖,并在其依赖变更时重新运行回调函数

selectedItem.value is null 错误解决过程

  • watchEffect在高在之前就执行回调,导致selectedItem.value还未取到值
  • 打log查看
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
...
onMounted(() => {
    console.log('mounted')
})
watchEffect(() => {
  console.log('watchEffect')
  const {width, left} = selectedItem.value!.getBoundingClientRect();
  const {left: containerLeft} = container!.value!.getBoundingClientRect();
  const leftPos = left - containerLeft;
  indicator!.value!.style.width = `${width}px`;
  indicator!.value!.style.transform = `translate3D(${leftPos}px, 0, 0)`;
});
...
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
...

onMounted(() => {
  checkTabItem();

// 追踪变更,执行回调
  watchEffect(() => {
    const {width, left} = selectedItem.value!.getBoundingClientRect();
    const {left: containerLeft} = container.value!.getBoundingClientRect();
    const leftPos = left - containerLeft;
    indicator.value!.style.width = `${width}px`;
    indicator.value!.style.transform = `translate3D(${leftPos}px, 0, 0)`;
  });

});
...

Tabs.vue

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
<script setup lang="ts">
import {onBeforeUpdate,/*computed, */ onMounted, ref, useSlots, VNode, watchEffect} from 'vue';
import TabItem from '@/lib/TabItem.vue';

// 获取slots
const slots = useSlots();
const defaults: VNode[] = [...(slots.default as Function)()];

// 检查子标签名方法
const checkTabItem = () => {
  defaults.forEach((tag: VNode) => {
    if (tag.type !== TabItem) {
      console.error(new Error('Tabs 子标签必须是 TabItem'));
    }
  });
};

// 获取子组件VNode中对应title属性组成的数组 titles: string[]
const titles = defaults?.map((tag: VNode) => {
  return tag?.props?.title;
});

// 声明外部数据 获取 props.selected 属性
const props = defineProps({
  selected: String
});

// 声明 发布方法名
const emits = defineEmits(['update:selected']);
// 点击选中项目时执行的方法 通知父组件当前的选中项
const select = (title: string) => {
  emits('update:selected', title);
};

// 获取导航标签项目引用
const div = document.createElement('div');
let selectedItem = ref<HTMLDivElement>(div);
// 获取导航标签指示横线引用
const indicator = ref<HTMLDivElement>(div);
// 获取导航外部div引用
const container = ref<HTMLDivElement>(div);

// 确保在每次更新之前重置ref
onBeforeUpdate(() => {
  selectedItem.value = div;
});

onMounted(() => {
  checkTabItem();

// 追踪变更,执行回调
  watchEffect(() => {
    const {width, left} = selectedItem.value!.getBoundingClientRect();
    const {left: containerLeft} = container.value!.getBoundingClientRect();
    const leftPos = left - containerLeft;
    indicator.value!.style.width = `${width}px`;
    indicator.value!.style.transform = `translate3D(${leftPos}px, 0, 0)`;
  });

});

</script>

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

<script lang="ts">
export default {
  name: 'Tabs'
};
</script>

<style lang="scss">
$blue-underscore: #40a9ff;
$color: #333;
$border-color: #d9d9d9;

.vue-tabs {
  &-nav {
    display: flex;
    color: $color;
    border-bottom: 1px solid $border-color;
    position: relative;

    &-item {
      padding: 8px 0;
      margin: 0 16px;
      cursor: pointer;

      &:first-child {
        margin-left: 0;
      }

      &.selected {
        color: $blue-underscore;
      }
    }

    &-indicator {
      position: absolute;
      height: 3px;
      background-color: $blue-underscore;
      left: 0;
      bottom: -1px;
      transition: all .25s;
    }

  }

  &-content {
    padding: 8px 0;

    &-item {
      display: none;

      &.selected {
        display: block;
      }
    }
  }
}
</style>

阶段最终代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
<script setup lang="ts">
import {onBeforeUpdate, computed, onMounted, ref, useSlots, VNode, watchEffect} from 'vue';
import TabItem from '@/lib/TabItem.vue';

// 获取slots
const slots = useSlots();
const defaults: VNode[] = [...(slots.default as Function)()];

// 检查子标签名方法
const checkTabItem = () => {
  defaults.forEach((tag: VNode) => {
    if (tag.type !== TabItem) {
      console.error(new Error('Tabs 子标签必须是 TabItem'));
    }
  });
};

// 获取子组件VNode中对应title属性组成的数组 titles: string[]
const titles = defaults?.map((tag: VNode) => {
  return tag?.props?.title;
});

// 声明外部数据 获取 props.selected 属性
const props = defineProps({
  selected: String
});

// 对比所有项目的title和当前选中项的title 获取当前选中项currentTab
const currentTab = computed(() => {
  return defaults?.filter((tag: VNode) => {
    return tag?.props?.title === props.selected;
  })[0];
});

const currentTitle = computed(() => {
  return defaults.find((tag: VNode) => {
    return tag!.props!.title === props.selected;
  })!.props!.title;
});

// 声明 发布方法名
const emits = defineEmits(['update:selected']);
// 点击选中项目时执行的方法 通知父组件当前的选中项
const select = (title: string) => {
  emits('update:selected', title);
};

// 获取导航标签项目引用
const div = document.createElement('div');
let selectedItem = ref<HTMLDivElement>(div);
// 获取导航标签指示横线引用
const indicator = ref<HTMLDivElement>(div);
// 获取导航外部div引用
const container = ref<HTMLDivElement>(div);

// 确保在每次更新之前重置ref
onBeforeUpdate(() => {
  selectedItem.value = div;
});

onMounted(() => {
  checkTabItem();

// 追踪变更,执行回调
  watchEffect(() => {
    const {width, left} = selectedItem.value!.getBoundingClientRect();
    const {left: containerLeft} = container.value!.getBoundingClientRect();
    const leftPos = left - containerLeft;
    indicator.value!.style.width = `${width}px`;
    indicator.value!.style.transform = `translate3D(${leftPos}px, 0, 0)`;
  });

});

</script>

<template>
  <div class="vue-tabs">
    <nav class="vue-tabs-nav" ref="container">
      <div v-for="(title, index) in titles"
           :key="index"
           :ref="(el) => { if (el && (title === selected)) selectedItem = el }"
           :class="{selected: title === selected}"
           class="vue-tabs-nav-item"
           @click="select(title)">
        {{ title }}
      </div>
      <div class="vue-tabs-nav-indicator"
           ref="indicator">
      </div>
    </nav>
    <div class="vue-tabs-content">
      <keep-alive>
        <component :is="currentTab"
                   :key="currentTitle"
                   class="vue-tabs-content-item">
        </component>
      </keep-alive>
    </div>
  </div>
</template>

<script lang="ts">
export default {
  name: 'Tabs'
};
</script>

<style lang="scss">
$blue-underscore: #40a9ff;
$color: #333;
$border-color: #d9d9d9;

.vue-tabs {
  &-nav {
    display: flex;
    color: $color;
    border-bottom: 1px solid $border-color;
    position: relative;

    &-item {
      padding: 8px 0;
      margin: 0 16px;
      cursor: pointer;

      &:first-child {
        margin-left: 0;
      }

      &.selected {
        color: $blue-underscore;
      }
    }

    &-indicator {
      position: absolute;
      height: 3px;
      background-color: $blue-underscore;
      left: 0;
      bottom: -1px;
      transition: all .25s;
    }

  }

  &-content {
    padding: 8px 0;
  }
}
</style>


总结

  • 使用JS获取插槽内容const defaults = context.slots.default()
  • 如果使用了script setup法糖,则使用const defaults: VNode[] = [...(slots.default as Function)()];
  • 内置组件<component></component>的用法
    • :key <component :is="currentTab" :key="currentTab"></component>
  • 钩子onMounted/onUpdated/watchEffect的用法
  • TypeScript的泛型用法
    • const xxx = ref<HTMLDivElement>()
  • 获取元素宽高位置API + 解构赋值
    • const {width, left} = el.getBoundingClientRect()
  • ES6 解构赋值的重命名语法
    • const {left: left2} = xxx.getBoundingClientRect()

UI参考