制作 Tabs 组件
[toc]
需求分析
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]);
}
|
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);
})
|
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
用index
表示,不推荐,下标在增或删的情况下会出现问题
selected
用name
表示,不方便
selected
用title
表示,有漏洞,忌重复
先选择作title
为selected
标记的属性,类型为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-if
和v-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(){}
在页面挂载前时执行,普通方法或属性只会在渲染的时候计算一遍
- 当数据变化时,需要使用计算属性来响应变更
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 的位置
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>
|
优化二:干掉onMounted
和onUpdated
- 使用
watchEffect
代替钩子函数onMounted
和onUpdated
- 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参考